Add tests for featured thumbnail generation; apply Pint formatting and related edits
This commit is contained in:
@@ -298,7 +298,7 @@ REGISTRATION_IP_PER_DAY_LIMIT=20
|
||||
REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6
|
||||
REGISTRATION_EMAIL_COOLDOWN_MINUTES=30
|
||||
REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24
|
||||
REGISTRATION_ENABLE_TURNSTILE=true
|
||||
TURNSTILE_ENABLED=false
|
||||
REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true
|
||||
REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2
|
||||
REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30
|
||||
@@ -306,6 +306,7 @@ REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE=30
|
||||
REGISTRATION_MONTHLY_EMAIL_LIMIT=10000
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
TURNSTILE_FAIL_OPEN=false
|
||||
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
|
||||
TURNSTILE_TIMEOUT=5
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ final class AcademyLessonController extends Controller
|
||||
public function __construct(
|
||||
private readonly AcademyAccessService $access,
|
||||
private readonly AcademyCacheService $cache,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
@@ -40,8 +39,8 @@ final class AcademyLessonController extends Controller
|
||||
|
||||
if (filled($filters['q'] ?? null)) {
|
||||
$query->where(function ($builder) use ($filters): void {
|
||||
$builder->where('title', 'like', '%' . $filters['q'] . '%')
|
||||
->orWhere('excerpt', 'like', '%' . $filters['q'] . '%');
|
||||
$builder->where('title', 'like', '%'.$filters['q'].'%')
|
||||
->orWhere('excerpt', 'like', '%'.$filters['q'].'%');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,17 +80,31 @@ final class AcademyLessonController extends Controller
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
|
||||
$lesson = AcademyLesson::query()
|
||||
->with('category')
|
||||
->with(['category', 'activeBlocks.activeComparisonResults'])
|
||||
->active()
|
||||
->published()
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
$payload = $this->access->lessonPayload($lesson, $request->user(), true);
|
||||
$relatedLessons = $lesson->category_id !== null
|
||||
? AcademyLesson::query()
|
||||
->with('category')
|
||||
->active()
|
||||
->published()
|
||||
->where('category_id', $lesson->category_id)
|
||||
->where('id', '!=', $lesson->id)
|
||||
->orderByDesc('published_at')
|
||||
->limit(6)
|
||||
->get()
|
||||
->map(fn (AcademyLesson $relatedLesson): array => $this->access->lessonPayload($relatedLesson, $request->user()))
|
||||
->values()
|
||||
->all()
|
||||
: [];
|
||||
$canonical = route('academy.lessons.show', ['slug' => $lesson->slug]);
|
||||
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy lesson.')), 160, '...');
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
(string) ($lesson->seo_title ?? ($lesson->title . ' — Skinbase Academy')),
|
||||
(string) ($lesson->seo_title ?? ($lesson->title.' — Skinbase Academy')),
|
||||
$description,
|
||||
$canonical,
|
||||
$lesson->cover_image,
|
||||
@@ -100,10 +113,11 @@ final class AcademyLessonController extends Controller
|
||||
return Inertia::render('Academy/Show', [
|
||||
'pageType' => 'lesson',
|
||||
'item' => $payload,
|
||||
'relatedLessons' => $relatedLessons,
|
||||
'seo' => $seo,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
|
||||
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ final class AcademyPromptController extends Controller
|
||||
(string) ($prompt->seo_title ?? ($prompt->title . ' — Skinbase Academy')),
|
||||
$description,
|
||||
$canonical,
|
||||
$prompt->preview_image,
|
||||
$payload['preview_image'] ?? null,
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Academy/Show', [
|
||||
|
||||
@@ -30,7 +30,19 @@ class LatestCommentsApiController extends Controller
|
||||
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$query = ArtworkComment::with(['user', 'user.profile', 'artwork'])
|
||||
$query = ArtworkComment::query()
|
||||
->select([
|
||||
'artwork_comments.id',
|
||||
'artwork_comments.artwork_id',
|
||||
'artwork_comments.user_id',
|
||||
'artwork_comments.content',
|
||||
'artwork_comments.created_at',
|
||||
])
|
||||
->with([
|
||||
'user:id,username,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'artwork:id,title,slug,hash,file_path,file_name',
|
||||
])
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -37,10 +38,16 @@ class RegisteredUserController extends Controller
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$cspNonce = $this->resolveCspNonce($request);
|
||||
|
||||
return view('auth.register', [
|
||||
'prefillEmail' => (string) $request->query('email', ''),
|
||||
'requiresCaptcha' => $this->shouldRequireCaptcha($request->ip()),
|
||||
'captcha' => $this->captchaVerifier->frontendConfig(),
|
||||
'turnstile' => [
|
||||
'enabled' => $this->turnstileVerifier->isEnabled(),
|
||||
'siteKey' => $this->turnstileVerifier->siteKey(),
|
||||
'scriptUrl' => $this->turnstileVerifier->scriptUrl(),
|
||||
'cspNonce' => $cspNonce,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -62,25 +69,35 @@ class RegisteredUserController extends Controller
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$turnstileResponse = (string) ($request->input('turnstile_token') ?: $request->input('cf-turnstile-response', ''));
|
||||
|
||||
$rules = [
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
|
||||
'website' => ['nullable', 'max:0'],
|
||||
'turnstile_token' => ['nullable', 'string'],
|
||||
'cf-turnstile-response' => [$this->turnstileVerifier->isEnabled() ? 'required_without:turnstile_token' : 'nullable', 'string'],
|
||||
];
|
||||
$rules[$this->captchaVerifier->inputName()] = ['nullable', 'string'];
|
||||
|
||||
$validator = Validator::make($request->all(), $rules);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$errors = $validator->errors()->toArray();
|
||||
|
||||
if (array_key_exists('cf-turnstile-response', $errors) && ! array_key_exists('turnstile_token', $errors)) {
|
||||
$errors['turnstile_token'] = $errors['cf-turnstile-response'];
|
||||
unset($errors['cf-turnstile-response']);
|
||||
}
|
||||
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'register',
|
||||
request: $request,
|
||||
status: 'failed',
|
||||
reason: 'validation_failed',
|
||||
identifier: (string) $request->input('email'),
|
||||
metadata: ['fields' => array_keys($validator->errors()->toArray())],
|
||||
metadata: ['fields' => array_keys($errors)],
|
||||
);
|
||||
|
||||
$validator->validate();
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
|
||||
$validated = $validator->validated();
|
||||
@@ -90,32 +107,18 @@ class RegisteredUserController extends Controller
|
||||
|
||||
$this->trackRegisterAttempt($ip);
|
||||
|
||||
if ($this->shouldRequireCaptcha($ip)) {
|
||||
$verified = $this->captchaVerifier->verify(
|
||||
(string) $request->input($this->captchaVerifier->inputName(), ''),
|
||||
$ip
|
||||
if ($this->turnstileVerifier->isEnabled() && ! $this->turnstileVerifier->verify($turnstileResponse, $ip)) {
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'register',
|
||||
request: $request,
|
||||
status: 'failed',
|
||||
reason: 'captcha_failed',
|
||||
identifier: $email,
|
||||
);
|
||||
|
||||
if ($this->turnstileVerifier->isEnabled()) {
|
||||
$verified = $this->turnstileVerifier->verify(
|
||||
(string) $request->input($this->captchaVerifier->inputName(), ''),
|
||||
$ip
|
||||
);
|
||||
}
|
||||
|
||||
if (! $verified) {
|
||||
$this->authAuditLogger->log(
|
||||
eventType: 'register',
|
||||
request: $request,
|
||||
status: 'failed',
|
||||
reason: 'captcha_failed',
|
||||
identifier: $email,
|
||||
);
|
||||
|
||||
return back()
|
||||
->withInput($request->except('website'))
|
||||
->withErrors(['captcha' => 'Captcha verification failed. Please try again.']);
|
||||
}
|
||||
return back()
|
||||
->withInput($request->except('website', 'turnstile_token', 'cf-turnstile-response'))
|
||||
->withErrors(['turnstile_token' => 'Security verification failed. Please try again.']);
|
||||
}
|
||||
|
||||
if ($this->disposableEmailService->isDisposableEmail($email)) {
|
||||
@@ -264,23 +267,6 @@ class RegisteredUserController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
private function shouldRequireCaptcha(?string $ip): bool
|
||||
{
|
||||
if (! $this->captchaVerifier->isEnabled()) {
|
||||
if (! $this->turnstileVerifier->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! (bool) config('registration.enable_turnstile', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->turnstileVerifier->isEnabled() && $this->shouldRequireCaptchaForIp($ip);
|
||||
}
|
||||
|
||||
return $this->shouldRequireCaptchaForIp($ip);
|
||||
}
|
||||
|
||||
private function shouldRequireCaptchaForIp(?string $ip): bool
|
||||
{
|
||||
if (! $this->captchaVerifier->isEnabled() && ! $this->turnstileVerifier->isEnabled()) {
|
||||
@@ -387,4 +373,28 @@ class RegisteredUserController extends Controller
|
||||
|
||||
return $remaining >= 0 ? 0 : abs((int) $remaining);
|
||||
}
|
||||
|
||||
private function resolveCspNonce(Request $request): ?string
|
||||
{
|
||||
$candidates = [
|
||||
$request->attributes->get('csp_nonce'),
|
||||
$request->attributes->get('cspNonce'),
|
||||
$request->headers->get('X-CSP-Nonce'),
|
||||
$request->server('HTTP_X_CSP_NONCE'),
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (! is_string($candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$nonce = trim($candidate);
|
||||
|
||||
if ($nonce !== '') {
|
||||
return $nonce;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,19 @@ class LatestCommentsController extends Controller
|
||||
|
||||
// Build initial (first-page, type=all) data for React SSR props
|
||||
$initialData = Cache::remember('comments.latest.all.page1', 120, function () {
|
||||
return ArtworkComment::with(['user', 'user.profile', 'artwork'])
|
||||
return ArtworkComment::query()
|
||||
->select([
|
||||
'artwork_comments.id',
|
||||
'artwork_comments.artwork_id',
|
||||
'artwork_comments.user_id',
|
||||
'artwork_comments.content',
|
||||
'artwork_comments.created_at',
|
||||
])
|
||||
->with([
|
||||
'user:id,username,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'artwork:id,title,slug,hash,file_path,file_name',
|
||||
])
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers\News;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\NewsArticleComment;
|
||||
use App\Models\User;
|
||||
use App\Services\News\NewsService;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -147,40 +146,14 @@ class NewsController extends Controller
|
||||
// Track view (once per session / IP)
|
||||
$this->trackView($request, $article);
|
||||
|
||||
// Related articles (same category, excluding current)
|
||||
$related = NewsArticle::with('author', 'category')
|
||||
->published()
|
||||
->when($article->category_id, fn ($q) => $q->where('category_id', $article->category_id))
|
||||
->where('id', '!=', $article->id)
|
||||
->editorialOrder()
|
||||
->limit(config('news.related_limit', 4))
|
||||
->get();
|
||||
|
||||
$comments = collect();
|
||||
$commentsCount = 0;
|
||||
|
||||
if ($article->commentsAreEnabled()) {
|
||||
$comments = NewsArticleComment::query()
|
||||
->where('article_id', $article->id)
|
||||
->whereNull('parent_id')
|
||||
->where('status', 'visible')
|
||||
->with(['user.profile'])
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$commentsCount = (int) NewsArticleComment::query()
|
||||
->where('article_id', $article->id)
|
||||
->where('status', 'visible')
|
||||
->count();
|
||||
}
|
||||
$articleData = $this->news->publicArticleShowData($article, $request->user());
|
||||
|
||||
return view('news.show', [
|
||||
'article' => $article,
|
||||
'related' => $related,
|
||||
'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()),
|
||||
'comments' => $comments,
|
||||
'commentsCount' => $commentsCount,
|
||||
'related' => $articleData['related'],
|
||||
'relatedEntities' => $articleData['relatedEntities'],
|
||||
'comments' => $articleData['comments'],
|
||||
'commentsCount' => $articleData['commentsCount'],
|
||||
] + $this->sidebarData());
|
||||
}
|
||||
|
||||
|
||||
@@ -11,26 +11,36 @@ use App\Http\Requests\Academy\UpsertAcademyChallengeRequest;
|
||||
use App\Http\Requests\Academy\UpsertAcademyLessonRequest;
|
||||
use App\Http\Requests\Academy\UpsertAcademyPromptPackRequest;
|
||||
use App\Http\Requests\Academy\UpsertAcademyPromptTemplateRequest;
|
||||
use App\Models\AcademyAiComparisonResult;
|
||||
use App\Models\AcademyBadge;
|
||||
use App\Models\AcademyCategory;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyChallengeSubmission;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonBlock;
|
||||
use App\Models\AcademyPromptPack;
|
||||
use App\Models\AcademyPromptPackItem;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
use App\Services\Academy\AcademyCacheService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AcademyAdminController extends Controller
|
||||
{
|
||||
public function __construct(private readonly AcademyCacheService $cache)
|
||||
{
|
||||
}
|
||||
private const PROMPT_PREVIEW_WEBP_QUALITY = 84;
|
||||
|
||||
private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews';
|
||||
|
||||
public function __construct(private readonly AcademyCacheService $cache) {}
|
||||
|
||||
public function dashboard(): Response
|
||||
{
|
||||
@@ -65,18 +75,30 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function categoriesCreate(): Response
|
||||
{
|
||||
return $this->renderForm('categories', new AcademyCategory());
|
||||
return $this->renderForm('categories', new AcademyCategory);
|
||||
}
|
||||
|
||||
public function categoriesStore(UpsertAcademyCategoryRequest $request): RedirectResponse
|
||||
{
|
||||
$category = new AcademyCategory();
|
||||
$category = new AcademyCategory;
|
||||
$category->fill($request->validated())->save();
|
||||
$this->cache->clearAll();
|
||||
|
||||
return redirect()->route('admin.academy.categories.edit', ['academyCategory' => $category])->with('success', 'Academy category created.');
|
||||
}
|
||||
|
||||
public function categoriesStoreJson(UpsertAcademyCategoryRequest $request): JsonResponse
|
||||
{
|
||||
$category = new AcademyCategory;
|
||||
$category->fill($request->validated())->save();
|
||||
$this->cache->clearAll();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'category' => $this->serializeCategoryOption($category),
|
||||
]);
|
||||
}
|
||||
|
||||
public function categoriesEdit(AcademyCategory $academyCategory): Response
|
||||
{
|
||||
return $this->renderForm('categories', $academyCategory);
|
||||
@@ -105,13 +127,19 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function lessonsCreate(): Response
|
||||
{
|
||||
return $this->renderForm('lessons', new AcademyLesson());
|
||||
return $this->renderForm('lessons', new AcademyLesson);
|
||||
}
|
||||
|
||||
public function lessonsStore(UpsertAcademyLessonRequest $request): RedirectResponse
|
||||
{
|
||||
$lesson = new AcademyLesson();
|
||||
$lesson->fill($request->validated())->save();
|
||||
$lesson = DB::transaction(function () use ($request): AcademyLesson {
|
||||
$lesson = new AcademyLesson;
|
||||
$lesson->fill($this->persistLessonAttributes($request))->save();
|
||||
$this->syncLessonBlocks($lesson, $request->validated('blocks', []));
|
||||
|
||||
return $lesson;
|
||||
});
|
||||
|
||||
$this->cache->clearAll();
|
||||
|
||||
return redirect()->route('admin.academy.lessons.edit', ['academyLesson' => $lesson])->with('success', 'Academy lesson created.');
|
||||
@@ -119,12 +147,18 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function lessonsEdit(AcademyLesson $academyLesson): Response
|
||||
{
|
||||
$academyLesson->load(['blocks.comparisonResults']);
|
||||
|
||||
return $this->renderForm('lessons', $academyLesson);
|
||||
}
|
||||
|
||||
public function lessonsUpdate(UpsertAcademyLessonRequest $request, AcademyLesson $academyLesson): RedirectResponse
|
||||
{
|
||||
$academyLesson->fill($request->validated())->save();
|
||||
DB::transaction(function () use ($request, $academyLesson): void {
|
||||
$academyLesson->fill($this->persistLessonAttributes($request, $academyLesson))->save();
|
||||
$this->syncLessonBlocks($academyLesson, $request->validated('blocks', []));
|
||||
});
|
||||
|
||||
$this->cache->clearAll();
|
||||
|
||||
return redirect()->route('admin.academy.lessons.edit', ['academyLesson' => $academyLesson])->with('success', 'Academy lesson updated.');
|
||||
@@ -132,6 +166,16 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function lessonsDestroy(AcademyLesson $academyLesson): RedirectResponse
|
||||
{
|
||||
$academyLesson->load(['blocks.comparisonResults']);
|
||||
|
||||
foreach ($academyLesson->blocks as $block) {
|
||||
foreach ($block->comparisonResults as $result) {
|
||||
$this->deleteStoredLessonMediaIfLocal($result->image_path);
|
||||
$this->deleteStoredLessonMediaIfLocal($result->thumb_path);
|
||||
}
|
||||
}
|
||||
|
||||
$this->deleteStoredLessonCoverIfLocal((string) $academyLesson->cover_image);
|
||||
$academyLesson->delete();
|
||||
$this->cache->clearAll();
|
||||
|
||||
@@ -145,13 +189,13 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function promptsCreate(): Response
|
||||
{
|
||||
return $this->renderForm('prompts', new AcademyPromptTemplate());
|
||||
return $this->renderForm('prompts', new AcademyPromptTemplate);
|
||||
}
|
||||
|
||||
public function promptsStore(UpsertAcademyPromptTemplateRequest $request): RedirectResponse
|
||||
{
|
||||
$prompt = new AcademyPromptTemplate();
|
||||
$prompt->fill($request->validated())->save();
|
||||
$prompt = new AcademyPromptTemplate;
|
||||
$prompt->forceFill($this->persistPromptAttributes($request, $prompt))->save();
|
||||
$this->cache->clearAll();
|
||||
|
||||
return redirect()->route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt])->with('success', 'Academy prompt created.');
|
||||
@@ -164,7 +208,7 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function promptsUpdate(UpsertAcademyPromptTemplateRequest $request, AcademyPromptTemplate $academyPromptTemplate): RedirectResponse
|
||||
{
|
||||
$academyPromptTemplate->fill($request->validated())->save();
|
||||
$academyPromptTemplate->forceFill($this->persistPromptAttributes($request, $academyPromptTemplate))->save();
|
||||
$this->cache->clearAll();
|
||||
|
||||
return redirect()->route('admin.academy.prompts.edit', ['academyPromptTemplate' => $academyPromptTemplate])->with('success', 'Academy prompt updated.');
|
||||
@@ -185,12 +229,12 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function packsCreate(): Response
|
||||
{
|
||||
return $this->renderForm('packs', new AcademyPromptPack());
|
||||
return $this->renderForm('packs', new AcademyPromptPack);
|
||||
}
|
||||
|
||||
public function packsStore(UpsertAcademyPromptPackRequest $request): RedirectResponse
|
||||
{
|
||||
$pack = new AcademyPromptPack();
|
||||
$pack = new AcademyPromptPack;
|
||||
$pack->fill(collect($request->validated())->except('prompt_ids')->all())->save();
|
||||
$this->syncPackItems($pack, $request->validated('prompt_ids', []));
|
||||
$this->cache->clearAll();
|
||||
@@ -229,12 +273,12 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function challengesCreate(): Response
|
||||
{
|
||||
return $this->renderForm('challenges', new AcademyChallenge());
|
||||
return $this->renderForm('challenges', new AcademyChallenge);
|
||||
}
|
||||
|
||||
public function challengesStore(UpsertAcademyChallengeRequest $request): RedirectResponse
|
||||
{
|
||||
$challenge = new AcademyChallenge();
|
||||
$challenge = new AcademyChallenge;
|
||||
$challenge->fill($request->validated())->save();
|
||||
$this->cache->clearAll();
|
||||
|
||||
@@ -269,12 +313,12 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function badgesCreate(): Response
|
||||
{
|
||||
return $this->renderForm('badges', new AcademyBadge());
|
||||
return $this->renderForm('badges', new AcademyBadge);
|
||||
}
|
||||
|
||||
public function badgesStore(UpsertAcademyBadgeRequest $request): RedirectResponse
|
||||
{
|
||||
$badge = new AcademyBadge();
|
||||
$badge = new AcademyBadge;
|
||||
$badge->fill($request->validated())->save();
|
||||
|
||||
return redirect()->route('admin.academy.badges.edit', ['academyBadge' => $badge])->with('success', 'Academy badge created.');
|
||||
@@ -362,7 +406,7 @@ final class AcademyAdminController extends Controller
|
||||
'subtitle' => $meta['subtitle'],
|
||||
'items' => $items,
|
||||
'columns' => $meta['columns'],
|
||||
'createUrl' => route($meta['route_base'] . '.create'),
|
||||
'createUrl' => route($meta['route_base'].'.create'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -372,17 +416,44 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
return Inertia::render('Admin/Academy/CrudForm', [
|
||||
'resource' => $resource,
|
||||
'title' => $record->exists ? 'Edit ' . $meta['singular'] : 'Create ' . $meta['singular'],
|
||||
'title' => $record->exists ? 'Edit '.$meta['singular'] : 'Create '.$meta['singular'],
|
||||
'subtitle' => $meta['subtitle'],
|
||||
'fields' => $meta['fields'],
|
||||
'record' => $this->serializeFormRecord($resource, $record),
|
||||
'submitUrl' => $record->exists ? route($meta['route_base'] . '.update', $this->routeParams($resource, $record)) : route($meta['route_base'] . '.store'),
|
||||
'indexUrl' => route($meta['route_base'] . '.index'),
|
||||
'destroyUrl' => $record->exists ? route($meta['route_base'] . '.destroy', $this->routeParams($resource, $record)) : null,
|
||||
'submitUrl' => $record->exists ? route($meta['route_base'].'.update', $this->routeParams($resource, $record)) : route($meta['route_base'].'.store'),
|
||||
'indexUrl' => route($meta['route_base'].'.index'),
|
||||
'destroyUrl' => $record->exists ? route($meta['route_base'].'.destroy', $this->routeParams($resource, $record)) : null,
|
||||
'method' => $record->exists ? 'patch' : 'post',
|
||||
'editorContext' => $this->formEditorContext($resource),
|
||||
]);
|
||||
}
|
||||
|
||||
private function formEditorContext(string $resource): array
|
||||
{
|
||||
if ($resource !== 'lessons') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'coverUploadUrl' => route('api.studio.academy.lessons.media.upload'),
|
||||
'coverDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
|
||||
'bodyMediaUploadUrl' => route('api.studio.academy.lessons.media.upload'),
|
||||
'bodyMediaDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
|
||||
'bodyMediaAssetsUrl' => route('api.studio.academy.lessons.media.assets'),
|
||||
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
|
||||
'categories' => AcademyCategory::query()
|
||||
->where('type', 'lesson')
|
||||
->orderBy('order_num')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (AcademyCategory $category): array => $this->serializeCategoryOption($category))
|
||||
->values()
|
||||
->all(),
|
||||
'categoryStoreUrl' => route('api.academy.categories.store'),
|
||||
'categoryManageUrl' => route('admin.academy.categories.index'),
|
||||
];
|
||||
}
|
||||
|
||||
private function resourceMeta(string $resource): array
|
||||
{
|
||||
return match ($resource) {
|
||||
@@ -449,7 +520,7 @@ final class AcademyAdminController extends Controller
|
||||
['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
|
||||
['name' => 'aspect_ratio', 'label' => 'Aspect Ratio', 'type' => 'text'],
|
||||
['name' => 'tags', 'label' => 'Tags', 'type' => 'csv'],
|
||||
['name' => 'preview_image', 'label' => 'Preview Image', 'type' => 'text'],
|
||||
['name' => 'preview_image', 'label' => 'Preview Image URL', 'type' => 'text'],
|
||||
['name' => 'published_at', 'label' => 'Published At', 'type' => 'datetime-local'],
|
||||
['name' => 'seo_title', 'label' => 'SEO Title', 'type' => 'text'],
|
||||
['name' => 'seo_description', 'label' => 'SEO Description', 'type' => 'textarea'],
|
||||
@@ -526,7 +597,7 @@ final class AcademyAdminController extends Controller
|
||||
['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
|
||||
],
|
||||
],
|
||||
default => throw new \InvalidArgumentException('Unknown Academy resource [' . $resource . '].'),
|
||||
default => throw new \InvalidArgumentException('Unknown Academy resource ['.$resource.'].'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -616,6 +687,7 @@ final class AcademyAdminController extends Controller
|
||||
'access_level' => (string) ($record->access_level ?? 'free'),
|
||||
'lesson_type' => (string) ($record->lesson_type ?? 'article'),
|
||||
'cover_image' => (string) ($record->cover_image ?? ''),
|
||||
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->cover_image ?? '')),
|
||||
'video_url' => (string) ($record->video_url ?? ''),
|
||||
'reading_minutes' => (int) ($record->reading_minutes ?? 5),
|
||||
'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'),
|
||||
@@ -623,6 +695,9 @@ final class AcademyAdminController extends Controller
|
||||
'seo_description' => (string) ($record->seo_description ?? ''),
|
||||
'featured' => (bool) ($record->featured ?? false),
|
||||
'active' => (bool) ($record->active ?? true),
|
||||
'blocks' => $record instanceof AcademyLesson
|
||||
? $record->blocks->map(fn (AcademyLessonBlock $block): array => $this->serializeLessonBlock($block))->values()->all()
|
||||
: [],
|
||||
],
|
||||
'prompts' => [
|
||||
'category_id' => $record->category_id,
|
||||
@@ -638,6 +713,8 @@ final class AcademyAdminController extends Controller
|
||||
'aspect_ratio' => (string) ($record->aspect_ratio ?? ''),
|
||||
'tags' => implode(', ', (array) ($record->tags ?? [])),
|
||||
'preview_image' => (string) ($record->preview_image ?? ''),
|
||||
'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($record->preview_image ?? '')),
|
||||
'preview_image_file' => null,
|
||||
'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'),
|
||||
'seo_title' => (string) ($record->seo_title ?? ''),
|
||||
'seo_description' => (string) ($record->seo_description ?? ''),
|
||||
@@ -693,6 +770,429 @@ final class AcademyAdminController extends Controller
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function persistLessonAttributes(UpsertAcademyLessonRequest $request, ?AcademyLesson $lesson = null): array
|
||||
{
|
||||
$validated = $request->validated();
|
||||
unset($validated['blocks']);
|
||||
$currentCoverImage = trim((string) ($lesson?->cover_image ?? ''));
|
||||
$nextCoverImage = filled($validated['cover_image'] ?? null)
|
||||
? trim((string) $validated['cover_image'])
|
||||
: null;
|
||||
|
||||
if ($currentCoverImage !== '' && $currentCoverImage !== (string) $nextCoverImage) {
|
||||
$this->deleteStoredLessonCoverIfLocal($currentCoverImage);
|
||||
}
|
||||
|
||||
$validated['cover_image'] = $nextCoverImage;
|
||||
|
||||
// Auto-publish: if marked active but no published_at set, default to now.
|
||||
if (! empty($validated['active']) && empty($validated['published_at'])) {
|
||||
$validated['published_at'] = now();
|
||||
}
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $blocks
|
||||
*/
|
||||
private function syncLessonBlocks(AcademyLesson $lesson, array $blocks): void
|
||||
{
|
||||
$lesson->loadMissing(['blocks.comparisonResults']);
|
||||
|
||||
$existingBlocks = $lesson->blocks->keyBy(fn (AcademyLessonBlock $block): int => (int) $block->id);
|
||||
$retainedBlockIds = [];
|
||||
|
||||
foreach ($blocks as $index => $blockData) {
|
||||
$blockId = isset($blockData['id']) ? (int) $blockData['id'] : null;
|
||||
$block = $blockId !== null ? $existingBlocks->get($blockId) : null;
|
||||
|
||||
if (! $block instanceof AcademyLessonBlock) {
|
||||
$block = new AcademyLessonBlock;
|
||||
$block->lesson()->associate($lesson);
|
||||
}
|
||||
|
||||
$payload = is_array($blockData['payload'] ?? null) ? $blockData['payload'] : [];
|
||||
$block->fill([
|
||||
'type' => (string) ($blockData['type'] ?? 'ai_comparison'),
|
||||
'title' => $this->nullableTrimmedString($blockData['title'] ?? null) ?? $this->nullableTrimmedString($payload['title'] ?? null),
|
||||
'payload' => $this->normalizeLessonBlockPayload($payload),
|
||||
'sort_order' => (int) ($blockData['sort_order'] ?? $index),
|
||||
'active' => (bool) ($blockData['active'] ?? true),
|
||||
]);
|
||||
$block->save();
|
||||
|
||||
$retainedBlockIds[] = (int) $block->id;
|
||||
$this->syncLessonBlockComparisonResults($block, is_array($blockData['comparison_results'] ?? null) ? $blockData['comparison_results'] : []);
|
||||
}
|
||||
|
||||
$lesson->blocks
|
||||
->filter(fn (AcademyLessonBlock $block): bool => ! in_array((int) $block->id, $retainedBlockIds, true))
|
||||
->each(function (AcademyLessonBlock $block): void {
|
||||
foreach ($block->comparisonResults as $result) {
|
||||
$this->deleteStoredLessonMediaIfLocal($result->image_path);
|
||||
$this->deleteStoredLessonMediaIfLocal($result->thumb_path);
|
||||
}
|
||||
|
||||
$block->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $results
|
||||
*/
|
||||
private function syncLessonBlockComparisonResults(AcademyLessonBlock $block, array $results): void
|
||||
{
|
||||
$existingResults = $block->comparisonResults->keyBy(fn (AcademyAiComparisonResult $result): int => (int) $result->id);
|
||||
$retainedResultIds = [];
|
||||
|
||||
foreach ($results as $index => $resultData) {
|
||||
$resultId = isset($resultData['id']) ? (int) $resultData['id'] : null;
|
||||
$result = $resultId !== null ? $existingResults->get($resultId) : null;
|
||||
|
||||
if (! $result instanceof AcademyAiComparisonResult) {
|
||||
$result = new AcademyAiComparisonResult;
|
||||
$result->block()->associate($block);
|
||||
}
|
||||
|
||||
$previousImagePath = (string) ($result->image_path ?? '');
|
||||
$previousThumbPath = (string) ($result->thumb_path ?? '');
|
||||
$nextImagePath = $this->nullableTrimmedString($resultData['image_path'] ?? null);
|
||||
$nextThumbPath = $this->nullableTrimmedString($resultData['thumb_path'] ?? null);
|
||||
|
||||
$result->fill([
|
||||
'provider' => $this->nullableTrimmedString($resultData['provider'] ?? null),
|
||||
'model_name' => $this->nullableTrimmedString($resultData['model_name'] ?? null),
|
||||
'image_path' => $nextImagePath,
|
||||
'thumb_path' => $nextThumbPath,
|
||||
'settings' => $this->nullableTrimmedString($resultData['settings'] ?? null),
|
||||
'strengths' => $this->nullableTrimmedString($resultData['strengths'] ?? null),
|
||||
'weaknesses' => $this->nullableTrimmedString($resultData['weaknesses'] ?? null),
|
||||
'best_for' => $this->nullableTrimmedString($resultData['best_for'] ?? null),
|
||||
'score' => $resultData['score'] ?? null,
|
||||
'sort_order' => (int) ($resultData['sort_order'] ?? $index),
|
||||
'active' => (bool) ($resultData['active'] ?? true),
|
||||
]);
|
||||
$result->save();
|
||||
|
||||
if ($previousImagePath !== '' && $previousImagePath !== (string) $nextImagePath) {
|
||||
$this->deleteStoredLessonMediaIfLocal($previousImagePath);
|
||||
}
|
||||
|
||||
if ($previousThumbPath !== '' && $previousThumbPath !== (string) $nextThumbPath) {
|
||||
$this->deleteStoredLessonMediaIfLocal($previousThumbPath);
|
||||
}
|
||||
|
||||
$retainedResultIds[] = (int) $result->id;
|
||||
}
|
||||
|
||||
$block->comparisonResults
|
||||
->filter(fn (AcademyAiComparisonResult $result): bool => ! in_array((int) $result->id, $retainedResultIds, true))
|
||||
->each(function (AcademyAiComparisonResult $result): void {
|
||||
$this->deleteStoredLessonMediaIfLocal($result->image_path);
|
||||
$this->deleteStoredLessonMediaIfLocal($result->thumb_path);
|
||||
$result->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeLessonBlockPayload(array $payload): array
|
||||
{
|
||||
$criteria = collect($payload['criteria'] ?? [])
|
||||
->map(fn ($criterion): string => trim((string) $criterion))
|
||||
->filter(static fn (string $criterion): bool => $criterion !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'title' => $this->nullableTrimmedString($payload['title'] ?? null),
|
||||
'intro' => $this->nullableTrimmedString($payload['intro'] ?? null),
|
||||
'prompt' => $this->nullableTrimmedString($payload['prompt'] ?? null),
|
||||
'negative_prompt' => $this->nullableTrimmedString($payload['negative_prompt'] ?? null),
|
||||
'aspect_ratio' => $this->nullableTrimmedString($payload['aspect_ratio'] ?? null),
|
||||
'criteria' => $criteria,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeLessonBlock(AcademyLessonBlock $block): array
|
||||
{
|
||||
$payload = is_array($block->payload) ? $block->payload : [];
|
||||
|
||||
return [
|
||||
'id' => (int) $block->id,
|
||||
'type' => (string) $block->type,
|
||||
'title' => (string) ($block->title ?? ''),
|
||||
'payload' => $this->normalizeLessonBlockPayload($payload),
|
||||
'sort_order' => (int) $block->sort_order,
|
||||
'active' => (bool) $block->active,
|
||||
'comparison_results' => $block->comparisonResults->map(fn (AcademyAiComparisonResult $result): array => [
|
||||
'id' => (int) $result->id,
|
||||
'provider' => (string) ($result->provider ?? ''),
|
||||
'model_name' => (string) ($result->model_name ?? ''),
|
||||
'image_path' => (string) $result->image_path,
|
||||
'image_url' => $this->resolveLessonMediaUrl((string) $result->image_path),
|
||||
'thumb_path' => (string) ($result->thumb_path ?? ''),
|
||||
'thumb_url' => $this->resolveLessonMediaUrl((string) ($result->thumb_path ?? '')),
|
||||
'settings' => (string) ($result->settings ?? ''),
|
||||
'strengths' => (string) ($result->strengths ?? ''),
|
||||
'weaknesses' => (string) ($result->weaknesses ?? ''),
|
||||
'best_for' => (string) ($result->best_for ?? ''),
|
||||
'score' => $result->score,
|
||||
'sort_order' => (int) $result->sort_order,
|
||||
'active' => (bool) $result->active,
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function persistPromptAttributes(UpsertAcademyPromptTemplateRequest $request, ?AcademyPromptTemplate $prompt = null): array
|
||||
{
|
||||
$validated = $request->validated();
|
||||
unset($validated['preview_image_file']);
|
||||
|
||||
$currentPreviewImage = (string) ($prompt?->preview_image ?? '');
|
||||
$previewImageFile = $this->promptPreviewImageUpload($request);
|
||||
|
||||
if ($previewImageFile instanceof UploadedFile) {
|
||||
$this->deleteStoredPromptPreviewIfLocal($currentPreviewImage);
|
||||
$validated['preview_image'] = $this->storePromptPreviewImage($previewImageFile);
|
||||
} else {
|
||||
$validated['preview_image'] = filled($validated['preview_image'] ?? null)
|
||||
? trim((string) $validated['preview_image'])
|
||||
: null;
|
||||
}
|
||||
|
||||
// Auto-publish: if marked active but no published_at set, default to now.
|
||||
if (! empty($validated['active']) && empty($validated['published_at'])) {
|
||||
$validated['published_at'] = now();
|
||||
}
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
private function promptPreviewImageUpload(UpsertAcademyPromptTemplateRequest $request): ?UploadedFile
|
||||
{
|
||||
$file = $request->file('preview_image_file');
|
||||
|
||||
if (! $file instanceof UploadedFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pathName = trim((string) $file->getPathname());
|
||||
if ($file->isValid() && $pathName !== '' && is_file($pathName) && is_readable($pathName)) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'preview_image_file' => $this->promptPreviewImageUploadErrorMessage($file),
|
||||
]);
|
||||
}
|
||||
|
||||
private function storePromptPreviewImage(UploadedFile $file): string
|
||||
{
|
||||
$pathName = trim((string) $file->getPathname());
|
||||
if ($pathName === '' || ! is_file($pathName) || ! is_readable($pathName)) {
|
||||
throw ValidationException::withMessages([
|
||||
'preview_image_file' => $this->promptPreviewImageUploadErrorMessage($file),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) {
|
||||
throw ValidationException::withMessages([
|
||||
'preview_image_file' => 'The server is missing WebP image support. Enable the GD WebP extension to upload prompt preview images.',
|
||||
]);
|
||||
}
|
||||
|
||||
$binary = @file_get_contents($pathName);
|
||||
if ($binary === false) {
|
||||
throw ValidationException::withMessages([
|
||||
'preview_image_file' => 'The uploaded preview image could not be opened for conversion. Please choose the file again and retry.',
|
||||
]);
|
||||
}
|
||||
|
||||
$image = @imagecreatefromstring($binary);
|
||||
if (! $image instanceof \GdImage) {
|
||||
throw ValidationException::withMessages([
|
||||
'preview_image_file' => 'The uploaded preview image format could not be converted. Please use JPG, PNG, or WEBP.',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
if (! imageistruecolor($image)) {
|
||||
imagepalettetotruecolor($image);
|
||||
}
|
||||
|
||||
imagealphablending($image, true);
|
||||
imagesavealpha($image, true);
|
||||
|
||||
ob_start();
|
||||
$converted = imagewebp($image, null, self::PROMPT_PREVIEW_WEBP_QUALITY);
|
||||
$webpBinary = ob_get_clean();
|
||||
|
||||
if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'preview_image_file' => 'The uploaded preview image could not be converted to WebP. Please try a different image.',
|
||||
]);
|
||||
}
|
||||
|
||||
$storedPath = self::PROMPT_PREVIEW_PREFIX.'/'.pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME).'.webp';
|
||||
Storage::disk($this->promptPreviewImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']);
|
||||
} finally {
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
return $storedPath;
|
||||
}
|
||||
|
||||
private function deleteStoredPromptPreviewIfLocal(?string $path): void
|
||||
{
|
||||
$path = trim((string) $path);
|
||||
if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! str_starts_with($path, self::PROMPT_PREVIEW_PREFIX.'/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$disk = $this->promptPreviewImageDisk();
|
||||
|
||||
if (Storage::disk($disk)->exists($path)) {
|
||||
Storage::disk($disk)->delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function promptPreviewImageUploadErrorMessage(UploadedFile $file): string
|
||||
{
|
||||
return match ($file->getError()) {
|
||||
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded preview image exceeds the server upload limit.',
|
||||
UPLOAD_ERR_PARTIAL => 'The uploaded preview image was only partially received. Please retry the upload.',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'The server upload temp directory is unavailable. Check PHP upload temp configuration.',
|
||||
UPLOAD_ERR_CANT_WRITE => 'The server could not write the uploaded preview image to temporary storage.',
|
||||
UPLOAD_ERR_EXTENSION => 'A PHP extension blocked the preview image upload.',
|
||||
default => 'The uploaded preview image could not be read. Please choose the file again and retry.',
|
||||
};
|
||||
}
|
||||
|
||||
private function promptPreviewImageDisk(): string
|
||||
{
|
||||
return (string) config('uploads.object_storage.disk', 's3');
|
||||
}
|
||||
|
||||
private function resolvePromptPreviewImageUrl(?string $previewImage): ?string
|
||||
{
|
||||
$previewImage = trim((string) $previewImage);
|
||||
|
||||
if ($previewImage === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($previewImage, 'http://') || str_starts_with($previewImage, 'https://') || str_starts_with($previewImage, '/')) {
|
||||
return $previewImage;
|
||||
}
|
||||
|
||||
return Storage::disk($this->promptPreviewImageDisk())->url($previewImage);
|
||||
}
|
||||
|
||||
private function resolveLessonMediaUrl(?string $path): ?string
|
||||
{
|
||||
$path = trim((string) $path);
|
||||
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return Storage::disk($this->promptPreviewImageDisk())->url($path);
|
||||
}
|
||||
|
||||
private function deleteStoredLessonCoverIfLocal(?string $path): void
|
||||
{
|
||||
$path = trim((string) $path);
|
||||
if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! str_starts_with($path, 'academy/lessons/covers/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$disk = $this->promptPreviewImageDisk();
|
||||
|
||||
if (Storage::disk($disk)->exists($path)) {
|
||||
Storage::disk($disk)->delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteStoredLessonMediaIfLocal(?string $path): void
|
||||
{
|
||||
$path = trim((string) $path);
|
||||
if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! str_starts_with($path, 'academy/lessons/body/') && ! str_starts_with($path, 'academy/lessons/covers/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$disk = $this->promptPreviewImageDisk();
|
||||
|
||||
if (Storage::disk($disk)->exists($path)) {
|
||||
Storage::disk($disk)->delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function nullableTrimmedString(mixed $value): ?string
|
||||
{
|
||||
$trimmed = trim((string) $value);
|
||||
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
|
||||
private function resolveLessonCoverImageUrl(?string $coverImage): ?string
|
||||
{
|
||||
$coverImage = trim((string) $coverImage);
|
||||
|
||||
if ($coverImage === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($coverImage, 'http://') || str_starts_with($coverImage, 'https://') || str_starts_with($coverImage, '/')) {
|
||||
return $coverImage;
|
||||
}
|
||||
|
||||
return Storage::disk($this->promptPreviewImageDisk())->url($coverImage);
|
||||
}
|
||||
|
||||
private function serializeCategoryOption(AcademyCategory $category): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $category->id,
|
||||
'value' => (int) $category->id,
|
||||
'label' => (string) $category->name,
|
||||
'name' => (string) $category->name,
|
||||
'slug' => (string) $category->slug,
|
||||
'description' => (string) ($category->description ?? ''),
|
||||
'order_num' => (int) ($category->order_num ?? 0),
|
||||
'active' => (bool) ($category->active ?? true),
|
||||
'edit_url' => route('admin.academy.categories.edit', ['academyCategory' => $category]),
|
||||
];
|
||||
}
|
||||
|
||||
private function routeParams(string $resource, Model $record): array
|
||||
{
|
||||
return match ($resource) {
|
||||
@@ -758,4 +1258,4 @@ final class AcademyAdminController extends Controller
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,42 +5,15 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\News\NewsCoverImageService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
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 StudioNewsMediaApiController extends Controller
|
||||
{
|
||||
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
private const MAX_FILE_SIZE_KB = 6144;
|
||||
|
||||
private const MAX_WIDTH = 2200;
|
||||
|
||||
private const MAX_HEIGHT = 1400;
|
||||
|
||||
private const MIN_WIDTH = 1200;
|
||||
|
||||
private const MIN_HEIGHT = 630;
|
||||
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct()
|
||||
public function __construct(private readonly NewsCoverImageService $covers)
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd')
|
||||
? new ImageManager(new GdDriver())
|
||||
: new ImageManager(new ImagickDriver());
|
||||
} catch (\Throwable) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
@@ -52,26 +25,28 @@ final class StudioNewsMediaApiController extends Controller
|
||||
'required',
|
||||
'file',
|
||||
'image',
|
||||
'max:' . self::MAX_FILE_SIZE_KB,
|
||||
'max:' . $this->covers->maxFileSizeKb(),
|
||||
'mimes:jpg,jpeg,png,webp',
|
||||
'mimetypes:image/jpeg,image/png,image/webp',
|
||||
],
|
||||
]);
|
||||
|
||||
/** @var UploadedFile $file */
|
||||
$file = $validated['image'];
|
||||
|
||||
try {
|
||||
$stored = $this->storeMediaFile($file);
|
||||
$stored = $this->covers->storeUploadedFile($file);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'path' => $stored['path'],
|
||||
'url' => $this->publicUrlForPath($stored['path']),
|
||||
'url' => $stored['url'],
|
||||
'width' => $stored['width'],
|
||||
'height' => $stored['height'],
|
||||
'mime_type' => 'image/webp',
|
||||
'size_bytes' => $stored['size_bytes'],
|
||||
'mobile_url' => $stored['mobile_url'],
|
||||
'desktop_url' => $stored['desktop_url'],
|
||||
'srcset' => $stored['srcset'],
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json([
|
||||
@@ -99,7 +74,7 @@ final class StudioNewsMediaApiController extends Controller
|
||||
'path' => ['required', 'string', 'max:2048'],
|
||||
]);
|
||||
|
||||
$this->deleteMediaFile((string) $validated['path']);
|
||||
$this->covers->deleteManagedFiles((string) $validated['path']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -176,56 +151,4 @@ final class StudioNewsMediaApiController extends Controller
|
||||
{
|
||||
abort_unless($request->user() && ($request->user()->isAdmin() || $request->user()->isModerator()), 403);
|
||||
}
|
||||
|
||||
private function mediaDiskName(): string
|
||||
{
|
||||
return (string) config('uploads.object_storage.disk', 's3');
|
||||
}
|
||||
|
||||
private function mediaPath(string $hash): string
|
||||
{
|
||||
return sprintf(
|
||||
'news/covers/%s/%s/%s.webp',
|
||||
substr($hash, 0, 2),
|
||||
substr($hash, 2, 2),
|
||||
$hash,
|
||||
);
|
||||
}
|
||||
|
||||
private function publicUrlForPath(string $path): string
|
||||
{
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function deleteMediaFile(string $path): void
|
||||
{
|
||||
$trimmed = ltrim(trim($path), '/');
|
||||
|
||||
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk($this->mediaDiskName())->delete($trimmed);
|
||||
}
|
||||
|
||||
private function assertImageManager(): void
|
||||
{
|
||||
if ($this->manager !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Image processing is not available on this environment.');
|
||||
}
|
||||
|
||||
private function assertStorageIsAllowed(): void
|
||||
{
|
||||
if (! app()->environment('production')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diskName = $this->mediaDiskName();
|
||||
if (in_array($diskName, ['local', 'public'], true)) {
|
||||
throw new RuntimeException('Production news media storage must use object storage, not local/public disks.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\Academy;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpsertAcademyLessonRequest extends FormRequest
|
||||
@@ -16,10 +17,62 @@ class UpsertAcademyLessonRequest extends FormRequest
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$blocks = collect($this->input('blocks', []))
|
||||
->filter(static fn ($block): bool => is_array($block))
|
||||
->map(function (array $block): array {
|
||||
$payload = Arr::wrap($block['payload'] ?? []);
|
||||
$criteria = collect($payload['criteria'] ?? [])
|
||||
->map(static fn ($criterion) => trim((string) $criterion))
|
||||
->filter(static fn (string $criterion): bool => $criterion !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$results = collect($block['comparison_results'] ?? [])
|
||||
->filter(static fn ($result): bool => is_array($result))
|
||||
->map(function (array $result): array {
|
||||
return [
|
||||
'id' => filled($result['id'] ?? null) ? (int) $result['id'] : null,
|
||||
'provider' => $result['provider'] ?? null,
|
||||
'model_name' => $result['model_name'] ?? null,
|
||||
'image_path' => $result['image_path'] ?? null,
|
||||
'thumb_path' => $result['thumb_path'] ?? null,
|
||||
'settings' => $result['settings'] ?? null,
|
||||
'strengths' => $result['strengths'] ?? null,
|
||||
'weaknesses' => $result['weaknesses'] ?? null,
|
||||
'best_for' => $result['best_for'] ?? null,
|
||||
'score' => filled($result['score'] ?? null) ? (int) $result['score'] : null,
|
||||
'sort_order' => filled($result['sort_order'] ?? null) ? (int) $result['sort_order'] : 0,
|
||||
'active' => filter_var($result['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'id' => filled($block['id'] ?? null) ? (int) $block['id'] : null,
|
||||
'type' => (string) ($block['type'] ?? 'ai_comparison'),
|
||||
'title' => $block['title'] ?? null,
|
||||
'payload' => [
|
||||
'title' => $payload['title'] ?? null,
|
||||
'intro' => $payload['intro'] ?? null,
|
||||
'prompt' => $payload['prompt'] ?? null,
|
||||
'negative_prompt' => $payload['negative_prompt'] ?? null,
|
||||
'aspect_ratio' => $payload['aspect_ratio'] ?? null,
|
||||
'criteria' => $criteria,
|
||||
],
|
||||
'sort_order' => filled($block['sort_order'] ?? null) ? (int) $block['sort_order'] : 0,
|
||||
'active' => filter_var($block['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
|
||||
'comparison_results' => $results,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->merge([
|
||||
'reading_minutes' => $this->filled('reading_minutes') ? (int) $this->input('reading_minutes') : 5,
|
||||
'featured' => $this->boolean('featured'),
|
||||
'active' => $this->boolean('active', true),
|
||||
'blocks' => $blocks,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -44,6 +97,33 @@ class UpsertAcademyLessonRequest extends FormRequest
|
||||
'published_at' => ['nullable', 'date'],
|
||||
'seo_title' => ['nullable', 'string', 'max:180'],
|
||||
'seo_description' => ['nullable', 'string', 'max:255'],
|
||||
'blocks' => ['nullable', 'array'],
|
||||
'blocks.*.id' => ['nullable', 'integer', 'exists:academy_lesson_blocks,id'],
|
||||
'blocks.*.type' => ['required', 'string', Rule::in(['ai_comparison'])],
|
||||
'blocks.*.title' => ['nullable', 'string', 'max:255'],
|
||||
'blocks.*.payload' => ['nullable', 'array'],
|
||||
'blocks.*.payload.title' => ['nullable', 'string', 'max:255'],
|
||||
'blocks.*.payload.intro' => ['nullable', 'string'],
|
||||
'blocks.*.payload.prompt' => ['nullable', 'string'],
|
||||
'blocks.*.payload.negative_prompt' => ['nullable', 'string'],
|
||||
'blocks.*.payload.aspect_ratio' => ['nullable', 'string', 'max:20'],
|
||||
'blocks.*.payload.criteria' => ['nullable', 'array'],
|
||||
'blocks.*.payload.criteria.*' => ['nullable', 'string', 'max:100'],
|
||||
'blocks.*.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'blocks.*.active' => ['required', 'boolean'],
|
||||
'blocks.*.comparison_results' => ['nullable', 'array'],
|
||||
'blocks.*.comparison_results.*.id' => ['nullable', 'integer', 'exists:academy_ai_comparison_results,id'],
|
||||
'blocks.*.comparison_results.*.provider' => ['nullable', 'string', 'max:100'],
|
||||
'blocks.*.comparison_results.*.model_name' => ['nullable', 'string', 'max:150'],
|
||||
'blocks.*.comparison_results.*.image_path' => ['required', 'string', 'max:500'],
|
||||
'blocks.*.comparison_results.*.thumb_path' => ['nullable', 'string', 'max:500'],
|
||||
'blocks.*.comparison_results.*.settings' => ['nullable', 'string'],
|
||||
'blocks.*.comparison_results.*.strengths' => ['nullable', 'string'],
|
||||
'blocks.*.comparison_results.*.weaknesses' => ['nullable', 'string'],
|
||||
'blocks.*.comparison_results.*.best_for' => ['nullable', 'string'],
|
||||
'blocks.*.comparison_results.*.score' => ['nullable', 'integer', 'min:1', 'max:10'],
|
||||
'blocks.*.comparison_results.*.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'blocks.*.comparison_results.*.active' => ['required', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
|
||||
'tags.*' => ['string', 'max:60'],
|
||||
'tool_notes' => ['nullable', 'array'],
|
||||
'preview_image' => ['nullable', 'string', 'max:2048'],
|
||||
'preview_image_file' => ['nullable', 'file', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||
'featured' => ['required', 'boolean'],
|
||||
'prompt_of_week' => ['required', 'boolean'],
|
||||
'active' => ['required', 'boolean'],
|
||||
|
||||
@@ -14,6 +14,21 @@ class AcademyLesson extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function (self $lesson): void {
|
||||
$lesson->blocks()->with('comparisonResults')->get()->each(function (AcademyLessonBlock $block) use ($lesson): void {
|
||||
if ($lesson->isForceDeleting()) {
|
||||
$block->forceDelete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$block->delete();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'title',
|
||||
@@ -59,4 +74,16 @@ class AcademyLesson extends Model
|
||||
{
|
||||
return $this->hasMany(AcademyLessonProgress::class, 'lesson_id');
|
||||
}
|
||||
}
|
||||
|
||||
public function blocks(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademyLessonBlock::class, 'lesson_id')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function activeBlocks(): HasMany
|
||||
{
|
||||
return $this->blocks()->where('active', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Academy;
|
||||
|
||||
use App\Models\AcademyAiComparisonResult;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonBlock;
|
||||
use App\Models\AcademyPromptPack;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class AcademyAccessService
|
||||
@@ -66,6 +69,7 @@ final class AcademyAccessService
|
||||
'access_level' => (string) $lesson->access_level,
|
||||
'lesson_type' => (string) $lesson->lesson_type,
|
||||
'cover_image' => $lesson->cover_image,
|
||||
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?? '')),
|
||||
'video_url' => $authorized ? $lesson->video_url : null,
|
||||
'reading_minutes' => (int) $lesson->reading_minutes,
|
||||
'featured' => (bool) $lesson->featured,
|
||||
@@ -76,6 +80,9 @@ final class AcademyAccessService
|
||||
'name' => (string) $lesson->category->name,
|
||||
'slug' => (string) $lesson->category->slug,
|
||||
] : null,
|
||||
'blocks' => ($authorized && $includeFull)
|
||||
? $lesson->activeBlocks->map(fn (AcademyLessonBlock $block): ?array => $this->lessonBlockPayload($block))->filter()->values()->all()
|
||||
: [],
|
||||
'locked' => ! $authorized,
|
||||
'can_access' => $authorized,
|
||||
];
|
||||
@@ -100,7 +107,7 @@ final class AcademyAccessService
|
||||
'aspect_ratio' => $prompt->aspect_ratio,
|
||||
'tags' => array_values((array) ($prompt->tags ?? [])),
|
||||
'tool_notes' => $authorized ? (array) ($prompt->tool_notes ?? []) : [],
|
||||
'preview_image' => $prompt->preview_image,
|
||||
'preview_image' => $this->resolvePreviewImageUrl((string) ($prompt->preview_image ?? '')),
|
||||
'featured' => (bool) $prompt->featured,
|
||||
'prompt_of_week' => (bool) $prompt->prompt_of_week,
|
||||
'published_at' => $prompt->published_at?->toISOString(),
|
||||
@@ -204,6 +211,115 @@ final class AcademyAccessService
|
||||
$previewLength = max(1, $length - 1);
|
||||
}
|
||||
|
||||
return rtrim(mb_substr($plain, 0, $previewLength)) . '...';
|
||||
return rtrim(mb_substr($plain, 0, $previewLength)).'...';
|
||||
}
|
||||
}
|
||||
|
||||
private function resolvePreviewImageUrl(string $previewImage): ?string
|
||||
{
|
||||
$previewImage = trim($previewImage);
|
||||
|
||||
if ($previewImage === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($previewImage, 'http://') || str_starts_with($previewImage, 'https://') || str_starts_with($previewImage, '/')) {
|
||||
return $previewImage;
|
||||
}
|
||||
|
||||
return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($previewImage);
|
||||
}
|
||||
|
||||
private function resolveLessonCoverImageUrl(string $coverImage): ?string
|
||||
{
|
||||
$coverImage = trim($coverImage);
|
||||
|
||||
if ($coverImage === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($coverImage, 'http://') || str_starts_with($coverImage, 'https://') || str_starts_with($coverImage, '/')) {
|
||||
return $coverImage;
|
||||
}
|
||||
|
||||
return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($coverImage);
|
||||
}
|
||||
|
||||
private function resolveLessonMediaUrl(string $path): ?string
|
||||
{
|
||||
$path = trim($path);
|
||||
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function lessonBlockPayload(AcademyLessonBlock $block): ?array
|
||||
{
|
||||
if ($block->type !== 'ai_comparison') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = is_array($block->payload) ? $block->payload : [];
|
||||
$criteria = collect($payload['criteria'] ?? [])
|
||||
->map(static fn ($criterion): string => trim((string) $criterion))
|
||||
->filter(static fn (string $criterion): bool => $criterion !== '')
|
||||
->values()
|
||||
->all();
|
||||
$results = $block->activeComparisonResults
|
||||
->map(fn (AcademyAiComparisonResult $result): array => [
|
||||
'id' => (int) $result->id,
|
||||
'provider' => (string) ($result->provider ?? ''),
|
||||
'model_name' => (string) ($result->model_name ?? ''),
|
||||
'image_path' => (string) $result->image_path,
|
||||
'image_url' => $this->resolveLessonMediaUrl((string) $result->image_path),
|
||||
'thumb_path' => (string) ($result->thumb_path ?? ''),
|
||||
'thumb_url' => $this->resolveLessonMediaUrl((string) ($result->thumb_path ?? '')),
|
||||
'settings' => (string) ($result->settings ?? ''),
|
||||
'strengths' => (string) ($result->strengths ?? ''),
|
||||
'weaknesses' => (string) ($result->weaknesses ?? ''),
|
||||
'best_for' => (string) ($result->best_for ?? ''),
|
||||
'score' => $result->score,
|
||||
'sort_order' => (int) $result->sort_order,
|
||||
'active' => (bool) $result->active,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$hasPromptData = filled($payload['prompt'] ?? null)
|
||||
|| filled($payload['negative_prompt'] ?? null)
|
||||
|| filled($payload['intro'] ?? null)
|
||||
|| filled($payload['title'] ?? null)
|
||||
|| filled($payload['aspect_ratio'] ?? null)
|
||||
|| ! empty($criteria);
|
||||
|
||||
if (! $hasPromptData && $results === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $block->id,
|
||||
'type' => (string) $block->type,
|
||||
'title' => (string) ($block->title ?? ($payload['title'] ?? '')),
|
||||
'payload' => [
|
||||
'title' => (string) ($payload['title'] ?? ''),
|
||||
'intro' => (string) ($payload['intro'] ?? ''),
|
||||
'prompt' => (string) ($payload['prompt'] ?? ''),
|
||||
'negative_prompt' => (string) ($payload['negative_prompt'] ?? ''),
|
||||
'aspect_ratio' => (string) ($payload['aspect_ratio'] ?? ''),
|
||||
'criteria' => $criteria,
|
||||
],
|
||||
'sort_order' => (int) $block->sort_order,
|
||||
'active' => (bool) $block->active,
|
||||
'comparison_results' => $results,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ class ArtworkService
|
||||
{
|
||||
protected int $cacheTtl = 3600; // seconds
|
||||
|
||||
private ?bool $featureTypeColumnExists = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ContentTypeSlugResolver $contentTypeResolver,
|
||||
private readonly ArtworkMaturityService $maturity,
|
||||
@@ -340,7 +342,7 @@ class ArtworkService
|
||||
*/
|
||||
private function featuredBaseQuery(?int $type): Builder
|
||||
{
|
||||
return Artwork::query()
|
||||
$query = Artwork::query()
|
||||
->select('artworks.*')
|
||||
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
|
||||
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
|
||||
@@ -349,10 +351,13 @@ class ArtworkService
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('af.expires_at')
|
||||
->orWhere('af.expires_at', '>', now());
|
||||
})
|
||||
->when($type !== null, function ($q) use ($type) {
|
||||
$q->where('af.type', $type);
|
||||
});
|
||||
|
||||
if ($type !== null && $this->featuredTypeColumnExists()) {
|
||||
$query->where('af.type', $type);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyFeaturedEligibilityFilters(Builder $query): void
|
||||
@@ -384,6 +389,15 @@ class ArtworkService
|
||||
return $this->applyFeaturedOrdering($query);
|
||||
}
|
||||
|
||||
private function featuredTypeColumnExists(): bool
|
||||
{
|
||||
if ($this->featureTypeColumnExists === null) {
|
||||
$this->featureTypeColumnExists = Schema::hasColumn('artwork_features', 'type');
|
||||
}
|
||||
|
||||
return $this->featureTypeColumnExists;
|
||||
}
|
||||
|
||||
private function featuredHeroSelectionQuery(?int $type): Builder
|
||||
{
|
||||
$query = $this->featuredBaseQuery($type);
|
||||
|
||||
@@ -12,6 +12,10 @@ use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
final class NewsArticleCommentService
|
||||
{
|
||||
public function __construct(private readonly NewsService $news)
|
||||
{
|
||||
}
|
||||
|
||||
public function create(NewsArticle $article, User $actor, string $body, ?NewsArticleComment $parent = null): NewsArticleComment
|
||||
{
|
||||
if (! $article->commentsAreEnabled()) {
|
||||
@@ -42,6 +46,8 @@ final class NewsArticleCommentService
|
||||
'status' => 'visible',
|
||||
]);
|
||||
|
||||
$this->news->invalidatePublicCache();
|
||||
|
||||
return $comment->fresh(['user.profile', 'replies.user.profile']);
|
||||
}
|
||||
|
||||
@@ -60,6 +66,8 @@ final class NewsArticleCommentService
|
||||
}
|
||||
|
||||
$comment->delete();
|
||||
|
||||
$this->news->invalidatePublicCache();
|
||||
}
|
||||
|
||||
private function canDelete(NewsArticleComment $comment, NewsArticle $article, User $actor): bool
|
||||
|
||||
@@ -11,11 +11,15 @@ use App\Models\GroupChallenge;
|
||||
use App\Models\GroupEvent;
|
||||
use App\Models\GroupProject;
|
||||
use App\Models\GroupRelease;
|
||||
use App\Models\NewsArticleComment;
|
||||
use App\Models\User;
|
||||
use App\Support\News\NewsCoverImage;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
@@ -25,6 +29,8 @@ use cPad\Plugins\News\Models\NewsTag;
|
||||
|
||||
final class NewsService
|
||||
{
|
||||
private const PUBLIC_CACHE_VERSION_KEY = 'news.public.cache.version';
|
||||
|
||||
public const RELATION_GROUP = 'group';
|
||||
public const RELATION_ARTWORK = 'artwork';
|
||||
public const RELATION_COLLECTION = 'collection';
|
||||
@@ -45,6 +51,8 @@ final class NewsService
|
||||
self::RELATION_USER => 'Profile',
|
||||
];
|
||||
|
||||
private ?bool $artworkStatsViewsColumnExists = null;
|
||||
|
||||
public function articleTypeOptions(): array
|
||||
{
|
||||
return \collect(NewsArticle::TYPE_LABELS)
|
||||
@@ -98,15 +106,23 @@ final class NewsService
|
||||
|
||||
public function sidebarData(): array
|
||||
{
|
||||
return [
|
||||
'categories' => NewsCategory::active()->withCount('publishedArticles')->ordered()->get(),
|
||||
'trending' => NewsArticle::published()
|
||||
->with('category')
|
||||
->orderByDesc('views')
|
||||
->limit(config('news.trending_limit', 5))
|
||||
->get(['id', 'title', 'slug', 'views', 'published_at', 'category_id', 'type']),
|
||||
'tags' => NewsTag::whereHas('articles', fn ($query) => $query->published())->orderBy('name')->get(),
|
||||
];
|
||||
return Cache::remember($this->publicCacheKey('sidebar'), $this->publicCacheTtl(), function (): array {
|
||||
return [
|
||||
'categories' => NewsCategory::active()->withCount('publishedArticles')->ordered()->get(),
|
||||
'trending' => NewsArticle::published()
|
||||
->with('category')
|
||||
->orderByDesc('views')
|
||||
->limit(config('news.trending_limit', 5))
|
||||
->get(['id', 'title', 'slug', 'views', 'published_at', 'category_id', 'type']),
|
||||
'tags' => NewsTag::query()
|
||||
->whereHas('articles', fn ($query) => $query->published())
|
||||
->withCount(['articles as published_articles_count' => fn ($query) => $query->published()])
|
||||
->orderByDesc('published_articles_count')
|
||||
->orderBy('name')
|
||||
->limit((int) config('news.sidebar_tags_limit', 18))
|
||||
->get(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function studioListing(array $filters = []): array
|
||||
@@ -169,6 +185,9 @@ final class NewsService
|
||||
'content' => (string) ($article->content ?? ''),
|
||||
'cover_image' => (string) ($article->cover_image ?? ''),
|
||||
'cover_url' => $article->cover_url,
|
||||
'cover_mobile_url' => $article->cover_mobile_url,
|
||||
'cover_desktop_url' => $article->cover_desktop_url,
|
||||
'cover_srcset' => $article->cover_srcset,
|
||||
'type' => (string) ($article->type ?? NewsArticle::TYPE_ANNOUNCEMENT),
|
||||
'editorial_status' => (string) ($article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT),
|
||||
'published_at' => \optional($article->published_at)?->toIso8601String(),
|
||||
@@ -214,6 +233,8 @@ final class NewsService
|
||||
public function deleteArticle(NewsArticle $article): void
|
||||
{
|
||||
$article->delete();
|
||||
|
||||
$this->invalidatePublicCache();
|
||||
}
|
||||
|
||||
public function publish(NewsArticle $article): NewsArticle
|
||||
@@ -224,6 +245,8 @@ final class NewsService
|
||||
'published_at' => $article->published_at ?? \now(),
|
||||
])->save();
|
||||
|
||||
$this->invalidatePublicCache();
|
||||
|
||||
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
|
||||
}
|
||||
|
||||
@@ -234,6 +257,8 @@ final class NewsService
|
||||
'status' => 'draft',
|
||||
])->save();
|
||||
|
||||
$this->invalidatePublicCache();
|
||||
|
||||
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
|
||||
}
|
||||
|
||||
@@ -241,6 +266,8 @@ final class NewsService
|
||||
{
|
||||
$article->forceFill(['is_featured' => ! $article->is_featured])->save();
|
||||
|
||||
$this->invalidatePublicCache();
|
||||
|
||||
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
|
||||
}
|
||||
|
||||
@@ -248,6 +275,8 @@ final class NewsService
|
||||
{
|
||||
$article->forceFill(['is_pinned' => ! (bool) $article->is_pinned])->save();
|
||||
|
||||
$this->invalidatePublicCache();
|
||||
|
||||
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
|
||||
}
|
||||
|
||||
@@ -280,6 +309,61 @@ final class NewsService
|
||||
->all();
|
||||
}
|
||||
|
||||
public function publicArticleShowData(NewsArticle $article, ?User $viewer = null): array
|
||||
{
|
||||
if ($viewer !== null) {
|
||||
return $this->buildPublicArticleShowData($article, $viewer);
|
||||
}
|
||||
|
||||
return Cache::remember(
|
||||
$this->publicCacheKey('article.show.' . $article->id),
|
||||
$this->publicCacheTtl(),
|
||||
fn (): array => $this->buildPublicArticleShowData($article, null),
|
||||
);
|
||||
}
|
||||
|
||||
private function buildPublicArticleShowData(NewsArticle $article, ?User $viewer = null): array
|
||||
{
|
||||
$related = NewsArticle::with('author', 'category')
|
||||
->published()
|
||||
->when($article->category_id, fn ($query) => $query->where('category_id', $article->category_id))
|
||||
->where('id', '!=', $article->id)
|
||||
->editorialOrder()
|
||||
->limit(config('news.related_limit', 4))
|
||||
->get();
|
||||
|
||||
$comments = collect();
|
||||
$commentsCount = 0;
|
||||
|
||||
if ($article->commentsAreEnabled()) {
|
||||
$comments = NewsArticleComment::query()
|
||||
->where('article_id', $article->id)
|
||||
->whereNull('parent_id')
|
||||
->where('status', 'visible')
|
||||
->with(['user.profile'])
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$commentsCount = (int) NewsArticleComment::query()
|
||||
->where('article_id', $article->id)
|
||||
->where('status', 'visible')
|
||||
->count();
|
||||
}
|
||||
|
||||
return [
|
||||
'related' => $related,
|
||||
'relatedEntities' => $this->resolveRelatedEntities($article, $viewer),
|
||||
'comments' => $comments,
|
||||
'commentsCount' => $commentsCount,
|
||||
];
|
||||
}
|
||||
|
||||
public function invalidatePublicCache(): void
|
||||
{
|
||||
Cache::forever(self::PUBLIC_CACHE_VERSION_KEY, $this->publicCacheVersion() + 1);
|
||||
}
|
||||
|
||||
public function syncRelations(NewsArticle $article, array $relations): void
|
||||
{
|
||||
$normalized = \collect($relations)
|
||||
@@ -311,6 +395,23 @@ final class NewsService
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->invalidatePublicCache();
|
||||
}
|
||||
|
||||
private function publicCacheKey(string $suffix): string
|
||||
{
|
||||
return 'news.public.v' . $this->publicCacheVersion() . '.' . $suffix;
|
||||
}
|
||||
|
||||
private function publicCacheTtl(): int
|
||||
{
|
||||
return max(60, (int) config('news.public_cache_ttl', 120));
|
||||
}
|
||||
|
||||
private function publicCacheVersion(): int
|
||||
{
|
||||
return (int) Cache::get(self::PUBLIC_CACHE_VERSION_KEY, 1);
|
||||
}
|
||||
|
||||
private function persistArticle(NewsArticle $article, User $editor, array $data): NewsArticle
|
||||
@@ -362,6 +463,8 @@ final class NewsService
|
||||
$article->tags()->sync($this->resolveArticleTagIds($data));
|
||||
$this->syncRelations($article, $data['relations'] ?? []);
|
||||
|
||||
$this->invalidatePublicCache();
|
||||
|
||||
return $article->fresh(['author.profile', 'category', 'tags', 'relatedEntities']);
|
||||
}
|
||||
|
||||
@@ -376,6 +479,7 @@ final class NewsService
|
||||
'editorial_status' => (string) ($article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT),
|
||||
'published_at' => \optional($article->published_at)?->toIso8601String(),
|
||||
'cover_url' => $article->cover_url,
|
||||
'cover_srcset' => $article->cover_srcset,
|
||||
'author_name' => (string) ($article->author?->name ?? 'Skinbase'),
|
||||
'category_name' => (string) ($article->category?->name ?? ''),
|
||||
'is_featured' => (bool) $article->is_featured,
|
||||
@@ -485,13 +589,13 @@ final class NewsService
|
||||
|
||||
private function deleteManagedCoverImage(string $path): void
|
||||
{
|
||||
$trimmed = ltrim(trim($path), '/');
|
||||
$paths = NewsCoverImage::managedPaths($path);
|
||||
|
||||
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
|
||||
if ($paths === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk((string) config('uploads.object_storage.disk', 's3'))->delete($trimmed);
|
||||
Storage::disk((string) config('uploads.object_storage.disk', 's3'))->delete($paths);
|
||||
}
|
||||
|
||||
private function searchGroups(string $query, ?User $viewer): array
|
||||
@@ -517,8 +621,9 @@ final class NewsService
|
||||
|
||||
private function searchArtworks(string $query): array
|
||||
{
|
||||
return Artwork::query()
|
||||
$queryBuilder = Artwork::query()
|
||||
->with(['user.profile'])
|
||||
->select('artworks.*')
|
||||
->where('artwork_status', 'published')
|
||||
->where('visibility', Artwork::VISIBILITY_PUBLIC)
|
||||
->when($query !== '', function (Builder $builder) use ($query): void {
|
||||
@@ -528,8 +633,17 @@ final class NewsService
|
||||
->orWhere('description', 'like', '%' . $query . '%');
|
||||
});
|
||||
})
|
||||
->orderByDesc('views')
|
||||
->limit(8)
|
||||
->limit(8);
|
||||
|
||||
if ($this->artworkStatsViewsAvailable()) {
|
||||
$queryBuilder->leftJoin('artwork_stats as stats', 'stats.artwork_id', '=', 'artworks.id')
|
||||
->orderByRaw('COALESCE(stats.views, 0) DESC');
|
||||
} else {
|
||||
$queryBuilder->orderByDesc('published_at');
|
||||
}
|
||||
|
||||
return $queryBuilder
|
||||
->orderByDesc('artworks.id')
|
||||
->get()
|
||||
->map(fn (Artwork $artwork): ?array => $this->resolveArtworkPreview((int) $artwork->id, ''))
|
||||
->filter()
|
||||
@@ -537,6 +651,16 @@ final class NewsService
|
||||
->all();
|
||||
}
|
||||
|
||||
private function artworkStatsViewsAvailable(): bool
|
||||
{
|
||||
if ($this->artworkStatsViewsColumnExists === null) {
|
||||
$this->artworkStatsViewsColumnExists = Schema::hasTable('artwork_stats')
|
||||
&& Schema::hasColumn('artwork_stats', 'views');
|
||||
}
|
||||
|
||||
return $this->artworkStatsViewsColumnExists;
|
||||
}
|
||||
|
||||
private function searchCollections(string $query, ?User $viewer): array
|
||||
{
|
||||
return Collection::query()
|
||||
|
||||
@@ -7,6 +7,10 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TurnstileCaptchaProvider implements CaptchaProviderInterface
|
||||
{
|
||||
private const DEFAULT_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
private const DEFAULT_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'turnstile';
|
||||
@@ -14,7 +18,7 @@ class TurnstileCaptchaProvider implements CaptchaProviderInterface
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (bool) config('registration.enable_turnstile', true)
|
||||
return (bool) config('services.turnstile.enabled', false)
|
||||
&& $this->siteKey() !== ''
|
||||
&& (string) config('services.turnstile.secret_key', '') !== '';
|
||||
}
|
||||
@@ -31,7 +35,7 @@ class TurnstileCaptchaProvider implements CaptchaProviderInterface
|
||||
|
||||
public function scriptUrl(): string
|
||||
{
|
||||
return (string) config('services.turnstile.script_url', 'https://challenges.cloudflare.com/turnstile/v0/api.js');
|
||||
return (string) config('services.turnstile.script_url', self::DEFAULT_SCRIPT_URL);
|
||||
}
|
||||
|
||||
public function verify(string $token, ?string $ip = null): bool
|
||||
@@ -47,23 +51,39 @@ class TurnstileCaptchaProvider implements CaptchaProviderInterface
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->timeout((int) config('services.turnstile.timeout', 5))
|
||||
->post((string) config('services.turnstile.verify_url', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), [
|
||||
->post((string) config('services.turnstile.verify_url', self::DEFAULT_VERIFY_URL), [
|
||||
'secret' => (string) config('services.turnstile.secret_key', ''),
|
||||
'response' => $token,
|
||||
'remoteip' => $ip,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::info('turnstile verification rejected registration attempt', [
|
||||
'ip' => $ip,
|
||||
'hostname' => data_get($response->json(), 'hostname'),
|
||||
'error_codes' => data_get($response->json(), 'error-codes', []),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) data_get($response->json(), 'success', false);
|
||||
$success = (bool) data_get($response->json(), 'success', false);
|
||||
|
||||
if (! $success) {
|
||||
Log::info('turnstile verification rejected registration attempt', [
|
||||
'ip' => $ip,
|
||||
'hostname' => data_get($response->json(), 'hostname'),
|
||||
'error_codes' => data_get($response->json(), 'error-codes', []),
|
||||
]);
|
||||
}
|
||||
|
||||
return $success;
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('turnstile verification request failed', [
|
||||
'message' => $exception->getMessage(),
|
||||
'ip' => $ip,
|
||||
]);
|
||||
|
||||
return false;
|
||||
return (bool) config('services.turnstile.fail_open', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,32 @@
|
||||
|
||||
namespace App\Services\Security;
|
||||
|
||||
use App\Services\Security\Captcha\TurnstileCaptchaProvider;
|
||||
|
||||
class TurnstileVerifier
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CaptchaVerifier $captchaVerifier,
|
||||
private readonly TurnstileCaptchaProvider $turnstileProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->captchaVerifier->provider() === 'turnstile'
|
||||
&& $this->captchaVerifier->isEnabled();
|
||||
return $this->turnstileProvider->isEnabled();
|
||||
}
|
||||
|
||||
public function siteKey(): string
|
||||
{
|
||||
return $this->turnstileProvider->siteKey();
|
||||
}
|
||||
|
||||
public function scriptUrl(): string
|
||||
{
|
||||
return $this->turnstileProvider->scriptUrl();
|
||||
}
|
||||
|
||||
public function verify(string $token, ?string $ip = null): bool
|
||||
{
|
||||
return $this->captchaVerifier->verify($token, $ip);
|
||||
return $this->turnstileProvider->verify($token, $ip);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,14 +183,32 @@ final class UploadDerivativesService
|
||||
* @return array<string, array{path: string, size: int, mime: string}>
|
||||
*/
|
||||
public function generatePublicDerivatives(string $sourcePath, string $hash): array
|
||||
{
|
||||
return $this->generateSelectedPublicDerivatives(
|
||||
$sourcePath,
|
||||
$hash,
|
||||
array_keys((array) config('uploads.derivatives', [])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $variants
|
||||
* @return array<string, array{path: string, size: int, mime: string}>
|
||||
*/
|
||||
public function generateSelectedPublicDerivatives(string $sourcePath, string $hash, array $variants): array
|
||||
{
|
||||
$this->assertImageAvailable();
|
||||
$quality = (int) config('uploads.quality', 85);
|
||||
$variants = (array) config('uploads.derivatives', []);
|
||||
$configuredVariants = (array) config('uploads.derivatives', []);
|
||||
$written = [];
|
||||
|
||||
foreach ($variants as $variant => $options) {
|
||||
$variant = (string) $variant;
|
||||
foreach ($variants as $variant) {
|
||||
$variant = strtolower(trim((string) $variant));
|
||||
if ($variant === '' || ! array_key_exists($variant, $configuredVariants)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options = (array) $configuredVariants[$variant];
|
||||
|
||||
if ($variant === 'sq') {
|
||||
$written[$variant] = $this->generateSquareDerivative($sourcePath, $hash);
|
||||
|
||||
@@ -36,6 +36,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\RedirectLegacyProfileSubdomain::class,
|
||||
\App\Http\Middleware\TrackOnlineVisitor::class,
|
||||
\App\Http\Middleware\UpdateLastVisit::class,
|
||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||
// Runs on every web request; no-ops for guests, redirects authenticated
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
import { r as reactExports, a as reactDomExports, R as React } from "./vendor-tiptap-BUUKoc3C.js";
|
||||
import { S as ShareToast } from "../ssr.js";
|
||||
import "util";
|
||||
import "stream";
|
||||
import "path";
|
||||
import "http";
|
||||
import "https";
|
||||
import "url";
|
||||
import "fs";
|
||||
import "crypto";
|
||||
import "http2";
|
||||
import "assert";
|
||||
import "tty";
|
||||
import "os";
|
||||
import "zlib";
|
||||
import "events";
|
||||
import "node:process";
|
||||
import "node:path";
|
||||
import "node:url";
|
||||
import "./vendor-tooltip-CIQaDNlG.js";
|
||||
import "./vendor-realtime-BGlcW0gB.js";
|
||||
import "buffer";
|
||||
import "child_process";
|
||||
import "net";
|
||||
import "tls";
|
||||
import "./vendor-motion-Dg7DlHqj.js";
|
||||
import "process";
|
||||
import "async_hooks";
|
||||
const FeedShareArtworkModal = reactExports.lazy(() => import("../ssr.js").then((n) => n.a));
|
||||
function facebookUrl(url) {
|
||||
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`;
|
||||
}
|
||||
function twitterUrl(url, title) {
|
||||
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`;
|
||||
}
|
||||
function pinterestUrl(url, imageUrl, title) {
|
||||
return `https://pinterest.com/pin/create/button/?url=${encodeURIComponent(url)}&media=${encodeURIComponent(imageUrl)}&description=${encodeURIComponent(title)}`;
|
||||
}
|
||||
function emailUrl(url, title) {
|
||||
return `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(url)}`;
|
||||
}
|
||||
function CopyIcon() {
|
||||
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" }));
|
||||
}
|
||||
function CheckIcon() {
|
||||
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 20 20", fill: "currentColor", className: "h-5 w-5 text-emerald-400" }, /* @__PURE__ */ React.createElement("path", { fillRule: "evenodd", d: "M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z", clipRule: "evenodd" }));
|
||||
}
|
||||
function FacebookIcon() {
|
||||
return /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 24 24", fill: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { d: "M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12Z" }));
|
||||
}
|
||||
function XTwitterIcon() {
|
||||
return /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 24 24", fill: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { d: "M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77Z" }));
|
||||
}
|
||||
function PinterestIcon() {
|
||||
return /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 24 24", fill: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { d: "M12 2C6.477 2 2 6.477 2 12c0 4.236 2.636 7.855 6.356 9.312-.088-.791-.167-2.005.035-2.868.181-.78 1.172-4.97 1.172-4.97s-.299-.598-.299-1.482c0-1.388.806-2.425 1.808-2.425.853 0 1.265.64 1.265 1.408 0 .858-.546 2.14-.828 3.33-.236.995.5 1.807 1.482 1.807 1.778 0 3.144-1.874 3.144-4.58 0-2.393-1.72-4.068-4.177-4.068-2.845 0-4.515 2.135-4.515 4.34 0 .859.331 1.781.745 2.282a.3.3 0 0 1 .069.288l-.278 1.133c-.044.183-.145.222-.335.134-1.249-.581-2.03-2.407-2.03-3.874 0-3.154 2.292-6.052 6.608-6.052 3.469 0 6.165 2.472 6.165 5.776 0 3.447-2.173 6.22-5.19 6.22-1.013 0-1.965-.527-2.291-1.148l-.623 2.378c-.226.869-.835 1.958-1.244 2.621.937.29 1.931.446 2.962.446 5.523 0 10-4.477 10-10S17.523 2 12 2Z" }));
|
||||
}
|
||||
function EmailIcon() {
|
||||
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" }));
|
||||
}
|
||||
function EmbedIcon() {
|
||||
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" }));
|
||||
}
|
||||
function CloseIcon() {
|
||||
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 2, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M6 18 18 6M6 6l12 12" }));
|
||||
}
|
||||
function openShareWindow(url) {
|
||||
window.open(url, "_blank", "noopener,noreferrer,width=600,height=500");
|
||||
}
|
||||
function trackShare(artworkId, platform) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content");
|
||||
fetch(`/api/artworks/${artworkId}/share`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": csrfToken || "" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ platform })
|
||||
}).catch(() => {
|
||||
});
|
||||
}
|
||||
function ArtworkShareModal({ open, onClose, artwork, shareUrl, isLoggedIn = false }) {
|
||||
const backdropRef = reactExports.useRef(null);
|
||||
const [linkCopied, setLinkCopied] = reactExports.useState(false);
|
||||
const [embedCopied, setEmbedCopied] = reactExports.useState(false);
|
||||
const [showEmbed, setShowEmbed] = reactExports.useState(false);
|
||||
const [toastVisible, setToastVisible] = reactExports.useState(false);
|
||||
const [toastMessage, setToastMessage] = reactExports.useState("");
|
||||
const [profileShareOpen, setProfileShareOpen] = reactExports.useState(false);
|
||||
const url = shareUrl || artwork?.canonical_url || (typeof window !== "undefined" ? window.location.href : "#");
|
||||
const title = artwork?.title || "Artwork";
|
||||
const imageUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.thumbs?.md?.url || "";
|
||||
const thumbMdUrl = artwork?.thumbs?.md?.url || imageUrl;
|
||||
const embedCode = `<a href="${url}">
|
||||
<img src="${thumbMdUrl}" alt="${title.replace(/"/g, """)}" />
|
||||
</a>`;
|
||||
reactExports.useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}
|
||||
}, [open]);
|
||||
reactExports.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
reactExports.useEffect(() => {
|
||||
if (open) {
|
||||
setLinkCopied(false);
|
||||
setEmbedCopied(false);
|
||||
setShowEmbed(false);
|
||||
}
|
||||
}, [open]);
|
||||
const showToast = reactExports.useCallback((msg) => {
|
||||
setToastMessage(msg);
|
||||
setToastVisible(true);
|
||||
}, []);
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setLinkCopied(true);
|
||||
showToast("Link copied!");
|
||||
trackShare(artwork?.id, "copy");
|
||||
setTimeout(() => setLinkCopied(false), 2500);
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
const handleCopyEmbed = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(embedCode);
|
||||
setEmbedCopied(true);
|
||||
showToast("Embed code copied!");
|
||||
trackShare(artwork?.id, "embed");
|
||||
setTimeout(() => setEmbedCopied(false), 2500);
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
const handlePlatformShare = (platform, shareLink) => {
|
||||
openShareWindow(shareLink);
|
||||
trackShare(artwork?.id, platform);
|
||||
onClose();
|
||||
};
|
||||
if (!open) return null;
|
||||
const SHARE_OPTIONS = [
|
||||
{
|
||||
label: linkCopied ? "Copied!" : "Copy Link",
|
||||
icon: linkCopied ? /* @__PURE__ */ React.createElement(CheckIcon, null) : /* @__PURE__ */ React.createElement(CopyIcon, null),
|
||||
onClick: handleCopyLink,
|
||||
className: linkCopied ? "border-emerald-500/40 bg-emerald-500/15 text-emerald-400" : "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
|
||||
},
|
||||
{
|
||||
label: "Facebook",
|
||||
icon: /* @__PURE__ */ React.createElement(FacebookIcon, null),
|
||||
onClick: () => handlePlatformShare("facebook", facebookUrl(url)),
|
||||
className: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-[#1877F2]/40 hover:bg-[#1877F2]/15 hover:text-[#1877F2]"
|
||||
},
|
||||
{
|
||||
label: "X (Twitter)",
|
||||
icon: /* @__PURE__ */ React.createElement(XTwitterIcon, null),
|
||||
onClick: () => handlePlatformShare("twitter", twitterUrl(url, title)),
|
||||
className: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/30 hover:bg-white/[0.10] hover:text-white"
|
||||
},
|
||||
{
|
||||
label: "Pinterest",
|
||||
icon: /* @__PURE__ */ React.createElement(PinterestIcon, null),
|
||||
onClick: () => handlePlatformShare("pinterest", pinterestUrl(url, imageUrl, title)),
|
||||
className: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-[#E60023]/40 hover:bg-[#E60023]/15 hover:text-[#E60023]"
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
icon: /* @__PURE__ */ React.createElement(EmailIcon, null),
|
||||
onClick: () => {
|
||||
window.location.href = emailUrl(url, title);
|
||||
trackShare(artwork?.id, "email");
|
||||
},
|
||||
className: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
|
||||
},
|
||||
...isLoggedIn ? [{
|
||||
label: "My Profile",
|
||||
icon: /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-share-nodes h-5 w-5 text-[1.1rem]" }),
|
||||
onClick: () => setProfileShareOpen(true),
|
||||
className: "border-sky-500/30 bg-sky-500/10 text-sky-400 hover:border-sky-400/50 hover:bg-sky-500/20"
|
||||
}] : []
|
||||
];
|
||||
return reactDomExports.createPortal(
|
||||
/* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
|
||||
"div",
|
||||
{
|
||||
ref: backdropRef,
|
||||
onClick: (e) => {
|
||||
if (e.target === backdropRef.current) onClose();
|
||||
},
|
||||
className: "fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4",
|
||||
role: "dialog",
|
||||
"aria-modal": "true",
|
||||
"aria-label": "Share this artwork"
|
||||
},
|
||||
/* @__PURE__ */ React.createElement("div", { className: "w-full max-w-md rounded-2xl border border-nova-700/50 bg-nova-900/80 shadow-2xl backdrop-blur-xl" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between border-b border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement("h3", { className: "text-base font-semibold text-white" }, "Share this artwork"), /* @__PURE__ */ React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
onClick: onClose,
|
||||
className: "rounded-lg p-1.5 text-white/40 transition hover:bg-white/[0.06] hover:text-white/70",
|
||||
"aria-label": "Close share dialog"
|
||||
},
|
||||
/* @__PURE__ */ React.createElement(CloseIcon, null)
|
||||
)), thumbMdUrl && /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 border-b border-white/[0.06] px-6 py-3" }, /* @__PURE__ */ React.createElement(
|
||||
"img",
|
||||
{
|
||||
src: thumbMdUrl,
|
||||
alt: title,
|
||||
className: "h-14 w-14 rounded-lg object-cover",
|
||||
loading: "lazy"
|
||||
}
|
||||
), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("p", { className: "truncate text-sm font-medium text-white" }, title), artwork?.user?.username && /* @__PURE__ */ React.createElement("p", { className: "truncate text-xs text-white/50" }, "by ", artwork.user.username))), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-3 gap-2.5 px-6 py-5 sm:grid-cols-5" }, SHARE_OPTIONS.map((opt) => /* @__PURE__ */ React.createElement(
|
||||
"button",
|
||||
{
|
||||
key: opt.label,
|
||||
type: "button",
|
||||
onClick: opt.onClick,
|
||||
className: [
|
||||
"flex flex-col items-center gap-1.5 rounded-xl border px-2 py-3 text-xs font-medium transition-all duration-200",
|
||||
opt.className
|
||||
].join(" ")
|
||||
},
|
||||
opt.icon,
|
||||
/* @__PURE__ */ React.createElement("span", { className: "truncate" }, opt.label)
|
||||
))), /* @__PURE__ */ React.createElement("div", { className: "border-t border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
onClick: () => setShowEmbed(!showEmbed),
|
||||
className: "flex items-center gap-2 text-sm font-medium text-white/60 transition hover:text-white/80"
|
||||
},
|
||||
/* @__PURE__ */ React.createElement(EmbedIcon, null),
|
||||
showEmbed ? "Hide Embed Code" : "Embed Code",
|
||||
/* @__PURE__ */ React.createElement(
|
||||
"svg",
|
||||
{
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
viewBox: "0 0 16 16",
|
||||
fill: "currentColor",
|
||||
className: `h-3.5 w-3.5 transition-transform duration-200 ${showEmbed ? "rotate-180" : ""}`
|
||||
},
|
||||
/* @__PURE__ */ React.createElement("path", { fillRule: "evenodd", d: "M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z", clipRule: "evenodd" })
|
||||
)
|
||||
), showEmbed && /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2" }, /* @__PURE__ */ React.createElement(
|
||||
"textarea",
|
||||
{
|
||||
readOnly: true,
|
||||
value: embedCode,
|
||||
rows: 3,
|
||||
className: "w-full resize-none rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-3 font-mono text-xs text-white/70 outline-none focus:border-white/[0.15]",
|
||||
onClick: (e) => e.target.select()
|
||||
}
|
||||
), /* @__PURE__ */ React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
onClick: handleCopyEmbed,
|
||||
className: [
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 text-xs font-medium transition-all duration-200",
|
||||
embedCopied ? "border-emerald-500/40 bg-emerald-500/15 text-emerald-400" : "border-white/[0.08] bg-white/[0.04] text-white/60 hover:border-white/[0.15] hover:text-white/80"
|
||||
].join(" ")
|
||||
},
|
||||
embedCopied ? /* @__PURE__ */ React.createElement(CheckIcon, null) : /* @__PURE__ */ React.createElement(CopyIcon, null),
|
||||
embedCopied ? "Copied!" : "Copy Embed"
|
||||
))))
|
||||
), /* @__PURE__ */ React.createElement(
|
||||
ShareToast,
|
||||
{
|
||||
message: toastMessage,
|
||||
visible: toastVisible,
|
||||
onHide: () => setToastVisible(false)
|
||||
}
|
||||
), profileShareOpen && /* @__PURE__ */ React.createElement(reactExports.Suspense, { fallback: null }, /* @__PURE__ */ React.createElement(
|
||||
FeedShareArtworkModal,
|
||||
{
|
||||
isOpen: profileShareOpen,
|
||||
onClose: () => setProfileShareOpen(false),
|
||||
preselectedArtwork: artwork?.id ? {
|
||||
id: artwork.id,
|
||||
title: artwork.title,
|
||||
thumb_url: artwork.thumbs?.md?.url ?? artwork.thumbs?.lg?.url ?? null,
|
||||
user: artwork.user ?? null
|
||||
} : null,
|
||||
onShared: () => {
|
||||
setProfileShareOpen(false);
|
||||
showToast("Shared to your profile!");
|
||||
}
|
||||
}
|
||||
))),
|
||||
document.body
|
||||
);
|
||||
}
|
||||
export {
|
||||
ArtworkShareModal as default
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
8110
bootstrap/ssr/ssr.js
8110
bootstrap/ssr/ssr.js
File diff suppressed because one or more lines are too long
@@ -209,7 +209,10 @@ return [
|
||||
'maxJobs' => 0,
|
||||
'memory' => 128,
|
||||
'tries' => 1,
|
||||
'timeout' => 60,
|
||||
// Long-running recommendation rebuild jobs declare timeouts up to 900s.
|
||||
// Keep the worker timeout above that ceiling so Horizon does not kill
|
||||
// healthy jobs before Laravel can enforce the job-level timeout.
|
||||
'timeout' => 960,
|
||||
'nice' => 0,
|
||||
],
|
||||
'supervisor-messaging' => [
|
||||
|
||||
@@ -6,7 +6,7 @@ return [
|
||||
'email_per_minute_limit' => (int) env('REGISTRATION_EMAIL_PER_MINUTE_LIMIT', 6),
|
||||
'email_cooldown_minutes' => (int) env('REGISTRATION_EMAIL_COOLDOWN_MINUTES', 30),
|
||||
'verify_token_ttl_hours' => (int) env('REGISTRATION_VERIFY_TOKEN_TTL_HOURS', 24),
|
||||
'enable_turnstile' => (bool) env('REGISTRATION_ENABLE_TURNSTILE', true),
|
||||
'enable_turnstile' => (bool) env('TURNSTILE_ENABLED', env('REGISTRATION_ENABLE_TURNSTILE', false)),
|
||||
'disposable_domains_enabled' => (bool) env('REGISTRATION_DISPOSABLE_DOMAINS_ENABLED', true),
|
||||
'turnstile_suspicious_attempts' => (int) env('REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS', 2),
|
||||
'turnstile_attempt_window_minutes' => (int) env('REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES', 30),
|
||||
|
||||
@@ -58,8 +58,10 @@ return [
|
||||
],
|
||||
|
||||
'turnstile' => [
|
||||
'enabled' => env('TURNSTILE_ENABLED', false),
|
||||
'site_key' => env('TURNSTILE_SITE_KEY'),
|
||||
'secret_key' => env('TURNSTILE_SECRET_KEY'),
|
||||
'fail_open' => env('TURNSTILE_FAIL_OPEN', false),
|
||||
'script_url' => env('TURNSTILE_SCRIPT_URL', 'https://challenges.cloudflare.com/turnstile/v0/api.js'),
|
||||
'verify_url' => env('TURNSTILE_VERIFY_URL', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'),
|
||||
'timeout' => (int) env('TURNSTILE_TIMEOUT', 5),
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\AcademyBadge;
|
||||
use App\Models\AcademyCategory;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonBlock;
|
||||
use App\Models\AcademyPromptPack;
|
||||
use App\Models\AcademyPromptPackItem;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
@@ -114,7 +115,7 @@ class AcademyDemoSeeder extends Seeder
|
||||
];
|
||||
|
||||
foreach ($lessons as $index => $lesson) {
|
||||
AcademyLesson::query()->updateOrCreate(
|
||||
$academyLesson = AcademyLesson::query()->updateOrCreate(
|
||||
['slug' => $lesson['slug']],
|
||||
[
|
||||
'category_id' => $categories->get($lesson['category_slug'])?->id,
|
||||
@@ -129,6 +130,35 @@ class AcademyDemoSeeder extends Seeder
|
||||
'published_at' => now()->subDays(8 - $index),
|
||||
],
|
||||
);
|
||||
|
||||
if ($lesson['slug'] === 'what-is-ai-assisted-digital-art') {
|
||||
AcademyLessonBlock::query()->updateOrCreate(
|
||||
[
|
||||
'lesson_id' => $academyLesson->id,
|
||||
'type' => 'ai_comparison',
|
||||
'sort_order' => 0,
|
||||
],
|
||||
[
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'payload' => [
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'intro' => 'We used the same fantasy forest prompt in different AI image tools to compare how each model handles mood, composition, detail, lighting, and wallpaper quality.',
|
||||
'prompt' => 'A peaceful fantasy forest wallpaper, glowing blue flowers, soft morning light, gentle mist, wide cinematic composition, detailed digital painting, calm mood, high-resolution wallpaper, no text, no watermark.',
|
||||
'negative_prompt' => 'text, watermark, blurry pixels, distorted objects, bad anatomy, modern UI',
|
||||
'aspect_ratio' => '16:9',
|
||||
'criteria' => [
|
||||
'Composition',
|
||||
'Lighting',
|
||||
'Wallpaper quality',
|
||||
'Prompt accuracy',
|
||||
'Detail quality',
|
||||
'Beginner friendliness',
|
||||
],
|
||||
],
|
||||
'active' => true,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$prompts = [
|
||||
@@ -419,4 +449,4 @@ class AcademyDemoSeeder extends Seeder
|
||||
|
||||
app(AcademyCacheService::class)->clearAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
274
package-lock.json
generated
274
package-lock.json
generated
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@inertiajs/core": "^1.0.4",
|
||||
"@inertiajs/react": "^1.0.4",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.20.0",
|
||||
@@ -13,6 +14,10 @@
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-mention": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.20.0",
|
||||
"@tiptap/extension-table": "^3.22.5",
|
||||
"@tiptap/extension-table-cell": "^3.22.5",
|
||||
"@tiptap/extension-table-header": "^3.22.5",
|
||||
"@tiptap/extension-table-row": "^3.22.5",
|
||||
"@tiptap/extension-underline": "^3.20.0",
|
||||
"@tiptap/react": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.0",
|
||||
@@ -680,17 +685,26 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@inertiajs/core": {
|
||||
"version": "1.3.0",
|
||||
@@ -1141,12 +1155,6 @@
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@remirror/core-constants": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||
@@ -1866,9 +1874,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.1.tgz",
|
||||
"integrity": "sha512-6wPNhkdLIGYiKAGqepDCRtR0TYGJxV40SwOEN2vlPhsXqAgzmyG37UyREj5pGH5xTekugqMCgCnyRg7m5nYoYQ==",
|
||||
"version": "3.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
|
||||
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
@@ -1876,7 +1884,7 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "^3.22.1"
|
||||
"@tiptap/pm": "3.22.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
@@ -2227,6 +2235,60 @@
|
||||
"@tiptap/core": "^3.22.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "3.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.5.tgz",
|
||||
"integrity": "sha512-GMBM07bCwzHx1NK08zXRr2mNTDnP78Hd0VxFsRBIDFddDMZ2qG5jhwKHXN5cHMTrdWokWFUjvnEeJeV3guHoGg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.22.5",
|
||||
"@tiptap/pm": "3.22.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-cell": {
|
||||
"version": "3.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.22.5.tgz",
|
||||
"integrity": "sha512-Wn4asCgNLfOPH5EOpiMjzOJXTZvv+TTqUT+gzm2fV69ZkleCGNO0BZwuR/TCIDLGIArbvHzyYy2/lJAfG4UCtg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-table": "3.22.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-header": {
|
||||
"version": "3.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.22.5.tgz",
|
||||
"integrity": "sha512-aJmbgbO6QbSj0Rw3X4ogGPyd+8FwP6RgG71Dpa3NovzVkqJc3ZUq0wC3XH48U9Hd89F8f4AggFgHjU6/kQAgQQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-table": "3.22.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-row": {
|
||||
"version": "3.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.22.5.tgz",
|
||||
"integrity": "sha512-9A2BdX+R+P71f192Fo74OttMHj1WoFVO0ezaCzFbT8uNVG3nCJ7B5/1UkTlzqDdGOuWh1VpR63pFZP9LFsUv6A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-table": "3.22.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.1.tgz",
|
||||
@@ -2269,28 +2331,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.1.tgz",
|
||||
"integrity": "sha512-OSqSg2974eLJT5PNKFLM7156lBXCUf/dsKTQXWSzsLTf6HOP4dYP6c0YbAk6lgbNI+BdszsHNClmLVLA8H/L9A==",
|
||||
"version": "3.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
|
||||
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.1",
|
||||
"prosemirror-menu": "^1.2.4",
|
||||
"prosemirror-model": "^1.24.1",
|
||||
"prosemirror-schema-basic": "^1.2.3",
|
||||
"prosemirror-schema-list": "^1.5.0",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-trailing-node": "^3.0.0",
|
||||
"prosemirror-transform": "^1.10.2",
|
||||
"prosemirror-view": "^1.38.1"
|
||||
},
|
||||
@@ -2417,22 +2473,6 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
@@ -2442,12 +2482,6 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
@@ -2682,12 +2716,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
@@ -3131,12 +3159,6 @@
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -3517,18 +3539,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-util-is-identifier-name": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
|
||||
@@ -4509,15 +4519,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||
@@ -4591,35 +4592,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -4782,12 +4754,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -5772,15 +5738,6 @@
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-collab": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
|
||||
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||
@@ -5827,16 +5784,6 @@
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
@@ -5847,48 +5794,15 @@
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-markdown": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
|
||||
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/markdown-it": "^14.0.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"prosemirror-model": "^1.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-menu": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz",
|
||||
"integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crelt": "^1.0.0",
|
||||
"prosemirror-commands": "^1.0.0",
|
||||
"prosemirror-history": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-basic": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
|
||||
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
@@ -5905,7 +5819,6 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
@@ -5925,21 +5838,6 @@
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-trailing-node": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
|
||||
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remirror/core-constants": "3.0.0",
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prosemirror-model": "^1.22.1",
|
||||
"prosemirror-state": "^1.4.2",
|
||||
"prosemirror-view": "^1.33.8"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||
@@ -5954,7 +5852,6 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
@@ -5980,15 +5877,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pusher-js": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
|
||||
@@ -6933,12 +6821,6 @@
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@inertiajs/core": "^1.0.4",
|
||||
"@inertiajs/react": "^1.0.4",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.20.0",
|
||||
@@ -41,6 +42,10 @@
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-mention": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.20.0",
|
||||
"@tiptap/extension-table": "^3.22.5",
|
||||
"@tiptap/extension-table-cell": "^3.22.5",
|
||||
"@tiptap/extension-table-header": "^3.22.5",
|
||||
"@tiptap/extension-table-row": "^3.22.5",
|
||||
"@tiptap/extension-underline": "^3.20.0",
|
||||
"@tiptap/react": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.0",
|
||||
|
||||
@@ -458,6 +458,420 @@
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror {
|
||||
color: rgb(226 232 240 / 0.92);
|
||||
font-family: 'Libre Franklin', 'Inter', sans-serif;
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror p {
|
||||
padding-top: 0.18rem;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror p:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror p + p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror h2,
|
||||
.news-rich-text-editor .ProseMirror h3,
|
||||
.news-rich-text-editor .ProseMirror h4,
|
||||
.news-rich-text-editor .ProseMirror h5,
|
||||
.news-rich-text-editor .ProseMirror h6 {
|
||||
color: rgb(255 255 255);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror h2 {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: clamp(1.65rem, 1.2rem + 1vw, 2rem);
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror h3 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: clamp(1.35rem, 1.05rem + 0.8vw, 1.6rem);
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror h4,
|
||||
.news-rich-text-editor .ProseMirror h5,
|
||||
.news-rich-text-editor .ProseMirror h6 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror ul,
|
||||
.news-rich-text-editor .ProseMirror ol {
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror li {
|
||||
color: rgb(255 255 255 / 0.72);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror li::marker {
|
||||
color: rgb(255 255 255 / 0.45);
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror blockquote {
|
||||
margin: 2rem 0;
|
||||
border-left: 3px solid rgb(14 165 233 / 0.7);
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
padding: 0.6rem 0 0.6rem 1.25rem;
|
||||
color: rgb(255 255 255 / 0.68);
|
||||
}
|
||||
|
||||
.news-rich-text-editor .ProseMirror hr {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-viewport {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border-bottom-left-radius: 0.75rem;
|
||||
border-bottom-right-radius: 0.75rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.rich-text-editor-viewport .ProseMirror {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.rich-text-editor-viewport .ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rich-text-editor-viewport .ProseMirror > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-viewport .ProseMirror > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-viewport::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.rich-text-editor-viewport::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.34);
|
||||
}
|
||||
|
||||
.rich-text-editor-viewport::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(56, 189, 248, 0.22), rgba(125, 211, 252, 0.35));
|
||||
border: 2px solid rgba(15, 23, 42, 0.48);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.rich-text-editor-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(56, 189, 248, 0.36), rgba(125, 211, 252, 0.5));
|
||||
}
|
||||
|
||||
.rich-text-editor-viewport::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tiptap .ProseMirror :is(p, div, h2, h3, hr) {
|
||||
margin-bottom: 1.05em;
|
||||
}
|
||||
|
||||
.tiptap .ProseMirror p {
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.tiptap .ProseMirror h2 {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.18;
|
||||
margin-top: 1.8em;
|
||||
margin-bottom: 0.95em;
|
||||
}
|
||||
|
||||
.tiptap .ProseMirror h3 {
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.22;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.85em;
|
||||
}
|
||||
|
||||
.tiptap .ProseMirror hr {
|
||||
margin-top: 1.75em;
|
||||
margin-bottom: 1.75em;
|
||||
}
|
||||
|
||||
.rich-image-node {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin: 1.75rem auto;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rich-image-node.is-selected .rich-image-node__frame {
|
||||
box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.45), 0 0 0 6px rgba(14, 165, 233, 0.12);
|
||||
}
|
||||
|
||||
.rich-image-node__frame {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.rich-image-node__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 1.25rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.rich-image-node__drag-handle,
|
||||
.rich-image-node__resize-handle {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(8, 12, 20, 0.9);
|
||||
color: rgb(241 245 249 / 0.95);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.38);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.rich-image-node__drag-handle {
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
}
|
||||
|
||||
.rich-image-node__resize-handle {
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.rich-image-node__caption {
|
||||
max-width: min(100%, 46rem);
|
||||
margin: 0 auto;
|
||||
color: rgb(148 163 184 / 0.9);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rich-image-node__editor {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 1.1rem;
|
||||
background: rgba(8, 12, 20, 0.92);
|
||||
}
|
||||
|
||||
.rich-image-node.is-selected .rich-image-node__editor {
|
||||
margin-inline: auto;
|
||||
width: min(100%, 52rem);
|
||||
}
|
||||
|
||||
.rich-compare-node {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
margin: 1.75rem auto;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rich-compare-node.is-selected {
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.rich-compare-node.is-selected .rich-compare-node__grid,
|
||||
.ProseMirror-selectednode.rich-compare-node .rich-compare-node__grid {
|
||||
box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.45), 0 0 0 6px rgba(14, 165, 233, 0.12);
|
||||
}
|
||||
|
||||
.rich-compare-node__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rich-compare-node__tile {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 12, 20, 0.92);
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.rich-compare-node__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.rich-compare-node__badge {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 0.75rem;
|
||||
z-index: 1;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(8, 12, 20, 0.92);
|
||||
color: rgb(226 232 240 / 0.9);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
line-height: 1;
|
||||
padding: 0.45rem 0.65rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rich-compare-node__subtitle {
|
||||
max-width: min(100%, 52rem);
|
||||
margin: 0 auto;
|
||||
color: rgb(148 163 184 / 0.9);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.7;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rich-compare-node__editor {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 1.1rem;
|
||||
background: rgba(8, 12, 20, 0.92);
|
||||
}
|
||||
|
||||
.rich-compare-node.is-selected .rich-compare-node__editor {
|
||||
margin-inline: auto;
|
||||
width: min(100%, 72rem);
|
||||
}
|
||||
|
||||
.ProseMirror-selectednode.rich-image-node,
|
||||
.rich-image-node.is-selected {
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.ProseMirror-selectednode.rich-image-node .rich-image-node__img {
|
||||
outline: 2px solid rgba(125, 211, 252, 0.55);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.tiptap .tableWrapper,
|
||||
.rich-text-editor-viewport .tableWrapper {
|
||||
margin: 1.5rem 0;
|
||||
overflow-x: auto;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid rgba(125, 211, 252, 0.3);
|
||||
border-radius: 1rem;
|
||||
background: rgba(8, 12, 20, 0.72);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 16px 42px rgba(2, 6, 23, 0.22);
|
||||
}
|
||||
|
||||
.tiptap table.rich-table,
|
||||
.rich-text-editor-viewport .tableWrapper table {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(125, 211, 252, 0.38);
|
||||
border-radius: 0.8rem;
|
||||
}
|
||||
|
||||
.tiptap table.rich-table th,
|
||||
.tiptap table.rich-table td,
|
||||
.rich-text-editor-viewport .tableWrapper th,
|
||||
.rich-text-editor-viewport .tableWrapper td {
|
||||
position: relative;
|
||||
min-width: 120px;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3) !important;
|
||||
vertical-align: top;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.tiptap table.rich-table th,
|
||||
.rich-text-editor-viewport .tableWrapper th {
|
||||
background: rgba(14, 165, 233, 0.16);
|
||||
color: rgb(241 245 249);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tiptap table.rich-table td p,
|
||||
.tiptap table.rich-table th p,
|
||||
.rich-text-editor-viewport .tableWrapper td p,
|
||||
.rich-text-editor-viewport .tableWrapper th p {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.tiptap table.rich-table .selectedCell,
|
||||
.rich-text-editor-viewport .tableWrapper .selectedCell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tiptap table.rich-table .selectedCell::after,
|
||||
.rich-text-editor-viewport .tableWrapper .selectedCell::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(56, 189, 248, 0.14);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiptap table.rich-table .column-resize-handle,
|
||||
.rich-text-editor-viewport .tableWrapper .column-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -2px;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: rgba(125, 211, 252, 0.82);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
|
||||
.tiptap table.rich-table tr:nth-child(even) td,
|
||||
.rich-text-editor-viewport .tableWrapper tr:nth-child(even) td {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
@@ -483,6 +897,73 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.forum-code-block {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin: 1.4rem 0;
|
||||
border: 1px solid rgba(56, 189, 248, 0.16);
|
||||
border-radius: 1.15rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.98) 0, rgba(15, 23, 42, 0.98) 3rem, rgba(2, 6, 23, 0.98) 3rem, rgba(2, 6, 23, 0.98) 100%);
|
||||
box-shadow:
|
||||
0 22px 58px rgba(2, 6, 23, 0.42),
|
||||
inset 0 1px 0 rgba(56, 189, 248, 0.1);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.14) transparent;
|
||||
}
|
||||
|
||||
.forum-code-block::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.forum-code-block::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.forum-code-block::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255,255,255,0.16);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.forum-code-block::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255,255,255,0.28);
|
||||
}
|
||||
|
||||
.forum-code-block code {
|
||||
display: block;
|
||||
min-width: max-content;
|
||||
padding: 3.75rem 1.25rem 1.25rem;
|
||||
color: rgb(226 232 240);
|
||||
font-family: 'JetBrains Mono', 'SFMono-Regular', 'Consolas', 'Liberation Mono', monospace;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.75;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.forum-code-block::before {
|
||||
content: 'Code';
|
||||
position: absolute;
|
||||
top: 0.82rem;
|
||||
left: 1.1rem;
|
||||
z-index: 1;
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
padding: 0.2rem 0.55rem;
|
||||
color: rgb(125 211 252);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.forum-code-block .story-code-copy-button {
|
||||
top: 0.78rem;
|
||||
right: 1.05rem;
|
||||
}
|
||||
|
||||
.tiptap pre code {
|
||||
display: block;
|
||||
background: none;
|
||||
@@ -621,7 +1102,8 @@
|
||||
|
||||
.story-prose pre {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border-color: rgba(51, 65, 85, 0.95) !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.98) 0, rgba(15, 23, 42, 0.98) 3rem, rgba(2, 6, 23, 0.98) 3rem, rgba(2, 6, 23, 0.98) 100%) !important;
|
||||
@@ -629,6 +1111,30 @@
|
||||
0 26px 75px rgba(2, 6, 23, 0.5),
|
||||
inset 0 1px 0 rgba(56, 189, 248, 0.08);
|
||||
padding: 4rem 1.5rem 1.5rem !important;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(125, 211, 252, 0.34) rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
|
||||
.story-prose pre::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.story-prose pre::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.story-prose pre::-webkit-scrollbar-thumb {
|
||||
border: 2px solid rgba(15, 23, 42, 0.28);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(56, 189, 248, 0.28), rgba(125, 211, 252, 0.48));
|
||||
}
|
||||
|
||||
.story-prose pre::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(90deg, rgba(56, 189, 248, 0.42), rgba(125, 211, 252, 0.58));
|
||||
}
|
||||
|
||||
.story-prose pre::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.story-prose p {
|
||||
@@ -737,12 +1243,14 @@
|
||||
.story-prose figure iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .news-embed,
|
||||
.story-prose .news-embed {
|
||||
width: 100%;
|
||||
margin: 1.75rem 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
@@ -798,10 +1306,377 @@
|
||||
.story-prose .news-embed-video iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 0;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.academy-lesson-prose {
|
||||
color: rgb(226 232 240 / 0.9);
|
||||
font-family: 'Libre Franklin', 'Inter', sans-serif;
|
||||
font-size: calc(clamp(0.88rem, 1.02rem + 0.28vw, 1.2rem) * var(--academy-lesson-font-scale, 1));
|
||||
line-height: 1.74;
|
||||
}
|
||||
|
||||
.academy-lesson-prose > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.academy-lesson-prose > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.academy-lesson-prose :is(p, li) {
|
||||
color: rgb(226 232 240 / 0.9);
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.academy-lesson-prose p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.0rem;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.academy-lesson-prose p + p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.academy-lesson-prose h2,
|
||||
.academy-lesson-prose h3 {
|
||||
position: relative;
|
||||
text-wrap: balance;
|
||||
scroll-margin-top: 7rem;
|
||||
}
|
||||
|
||||
.academy-lesson-prose h2 {
|
||||
margin-top: 3.35rem;
|
||||
margin-bottom: 1.05rem;
|
||||
font-size: clamp(2.08rem, 1.56rem + 1.12vw, 2.8rem);
|
||||
line-height: 1.06;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.045em;
|
||||
color: rgb(255 255 255);
|
||||
}
|
||||
|
||||
.academy-lesson-prose h2::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 3.25rem;
|
||||
height: 2px;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(125, 211, 252, 0.95), rgba(125, 211, 252, 0.12));
|
||||
}
|
||||
|
||||
.academy-lesson-prose h3 {
|
||||
margin-top: 2.45rem;
|
||||
margin-bottom: 0.9rem;
|
||||
font-size: clamp(1.48rem, 1.22rem + 0.66vw, 1.9rem);
|
||||
line-height: 1.18;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.03em;
|
||||
color: rgb(248 250 252);
|
||||
}
|
||||
|
||||
.academy-lesson-prose ul,
|
||||
.academy-lesson-prose ol {
|
||||
margin: 1rem 0 1.15rem;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.academy-lesson-prose ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.academy-lesson-prose ol {
|
||||
list-style: none;
|
||||
counter-reset: lesson-ordered-list;
|
||||
}
|
||||
|
||||
.academy-lesson-prose ul li,
|
||||
.academy-lesson-prose ol li {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding-left: 1.95rem;
|
||||
line-height: 1.64;
|
||||
}
|
||||
|
||||
.academy-lesson-prose ul li + li,
|
||||
.academy-lesson-prose ol li + li {
|
||||
margin-top: 0.14rem;
|
||||
}
|
||||
|
||||
.academy-lesson-prose ul li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.82em;
|
||||
left: 0.2rem;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(125, 211, 252, 0.95), rgba(56, 189, 248, 0.65));
|
||||
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
.academy-lesson-prose ol li::before {
|
||||
counter-increment: lesson-ordered-list;
|
||||
content: counter(lesson-ordered-list);
|
||||
position: absolute;
|
||||
top: 0.16rem;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border: 1px solid rgba(125, 211, 252, 0.24);
|
||||
border-radius: 999px;
|
||||
background: rgba(56, 189, 248, 0.09);
|
||||
color: rgb(224 242 254);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.academy-lesson-prose li > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.academy-lesson-prose li > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.academy-lesson-prose li > p + p {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.academy-lesson-prose hr {
|
||||
position: relative;
|
||||
height: 1px;
|
||||
margin: 2.9rem 0;
|
||||
border: 0;
|
||||
background: linear-gradient(90deg, rgba(148, 163, 184, 0), rgba(148, 163, 184, 0.34), rgba(148, 163, 184, 0));
|
||||
}
|
||||
|
||||
.academy-lesson-prose hr::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
border: 1px solid rgba(125, 211, 252, 0.3);
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
box-shadow: 0 0 0 8px rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
|
||||
.academy-lesson-prose table {
|
||||
width: 100%;
|
||||
margin: 2rem 0;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border: 1px solid rgba(125, 211, 252, 0.28);
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
background: rgba(8, 12, 20, 0.72);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 16px 42px rgba(2, 6, 23, 0.2);
|
||||
}
|
||||
|
||||
.academy-lesson-prose th,
|
||||
.academy-lesson-prose td {
|
||||
min-width: 120px;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
vertical-align: top;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.academy-lesson-prose th {
|
||||
background: rgba(14, 165, 233, 0.14);
|
||||
color: rgb(248 250 252);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.academy-lesson-prose td p,
|
||||
.academy-lesson-prose th p {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.academy-lesson-prose tbody tr:nth-child(even) td {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.academy-lesson-prose pre {
|
||||
margin: .4rem 0;
|
||||
padding: 0.5rem !important;
|
||||
border-color: rgba(56, 189, 248, 0.16) !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.98) 0, rgba(15, 23, 42, 0.98) 3rem, rgba(2, 6, 23, 0.98) 3rem, rgba(2, 6, 23, 0.98) 100%) !important;
|
||||
box-shadow:
|
||||
0 28px 78px rgba(2, 6, 23, 0.56),
|
||||
inset 0 1px 0 rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
|
||||
.academy-lesson-prose pre code,
|
||||
.academy-lesson-prose pre code.hljs,
|
||||
.academy-lesson-prose pre code[class*='language-'] {
|
||||
color: rgb(226 232 240);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.8;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
|
||||
.academy-lesson-prose pre[data-language]::before {
|
||||
top: 0.8rem;
|
||||
left: 1.2rem;
|
||||
}
|
||||
|
||||
.academy-lesson-prose > img,
|
||||
.academy-lesson-prose figure,
|
||||
.academy-lesson-prose video,
|
||||
.academy-lesson-prose iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 2.15rem 0;
|
||||
}
|
||||
|
||||
.academy-lesson-prose > img,
|
||||
.academy-lesson-prose figure img,
|
||||
.academy-lesson-prose video,
|
||||
.academy-lesson-prose iframe {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 1.5rem;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.94), rgba(2, 6, 23, 0.96));
|
||||
box-shadow: 0 24px 55px rgba(2, 6, 23, 0.34);
|
||||
}
|
||||
|
||||
.academy-lesson-prose iframe,
|
||||
.academy-lesson-prose video,
|
||||
.academy-lesson-prose figure iframe,
|
||||
.academy-lesson-prose figure video {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.academy-code-copy-button {
|
||||
top: 0.78rem;
|
||||
right: 1.1rem;
|
||||
}
|
||||
|
||||
.academy-lesson-prose figure {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 1.75rem;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.academy-lesson-prose figure img,
|
||||
.academy-lesson-prose figure iframe,
|
||||
.academy-lesson-prose figure video {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.academy-lesson-prose figcaption {
|
||||
padding: 0.9rem 0.45rem 0.25rem;
|
||||
color: rgb(148 163 184 / 0.92);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.7;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.academy-lesson-prose a {
|
||||
color: rgb(125 211 252);
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
|
||||
.academy-lesson-toc-link {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.8rem;
|
||||
border-radius: 1rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
color: rgb(226 232 240 / 0.88);
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.55;
|
||||
text-decoration: none;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.academy-lesson-toc-link:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgb(255 255 255);
|
||||
}
|
||||
|
||||
.academy-lesson-toc-link-active {
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
color: rgb(255 255 255);
|
||||
box-shadow: inset 0 0 0 1px rgba(125, 211, 252, 0.18);
|
||||
}
|
||||
|
||||
.academy-lesson-toc-link-active .academy-lesson-toc-link-indicator {
|
||||
background: linear-gradient(180deg, rgba(125, 211, 252, 1), rgba(56, 189, 248, 0.9));
|
||||
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12);
|
||||
}
|
||||
|
||||
.academy-lesson-toc-link-subtle {
|
||||
padding-left: 1.55rem;
|
||||
color: rgb(148 163 184 / 0.95);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.academy-lesson-toc-link-indicator {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
margin-top: 0.48rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(125, 211, 252, 0.95), rgba(56, 189, 248, 0.68));
|
||||
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
.academy-lesson-toc-link-subtle .academy-lesson-toc-link-indicator {
|
||||
width: 0.35rem;
|
||||
height: 0.35rem;
|
||||
margin-top: 0.56rem;
|
||||
background: rgba(148, 163, 184, 0.72);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.academy-lesson-prose strong {
|
||||
color: rgb(255 255 255);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.academy-lesson-prose em {
|
||||
color: rgb(226 232 240 / 0.94);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.academy-lesson-prose {
|
||||
font-size: calc(clamp(1.02rem, 0.98rem + 0.18vw, 1.1rem) * var(--academy-lesson-font-scale, 1));
|
||||
line-height: 1.78;
|
||||
}
|
||||
|
||||
.academy-lesson-prose h2 {
|
||||
margin-top: 2.8rem;
|
||||
}
|
||||
|
||||
.academy-lesson-prose h3 {
|
||||
margin-top: 2.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.news-editor-outline .ProseMirror :is(p, div, figure, blockquote, ul, ol, li, h2, h3, pre, hr) {
|
||||
position: relative;
|
||||
outline: 1px dashed rgba(56, 189, 248, 0.24);
|
||||
@@ -821,7 +1696,7 @@
|
||||
.news-editor-outline .ProseMirror hr::before {
|
||||
position: absolute;
|
||||
top: -0.55rem;
|
||||
left: 0.1rem;
|
||||
right: 0.1rem;
|
||||
z-index: 2;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 0.35rem;
|
||||
@@ -968,6 +1843,7 @@
|
||||
.story-prose pre code.hljs,
|
||||
.story-prose pre code[class*='language-'] {
|
||||
display: block;
|
||||
min-width: max-content;
|
||||
overflow-x: auto;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
|
||||
@@ -7,6 +7,7 @@ const buildAdminNavGroups = (isAdmin) => [
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/moderation', icon: 'fa-solid fa-gauge-high', exact: true },
|
||||
{ label: 'Daily Activity', href: '/moderation/activity', icon: 'fa-solid fa-calendar-day' },
|
||||
{ label: 'Online Users', href: '/moderation/traffic/online', icon: 'fa-solid fa-user-check' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -22,10 +23,6 @@ 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: 'Academy Dashboard', href: '/moderation/academy/dashboard', icon: 'fa-solid fa-graduation-cap' },
|
||||
{ label: 'Academy Lessons', href: '/moderation/academy/lessons', icon: 'fa-solid fa-book-open' },
|
||||
{ label: 'Academy Prompts', href: '/moderation/academy/prompts', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||||
{ label: 'Academy Challenges', href: '/moderation/academy/challenges', icon: 'fa-solid fa-trophy' },
|
||||
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star' },
|
||||
{ label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' },
|
||||
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up' },
|
||||
@@ -33,6 +30,15 @@ const buildAdminNavGroups = (isAdmin) => [
|
||||
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Academy',
|
||||
items: [
|
||||
{ label: 'Academy Dashboard', href: '/moderation/academy/dashboard', icon: 'fa-solid fa-graduation-cap' },
|
||||
{ label: 'Academy Lessons', href: '/moderation/academy/lessons', icon: 'fa-solid fa-book-open' },
|
||||
{ label: 'Academy Prompts', href: '/moderation/academy/prompts', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||||
{ label: 'Academy Challenges', href: '/moderation/academy/challenges', icon: 'fa-solid fa-trophy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
@@ -49,6 +55,18 @@ function NavLink({ item, active }) {
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`
|
||||
|
||||
// For some moderation surfaces (traffic/online) we prefer a full-page
|
||||
// navigation so the server-rendered blade view opens as its own page
|
||||
// instead of being handled by Inertia or opened inline.
|
||||
if (item.href === '/moderation/traffic/online') {
|
||||
return (
|
||||
<a href={item.href} className={cls}>
|
||||
<i className={`${item.icon} w-5 text-center text-base`} />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={item.href} className={cls}>
|
||||
<i className={`${item.icon} w-5 text-center text-base`} />
|
||||
@@ -66,7 +84,7 @@ function Sidebar({ pathname, isAdmin }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-64 flex-col overflow-y-auto border-r border-white/[0.07] bg-[rgba(10,14,22,0.98)] px-3 py-6">
|
||||
<aside className="nova-scrollbar flex h-full w-64 flex-col overflow-y-auto border-r border-white/[0.07] bg-[rgba(10,14,22,0.98)] px-3 py-6">
|
||||
{/* Brand */}
|
||||
<div className="mb-8 px-3">
|
||||
<Link href="/moderation" className="flex items-center gap-2.5">
|
||||
|
||||
@@ -1,7 +1,53 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Link, router, usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function slugifyHeading(value, fallback = 'section') {
|
||||
const normalized = String(value || '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
return normalized || fallback
|
||||
}
|
||||
|
||||
function formatLessonDate(value) {
|
||||
if (!value) return 'Recently updated'
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) return 'Recently updated'
|
||||
|
||||
return new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
|
||||
}
|
||||
|
||||
function formatLessonMinutes(minutes) {
|
||||
const value = Number(minutes || 0)
|
||||
|
||||
return value > 0 ? `${value} min read` : 'Quick read'
|
||||
}
|
||||
|
||||
function StatPill({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-slate-400">{label}</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LessonInfoRow({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">{label}</span>
|
||||
<span className="text-sm font-semibold text-white">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LockedPanel({ pricingUrl, label }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-amber-300/20 bg-amber-300/10 p-6 text-amber-50">
|
||||
@@ -13,10 +59,206 @@ function LockedPanel({ pricingUrl, label }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyShow({ pageType, item, seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl }) {
|
||||
function copyTextToClipboard(text) {
|
||||
const source = String(text || '')
|
||||
if (!source) return Promise.reject(new Error('Nothing to copy'))
|
||||
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
return navigator.clipboard.writeText(source)
|
||||
}
|
||||
|
||||
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 PromptCopyButton({ prompt }) {
|
||||
const [status, setStatus] = useState('idle')
|
||||
const resetTimerRef = useRef(0)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
copyTextToClipboard(prompt)
|
||||
.then(() => setStatus('copied'))
|
||||
.catch(() => setStatus('failed'))
|
||||
.finally(() => {
|
||||
window.clearTimeout(resetTimerRef.current)
|
||||
resetTimerRef.current = window.setTimeout(() => setStatus('idle'), 1800)
|
||||
})
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[#ffb9ab]/20 bg-[#ffb9ab]/10 px-4 py-2 text-sm font-semibold text-[#ffe2dc] transition hover:border-[#ffb9ab]/35 hover:bg-[#ffb9ab]/16"
|
||||
aria-label="Copy prompt"
|
||||
>
|
||||
<i className={`fa-solid ${status === 'copied' ? 'fa-check' : status === 'failed' ? 'fa-triangle-exclamation' : 'fa-copy'}`} />
|
||||
<span>{status === 'copied' ? 'Copied' : status === 'failed' ? 'Copy failed' : 'Copy prompt'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function AiComparisonSection({ block }) {
|
||||
const payload = block?.payload || {}
|
||||
const criteria = Array.isArray(payload.criteria) ? payload.criteria.filter(Boolean) : []
|
||||
const results = Array.isArray(block?.comparison_results) ? block.comparison_results.filter((result) => result?.active !== false) : []
|
||||
const hasPrompt = Boolean(payload.prompt)
|
||||
const hasNegativePrompt = Boolean(payload.negative_prompt)
|
||||
const hasUsefulData = Boolean(block?.title || payload.title || payload.intro || hasPrompt || hasNegativePrompt || payload.aspect_ratio || criteria.length || results.length)
|
||||
|
||||
if (!hasUsefulData) return null
|
||||
|
||||
return (
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,151,132,0.14),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-7">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffb8aa]">AI Model Comparison</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">{payload.title || block.title || 'Same Prompt, Different AI Models'}</h2>
|
||||
{payload.intro ? <p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">{payload.intro}</p> : null}
|
||||
</div>
|
||||
{payload.aspect_ratio ? <div className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-200">Aspect ratio {payload.aspect_ratio}</div> : null}
|
||||
</div>
|
||||
|
||||
{hasPrompt ? (
|
||||
<div className="mt-6 rounded-[26px] border border-[#ffb8aa]/15 bg-black/25 p-4 md:p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-[#ffd0c6]">Prompt used</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">Shared source prompt across all compared models</p>
|
||||
</div>
|
||||
<PromptCopyButton prompt={payload.prompt} />
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100">{payload.prompt}</pre>
|
||||
{hasNegativePrompt ? (
|
||||
<div className="mt-4 rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Negative prompt</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-300">{payload.negative_prompt}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{criteria.length ? (
|
||||
<div className="mt-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">What we compare</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{criteria.map((criterion) => (
|
||||
<span key={criterion} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-medium text-slate-100">{criterion}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results.length ? (
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2 2xl:grid-cols-4">
|
||||
{results.map((result) => {
|
||||
const imageUrl = result.thumb_url || result.image_url || result.thumb_path || result.image_path || ''
|
||||
const score = Number(result.score || 0)
|
||||
const hasScore = Number.isFinite(score) && score > 0
|
||||
const altText = `${result.model_name || 'AI model'} by ${result.provider || 'unknown provider'} result for ${payload.prompt || 'comparison prompt'}`
|
||||
|
||||
return (
|
||||
<article key={result.id || `${result.provider}-${result.model_name}-${result.sort_order || 0}`} className="overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] shadow-[0_16px_40px_rgba(2,6,23,0.18)]">
|
||||
<div className="aspect-video overflow-hidden bg-slate-950/80">
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt={altText} loading="lazy" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-slate-500">No comparison image provided.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold tracking-[-0.03em] text-white">{result.model_name || result.provider || 'AI model'}</h3>
|
||||
{result.provider ? <p className="mt-1 text-sm text-slate-400">{result.provider}</p> : null}
|
||||
</div>
|
||||
{hasScore ? <div className="rounded-full border border-[#ffb8aa]/20 bg-[#ffb8aa]/10 px-3 py-1 text-sm font-semibold text-[#ffe3dd]">{`Skinbase score ${score}/10`}</div> : null}
|
||||
</div>
|
||||
|
||||
{result.settings ? (
|
||||
<div className="rounded-[20px] border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Settings</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-300">{result.settings}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result.strengths ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-200/75">Strengths</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200">{result.strengths}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result.weaknesses ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-200/75">Weaknesses</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-300">{result.weaknesses}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result.best_for ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/75">Best for</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200">{result.best_for}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyShow({ pageType, item, relatedLessons = [], seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
const [completed, setCompleted] = useState(Boolean(initialCompleted))
|
||||
const [saved, setSaved] = useState(Boolean(initialSaved))
|
||||
const [tableOfContents, setTableOfContents] = useState([])
|
||||
const [activeHeadingId, setActiveHeadingId] = useState('')
|
||||
const articleContentRef = useRef(null)
|
||||
const lessonCover = item?.cover_image_url || item?.cover_image || ''
|
||||
const lessonCategory = item?.category?.name || 'Academy'
|
||||
const lessonDifficulty = item?.difficulty || 'Intermediate'
|
||||
const lessonMinutes = formatLessonMinutes(item?.reading_minutes)
|
||||
const lessonUpdated = formatLessonDate(item?.published_at)
|
||||
const lessonBlocks = Array.isArray(item?.blocks) ? item.blocks : []
|
||||
const relatedLessonList = Array.isArray(relatedLessons) ? relatedLessons : []
|
||||
const lessonSummary = item.excerpt || item.description || item.prompt_preview || item.content_preview || 'A focused Academy lesson with practical guidance and examples.'
|
||||
const fontScaleStorageKey = 'academy.lesson.font-scale'
|
||||
const fontScaleMin = 0.95
|
||||
const fontScaleMax = 1.12
|
||||
const fontScaleStep = 0.04
|
||||
const [lessonFontScale, setLessonFontScale] = useState(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 1.04
|
||||
}
|
||||
|
||||
const storedValue = Number(window.localStorage.getItem(fontScaleStorageKey))
|
||||
|
||||
if (Number.isFinite(storedValue)) {
|
||||
return Math.min(fontScaleMax, Math.max(fontScaleMin, storedValue))
|
||||
}
|
||||
|
||||
return 1.04
|
||||
})
|
||||
|
||||
const markComplete = () => {
|
||||
if (!completeUrl || completed) return
|
||||
@@ -35,84 +277,414 @@ export default function AcademyShow({ pageType, item, seo, pricingUrl, completeU
|
||||
})
|
||||
}
|
||||
|
||||
const decreaseFontSize = () => {
|
||||
setLessonFontScale((current) => Math.max(fontScaleMin, Number((current - fontScaleStep).toFixed(2))))
|
||||
}
|
||||
|
||||
const increaseFontSize = () => {
|
||||
setLessonFontScale((current) => Math.min(fontScaleMax, Number((current + fontScaleStep).toFixed(2))))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (pageType !== 'lesson' || !item?.content || !articleContentRef.current) {
|
||||
setTableOfContents([])
|
||||
setActiveHeadingId('')
|
||||
return
|
||||
}
|
||||
|
||||
const headings = Array.from(articleContentRef.current.querySelectorAll('h2, h3'))
|
||||
const seenIds = new Map()
|
||||
const nextTableOfContents = headings.map((heading, index) => {
|
||||
const baseId = slugifyHeading(heading.textContent, `section-${index + 1}`)
|
||||
const seenCount = seenIds.get(baseId) ?? 0
|
||||
const nextId = seenCount > 0 ? `${baseId}-${seenCount + 1}` : baseId
|
||||
|
||||
seenIds.set(baseId, seenCount + 1)
|
||||
heading.id = nextId
|
||||
|
||||
return {
|
||||
id: nextId,
|
||||
title: heading.textContent?.trim() || `Section ${index + 1}`,
|
||||
level: heading.tagName.toLowerCase(),
|
||||
}
|
||||
})
|
||||
|
||||
setTableOfContents(nextTableOfContents)
|
||||
}, [item?.content, pageType])
|
||||
|
||||
useEffect(() => {
|
||||
if (pageType !== 'lesson' || tableOfContents.length === 0 || !articleContentRef.current) {
|
||||
setActiveHeadingId('')
|
||||
return
|
||||
}
|
||||
|
||||
const headingElements = Array.from(articleContentRef.current.querySelectorAll('h2, h3'))
|
||||
|
||||
if (!headingElements.length) {
|
||||
setActiveHeadingId('')
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const visibleEntries = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((left, right) => left.boundingClientRect.top - right.boundingClientRect.top)
|
||||
|
||||
if (visibleEntries.length) {
|
||||
setActiveHeadingId((current) => visibleEntries[0].target.id || current)
|
||||
}
|
||||
}, {
|
||||
root: null,
|
||||
rootMargin: '-18% 0px -68% 0px',
|
||||
threshold: [0, 1],
|
||||
})
|
||||
|
||||
headingElements.forEach((heading) => observer.observe(heading))
|
||||
|
||||
const firstVisibleHeading = headingElements.find((heading) => heading.getBoundingClientRect().top >= 0) || headingElements[0]
|
||||
if (firstVisibleHeading?.id) {
|
||||
setActiveHeadingId(firstVisibleHeading.id)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [pageType, tableOfContents, lessonFontScale])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
window.localStorage.setItem(fontScaleStorageKey, String(lessonFontScale))
|
||||
}, [lessonFontScale])
|
||||
|
||||
useEffect(() => {
|
||||
if (pageType !== 'lesson' || !item?.content || !articleContentRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const codeBlocks = Array.from(articleContentRef.current.querySelectorAll('pre code'))
|
||||
|
||||
if (!codeBlocks.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackCopyText = (text) => {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', 'true')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.top = '-1000px'
|
||||
textarea.style.left = '-1000px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
try {
|
||||
return document.execCommand('copy')
|
||||
} catch (_error) {
|
||||
return false
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = (text) => {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
return navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
return fallbackCopyText(text)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('Clipboard unavailable'))
|
||||
}
|
||||
|
||||
codeBlocks.forEach((block) => {
|
||||
const pre = block.parentElement
|
||||
|
||||
if (!pre || pre.dataset.academyCopyButtonMounted === 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
const button = document.createElement('button')
|
||||
const icon = document.createElement('span')
|
||||
const label = document.createElement('span')
|
||||
|
||||
button.type = 'button'
|
||||
button.className = 'story-code-copy-button academy-code-copy-button'
|
||||
icon.className = 'story-code-copy-icon'
|
||||
icon.setAttribute('aria-hidden', 'true')
|
||||
icon.textContent = '⧉'
|
||||
label.className = 'story-code-copy-label'
|
||||
label.textContent = 'Copy'
|
||||
button.appendChild(icon)
|
||||
button.appendChild(label)
|
||||
button.dataset.copied = 'idle'
|
||||
button.setAttribute('aria-label', 'Copy code block')
|
||||
|
||||
let resetTimer = 0
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const source = block.innerText || block.textContent || ''
|
||||
|
||||
copyText(source)
|
||||
.then(() => {
|
||||
icon.textContent = '✓'
|
||||
label.textContent = 'Copied'
|
||||
button.dataset.copied = 'true'
|
||||
})
|
||||
.catch(() => {
|
||||
icon.textContent = '!'
|
||||
label.textContent = 'Failed'
|
||||
button.dataset.copied = 'false'
|
||||
})
|
||||
.finally(() => {
|
||||
window.clearTimeout(resetTimer)
|
||||
resetTimer = window.setTimeout(() => {
|
||||
icon.textContent = '⧉'
|
||||
label.textContent = 'Copy'
|
||||
button.dataset.copied = 'idle'
|
||||
}, 1800)
|
||||
})
|
||||
})
|
||||
|
||||
pre.appendChild(button)
|
||||
pre.dataset.academyCopyButtonMounted = 'true'
|
||||
})
|
||||
}, [item?.content, lessonFontScale, pageType])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={seo || {}} title={item?.title} description={item?.excerpt || item?.description} />
|
||||
|
||||
<div className="mx-auto max-w-[1200px] space-y-6">
|
||||
<section className="rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10">
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Skinbase AI Academy</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{item.title}</h1>
|
||||
<p className="mt-4 text-base leading-8 text-slate-300">{item.excerpt || item.description || item.prompt_preview || item.content_preview}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{completeUrl ? <button type="button" onClick={markComplete} className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100">{completed ? 'Completed' : 'Mark complete'}</button> : null}
|
||||
{saveUrl ? <button type="button" onClick={toggleSave} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{saved ? 'Saved' : 'Save prompt'}</button> : null}
|
||||
{submitUrl ? <Link href={submitUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Submit artwork</Link> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mx-auto max-w-[1320px] space-y-6">
|
||||
{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}
|
||||
|
||||
{item.locked ? <LockedPanel pricingUrl={pricingUrl} label={pageType} /> : null}
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||
{pageType === 'lesson' ? <div className="whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.content || item.content_preview}</div> : null}
|
||||
{pageType === 'prompt' ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.prompt || item.prompt_preview}</pre>
|
||||
</div>
|
||||
{item.negative_prompt ? <div><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Negative prompt</p><pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.negative_prompt}</pre></div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{pageType === 'pack' ? (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm leading-8 text-slate-200">{item.description}</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(item.prompts || []).map((prompt) => (
|
||||
<div key={prompt.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{prompt.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{prompt.excerpt || prompt.prompt_preview}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{pageType === 'challenge' ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Brief</p>
|
||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.brief || item.description}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Rules</p>
|
||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.rules || 'No special rules posted yet.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{(item.submissions || []).length ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Approved submissions</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{item.submissions.map((submission) => (
|
||||
<div key={submission.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{submission.artwork?.title || 'Submission'}</h3>
|
||||
<p className="mt-2 text-sm text-slate-400">{submission.user?.name || 'Unknown creator'}</p>
|
||||
</div>
|
||||
))}
|
||||
{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">
|
||||
<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-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>
|
||||
|
||||
<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-7 flex flex-wrap gap-3">
|
||||
{completeUrl ? <button type="button" onClick={markComplete} className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100">{completed ? 'Completed' : 'Mark complete'}</button> : null}
|
||||
{saveUrl ? <button type="button" onClick={toggleSave} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{saved ? 'Saved' : 'Save prompt'}</button> : null}
|
||||
{submitUrl ? <Link href={submitUrl} className="rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-sky-300/25 hover:bg-sky-300/12 hover:text-sky-100">Submit artwork</Link> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatPill label="Category" value={lessonCategory} />
|
||||
<StatPill label="Reading" value={lessonMinutes} />
|
||||
<StatPill label="Updated" value={lessonUpdated} />
|
||||
<StatPill label="Access" value={item.access_level || 'free'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<LessonInfoRow label="Series" value={lessonCategory} />
|
||||
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
|
||||
<LessonInfoRow label="Reading time" value={lessonMinutes} />
|
||||
<LessonInfoRow label="Published" value={lessonUpdated} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<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.' : 'Full lesson content is available below.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<article className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200 md:p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-white/10 pb-5">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Article</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Lesson content</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-300">{lessonMinutes}</span>
|
||||
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-black/20 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={decreaseFontSize}
|
||||
disabled={lessonFontScale <= fontScaleMin}
|
||||
aria-label="Decrease article text size"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-sm font-semibold text-slate-200 transition hover:border-sky-300/30 hover:bg-sky-300/12 hover:text-sky-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="min-w-12 px-1 text-center text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">{Math.round(lessonFontScale * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={increaseFontSize}
|
||||
disabled={lessonFontScale >= fontScaleMax}
|
||||
aria-label="Increase article text size"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-sm font-semibold text-slate-200 transition hover:border-sky-300/30 hover:bg-sky-300/12 hover:text-sky-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{item.content ? (
|
||||
<div className="space-y-8">
|
||||
<div
|
||||
ref={articleContentRef}
|
||||
className="story-prose academy-lesson-prose prose prose-invert max-w-none"
|
||||
style={{ '--academy-lesson-font-scale': lessonFontScale }}
|
||||
dangerouslySetInnerHTML={{ __html: item.content }}
|
||||
/>
|
||||
{lessonBlocks.map((block) => <AiComparisonSection key={block.id || `${block.type}-${block.sort_order || 0}`} block={block} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<div className="whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.content_preview}</div>
|
||||
{lessonBlocks.map((block) => <AiComparisonSection key={block.id || `${block.type}-${block.sort_order || 0}`} block={block} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="space-y-6 lg:sticky lg:top-6 lg:self-start">
|
||||
{tableOfContents.length ? (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">On this page</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Table of contents</h3>
|
||||
|
||||
<nav aria-label="Lesson table of contents" className="mt-5 space-y-1.5">
|
||||
{tableOfContents.map((entry) => (
|
||||
<a
|
||||
key={entry.id}
|
||||
href={`#${entry.id}`}
|
||||
aria-current={activeHeadingId === entry.id ? 'location' : undefined}
|
||||
className={`academy-lesson-toc-link ${entry.level === 'h3' ? 'academy-lesson-toc-link-subtle' : ''} ${activeHeadingId === entry.id ? 'academy-lesson-toc-link-active' : ''}`}
|
||||
>
|
||||
<span className="academy-lesson-toc-link-indicator" aria-hidden="true" />
|
||||
<span>{entry.title}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Series info</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{lessonCategory}</h3>
|
||||
<div className="mt-5 space-y-3">
|
||||
<LessonInfoRow label="Category" value={lessonCategory} />
|
||||
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
|
||||
<LessonInfoRow label="Reading" value={lessonMinutes} />
|
||||
<LessonInfoRow label="Updated" value={lessonUpdated} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{relatedLessonList.length ? (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Continue learning</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">More in {lessonCategory}</h3>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{relatedLessonList.map((relatedLesson, index) => (
|
||||
<Link
|
||||
key={relatedLesson.id}
|
||||
href={`/academy/lessons/${relatedLesson.slug}`}
|
||||
className="group flex gap-4 rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-sky-300/25 hover:bg-white/[0.06]"
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/15 bg-sky-300/10 text-sm font-semibold text-sky-100">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h4 className="text-sm font-semibold text-white transition group-hover:text-sky-100">{relatedLesson.title}</h4>
|
||||
<span className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">{formatLessonMinutes(relatedLesson.reading_minutes)}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-6 text-slate-400">{relatedLesson.excerpt || relatedLesson.content_preview || 'Continue the series with the next lesson.'}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||
{pageType === 'prompt' ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.prompt || item.prompt_preview}</pre>
|
||||
</div>
|
||||
{item.negative_prompt ? <div><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Negative prompt</p><pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.negative_prompt}</pre></div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{pageType === 'pack' ? (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm leading-8 text-slate-200">{item.description}</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(item.prompts || []).map((prompt) => (
|
||||
<div key={prompt.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{prompt.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{prompt.excerpt || prompt.prompt_preview}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{pageType === 'challenge' ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Brief</p>
|
||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.brief || item.description}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Rules</p>
|
||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.rules || 'No special rules posted yet.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{(item.submissions || []).length ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Approved submissions</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{item.submissions.map((submission) => (
|
||||
<div key={submission.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{submission.artwork?.title || 'Submission'}</h3>
|
||||
<p className="mt-2 text-sm text-slate-400">{submission.user?.name || 'Unknown creator'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
import DateTimePicker from '../../../components/ui/DateTimePicker'
|
||||
import NovaSelect from '../../../components/ui/NovaSelect'
|
||||
import LessonEditor from './LessonEditor'
|
||||
|
||||
function normalizePayload(fields, data) {
|
||||
const payload = { ...data }
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (field.type === 'csv') {
|
||||
payload[field.name] = String(payload[field.name] || '').split(',').map((item) => item.trim()).filter(Boolean)
|
||||
payload[field.name] = String(payload[field.name] || '')
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
if (field.type === 'json') {
|
||||
@@ -23,6 +28,57 @@ function normalizePayload(fields, data) {
|
||||
return payload
|
||||
}
|
||||
|
||||
function getField(fields, name) {
|
||||
return fields.find((field) => field.name === name) || null
|
||||
}
|
||||
|
||||
function SectionCard({ eyebrow, title, description, children, className = '' }) {
|
||||
return (
|
||||
<section className={`w-full min-w-0 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_20px_80px_rgba(15,23,42,0.18)] ${className}`.trim()}>
|
||||
<div className="mb-5">
|
||||
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">{eyebrow}</p> : null}
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-[-0.04em] text-white">{title}</h2>
|
||||
{description ? <p className="mt-2 text-sm leading-7 text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
<div className="grid gap-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({ label, value, onChange, error, ...rest }) {
|
||||
return (
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>{label}</span>
|
||||
<input value={value ?? ''} onChange={onChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" {...rest} />
|
||||
{error ? <p className="text-xs text-rose-300">{error}</p> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function TextAreaField({ label, value, onChange, error, rows = 6, hint }) {
|
||||
return (
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>{label}</span>
|
||||
<textarea value={value ?? ''} onChange={onChange} rows={rows} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-white outline-none" />
|
||||
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
||||
{error ? <p className="text-xs text-rose-300">{error}</p> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleField({ label, checked, onChange, help, error }) {
|
||||
return (
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">
|
||||
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="mt-1" />
|
||||
<span>
|
||||
<span className="block font-semibold text-white">{label}</span>
|
||||
{help ? <span className="mt-1 block text-xs leading-5 text-slate-400">{help}</span> : null}
|
||||
{error ? <span className="mt-2 block text-xs text-rose-300">{error}</span> : null}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ field, form }) {
|
||||
const value = form.data[field.name]
|
||||
|
||||
@@ -35,18 +91,44 @@ function Field({ field, form }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'datetime-local') {
|
||||
return (
|
||||
<DateTimePicker
|
||||
label={field.label}
|
||||
value={value || ''}
|
||||
onChange={(nextValue) => form.setData(field.name, nextValue || '')}
|
||||
error={form.errors[field.name]}
|
||||
clearable
|
||||
className="bg-black/20"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return <textarea value={value || ''} onChange={(event) => form.setData(field.name, event.target.value)} rows={6} className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" />
|
||||
return (
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>{field.label}</span>
|
||||
<textarea
|
||||
value={value || ''}
|
||||
onChange={(event) => form.setData(field.name, event.target.value)}
|
||||
rows={field.rows || 6}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-white outline-none"
|
||||
/>
|
||||
{form.errors[field.name] ? <p className="text-xs text-rose-300">{form.errors[field.name]}</p> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<NovaSelect
|
||||
label={field.label}
|
||||
value={value ?? ''}
|
||||
onChange={(nextValue) => form.setData(field.name, nextValue ?? '')}
|
||||
options={field.options || []}
|
||||
searchable={false}
|
||||
className="mt-2 rounded-2xl bg-black/20"
|
||||
className="rounded-2xl bg-black/20"
|
||||
error={form.errors[field.name]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -55,30 +137,329 @@ function Field({ field, form }) {
|
||||
return (
|
||||
<NovaSelect
|
||||
multi
|
||||
label={field.label}
|
||||
value={value || []}
|
||||
onChange={(nextValue) => form.setData(field.name, Array.isArray(nextValue) ? nextValue : [])}
|
||||
options={field.options || []}
|
||||
className="mt-2 rounded-2xl bg-black/20"
|
||||
className="rounded-2xl bg-black/20"
|
||||
error={form.errors[field.name]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <input type={field.type || 'text'} value={value ?? ''} onChange={(event) => form.setData(field.name, event.target.value)} className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" />
|
||||
return (
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
type={field.type || 'text'}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => form.setData(field.name, event.target.value)}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none"
|
||||
/>
|
||||
{form.errors[field.name] ? <p className="text-xs text-rose-300">{form.errors[field.name]}</p> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyCrudForm({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method }) {
|
||||
function PromptPreviewDropzone({ form, previewUrl }) {
|
||||
const inputRef = useRef(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState('')
|
||||
const [selectedFileName, setSelectedFileName] = useState('')
|
||||
|
||||
const previewSrc = localPreviewUrl || previewUrl || form.data.preview_image || ''
|
||||
|
||||
useEffect(() => () => {
|
||||
if (localPreviewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(localPreviewUrl)
|
||||
}
|
||||
}, [localPreviewUrl])
|
||||
|
||||
const setSelectedFile = (file) => {
|
||||
if (localPreviewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(localPreviewUrl)
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
setLocalPreviewUrl('')
|
||||
setSelectedFileName('')
|
||||
form.setData('preview_image_file', null)
|
||||
form.clearErrors('preview_image_file')
|
||||
return
|
||||
}
|
||||
|
||||
const nextPreviewUrl = URL.createObjectURL(file)
|
||||
setLocalPreviewUrl(nextPreviewUrl)
|
||||
setSelectedFileName(file.name)
|
||||
form.setData('preview_image_file', file)
|
||||
form.clearErrors('preview_image_file')
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
if (localPreviewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(localPreviewUrl)
|
||||
}
|
||||
|
||||
setLocalPreviewUrl('')
|
||||
setSelectedFileName('')
|
||||
form.setData('preview_image_file', null)
|
||||
form.clearErrors('preview_image_file')
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
eyebrow="Visual preview"
|
||||
title="Preview image"
|
||||
description="Drag an image here or paste a URL. Uploaded files are converted to WebP and stored on Contabo automatically."
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
inputRef.current?.click()
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
setSelectedFile(event.dataTransfer?.files?.[0] || null)
|
||||
}}
|
||||
className={[
|
||||
'w-full min-w-0 rounded-[28px] border border-dashed p-5 outline-none transition',
|
||||
dragging ? 'border-sky-300/50 bg-sky-400/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100">
|
||||
<i className="fa-solid fa-image" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">Drop a preview image or browse</div>
|
||||
<div className="mt-1 text-xs leading-5 text-slate-400">JPG, PNG, or WEBP. The server re-encodes the final asset to WebP before uploading it to the CDN.</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">JPG</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">PNG</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">WEBP</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">Max 5 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full max-w-full gap-3">
|
||||
<div className="overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
|
||||
{previewSrc ? (
|
||||
<img src={previewSrc} alt="Prompt preview" className="h-40 w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-40 items-center justify-center px-4 text-center text-sm text-slate-500">No preview image selected</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => inputRef.current?.click()} className="flex-1 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Browse</button>
|
||||
{selectedFileName || localPreviewUrl ? <button type="button" onClick={clearSelection} className="rounded-full border border-white/10 bg-transparent px-4 py-2.5 text-sm font-semibold text-slate-300 transition hover:bg-white/[0.04]">Clear</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
setSelectedFile(event.target.files?.[0] || null)
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid min-w-0 gap-3 md:grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,220px)]">
|
||||
<TextField
|
||||
label="Preview image URL fallback"
|
||||
value={form.data.preview_image || ''}
|
||||
onChange={(event) => form.setData('preview_image', event.target.value)}
|
||||
error={form.errors.preview_image}
|
||||
placeholder="Paste a URL or leave empty if you upload a file"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-300">
|
||||
<div className="font-semibold text-white">Stored value</div>
|
||||
<div className="mt-1 break-all text-slate-400">{form.data.preview_image_file?.name || form.data.preview_image || previewUrl || 'None yet'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.errors.preview_image_file ? <p className="mt-3 text-sm text-rose-300">{form.errors.preview_image_file}</p> : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method }) {
|
||||
const form = useForm({ ...record, preview_image_file: null })
|
||||
const categoryField = useMemo(() => getField(fields, 'category_id'), [fields])
|
||||
const difficultyField = useMemo(() => getField(fields, 'difficulty'), [fields])
|
||||
const accessField = useMemo(() => getField(fields, 'access_level'), [fields])
|
||||
const publishedAtField = useMemo(() => getField(fields, 'published_at'), [fields])
|
||||
const featuredField = useMemo(() => getField(fields, 'featured'), [fields])
|
||||
const promptOfWeekField = useMemo(() => getField(fields, 'prompt_of_week'), [fields])
|
||||
const activeField = useMemo(() => getField(fields, 'active'), [fields])
|
||||
const seoDescriptionField = useMemo(() => getField(fields, 'seo_description'), [fields])
|
||||
const previewUrl = form.data.preview_image_url || ''
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
const payload = normalizePayload(fields, form.data)
|
||||
form.transform(() => payload)
|
||||
|
||||
if (method === 'patch') {
|
||||
form.patch(submitUrl)
|
||||
return
|
||||
}
|
||||
|
||||
form.post(submitUrl)
|
||||
}
|
||||
|
||||
const tagCount = String(form.data.tags || '')
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean).length
|
||||
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle}>
|
||||
<Head title={`Admin · ${title}`} />
|
||||
|
||||
<form onSubmit={submit} className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,340px)]">
|
||||
<div className="min-w-0 space-y-6">
|
||||
<SectionCard
|
||||
eyebrow="Identity"
|
||||
title="Core prompt details"
|
||||
description="Set the catalog identity first so the prompt is easy to find, sort, and preview."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{categoryField ? <NovaSelect label={categoryField.label} value={form.data.category_id ?? ''} onChange={(nextValue) => form.setData('category_id', nextValue ?? '')} options={categoryField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.category_id} /> : null}
|
||||
{difficultyField ? <NovaSelect label={difficultyField.label} value={form.data.difficulty ?? ''} onChange={(nextValue) => form.setData('difficulty', nextValue ?? '')} options={difficultyField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.difficulty} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{accessField ? <NovaSelect label={accessField.label} value={form.data.access_level ?? ''} onChange={(nextValue) => form.setData('access_level', nextValue ?? '')} options={accessField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.access_level} /> : null}
|
||||
<TextField label="Aspect ratio" value={form.data.aspect_ratio || ''} onChange={(event) => form.setData('aspect_ratio', event.target.value)} error={form.errors.aspect_ratio} placeholder="1:1, 16:9, 3:2" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<TextField label="Title" value={form.data.title || ''} onChange={(event) => form.setData('title', event.target.value)} error={form.errors.title} maxLength={180} />
|
||||
<TextField label="Slug" value={form.data.slug || ''} onChange={(event) => form.setData('slug', event.target.value)} error={form.errors.slug} maxLength={180} placeholder="prompt-template-slug" />
|
||||
</div>
|
||||
|
||||
<TextAreaField label="Excerpt" value={form.data.excerpt || ''} onChange={(event) => form.setData('excerpt', event.target.value)} error={form.errors.excerpt} rows={4} hint="Short summary shown in the library and preview cards." />
|
||||
|
||||
<TextField label="Tags" value={form.data.tags || ''} onChange={(event) => form.setData('tags', event.target.value)} error={form.errors.tags} placeholder="wallpaper, cinematic, neon, portrait" />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
eyebrow="Prompt body"
|
||||
title="Prompt instructions"
|
||||
description="Write the instruction stack, guardrails, and production notes in a way that is easy to scan."
|
||||
>
|
||||
<TextAreaField label="Prompt" value={form.data.prompt || ''} onChange={(event) => form.setData('prompt', event.target.value)} error={form.errors.prompt} rows={10} hint="This is the main model instruction used by creators." />
|
||||
<TextAreaField label="Negative prompt" value={form.data.negative_prompt || ''} onChange={(event) => form.setData('negative_prompt', event.target.value)} error={form.errors.negative_prompt} rows={5} hint="Optional exclusions, artifacts, or anti-patterns to avoid." />
|
||||
<TextAreaField label="Usage notes" value={form.data.usage_notes || ''} onChange={(event) => form.setData('usage_notes', event.target.value)} error={form.errors.usage_notes} rows={5} hint="Explain how to apply the prompt in a practical workflow." />
|
||||
<TextAreaField label="Workflow notes" value={form.data.workflow_notes || ''} onChange={(event) => form.setData('workflow_notes', event.target.value)} error={form.errors.workflow_notes} rows={5} hint="Internal editorial notes, camera settings, or prompt variants." />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
eyebrow="Publishing"
|
||||
title="Release controls"
|
||||
description="Choose when the prompt becomes visible and how it behaves in the academy."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{publishedAtField ? <DateTimePicker label={publishedAtField.label} value={form.data.published_at || ''} onChange={(nextValue) => form.setData('published_at', nextValue || '')} error={form.errors.published_at} clearable className="bg-black/20" /> : null}
|
||||
<TextField label="SEO title" value={form.data.seo_title || ''} onChange={(event) => form.setData('seo_title', event.target.value)} error={form.errors.seo_title} maxLength={180} />
|
||||
</div>
|
||||
{seoDescriptionField ? <TextAreaField label={seoDescriptionField.label} value={form.data.seo_description || ''} onChange={(event) => form.setData('seo_description', event.target.value)} error={form.errors.seo_description} rows={4} /> : null}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{featuredField ? <ToggleField label={featuredField.label} checked={Boolean(form.data.featured)} onChange={(event) => form.setData('featured', event.target.checked)} help="Highlight this prompt in featured rails." error={form.errors.featured} /> : null}
|
||||
{promptOfWeekField ? <ToggleField label={promptOfWeekField.label} checked={Boolean(form.data.prompt_of_week)} onChange={(event) => form.setData('prompt_of_week', event.target.checked)} help="Promote this prompt as the current weekly pick." error={form.errors.prompt_of_week} /> : null}
|
||||
{activeField ? <ToggleField label={activeField.label} checked={Boolean(form.data.active)} onChange={(event) => form.setData('active', event.target.checked)} help="Keep draft prompts hidden until they are ready." error={form.errors.active} /> : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-6 xl:sticky xl:top-6 xl:self-start">
|
||||
<SectionCard
|
||||
eyebrow="At a glance"
|
||||
title="Prompt preview"
|
||||
description="A compact summary of what editors and visitors will see."
|
||||
>
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/30">
|
||||
{previewUrl || form.data.preview_image ? (
|
||||
<img src={previewUrl || form.data.preview_image} alt="Prompt preview" className="h-56 w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-56 items-center justify-center px-6 text-center text-sm text-slate-500">No preview image selected yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt summary</p>
|
||||
<h3 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white">{form.data.title || 'Untitled prompt'}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-400">{form.data.excerpt || 'Add a concise excerpt to give the prompt some context in the library.'}</p>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-400">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Difficulty</dt><dd className="mt-1 text-sm text-white">{form.data.difficulty || '—'}</dd></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Access</dt><dd className="mt-1 text-sm text-white">{form.data.access_level || '—'}</dd></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Aspect</dt><dd className="mt-1 text-sm text-white">{form.data.aspect_ratio || '—'}</dd></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Tags</dt><dd className="mt-1 text-sm text-white">{tagCount}</dd></div>
|
||||
</dl>
|
||||
<p className="mt-4 text-xs leading-6 text-slate-500">Uploaded images are converted to WebP and stored on the Contabo S3-backed CDN before the record is saved.</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<PromptPreviewDropzone form={form} previewUrl={previewUrl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save prompt'}</button>
|
||||
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back</Link>
|
||||
{destroyUrl ? <button type="button" onClick={() => { if (!window.confirm('Delete this record?')) return; router.delete(destroyUrl) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete</button> : null}
|
||||
</div>
|
||||
</form>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function GenericEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method }) {
|
||||
const form = useForm(record)
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
const payload = normalizePayload(fields, form.data)
|
||||
form.transform(() => payload)
|
||||
|
||||
if (method === 'patch') {
|
||||
form.transform(() => payload).patch(submitUrl)
|
||||
form.patch(submitUrl)
|
||||
return
|
||||
}
|
||||
|
||||
form.transform(() => payload).post(submitUrl)
|
||||
form.post(submitUrl)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -86,13 +467,11 @@ export default function AcademyCrudForm({ title, subtitle, fields, record, submi
|
||||
<Head title={`Admin · ${title}`} />
|
||||
|
||||
<form onSubmit={submit} className="space-y-5 rounded-[30px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
{fields.map((field) => (
|
||||
<div key={field.name}>
|
||||
{field.type !== 'checkbox' ? <label className="text-sm font-semibold text-white">{field.label}</label> : null}
|
||||
<Field field={field} form={form} />
|
||||
{form.errors[field.name] ? <p className="mt-2 text-sm text-rose-300">{form.errors[field.name]}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
<div className="grid gap-5">
|
||||
{fields.map((field) => (
|
||||
<Field key={field.name} field={field} form={form} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save'}</button>
|
||||
@@ -102,4 +481,50 @@ export default function AcademyCrudForm({ title, subtitle, fields, record, submi
|
||||
</form>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyCrudForm({ resource, title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) {
|
||||
if (resource === 'lessons') {
|
||||
return (
|
||||
<LessonEditor
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
fields={fields}
|
||||
record={record}
|
||||
submitUrl={submitUrl}
|
||||
indexUrl={indexUrl}
|
||||
destroyUrl={destroyUrl}
|
||||
method={method}
|
||||
editorContext={editorContext}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (resource === 'prompts') {
|
||||
return (
|
||||
<PromptEditor
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
fields={fields}
|
||||
record={record}
|
||||
submitUrl={submitUrl}
|
||||
indexUrl={indexUrl}
|
||||
destroyUrl={destroyUrl}
|
||||
method={method}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericEditor
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
fields={fields}
|
||||
record={record}
|
||||
submitUrl={submitUrl}
|
||||
indexUrl={indexUrl}
|
||||
destroyUrl={destroyUrl}
|
||||
method={method}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import PostActions from './PostActions'
|
||||
import PostComments from './PostComments'
|
||||
import ArtworkCard from '../artwork/ArtworkCard'
|
||||
@@ -68,7 +69,6 @@ export default function PostCard({ post, isLoggedIn = false, viewerUsername = nu
|
||||
const handleSaveEdit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const { default: axios } = await import('axios')
|
||||
const { data } = await axios.patch(`/api/posts/${post.id}`, { body: editBody })
|
||||
setPostData(data.post)
|
||||
setEditMode(false)
|
||||
@@ -82,7 +82,6 @@ export default function PostCard({ post, isLoggedIn = false, viewerUsername = nu
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Delete this post?')) return
|
||||
try {
|
||||
const { default: axios } = await import('axios')
|
||||
await axios.delete(`/api/posts/${post.id}`)
|
||||
onDelete?.(post.id)
|
||||
} catch {
|
||||
@@ -91,7 +90,6 @@ export default function PostCard({ post, isLoggedIn = false, viewerUsername = nu
|
||||
}
|
||||
|
||||
const handlePin = async () => {
|
||||
const { default: axios } = await import('axios')
|
||||
try {
|
||||
if (postData.is_pinned) {
|
||||
await axios.delete(`/api/posts/${post.id}/pin`)
|
||||
@@ -109,7 +107,6 @@ export default function PostCard({ post, isLoggedIn = false, viewerUsername = nu
|
||||
const handleSaveToggle = async () => {
|
||||
if (!isLoggedIn || saveLoading) return
|
||||
setSaveLoading(true)
|
||||
const { default: axios } = await import('axios')
|
||||
try {
|
||||
if (postData.viewer_saved) {
|
||||
await axios.delete(`/api/posts/${post.id}/save`)
|
||||
@@ -130,7 +127,6 @@ export default function PostCard({ post, isLoggedIn = false, viewerUsername = nu
|
||||
if (!isOwn) return
|
||||
setAnalyticsOpen(true)
|
||||
if (!analytics) {
|
||||
const { default: axios } = await import('axios')
|
||||
try {
|
||||
const { data } = await axios.get(`/api/posts/${post.id}/analytics`)
|
||||
setAnalytics(data)
|
||||
|
||||
@@ -4,12 +4,10 @@ import ShareArtworkModal from './ShareArtworkModal'
|
||||
import LinkPreviewCard from './LinkPreviewCard'
|
||||
import TagPeopleModal from './TagPeopleModal'
|
||||
import DateTimePicker from '../ui/DateTimePicker'
|
||||
import EmojiMartPicker from '../common/EmojiMartPicker'
|
||||
import extractNativeEmoji from '../common/extractNativeEmoji'
|
||||
import isEventWithinNode from '../common/isEventWithinNode'
|
||||
|
||||
// Lazy-load the heavy emoji picker only when first opened
|
||||
const EmojiPicker = lazy(() => import('../common/EmojiMartPicker'))
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'public', icon: 'fa-globe', label: 'Public' },
|
||||
{ value: 'followers', icon: 'fa-user-friends', label: 'Followers' },
|
||||
@@ -347,7 +345,7 @@ export default function PostComposer({ user, onPosted }) {
|
||||
</div>
|
||||
}>
|
||||
{emojiData && (
|
||||
<EmojiPicker
|
||||
<EmojiMartPicker
|
||||
data={emojiData}
|
||||
onEmojiSelect={insertEmoji}
|
||||
theme="dark"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -220,7 +220,7 @@ mountStorySocial();
|
||||
mountRememberMeCheckboxes();
|
||||
|
||||
function initStorySyntaxHighlighting() {
|
||||
var codeBlocks = Array.prototype.slice.call(document.querySelectorAll('.story-prose pre code'));
|
||||
var codeBlocks = Array.prototype.slice.call(document.querySelectorAll('.story-prose pre code, .forum-code-block code'));
|
||||
if (!codeBlocks.length) return;
|
||||
|
||||
function fallbackCopyText(text) {
|
||||
|
||||
@@ -3,9 +3,16 @@ import { createInertiaApp } from '@inertiajs/react'
|
||||
import createServer from '@inertiajs/react/server'
|
||||
import ReactDOMServer from 'react-dom/server'
|
||||
|
||||
// Eagerly import every Inertia page component so the SSR server can resolve
|
||||
// any page name without async dynamic imports (Node.js + Vite SSR requirement).
|
||||
const pages = import.meta.glob(['./Pages/**/*.jsx', '!./Pages/**/*.test.jsx', '!./Pages/**/__tests__/**'], { eager: true })
|
||||
// Eagerly import Inertia page components so the SSR server can resolve any page
|
||||
// name without async dynamic imports (Node.js + Vite SSR requirement).
|
||||
// The standalone homepage is Blade-mounted through @vite, so it stays out of
|
||||
// the SSR graph to avoid duplicate lazy/static imports for its below-fold rails.
|
||||
const pages = import.meta.glob([
|
||||
'./Pages/**/*.jsx',
|
||||
'!./Pages/Home/**/*.jsx',
|
||||
'!./Pages/**/*.test.jsx',
|
||||
'!./Pages/**/__tests__/**',
|
||||
], { eager: true })
|
||||
|
||||
// Lightweight server-only placeholder for pages that must remain client-only.
|
||||
// Returning this prevents an error-level stacktrace while still avoiding
|
||||
|
||||
@@ -31,37 +31,34 @@
|
||||
<input type="text" name="homepage_url" value="" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true">
|
||||
<input type="hidden" name="_bot_fingerprint" value="">
|
||||
|
||||
@php
|
||||
$captchaProvider = $captcha['provider'] ?? 'turnstile';
|
||||
$captchaSiteKey = $captcha['siteKey'] ?? '';
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1 text-white/80" for="email">Email</label>
|
||||
<input id="email" name="email" type="email" required placeholder="you@example.com" value="{{ old('email', $prefillEmail ?? '') }}" class="w-full rounded-lg bg-slate-950/70 border border-white/10 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500 text-white" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && $captchaSiteKey !== '')
|
||||
@if($captchaProvider === 'recaptcha')
|
||||
<div class="g-recaptcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||
@elseif($captchaProvider === 'hcaptcha')
|
||||
<div class="h-captcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||
@else
|
||||
<div class="cf-turnstile" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||
@endif
|
||||
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
|
||||
@if(($turnstile['enabled'] ?? false) && (($turnstile['siteKey'] ?? '') !== ''))
|
||||
<div class="space-y-2" data-turnstile-container>
|
||||
<div
|
||||
class="cf-turnstile"
|
||||
data-sitekey="{{ $turnstile['siteKey'] }}"
|
||||
data-theme="dark"
|
||||
></div>
|
||||
<p class="text-xs text-white/50" data-turnstile-status>Complete the security check before continuing.</p>
|
||||
</div>
|
||||
<x-input-error :messages="$errors->get('turnstile_token')" class="mt-2" />
|
||||
@endif
|
||||
|
||||
<button type="submit" class="w-full rounded-lg py-3 font-medium bg-gradient-to-r from-cyan-500 to-sky-400 hover:from-cyan-400 hover:to-sky-300 text-slate-900 transition">Continue</button>
|
||||
<button type="submit" class="w-full rounded-lg py-3 font-medium bg-gradient-to-r from-cyan-500 to-sky-400 hover:from-cyan-400 hover:to-sky-300 text-slate-900 transition disabled:cursor-not-allowed disabled:opacity-60" data-turnstile-submit>Continue</button>
|
||||
|
||||
<p class="text-sm text-center text-white/60">Already registered? <a href="{{ route('login') }}" class="text-cyan-400 hover:underline">Sign in</a></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== ''))
|
||||
<script src="{{ $captcha['scriptUrl'] }}" async defer></script>
|
||||
@if(($turnstile['enabled'] ?? false) && (($turnstile['siteKey'] ?? '') !== '') && (($turnstile['scriptUrl'] ?? '') !== ''))
|
||||
<script src="{{ $turnstile['scriptUrl'] }}" @if(($turnstile['cspNonce'] ?? '') !== '') nonce="{{ $turnstile['cspNonce'] }}" @endif async defer></script>
|
||||
<script src="{{ asset('js/register-turnstile.js') }}" @if(($turnstile['cspNonce'] ?? '') !== '') nonce="{{ $turnstile['cspNonce'] }}" @endif defer></script>
|
||||
@endif
|
||||
@include('partials.bot-fingerprint-script')
|
||||
@endsection
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
]);
|
||||
$page_robots = $page_robots ?? ($isAuthSeoRoute ? 'noindex,nofollow' : null);
|
||||
$shouldRenderBladeSeo = ($useUnifiedSeo ?? ! $isInertiaPage) && (($renderBladeSeo ?? true) || ! $isInertiaPage);
|
||||
$novaCssEntries = [
|
||||
$novaCssEntries = $novaCssEntries ?? [
|
||||
'resources/css/app.css',
|
||||
'resources/css/nova-grid.css',
|
||||
'resources/scss/nova.scss',
|
||||
@@ -70,8 +70,27 @@
|
||||
@if(!$deferWebManifest)
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
@endif
|
||||
<style>
|
||||
html {
|
||||
background-color: rgb(14, 18, 27);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: rgb(14, 18, 27);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@foreach($novaCssEntries as $novaCssEntry)
|
||||
<link rel="stylesheet" href="{{ Vite::asset($novaCssEntry) }}">
|
||||
@php
|
||||
$novaCssHref = Vite::asset($novaCssEntry);
|
||||
@endphp
|
||||
<link rel="preload" href="{{ $novaCssHref }}" as="style" onload="this.rel='stylesheet'">
|
||||
<link rel="stylesheet" href="{{ $novaCssHref }}">
|
||||
@endforeach
|
||||
@vite($novaViteEntries)
|
||||
<script>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2 pr-2 shrink-0">
|
||||
<img src="https://cdn.skinbase.org/images/sb_logo.webp" alt="" width="289" height="100" class="h-9 w-auto rounded-sm shadow-sm object-contain">
|
||||
<img src="https://cdn.skinbase.org/images/sb_logo_full.webp" alt="" width="104" height="36" class="h-9 w-auto rounded-sm shadow-sm object-contain">
|
||||
<span class="sr-only">Skinbase.org</span>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<a href="{{ route('news.show', $article->slug) }}" class="block">
|
||||
<div class="relative aspect-[16/9] overflow-hidden bg-black/20">
|
||||
@if($article->cover_url)
|
||||
<img src="{{ $article->cover_url }}" alt="{{ $article->title }}" class="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]">
|
||||
<img src="{{ $article->cover_url }}" @if($article->cover_srcset) srcset="{{ $article->cover_srcset }}" sizes="(max-width: 767px) 100vw, (max-width: 1279px) 50vw, 390px" @endif alt="{{ $article->title }}" class="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]">
|
||||
@else
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_45%),linear-gradient(180deg,rgba(15,23,42,0.92),rgba(2,6,23,0.98))]"></div>
|
||||
@endif
|
||||
|
||||
@@ -37,9 +37,14 @@
|
||||
|
||||
@if(!empty($tags) && $tags->isNotEmpty())
|
||||
<section class="rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
|
||||
<div class="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-white/45">
|
||||
<i class="fa-solid fa-tags text-[11px] text-sky-300"></i>
|
||||
Topics
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-white/45">
|
||||
<i class="fa-solid fa-tags text-[11px] text-sky-300"></i>
|
||||
Popular Topics
|
||||
</div>
|
||||
<a href="{{ route('tags.index') }}" class="text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-200/75 transition hover:text-sky-100">
|
||||
All Tags
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach($tags as $tag)
|
||||
|
||||
@@ -11,6 +11,22 @@
|
||||
(object) ['name' => 'Announcements', 'url' => route('news.index')],
|
||||
(object) ['name' => $archiveDate->format('F Y'), 'url' => route('news.archive', ['year' => $archiveDate->year, 'month' => $archiveDate->month])],
|
||||
]);
|
||||
|
||||
$seo = \App\Support\Seo\SeoDataBuilder::fromArray([
|
||||
'title' => $archiveDate->format('F Y') . ' — News Archive',
|
||||
'description' => 'News archive for ' . $archiveDate->format('F Y') . ' on Skinbase.',
|
||||
'canonical' => route('news.archive', ['year' => $archiveDate->year, 'month' => $archiveDate->month]),
|
||||
'breadcrumbs' => $headerBreadcrumbs,
|
||||
'structured_data' => [
|
||||
[
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CollectionPage',
|
||||
'name' => $archiveDate->format('F Y') . ' — News Archive',
|
||||
'description' => 'Published News stories from ' . $archiveDate->format('F Y') . '.',
|
||||
'url' => route('news.archive', ['year' => $archiveDate->year, 'month' => $archiveDate->month]),
|
||||
],
|
||||
],
|
||||
])->build();
|
||||
@endphp
|
||||
|
||||
<x-nova-page-header
|
||||
|
||||
@@ -12,6 +12,22 @@
|
||||
(object) ['name' => 'Announcements', 'url' => route('news.index')],
|
||||
(object) ['name' => $authorLabel, 'url' => route('news.author', ['username' => $author->username])],
|
||||
]);
|
||||
|
||||
$seo = \App\Support\Seo\SeoDataBuilder::fromArray([
|
||||
'title' => $authorLabel . ' — News Author',
|
||||
'description' => 'News stories and announcements by ' . $authorLabel . '.',
|
||||
'canonical' => route('news.author', ['username' => $author->username]),
|
||||
'breadcrumbs' => $headerBreadcrumbs,
|
||||
'structured_data' => [
|
||||
[
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CollectionPage',
|
||||
'name' => $authorLabel . ' — News Author',
|
||||
'description' => 'Editorial stories and updates by ' . $authorLabel . '.',
|
||||
'url' => route('news.author', ['username' => $author->username]),
|
||||
],
|
||||
],
|
||||
])->build();
|
||||
@endphp
|
||||
|
||||
<x-nova-page-header
|
||||
|
||||
@@ -6,15 +6,50 @@
|
||||
|
||||
@section('news_content')
|
||||
@php
|
||||
$articleItems = collect($articles->items());
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => 'Community', 'url' => route('community.activity')],
|
||||
(object) ['name' => 'Announcements', 'url' => route('news.index')],
|
||||
(object) ['name' => 'News', 'url' => route('news.index')],
|
||||
(object) ['name' => $category->name, 'url' => route('news.category', $category->slug)],
|
||||
]);
|
||||
|
||||
$structuredData = [
|
||||
[
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CollectionPage',
|
||||
'name' => $category->name . ' — News',
|
||||
'description' => $category->description ?: ('Announcements filed under ' . $category->name . '.'),
|
||||
'url' => route('news.category', $category->slug),
|
||||
],
|
||||
];
|
||||
|
||||
if ($articleItems->isNotEmpty()) {
|
||||
$structuredData[] = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'ItemList',
|
||||
'name' => $category->name . ' — News Articles',
|
||||
'description' => 'Published News stories in the ' . $category->name . ' category.',
|
||||
'url' => route('news.category', $category->slug),
|
||||
'numberOfItems' => $articleItems->count(),
|
||||
'itemListElement' => $articleItems->values()->map(fn ($article, int $index): array => [
|
||||
'@type' => 'ListItem',
|
||||
'position' => $index + 1,
|
||||
'name' => $article->title,
|
||||
'url' => route('news.show', ['slug' => $article->slug]),
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
$seo = \App\Support\Seo\SeoDataBuilder::fromArray([
|
||||
'title' => $category->name . ' — News',
|
||||
'description' => $category->description ?: ('Announcements in the ' . $category->name . ' category.'),
|
||||
'canonical' => route('news.category', $category->slug),
|
||||
'breadcrumbs' => $headerBreadcrumbs,
|
||||
'structured_data' => $structuredData,
|
||||
])->build();
|
||||
@endphp
|
||||
|
||||
<x-nova-page-header
|
||||
section="Community"
|
||||
section="News"
|
||||
:title="$category->name"
|
||||
icon="fa-folder-open"
|
||||
:breadcrumbs="$headerBreadcrumbs"
|
||||
|
||||
@@ -6,14 +6,55 @@
|
||||
|
||||
@section('news_content')
|
||||
@php
|
||||
$articleItems = collect([$featured])
|
||||
->merge($highlights)
|
||||
->merge($articles->items())
|
||||
->filter(fn ($article) => $article !== null)
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => 'Community', 'url' => route('community.activity')],
|
||||
(object) ['name' => 'Announcements', 'url' => route('news.index')],
|
||||
(object) ['name' => 'News', 'url' => route('news.index')],
|
||||
]);
|
||||
|
||||
$structuredData = [
|
||||
[
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CollectionPage',
|
||||
'name' => config('news.rss_title', 'News'),
|
||||
'description' => config('news.rss_description', 'Latest news, feature rollouts, and team updates from Skinbase.'),
|
||||
'url' => route('news.index'),
|
||||
],
|
||||
];
|
||||
|
||||
if ($articleItems->isNotEmpty()) {
|
||||
$structuredData[] = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'ItemList',
|
||||
'name' => config('news.rss_title', 'News') . ' Articles',
|
||||
'description' => config('news.rss_description', 'Latest news, feature rollouts, and team updates from Skinbase.'),
|
||||
'url' => route('news.index'),
|
||||
'numberOfItems' => $articleItems->count(),
|
||||
'itemListElement' => $articleItems->map(fn ($article, int $index): array => [
|
||||
'@type' => 'ListItem',
|
||||
'position' => $index + 1,
|
||||
'name' => $article->title,
|
||||
'url' => route('news.show', $article->slug),
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
$seo = \App\Support\Seo\SeoDataBuilder::fromArray([
|
||||
'title' => config('news.rss_title', 'News'),
|
||||
'description' => config('news.rss_description', 'Latest news, feature rollouts, and team updates from Skinbase.'),
|
||||
'canonical' => route('news.index'),
|
||||
'breadcrumbs' => $headerBreadcrumbs,
|
||||
'structured_data' => $structuredData,
|
||||
])->build();
|
||||
@endphp
|
||||
|
||||
<x-nova-page-header
|
||||
section="Community"
|
||||
section="News"
|
||||
title="News"
|
||||
icon="fa-newspaper"
|
||||
:breadcrumbs="$headerBreadcrumbs"
|
||||
@@ -44,7 +85,7 @@
|
||||
<div class="grid lg:grid-cols-[1.25fr_0.95fr]">
|
||||
<div class="relative min-h-[280px] overflow-hidden bg-black/20">
|
||||
@if($featured->cover_url)
|
||||
<img src="{{ $featured->cover_url }}" alt="{{ $featured->title }}" class="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]">
|
||||
<img src="{{ $featured->cover_url }}" @if($featured->cover_srcset) srcset="{{ $featured->cover_srcset }}" sizes="(max-width: 1023px) 100vw, 768px" @endif alt="{{ $featured->title }}" class="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]">
|
||||
@else
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_45%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.98))]"></div>
|
||||
@endif
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
@php
|
||||
$useUnifiedSeo = true;
|
||||
$novaCssEntries = [
|
||||
'resources/css/app.css',
|
||||
'resources/scss/nova.scss',
|
||||
];
|
||||
@endphp
|
||||
@extends('layouts.nova')
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
@php
|
||||
$isPreview = (bool) ($previewMode ?? false);
|
||||
$articleUrl = $isPreview ? ($previewCanonical ?? url()->current()) : route('news.show', $article->slug);
|
||||
$articleSchemaImage = $article->effective_og_image
|
||||
? url($article->effective_og_image)
|
||||
: url((string) config('seo.fallback_image_path', '/gfx/skinbase_back_001.webp'));
|
||||
$articleCoverSizes = '(max-width: 767px) calc(100vw - 3rem), (max-width: 1279px) calc(100vw - 5rem), 768px';
|
||||
$articleCoverPreloadHref = $article->cover_desktop_url ?: $article->cover_url;
|
||||
$seo = \App\Support\Seo\SeoDataBuilder::fromArray([
|
||||
'title' => $article->meta_title ?: $article->title,
|
||||
'description' => $article->meta_description ?: Str::limit(strip_tags((string) $article->excerpt), 160),
|
||||
@@ -12,31 +17,55 @@
|
||||
'og_description' => $article->effective_og_description,
|
||||
'og_image' => $article->effective_og_image,
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Community', 'url' => route('community.activity')],
|
||||
(object) ['name' => 'Announcements', 'url' => route('news.index')],
|
||||
(object) ['name' => 'Home', 'url' => url('/')],
|
||||
(object) ['name' => 'News', 'url' => route('news.index')],
|
||||
$article->category
|
||||
? (object) ['name' => $article->category->name, 'url' => route('news.category', $article->category->slug)]
|
||||
: null,
|
||||
(object) ['name' => $article->title, 'url' => route('news.show', $article->slug)],
|
||||
])->filter()->values(),
|
||||
])
|
||||
->addJsonLd(array_filter([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Article',
|
||||
'@type' => 'NewsArticle',
|
||||
'headline' => $article->title,
|
||||
'description' => $article->meta_description ?: Str::limit(strip_tags((string) $article->excerpt), 160),
|
||||
'image' => $article->effective_og_image,
|
||||
'image' => $articleSchemaImage
|
||||
? array_filter([
|
||||
'@type' => 'ImageObject',
|
||||
'url' => $articleSchemaImage,
|
||||
'contentUrl' => $articleSchemaImage,
|
||||
'thumbnailUrl' => $article->cover_mobile_url,
|
||||
'caption' => $article->title,
|
||||
], fn (mixed $value): bool => $value !== null && $value !== '')
|
||||
: null,
|
||||
'datePublished' => $article->published_at?->toIso8601String(),
|
||||
'dateModified' => $article->updated_at?->toIso8601String(),
|
||||
'articleSection' => $article->category?->name,
|
||||
'author' => array_filter([
|
||||
'@type' => 'Person',
|
||||
'name' => $article->author?->name,
|
||||
]),
|
||||
'publisher' => [
|
||||
'@type' => 'Organization',
|
||||
'name' => config('seo.site_name', 'Skinbase'),
|
||||
],
|
||||
'mainEntityOfPage' => $articleUrl,
|
||||
], fn (mixed $value): bool => $value !== null && $value !== ''))
|
||||
->build();
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
@if($articleCoverPreloadHref)
|
||||
<link
|
||||
rel="preload"
|
||||
as="image"
|
||||
href="{{ $articleCoverPreloadHref }}"
|
||||
@if($article->cover_srcset) imagesrcset="{{ $article->cover_srcset }}" imagesizes="{{ $articleCoverSizes }}" @endif
|
||||
fetchpriority="high"
|
||||
>
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@extends('news.layout', [
|
||||
'metaTitle' => $article->meta_title ?: $article->title,
|
||||
'metaDescription' => $article->meta_description ?: Str::limit(strip_tags((string)$article->excerpt), 160),
|
||||
@@ -48,17 +77,16 @@
|
||||
|
||||
@php
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => 'Community', 'url' => route('community.activity')],
|
||||
(object) ['name' => 'Announcements', 'url' => route('news.index')],
|
||||
(object) ['name' => 'Home', 'url' => url('/')],
|
||||
(object) ['name' => 'News', 'url' => route('news.index')],
|
||||
$article->category
|
||||
? (object) ['name' => $article->category->name, 'url' => route('news.category', $article->category->slug)]
|
||||
: null,
|
||||
(object) ['name' => $article->title, 'url' => $articleUrl],
|
||||
])->filter()->values();
|
||||
@endphp
|
||||
|
||||
<x-nova-page-header
|
||||
section="Community"
|
||||
section="News"
|
||||
:title="$article->title"
|
||||
icon="fa-newspaper"
|
||||
:breadcrumbs="$headerBreadcrumbs"
|
||||
@@ -105,7 +133,18 @@
|
||||
<article class="min-w-0">
|
||||
@if($article->cover_url)
|
||||
<div class="overflow-hidden rounded-[32px] border border-white/[0.06] bg-black/20 shadow-[0_24px_60px_rgba(0,0,0,0.24)]">
|
||||
<img src="{{ $article->cover_url }}" alt="{{ $article->title }}" class="h-auto max-h-[520px] w-full object-cover">
|
||||
<a href="{{ $articleCoverPreloadHref }}" class="group block focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950" aria-label="Open full cover image">
|
||||
<div class="relative">
|
||||
<img src="{{ $article->cover_url }}" @if($article->cover_srcset) srcset="{{ $article->cover_srcset }}" sizes="{{ $articleCoverSizes }}" @endif alt="{{ $article->title }}" fetchpriority="high" loading="eager" decoding="async" class="h-auto max-h-[520px] w-full object-cover transition duration-300 group-hover:scale-[1.01]">
|
||||
<div class="pointer-events-none absolute inset-x-4 bottom-4 flex items-center justify-between gap-3 rounded-full border border-white/10 bg-slate-950/72 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/82 backdrop-blur-sm">
|
||||
<span>Open Image</span>
|
||||
<span class="inline-flex items-center gap-2 text-sky-200/90">
|
||||
<i class="fa-solid fa-magnifying-glass-plus text-[11px]"></i>
|
||||
Full Image
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -11,6 +11,22 @@
|
||||
(object) ['name' => 'Announcements', 'url' => route('news.index')],
|
||||
(object) ['name' => '#' . $tag->name, 'url' => route('news.tag', $tag->slug)],
|
||||
]);
|
||||
|
||||
$seo = \App\Support\Seo\SeoDataBuilder::fromArray([
|
||||
'title' => '#' . $tag->name . ' — News',
|
||||
'description' => 'Announcements tagged with ' . $tag->name . '.',
|
||||
'canonical' => route('news.tag', $tag->slug),
|
||||
'breadcrumbs' => $headerBreadcrumbs,
|
||||
'structured_data' => [
|
||||
[
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CollectionPage',
|
||||
'name' => '#' . $tag->name . ' — News',
|
||||
'description' => 'Stories and announcements tagged with #' . $tag->name . '.',
|
||||
'url' => route('news.tag', $tag->slug),
|
||||
],
|
||||
],
|
||||
])->build();
|
||||
@endphp
|
||||
|
||||
<x-nova-page-header
|
||||
|
||||
@@ -16,97 +16,78 @@
|
||||
$shouldBlur = (bool) ($maturity['should_blur'] ?? false);
|
||||
$cardImageId = ($idPrefix ?? 'artwork') . '-image-' . ($index ?? 0);
|
||||
$medalScore = (int) data_get($artwork, 'medals.score_30d', data_get($artwork, 'medals.score', 0));
|
||||
$cardFrameClass = ($layout ?? 'grid') === 'rail'
|
||||
? 'aspect-video'
|
||||
: 'aspect-[4/5] sm:aspect-[5/4] lg:aspect-video';
|
||||
@endphp
|
||||
|
||||
<article class="{{ ($layout ?? 'grid') === 'rail' ? 'min-w-[72%] snap-start sm:min-w-[44%] lg:min-w-0' : 'min-w-0' }}">
|
||||
<div class="group overflow-hidden rounded-2xl bg-black/20 shadow-lg shadow-black/40 ring-1 ring-white/5 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-within:ring-2 focus-within:ring-sky-300/70">
|
||||
<a href="{{ $artworkUrl }}" class="relative block overflow-hidden">
|
||||
<div class="relative aspect-video overflow-hidden bg-neutral-900">
|
||||
<div class="pointer-events-none absolute inset-0 z-10 bg-gradient-to-br from-white/10 via-white/5 to-transparent"></div>
|
||||
<img
|
||||
id="{{ $cardImageId }}"
|
||||
src="{{ $thumbUrl }}"
|
||||
@if (!empty($artwork['thumb_srcset']))
|
||||
srcset="{{ $artwork['thumb_srcset'] }}"
|
||||
sizes="{{ $sizes ?? '100vw' }}"
|
||||
@endif
|
||||
alt="{{ $titleText }}"
|
||||
width="{{ max(1, (int) ($artwork['width'] ?? 1600)) }}"
|
||||
height="{{ max(1, (int) ($artwork['height'] ?? 900)) }}"
|
||||
class="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04] {{ $shouldBlur ? 'blur-2xl scale-[1.03]' : '' }}"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
|
||||
@if (!empty($badge))
|
||||
<div class="absolute left-3 top-3 z-30">
|
||||
<span class="inline-flex items-center rounded-md px-2 py-1 text-[11px] font-bold ring-1 ring-white/10 backdrop-blur-sm {{ $badgeClass ?? 'bg-sky-500/80 text-white' }}">
|
||||
{{ $badge }}
|
||||
</span>
|
||||
</div>
|
||||
@elseif ($metricBadge && !empty($metricBadge['label']))
|
||||
<div class="absolute left-3 top-3 z-30">
|
||||
<span class="inline-flex items-center rounded-full border border-sky-300/30 bg-sky-500/14 px-2.5 py-1 text-[11px] font-semibold text-sky-100 ring-1 ring-sky-300/20 backdrop-blur-sm">
|
||||
{{ $metricBadge['label'] }}
|
||||
</span>
|
||||
</div>
|
||||
<a href="{{ $artworkUrl }}" class="group relative block overflow-hidden rounded-2xl bg-black/20 shadow-lg shadow-black/40 ring-1 ring-white/5 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70">
|
||||
<div class="relative {{ $cardFrameClass }} overflow-hidden bg-neutral-900">
|
||||
<div class="pointer-events-none absolute inset-0 z-10 bg-gradient-to-br from-white/10 via-white/5 to-transparent"></div>
|
||||
<img
|
||||
id="{{ $cardImageId }}"
|
||||
src="{{ $thumbUrl }}"
|
||||
@if (!empty($artwork['thumb_srcset']))
|
||||
srcset="{{ $artwork['thumb_srcset'] }}"
|
||||
sizes="{{ $sizes ?? '100vw' }}"
|
||||
@endif
|
||||
alt="{{ $titleText }}"
|
||||
width="{{ max(1, (int) ($artwork['width'] ?? 1600)) }}"
|
||||
height="{{ max(1, (int) ($artwork['height'] ?? 900)) }}"
|
||||
class="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04] {{ $shouldBlur ? 'blur-2xl scale-[1.03]' : '' }}"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
|
||||
@if ($medalScore > 0)
|
||||
<div class="absolute right-3 top-3 z-30">
|
||||
<span class="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/12 px-2.5 py-1 text-[11px] font-semibold text-amber-100 ring-1 ring-amber-300/20 backdrop-blur-sm">
|
||||
Medal {{ number_format($medalScore) }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@if (!empty($badge))
|
||||
<div class="absolute left-3 top-3 z-30">
|
||||
<span class="inline-flex items-center rounded-md px-2 py-1 text-[11px] font-bold ring-1 ring-white/10 backdrop-blur-sm {{ $badgeClass ?? 'bg-sky-500/80 text-white' }}">
|
||||
{{ $badge }}
|
||||
</span>
|
||||
</div>
|
||||
@elseif ($metricBadge && !empty($metricBadge['label']))
|
||||
<div class="absolute left-3 top-3 z-30">
|
||||
<span class="inline-flex items-center rounded-full border border-sky-300/30 bg-sky-500/14 px-2.5 py-1 text-[11px] font-semibold text-sky-100 ring-1 ring-sky-300/20 backdrop-blur-sm">
|
||||
{{ $metricBadge['label'] }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($shouldBlur)
|
||||
<div class="absolute inset-0 z-20 flex items-center justify-center bg-slate-950/55 p-4" data-home-mature-overlay>
|
||||
<div class="rounded-2xl border border-white/10 bg-black/45 px-4 py-4 text-center shadow-2xl backdrop-blur-md">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-white/70">Mature content</p>
|
||||
<p class="mt-2 max-w-[16rem] text-sm text-white/90">This artwork may contain mature material.</p>
|
||||
<button
|
||||
type="button"
|
||||
data-home-mature-toggle="{{ $cardImageId }}"
|
||||
class="mt-4 inline-flex items-center rounded-full border border-white/15 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/20"
|
||||
>
|
||||
Reveal image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if ($medalScore > 0)
|
||||
<div class="absolute right-3 top-3 z-30">
|
||||
<span class="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/12 px-2.5 py-1 text-[11px] font-semibold text-amber-100 ring-1 ring-amber-300/20 backdrop-blur-sm">
|
||||
Medal {{ number_format($medalScore) }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
|
||||
<div class="truncate text-sm font-semibold text-white">{{ $titleText }}</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-white/80">
|
||||
<img src="{{ $authorAvatar }}" alt="{{ $authorName }}" class="h-6 w-6 shrink-0 rounded-full object-cover" loading="lazy" decoding="async">
|
||||
<span class="truncate">{{ $authorName }}</span>
|
||||
@if ($authorUsername !== '')
|
||||
<span class="shrink-0 text-white/50">@{{ $authorUsername }}</span>
|
||||
@endif
|
||||
@if ($shouldBlur)
|
||||
<div class="absolute inset-0 z-20 flex items-center justify-center bg-slate-950/55 p-4" data-home-mature-overlay>
|
||||
<div class="rounded-2xl border border-white/10 bg-black/45 px-4 py-4 text-center shadow-2xl backdrop-blur-md">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-white/70">Mature content</p>
|
||||
<p class="mt-2 max-w-[16rem] text-sm text-white/90">This artwork may contain mature material.</p>
|
||||
<button
|
||||
type="button"
|
||||
data-home-mature-toggle="{{ $cardImageId }}"
|
||||
class="mt-4 inline-flex items-center rounded-full border border-white/15 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/20"
|
||||
>
|
||||
Reveal image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="flex items-start justify-between gap-3 border-t border-white/5 bg-slate-950/40 px-3 py-3">
|
||||
<div class="min-w-0">
|
||||
<a href="{{ $artworkUrl }}" class="block truncate text-sm font-semibold text-white transition hover:text-sky-100">{{ $titleText }}</a>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-soft">
|
||||
@if ($authorUrl)
|
||||
<a href="{{ $authorUrl }}" class="truncate text-nova-200 transition hover:text-white">{{ $authorName }}</a>
|
||||
@else
|
||||
<span class="truncate">{{ $authorName }}</span>
|
||||
@endif
|
||||
@if (!empty($artwork['category_name']))
|
||||
<span class="shrink-0 text-white/35">•</span>
|
||||
<span class="truncate">{{ $artwork['category_name'] }}</span>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
|
||||
<div class="truncate text-sm font-semibold text-white">{{ $titleText }}</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-white/80">
|
||||
<img src="{{ $authorAvatar }}" alt="{{ $authorName }}" class="h-6 w-6 shrink-0 rounded-full object-cover" loading="lazy" decoding="async">
|
||||
<span class="truncate">{{ $authorName }}</span>
|
||||
@if ($authorUsername !== '')
|
||||
<span class="shrink-0 text-white/50">{{ $authorUsername }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ $artworkUrl }}" class="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-white/80 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white">
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
|
||||
@if ($sectionItems->isNotEmpty())
|
||||
<div class="{{ $sectionLayout === 'rail' ? 'flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 ' . $sectionColumns . ' lg:grid lg:overflow-visible' : 'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 ' . $sectionColumns }}">
|
||||
<div class="{{ $sectionLayout === 'rail' ? 'flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 ' . $sectionColumns . ' lg:grid lg:overflow-visible' : 'grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 ' . $sectionColumns }}">
|
||||
@foreach ($sectionItems as $index => $item)
|
||||
@include('web.home.sections.artwork-card', [
|
||||
'item' => $item,
|
||||
@@ -36,7 +36,7 @@
|
||||
'badgeClass' => $badge_class ?? null,
|
||||
'sizes' => $sectionLayout === 'rail'
|
||||
? '(max-width: 640px) 72vw, (max-width: 1024px) 44vw, 240px'
|
||||
: '(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw',
|
||||
: '(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw',
|
||||
'idPrefix' => Str::slug((string) $title, '-'),
|
||||
'index' => $index,
|
||||
])
|
||||
|
||||
@@ -35,13 +35,13 @@ Route::middleware(['web', 'throttle:60,1'])->prefix('leaderboard')->name('api.le
|
||||
Route::get('worlds', [\App\Http\Controllers\Api\LeaderboardController::class, 'worlds'])->name('worlds');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth', 'creator.access'])->prefix('stories')->name('api.stories.')->group(function () {
|
||||
Route::middleware(['web', 'auth', 'verified', 'creator.access'])->prefix('stories')->name('api.stories.')->group(function () {
|
||||
Route::post('create', [\App\Http\Controllers\StoryController::class, 'apiCreate'])->middleware('forum.bot.protection:api_write')->name('create');
|
||||
Route::put('update', [\App\Http\Controllers\StoryController::class, 'apiUpdate'])->middleware('forum.bot.protection:api_write')->name('update');
|
||||
Route::post('autosave', [\App\Http\Controllers\StoryController::class, 'apiAutosave'])->middleware('forum.bot.protection:api_write')->name('autosave');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth', 'creator.access'])->prefix('story')->name('api.story.')->group(function () {
|
||||
Route::middleware(['web', 'auth', 'verified', 'creator.access'])->prefix('story')->name('api.story.')->group(function () {
|
||||
Route::post('upload-image', [\App\Http\Controllers\StoryController::class, 'apiUploadImage'])->middleware('forum.bot.protection:api_write')->name('upload-image');
|
||||
Route::get('artworks', [\App\Http\Controllers\StoryController::class, 'apiArtworks'])->name('artworks');
|
||||
});
|
||||
@@ -141,7 +141,7 @@ Route::prefix('rank')->name('api.rank.')->middleware(['throttle:60,1'])->group(f
|
||||
*/
|
||||
|
||||
// ── Studio Pro API (authenticated) ─────────────────────────────────────────────
|
||||
Route::middleware(['web', 'auth'])->prefix('studio')->name('api.studio.')->group(function () {
|
||||
Route::middleware(['web', 'auth', 'verified'])->prefix('studio')->name('api.studio.')->group(function () {
|
||||
Route::post('events', [\App\Http\Controllers\Studio\StudioEventsApiController::class, 'store'])->name('events.store');
|
||||
Route::get('upload-queue', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'index'])->name('upload-queue.index');
|
||||
Route::post('upload-queue/batches', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'store'])->name('upload-queue.store');
|
||||
@@ -150,6 +150,9 @@ Route::middleware(['web', 'auth'])->prefix('studio')->name('api.studio.')->group
|
||||
Route::post('upload-queue/items/{id}/retry', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'retry'])->whereNumber('id')->name('upload-queue.items.retry');
|
||||
Route::post('news/media/upload', [\App\Http\Controllers\Studio\StudioNewsMediaApiController::class, 'store'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('news.media.upload');
|
||||
Route::delete('news/media', [\App\Http\Controllers\Studio\StudioNewsMediaApiController::class, 'destroy'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('news.media.destroy');
|
||||
Route::post('academy/lessons/media/upload', [\App\Http\Controllers\Settings\AcademyLessonMediaApiController::class, 'store'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('academy.lessons.media.upload');
|
||||
Route::get('academy/lessons/media/assets', [\App\Http\Controllers\Settings\AcademyLessonMediaApiController::class, 'assets'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('academy.lessons.media.assets');
|
||||
Route::delete('academy/lessons/media', [\App\Http\Controllers\Settings\AcademyLessonMediaApiController::class, 'destroy'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('academy.lessons.media.destroy');
|
||||
Route::post('worlds/media/upload', [\App\Http\Controllers\Studio\StudioWorldMediaApiController::class, 'store'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('worlds.media.upload');
|
||||
Route::delete('worlds/media', [\App\Http\Controllers\Studio\StudioWorldMediaApiController::class, 'destroy'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('worlds.media.destroy');
|
||||
Route::put('preferences', [\App\Http\Controllers\Studio\StudioPreferencesApiController::class, 'updatePreferences'])->name('preferences.settings');
|
||||
@@ -180,7 +183,11 @@ Route::middleware(['web', 'auth'])->prefix('studio')->name('api.studio.')->group
|
||||
Route::get('tags/search', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'searchTags'])->name('tags.search');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth'])->prefix('cards')->name('api.cards.')->group(function () {
|
||||
Route::middleware(['web', 'auth', 'verified'])->prefix('academy')->name('api.academy.')->group(function () {
|
||||
Route::post('categories', [\App\Http\Controllers\Settings\AcademyAdminController::class, 'categoriesStoreJson'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('categories.store');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth', 'verified'])->prefix('cards')->name('api.cards.')->group(function () {
|
||||
Route::post('{id}/like', [\App\Http\Controllers\Api\NovaCards\NovaCardInteractionController::class, 'like'])
|
||||
->middleware(['throttle:nova-cards-render', 'forum.bot.protection:api_write'])
|
||||
->whereNumber('id')
|
||||
@@ -650,7 +657,7 @@ Route::middleware(['web'])
|
||||
Route::get('{id}/medal', [\App\Http\Controllers\Api\ArtworkAwardController::class, 'show'])->whereNumber('id')->name('show');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth', 'normalize.username'])->group(function () {
|
||||
Route::middleware(['web', 'auth', 'verified', 'normalize.username'])->group(function () {
|
||||
Route::match(['post', 'delete'], 'like', [\App\Http\Controllers\Api\SocialCompatibilityController::class, 'like'])
|
||||
->name('api.social.like');
|
||||
|
||||
@@ -706,7 +713,7 @@ Route::middleware(['web', 'throttle:60,1'])
|
||||
->whereNumber('id')
|
||||
->name('api.artworks.comments.index');
|
||||
|
||||
Route::middleware(['web', 'auth', 'normalize.username', 'throttle:20,1'])->group(function () {
|
||||
Route::middleware(['web', 'auth', 'verified', 'normalize.username', 'throttle:20,1'])->group(function () {
|
||||
Route::post('artworks/{id}/comments', [\App\Http\Controllers\Api\ArtworkCommentController::class, 'store'])
|
||||
->whereNumber('id')
|
||||
->name('api.artworks.comments.store');
|
||||
@@ -725,7 +732,7 @@ Route::middleware(['web', 'throttle:60,1'])
|
||||
->whereNumber('id')
|
||||
->name('api.news.comments.index');
|
||||
|
||||
Route::middleware(['web', 'auth', 'normalize.username', 'throttle:20,1'])->group(function () {
|
||||
Route::middleware(['web', 'auth', 'verified', 'normalize.username', 'throttle:20,1'])->group(function () {
|
||||
Route::post('news/articles/{id}/comments', [\App\Http\Controllers\Api\NewsArticleCommentController::class, 'store'])
|
||||
->whereNumber('id')
|
||||
->name('api.news.comments.store');
|
||||
@@ -755,7 +762,7 @@ Route::middleware(['web', 'throttle:reactions-read'])->group(function () {
|
||||
->name('api.news.comments.reactions.index');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth', 'normalize.username', 'throttle:reactions-write'])->group(function () {
|
||||
Route::middleware(['web', 'auth', 'verified', 'normalize.username', 'throttle:reactions-write'])->group(function () {
|
||||
Route::post('artworks/{id}/reactions', [\App\Http\Controllers\Api\ReactionController::class, 'toggleArtworkReaction'])
|
||||
->whereNumber('id')
|
||||
->name('api.artworks.reactions.toggle');
|
||||
@@ -808,7 +815,7 @@ Route::middleware(['web'])
|
||||
->name('following');
|
||||
|
||||
// Auth-required: follow / unfollow
|
||||
Route::middleware(['auth', 'normalize.username', 'throttle:follow-write', 'forum.security.firewall:follow', 'forum.bot.protection:follow'])->group(function () {
|
||||
Route::middleware(['auth', 'verified', 'normalize.username', 'throttle:follow-write', 'forum.security.firewall:follow', 'forum.bot.protection:follow'])->group(function () {
|
||||
Route::post('{username}/follow', [\App\Http\Controllers\Api\FollowController::class, 'follow'])
|
||||
->where('username', '[A-Za-z0-9_-]{3,20}')
|
||||
->name('follow');
|
||||
@@ -835,7 +842,7 @@ Route::middleware(['web'])
|
||||
// POST /api/messages/{conversation_id}/{message_id}/react → add reaction
|
||||
// DELETE /api/messages/{conversation_id}/{message_id}/react → remove reaction
|
||||
// DELETE /api/messages/message/{message_id} → soft-delete message
|
||||
Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1'])
|
||||
Route::middleware(['web', 'auth', 'verified', 'normalize.username', 'throttle:60,1'])
|
||||
->prefix('messages')
|
||||
->name('api.messages.')
|
||||
->group(function () {
|
||||
@@ -931,7 +938,7 @@ Route::middleware(['web', 'throttle:60,1'])
|
||||
->name('comments.index');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth', 'normalize.username'])
|
||||
Route::middleware(['web', 'auth', 'verified', 'normalize.username'])
|
||||
->prefix('posts')
|
||||
->name('api.posts.')
|
||||
->group(function () {
|
||||
@@ -1021,7 +1028,7 @@ Route::middleware(['web', 'throttle:social-read'])
|
||||
->name('index');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth'])
|
||||
Route::middleware(['web', 'auth', 'verified'])
|
||||
->prefix('stories')
|
||||
->name('api.stories.social.')
|
||||
->group(function () {
|
||||
|
||||
@@ -60,6 +60,7 @@ use App\Http\Controllers\User\MonthlyCommentatorsController;
|
||||
use App\Http\Controllers\User\ProfileCollectionController;
|
||||
use App\Http\Controllers\User\SavedCollectionController;
|
||||
use App\Http\Controllers\User\CollectionSavedLibraryController;
|
||||
use App\Http\Controllers\Moderation\Traffic\OnlineVisitorsController;
|
||||
use App\Http\Controllers\Settings\CollectionAiController;
|
||||
use App\Http\Controllers\Settings\CollectionInsightsController;
|
||||
use App\Http\Controllers\Settings\CollectionManageController;
|
||||
@@ -325,10 +326,10 @@ Route::prefix('news')->name('news.')->group(function () {
|
||||
->name('author');
|
||||
Route::get('category/{slug}', [FrontendNewsController::class, 'category'])->name('category');
|
||||
Route::get('tag/{slug}', [FrontendNewsController::class, 'tag'])->name('tag');
|
||||
Route::middleware('auth')->post('{slug}/comments', [NewsArticleCommentController::class, 'store'])
|
||||
Route::middleware(['auth', 'verified'])->post('{slug}/comments', [NewsArticleCommentController::class, 'store'])
|
||||
->where('slug', '[a-z0-9\-]+')
|
||||
->name('comments.store');
|
||||
Route::middleware('auth')->delete('{slug}/comments/{comment}', [NewsArticleCommentController::class, 'destroy'])
|
||||
Route::middleware(['auth', 'verified'])->delete('{slug}/comments/{comment}', [NewsArticleCommentController::class, 'destroy'])
|
||||
->where('slug', '[a-z0-9\-]+')
|
||||
->whereNumber('comment')
|
||||
->name('comments.destroy');
|
||||
@@ -400,7 +401,7 @@ Route::get('/groups/{group}/{section}', [\App\Http\Controllers\GroupController::
|
||||
Route::get('/groups/{group}/posts/{post}', [\App\Http\Controllers\GroupPostController::class, 'show'])
|
||||
->name('groups.posts.show');
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::post('/me/saved/collections/lists', [CollectionSavedLibraryController::class, 'storeList'])
|
||||
->name('me.saved.collections.lists.store');
|
||||
Route::get('/me/saved/collections/lists/{listSlug}', [\App\Http\Controllers\User\SavedCollectionController::class, 'showList'])
|
||||
@@ -459,11 +460,11 @@ Route::get('/@{username}', [ProfileController::class, 'showByUsername'])
|
||||
->where('username', '[A-Za-z0-9_-]{3,20}')
|
||||
->name('profile.show');
|
||||
|
||||
Route::middleware('auth')->post('/@{username}/follow', [ProfileController::class, 'toggleFollow'])
|
||||
Route::middleware(['auth', 'verified'])->post('/@{username}/follow', [ProfileController::class, 'toggleFollow'])
|
||||
->where('username', '[A-Za-z0-9_-]{3,20}')
|
||||
->name('profile.follow');
|
||||
|
||||
Route::middleware('auth')->post('/@{username}/comment', [ProfileController::class, 'storeComment'])
|
||||
Route::middleware(['auth', 'verified'])->post('/@{username}/comment', [ProfileController::class, 'storeComment'])
|
||||
->where('username', '[A-Za-z0-9_-]{3,20}')
|
||||
->name('profile.comment');
|
||||
|
||||
@@ -507,7 +508,7 @@ Route::middleware(['auth'])->get('/dashboard/profile', [ProfileController::class
|
||||
Route::middleware(['auth'])->get('/settings/profile', [ProfileController::class, 'editSettings'])->name('settings.profile');
|
||||
|
||||
// ── STUDIO Pro (/studio/*) ────────────────────────────────────────────────────
|
||||
Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->name('studio.')->group(function () {
|
||||
Route::middleware(['auth', 'verified', 'ensure.onboarding.complete'])->prefix('studio')->name('studio.')->group(function () {
|
||||
Route::get('/', [StudioController::class, 'index'])->name('index');
|
||||
Route::get('/content', [StudioController::class, 'content'])->name('content');
|
||||
Route::get('/artworks', [StudioController::class, 'artworks'])->name('artworks');
|
||||
@@ -708,7 +709,7 @@ Route::get('/internal/nova-cards/render-frame/{uuid}', [\App\Http\Controllers\In
|
||||
->where('uuid', '[0-9a-f\-]{36}')
|
||||
->name('nova-cards.render-frame');
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::post('/cards/{card}/comments', [\App\Http\Controllers\NovaCardCommentController::class, 'store'])
|
||||
->whereNumber('card')
|
||||
->name('cards.comments.store');
|
||||
@@ -756,7 +757,7 @@ Route::middleware(['artwork.maturity.access'])->prefix('cp/ai-biography')->name(
|
||||
});
|
||||
|
||||
// ── SETTINGS / PROFILE EDIT ───────────────────────────────────────────────────
|
||||
Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])->group(function () {
|
||||
Route::middleware(['auth', 'verified', 'normalize.username', 'ensure.onboarding.complete'])->group(function () {
|
||||
Route::get('/profile', fn () => redirect()->route('dashboard.profile', [], 301))->name('legacy.profile.redirect');
|
||||
Route::get('/settings', fn () => redirect()->route('dashboard.profile', [], 302))->name('settings');
|
||||
Route::get('/profile/edit', fn () => redirect()->route('dashboard.profile', [], 302))->name('profile.edit');
|
||||
@@ -993,12 +994,12 @@ Route::view('/data-deletion', 'privacy.data-deletion')->name('privacy.data_delet
|
||||
Route::view('/blank', 'blank')->name('blank');
|
||||
|
||||
// ── MESSAGES ──────────────────────────────────────────────────────────────────
|
||||
Route::middleware(['auth', 'ensure.onboarding.complete'])
|
||||
Route::middleware(['auth', 'verified', 'ensure.onboarding.complete'])
|
||||
->get('/messages/attachments/{id}', [\App\Http\Controllers\Api\Messaging\AttachmentController::class, 'show'])
|
||||
->whereNumber('id')
|
||||
->name('messages.attachments.show');
|
||||
|
||||
Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('messages')->name('messages.')->group(function () {
|
||||
Route::middleware(['auth', 'verified', 'ensure.onboarding.complete'])->prefix('messages')->name('messages.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Messaging\MessagesPageController::class, 'index'])->name('index');
|
||||
Route::get('/{id}', [\App\Http\Controllers\Messaging\MessagesPageController::class, 'show'])->whereNumber('id')->name('show');
|
||||
});
|
||||
@@ -1018,6 +1019,17 @@ Route::middleware(['auth'])
|
||||
})->where('path', '.*');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'admin.access'])
|
||||
->prefix('moderation')
|
||||
->name('moderation.')
|
||||
->group(function (): void {
|
||||
Route::get('/traffic/online', [OnlineVisitorsController::class, 'index'])
|
||||
->name('traffic.online');
|
||||
|
||||
Route::get('/traffic/online/data', [OnlineVisitorsController::class, 'data'])
|
||||
->name('traffic.online.data');
|
||||
});
|
||||
|
||||
// ── ADMIN PANEL ───────────────────────────────────────────────────────────────
|
||||
Route::middleware(['auth', 'admin.access'])
|
||||
->prefix('moderation')
|
||||
|
||||
@@ -5,13 +5,16 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature\Academy;
|
||||
|
||||
use App\Http\Middleware\ConditionalValidateCsrfToken;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Models\AcademyAiComparisonResult;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyChallengeSubmission;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonBlock;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Tests\TestCase;
|
||||
@@ -139,8 +142,8 @@ final class AcademyFeatureTest extends TestCase
|
||||
->where('item.prompt', null)
|
||||
->where('item.negative_prompt', null));
|
||||
|
||||
$version = app(\App\Http\Middleware\HandleInertiaRequests::class)
|
||||
->version(\Illuminate\Http\Request::create(route('academy.prompts.show', ['slug' => $prompt->slug]), 'GET'));
|
||||
$version = app(HandleInertiaRequests::class)
|
||||
->version(Request::create(route('academy.prompts.show', ['slug' => $prompt->slug]), 'GET'));
|
||||
|
||||
$this->withHeaders([
|
||||
'X-Inertia' => 'true',
|
||||
@@ -180,6 +183,96 @@ final class AcademyFeatureTest extends TestCase
|
||||
->where('item.prompt', 'VISIBLE PREMIUM PROMPT'));
|
||||
}
|
||||
|
||||
public function test_public_lesson_payload_includes_active_ai_comparison_block_and_hides_inactive_results(): void
|
||||
{
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Free Lesson With Comparison',
|
||||
'slug' => 'free-lesson-with-comparison',
|
||||
'excerpt' => 'Visible to guests.',
|
||||
'content' => 'Free lesson content',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
$block = AcademyLessonBlock::query()->create([
|
||||
'lesson_id' => $lesson->id,
|
||||
'type' => 'ai_comparison',
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'payload' => [
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'intro' => 'We used the same prompt in multiple tools.',
|
||||
'prompt' => 'A peaceful fantasy forest wallpaper.',
|
||||
'negative_prompt' => 'text, watermark',
|
||||
'aspect_ratio' => '16:9',
|
||||
'criteria' => ['Composition', 'Lighting'],
|
||||
],
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
AcademyAiComparisonResult::query()->create([
|
||||
'lesson_block_id' => $block->id,
|
||||
'provider' => 'OpenAI',
|
||||
'model_name' => 'ChatGPT Images',
|
||||
'image_path' => 'academy/lessons/body/aa/bb/example.webp',
|
||||
'strengths' => 'Strong composition',
|
||||
'score' => 9,
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
AcademyAiComparisonResult::query()->create([
|
||||
'lesson_block_id' => $block->id,
|
||||
'provider' => 'Google',
|
||||
'model_name' => 'Gemini',
|
||||
'image_path' => 'academy/lessons/body/aa/bb/example-2.webp',
|
||||
'score' => 7,
|
||||
'sort_order' => 1,
|
||||
'active' => false,
|
||||
]);
|
||||
|
||||
$this->get(route('academy.lessons.show', ['slug' => $lesson->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Academy/Show')
|
||||
->where('item.blocks.0.payload.prompt', 'A peaceful fantasy forest wallpaper.')
|
||||
->has('item.blocks.0.comparison_results', 1)
|
||||
->where('item.blocks.0.comparison_results.0.model_name', 'ChatGPT Images'));
|
||||
}
|
||||
|
||||
public function test_public_lesson_with_sparse_ai_comparison_block_still_renders_payload(): void
|
||||
{
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Sparse Comparison Lesson',
|
||||
'slug' => 'sparse-comparison-lesson',
|
||||
'excerpt' => 'Sparse block test.',
|
||||
'content' => 'Free lesson content',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
AcademyLessonBlock::query()->create([
|
||||
'lesson_id' => $lesson->id,
|
||||
'type' => 'ai_comparison',
|
||||
'title' => 'Prompt only block',
|
||||
'payload' => [
|
||||
'title' => 'Prompt only block',
|
||||
'prompt' => 'A fantasy forest at sunrise.',
|
||||
],
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$this->get(route('academy.lessons.show', ['slug' => $lesson->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->has('item.blocks', 1)
|
||||
->where('item.blocks.0.payload.prompt', 'A fantasy forest at sunrise.')
|
||||
->has('item.blocks.0.comparison_results', 0));
|
||||
}
|
||||
|
||||
public function test_logged_in_user_can_mark_lesson_completed(): void
|
||||
{
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
@@ -511,4 +604,4 @@ final class AcademyFeatureTest extends TestCase
|
||||
'workflow_notes' => 'Workflow two',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Http\Middleware\ConditionalValidateCsrfToken;
|
||||
use App\Models\AcademyAiComparisonResult;
|
||||
use App\Models\AcademyCategory;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyChallengeSubmission;
|
||||
use App\Models\AcademyCategory;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonBlock;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -128,4 +131,230 @@ final class AcademyAdminTest extends TestCase
|
||||
$this->assertNull(Cache::get('academy.home'));
|
||||
$this->assertNull(Cache::get('academy.categories.lesson'));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_admin_can_create_a_lesson_with_ai_comparison_block(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('admin.academy.lessons.store'), [
|
||||
'title' => 'AI Comparison Lesson',
|
||||
'slug' => 'ai-comparison-lesson',
|
||||
'excerpt' => 'Testing comparison block creation.',
|
||||
'content' => '<p>Lesson body.</p>',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'cover_image' => '',
|
||||
'video_url' => '',
|
||||
'reading_minutes' => 5,
|
||||
'featured' => false,
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||
'seo_title' => '',
|
||||
'seo_description' => '',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'ai_comparison',
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'payload' => [
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'intro' => 'Compare multiple tools.',
|
||||
'prompt' => 'A peaceful fantasy forest wallpaper.',
|
||||
'negative_prompt' => 'text, watermark',
|
||||
'aspect_ratio' => '16:9',
|
||||
'criteria' => ['Composition', 'Lighting'],
|
||||
],
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
'comparison_results' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$lesson = AcademyLesson::query()->where('slug', 'ai-comparison-lesson')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]));
|
||||
|
||||
$this->assertDatabaseHas('academy_lesson_blocks', [
|
||||
'lesson_id' => $lesson->id,
|
||||
'type' => 'ai_comparison',
|
||||
'active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_can_add_ai_comparison_result_to_existing_lesson(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Existing Lesson',
|
||||
'slug' => 'existing-lesson',
|
||||
'content' => 'Body',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.academy.lessons.update', ['academyLesson' => $lesson]), [
|
||||
'title' => $lesson->title,
|
||||
'slug' => $lesson->slug,
|
||||
'excerpt' => '',
|
||||
'content' => $lesson->content,
|
||||
'difficulty' => $lesson->difficulty,
|
||||
'access_level' => $lesson->access_level,
|
||||
'lesson_type' => $lesson->lesson_type,
|
||||
'cover_image' => '',
|
||||
'video_url' => '',
|
||||
'reading_minutes' => 5,
|
||||
'featured' => false,
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||
'seo_title' => '',
|
||||
'seo_description' => '',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'ai_comparison',
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'payload' => [
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'intro' => 'Compare multiple tools.',
|
||||
'prompt' => 'A peaceful fantasy forest wallpaper.',
|
||||
'negative_prompt' => '',
|
||||
'aspect_ratio' => '16:9',
|
||||
'criteria' => ['Composition'],
|
||||
],
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
'comparison_results' => [
|
||||
[
|
||||
'provider' => 'OpenAI',
|
||||
'model_name' => 'ChatGPT Images',
|
||||
'image_path' => 'academy/lessons/body/aa/bb/example.webp',
|
||||
'thumb_path' => 'academy/lessons/body/aa/bb/example-thumb.webp',
|
||||
'settings' => 'Default quality',
|
||||
'strengths' => 'Strong composition',
|
||||
'weaknesses' => 'Slightly over-polished',
|
||||
'best_for' => 'Wallpaper concepts',
|
||||
'score' => 9,
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]));
|
||||
|
||||
$block = AcademyLessonBlock::query()->where('lesson_id', $lesson->id)->firstOrFail();
|
||||
|
||||
$this->assertDatabaseHas('academy_ai_comparison_results', [
|
||||
'lesson_block_id' => $block->id,
|
||||
'provider' => 'OpenAI',
|
||||
'model_name' => 'ChatGPT Images',
|
||||
'score' => 9,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_ai_comparison_score_must_stay_in_range(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Validation Lesson',
|
||||
'slug' => 'validation-lesson',
|
||||
'content' => 'Body',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->from(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]))
|
||||
->actingAs($admin)
|
||||
->patch(route('admin.academy.lessons.update', ['academyLesson' => $lesson]), [
|
||||
'title' => $lesson->title,
|
||||
'slug' => $lesson->slug,
|
||||
'excerpt' => '',
|
||||
'content' => $lesson->content,
|
||||
'difficulty' => $lesson->difficulty,
|
||||
'access_level' => $lesson->access_level,
|
||||
'lesson_type' => $lesson->lesson_type,
|
||||
'cover_image' => '',
|
||||
'video_url' => '',
|
||||
'reading_minutes' => 5,
|
||||
'featured' => false,
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||
'seo_title' => '',
|
||||
'seo_description' => '',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'ai_comparison',
|
||||
'title' => 'Invalid score block',
|
||||
'payload' => [
|
||||
'title' => 'Invalid score block',
|
||||
'prompt' => 'Prompt',
|
||||
'criteria' => ['Composition'],
|
||||
],
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
'comparison_results' => [
|
||||
[
|
||||
'provider' => 'OpenAI',
|
||||
'model_name' => 'ChatGPT Images',
|
||||
'image_path' => 'academy/lessons/body/aa/bb/example.webp',
|
||||
'score' => 11,
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]))
|
||||
->assertSessionHasErrors(['blocks.0.comparison_results.0.score']);
|
||||
}
|
||||
|
||||
public function test_lesson_delete_soft_deletes_ai_comparison_children(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Delete Lesson',
|
||||
'slug' => 'delete-lesson',
|
||||
'content' => 'Body',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
$block = AcademyLessonBlock::query()->create([
|
||||
'lesson_id' => $lesson->id,
|
||||
'type' => 'ai_comparison',
|
||||
'title' => 'Delete Block',
|
||||
'payload' => ['title' => 'Delete Block', 'prompt' => 'Prompt'],
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
$result = AcademyAiComparisonResult::query()->create([
|
||||
'lesson_block_id' => $block->id,
|
||||
'provider' => 'OpenAI',
|
||||
'model_name' => 'ChatGPT Images',
|
||||
'image_path' => 'academy/lessons/body/aa/bb/example.webp',
|
||||
'score' => 8,
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->delete(route('admin.academy.lessons.destroy', ['academyLesson' => $lesson]))
|
||||
->assertRedirect(route('admin.academy.lessons.index'));
|
||||
|
||||
$this->assertSoftDeleted('academy_lessons', ['id' => $lesson->id]);
|
||||
$this->assertSoftDeleted('academy_lesson_blocks', ['id' => $block->id]);
|
||||
$this->assertSoftDeleted('academy_ai_comparison_results', ['id' => $result->id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GenerateFeaturedArtworkThumbnailsJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Support\ArtworkFeaturedImagePath;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeFeaturedArtworkSource(Artwork $artwork, string $root): string
|
||||
{
|
||||
$hash = strtolower((string) $artwork->hash);
|
||||
$directory = $root.DIRECTORY_SEPARATOR.substr($hash, 0, 2).DIRECTORY_SEPARATOR.substr($hash, 2, 2);
|
||||
|
||||
File::ensureDirectoryExists($directory);
|
||||
|
||||
$path = $directory.DIRECTORY_SEPARATOR.$hash.'.png';
|
||||
$file = UploadedFile::fake()->image($hash.'.png', 1800, 1200);
|
||||
file_put_contents($path, $file->get());
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
function insertFeaturedArtworkRow(Artwork $artwork): void
|
||||
{
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'featured_at' => now()->subHour(),
|
||||
'expires_at' => null,
|
||||
'priority' => 500,
|
||||
'label' => null,
|
||||
'note' => null,
|
||||
'is_active' => true,
|
||||
'force_hero' => true,
|
||||
'created_by' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
test('featured thumbnail command generates dedicated featured variants', function () {
|
||||
Storage::fake('s3');
|
||||
|
||||
$localRoot = storage_path('framework/testing/featured-originals-command');
|
||||
$backupRoot = storage_path('framework/testing/featured-originals-command-backup');
|
||||
|
||||
File::deleteDirectory($localRoot);
|
||||
File::deleteDirectory($backupRoot);
|
||||
File::ensureDirectoryExists($localRoot);
|
||||
File::ensureDirectoryExists($backupRoot);
|
||||
|
||||
config([
|
||||
'uploads.object_storage.disk' => 's3',
|
||||
'uploads.local_originals_root' => $localRoot,
|
||||
'uploads.readonly_backup_originals_root' => $backupRoot,
|
||||
'cdn.files_url' => 'https://files.skinbase.org',
|
||||
]);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Featured Command Artwork',
|
||||
'hash' => str_repeat('b', 64),
|
||||
'file_ext' => 'png',
|
||||
'thumb_ext' => 'webp',
|
||||
]);
|
||||
|
||||
insertFeaturedArtworkRow($artwork);
|
||||
makeFeaturedArtworkSource($artwork, $localRoot);
|
||||
|
||||
$this->artisan('skinbase:featured-thumbnails:generate', [
|
||||
'--artwork' => [(string) $artwork->id],
|
||||
])->assertExitCode(0);
|
||||
|
||||
$paths = app(ArtworkFeaturedImagePath::class);
|
||||
|
||||
foreach ($paths->variantNames() as $variant) {
|
||||
Storage::disk('s3')->assertExists($paths->objectPath($artwork, $variant));
|
||||
}
|
||||
});
|
||||
|
||||
test('featured thumbnail generation purges Cloudflare for generated featured variants', function () {
|
||||
Http::fake([
|
||||
'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache' => Http::response(['success' => true], 200),
|
||||
]);
|
||||
|
||||
Storage::fake('s3');
|
||||
|
||||
$localRoot = storage_path('framework/testing/featured-originals-command-purge');
|
||||
$backupRoot = storage_path('framework/testing/featured-originals-command-purge-backup');
|
||||
|
||||
File::deleteDirectory($localRoot);
|
||||
File::deleteDirectory($backupRoot);
|
||||
File::ensureDirectoryExists($localRoot);
|
||||
File::ensureDirectoryExists($backupRoot);
|
||||
|
||||
config([
|
||||
'uploads.object_storage.disk' => 's3',
|
||||
'uploads.local_originals_root' => $localRoot,
|
||||
'uploads.readonly_backup_originals_root' => $backupRoot,
|
||||
'cdn.files_url' => 'https://files.skinbase.org',
|
||||
'cdn.cloudflare.zone_id' => 'test-zone',
|
||||
'cdn.cloudflare.api_token' => 'test-token',
|
||||
]);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Featured Command Artwork Purge',
|
||||
'hash' => str_repeat('d', 64),
|
||||
'file_ext' => 'png',
|
||||
'thumb_ext' => 'webp',
|
||||
]);
|
||||
|
||||
insertFeaturedArtworkRow($artwork);
|
||||
makeFeaturedArtworkSource($artwork, $localRoot);
|
||||
|
||||
$this->artisan('skinbase:featured-thumbnails:generate', [
|
||||
'--artwork' => [(string) $artwork->id],
|
||||
])->assertExitCode(0);
|
||||
|
||||
$paths = app(ArtworkFeaturedImagePath::class);
|
||||
$expectedUrls = collect($paths->variantNames())
|
||||
->map(fn (string $variant): string => $paths->url($artwork, $variant))
|
||||
->all();
|
||||
|
||||
Http::assertSent(function ($request) use ($expectedUrls): bool {
|
||||
$data = $request->data();
|
||||
|
||||
return $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache'
|
||||
&& $request->method() === 'POST'
|
||||
&& ($data['files'] ?? null) === $expectedUrls;
|
||||
});
|
||||
});
|
||||
|
||||
test('featured thumbnail command can queue generation jobs', function () {
|
||||
Queue::fake();
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'hash' => str_repeat('c', 64),
|
||||
'file_ext' => 'png',
|
||||
'thumb_ext' => 'webp',
|
||||
]);
|
||||
|
||||
insertFeaturedArtworkRow($artwork);
|
||||
|
||||
$this->artisan('skinbase:featured-thumbnails:generate', [
|
||||
'--artwork' => [(string) $artwork->id],
|
||||
'--queue' => true,
|
||||
])->assertExitCode(0);
|
||||
|
||||
Queue::assertPushed(GenerateFeaturedArtworkThumbnailsJob::class, 1);
|
||||
});
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use App\Jobs\SendVerificationEmailJob;
|
||||
use App\Models\User;
|
||||
use App\Services\Security\TurnstileVerifier;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
@@ -11,6 +11,7 @@ uses(RefreshDatabase::class);
|
||||
|
||||
it('rejects registration when honeypot field is filled', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
|
||||
$response = $this->from('/register')->post('/register', [
|
||||
'email' => 'bot1@example.com',
|
||||
@@ -24,6 +25,7 @@ it('rejects registration when honeypot field is filled', function () {
|
||||
|
||||
it('throttles excessive registration attempts by ip', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
config()->set('registration.ip_per_minute_limit', 2);
|
||||
config()->set('registration.ip_per_day_limit', 100);
|
||||
|
||||
@@ -45,6 +47,7 @@ it('throttles excessive registration attempts by ip', function () {
|
||||
|
||||
it('blocks disposable email domains during registration', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
config()->set('registration.disposable_domains_enabled', true);
|
||||
config()->set('disposable_email_domains.domains', ['tempmail.com']);
|
||||
|
||||
@@ -59,42 +62,56 @@ it('blocks disposable email domains during registration', function () {
|
||||
|
||||
it('requires turnstile after suspicious registration attempts', function () {
|
||||
Queue::fake();
|
||||
config()->set('registration.enable_turnstile', true);
|
||||
config()->set('registration.turnstile_suspicious_attempts', 1);
|
||||
config()->set('services.turnstile.enabled', true);
|
||||
config()->set('services.turnstile.site_key', 'site-key');
|
||||
config()->set('services.turnstile.secret_key', 'secret-key');
|
||||
|
||||
$mock = \Mockery::mock(TurnstileVerifier::class);
|
||||
$mock->shouldReceive('isEnabled')->andReturn(true);
|
||||
$mock->shouldReceive('verify')->once()->andReturn(false);
|
||||
$this->app->instance(TurnstileVerifier::class, $mock);
|
||||
|
||||
$response = $this->from('/register')->post('/register', [
|
||||
'email' => 'captcha-user@example.com',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/register');
|
||||
$response->assertSessionHasErrors('captcha');
|
||||
$response->assertSessionHasErrors('turnstile_token');
|
||||
$this->assertDatabaseMissing('users', ['email' => 'captcha-user@example.com']);
|
||||
});
|
||||
|
||||
it('shows turnstile when ip is in rate-limited state', function () {
|
||||
config()->set('registration.enable_turnstile', true);
|
||||
config()->set('registration.ip_per_minute_limit', 1);
|
||||
it('shows turnstile on the registration screen when enabled', function () {
|
||||
config()->set('services.turnstile.enabled', true);
|
||||
config()->set('services.turnstile.site_key', 'site-key');
|
||||
config()->set('services.turnstile.secret_key', 'secret-key');
|
||||
|
||||
RateLimiter::hit('register:ip:127.0.0.1', 60);
|
||||
|
||||
$this->get('/register')
|
||||
->assertOk()
|
||||
->assertSee('cf-turnstile', false);
|
||||
});
|
||||
|
||||
RateLimiter::clear('register:ip:127.0.0.1');
|
||||
it('rejects registration when turnstile verification fails', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', true);
|
||||
config()->set('services.turnstile.site_key', 'site-key');
|
||||
config()->set('services.turnstile.secret_key', 'secret-key');
|
||||
|
||||
Http::fake([
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify' => Http::response([
|
||||
'success' => false,
|
||||
'error-codes' => ['invalid-input-response'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->from('/register')->post('/register', [
|
||||
'email' => 'captcha-fail@example.com',
|
||||
'turnstile_token' => 'bad-token',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/register');
|
||||
$response->assertSessionHasErrors('turnstile_token');
|
||||
$this->assertDatabaseMissing('users', ['email' => 'captcha-fail@example.com']);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
it('enforces verification email cooldown per address', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
|
||||
$first = $this->post('/register', [
|
||||
'email' => 'cooldown2@example.com',
|
||||
@@ -114,6 +131,7 @@ it('enforces verification email cooldown per address', function () {
|
||||
|
||||
it('rejects registration for existing completed emails', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
|
||||
User::factory()->create([
|
||||
'email' => 'existing@example.com',
|
||||
@@ -133,30 +151,36 @@ it('rejects registration for existing completed emails', function () {
|
||||
|
||||
it('still allows registration when turnstile passes', function () {
|
||||
Queue::fake();
|
||||
config()->set('registration.enable_turnstile', true);
|
||||
config()->set('registration.turnstile_suspicious_attempts', 1);
|
||||
config()->set('services.turnstile.enabled', true);
|
||||
config()->set('services.turnstile.site_key', 'site-key');
|
||||
config()->set('services.turnstile.secret_key', 'secret-key');
|
||||
|
||||
$mock = \Mockery::mock(TurnstileVerifier::class);
|
||||
$mock->shouldReceive('isEnabled')->andReturn(true);
|
||||
$mock->shouldReceive('verify')->once()->andReturn(false);
|
||||
$mock->shouldReceive('verify')->once()->andReturn(true);
|
||||
$this->app->instance(TurnstileVerifier::class, $mock);
|
||||
|
||||
$first = $this->from('/register')->post('/register', [
|
||||
'email' => 'captcha-block@example.com',
|
||||
Http::fake([
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify' => Http::response([
|
||||
'success' => true,
|
||||
'hostname' => 'skinbase.org',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$first->assertRedirect('/register');
|
||||
$first->assertSessionHasErrors('captcha');
|
||||
|
||||
$response = $this->post('/register', [
|
||||
'email' => 'captcha-pass@example.com',
|
||||
'cf-turnstile-response' => 'good-token',
|
||||
'turnstile_token' => 'good-token',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/setup/password');
|
||||
$this->assertDatabaseHas('users', ['email' => 'captcha-pass@example.com']);
|
||||
Queue::assertNothingPushed();
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
it('does not require turnstile when disabled', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
|
||||
$response = $this->post('/register', [
|
||||
'email' => 'turnstile-disabled@example.com',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/setup/password');
|
||||
$this->assertDatabaseHas('users', ['email' => 'turnstile-disabled@example.com']);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ test('registration screen can be rendered', function () {
|
||||
|
||||
test('new users can register', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
|
||||
$response = $this->post('/register', [
|
||||
'email' => 'test@example.com',
|
||||
|
||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Services\HomepageService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\HomepageService;
|
||||
use App\Support\ArtworkFeaturedImagePath;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -132,6 +134,29 @@ test('featured query excludes inactive and expired feature rows', function () {
|
||||
->and($featuredIds)->not->toContain($expiredArtwork->id);
|
||||
});
|
||||
|
||||
test('featured page ignores type filtering when the feature type column is absent', function () {
|
||||
$owner = User::factory()->create();
|
||||
$artwork = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Type Filter Fallback']);
|
||||
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'featured_at' => now()->subHour(),
|
||||
'expires_at' => null,
|
||||
'priority' => 100,
|
||||
'label' => null,
|
||||
'note' => null,
|
||||
'is_active' => true,
|
||||
'created_by' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
$this->get(route('featured', ['type' => 3]))
|
||||
->assertOk()
|
||||
->assertSee('Type Filter Fallback', false);
|
||||
});
|
||||
|
||||
test('featured hero sorts by priority before featured_at', function () {
|
||||
$owner = User::factory()->create();
|
||||
$higherPriority = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Higher Priority']);
|
||||
@@ -276,6 +301,60 @@ test('homepage hero payload uses the forced hero artwork when one is set', funct
|
||||
->and($hero['title'])->toBe('Forced Homepage Hero');
|
||||
});
|
||||
|
||||
test('homepage renders featured hero picture and preload from dedicated featured thumbnails', function () {
|
||||
Cache::flush();
|
||||
Storage::fake('s3');
|
||||
config([
|
||||
'uploads.object_storage.disk' => 's3',
|
||||
'cdn.files_url' => 'https://files.skinbase.org',
|
||||
]);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$artwork = makeFeaturedArtwork([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Hero With Dedicated Featured Images',
|
||||
'hash' => str_repeat('a', 64),
|
||||
'file_ext' => 'png',
|
||||
'thumb_ext' => 'webp',
|
||||
]);
|
||||
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'featured_at' => now()->subHour(),
|
||||
'expires_at' => null,
|
||||
'priority' => 900,
|
||||
'label' => null,
|
||||
'note' => null,
|
||||
'is_active' => true,
|
||||
'force_hero' => true,
|
||||
'created_by' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
$paths = app(ArtworkFeaturedImagePath::class);
|
||||
|
||||
foreach ($paths->variantNames() as $variant) {
|
||||
Storage::disk('s3')->put($paths->objectPath($artwork, $variant), 'featured-image');
|
||||
}
|
||||
|
||||
$desktopUrl = $paths->url($artwork, 'desktop');
|
||||
$desktopXlUrl = $paths->url($artwork, 'desktop_xl');
|
||||
$xsUrl = $paths->url($artwork, 'xs');
|
||||
$mobileUrl = $paths->url($artwork, 'mobile');
|
||||
|
||||
$this->get(route('index'))
|
||||
->assertOk()
|
||||
->assertSee($desktopUrl, false)
|
||||
->assertSee($desktopXlUrl, false)
|
||||
->assertSee($xsUrl, false)
|
||||
->assertSee($mobileUrl, false)
|
||||
->assertSee('rel="preload"', false)
|
||||
->assertSee('type="image/webp"', false)
|
||||
->assertSee('fetchpriority="high"', false);
|
||||
});
|
||||
|
||||
test('community favorites returns artworks ordered by recent medal score', function () {
|
||||
$owner = User::factory()->create();
|
||||
$leader = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Leader']);
|
||||
@@ -412,4 +491,4 @@ test('trending backfills with archive artworks when the recent ranking pool is s
|
||||
->and($resultIds[0])->toBe($recentLeader->id)
|
||||
->and($resultIds)->toContain($archiveA->id)
|
||||
->and($resultIds)->toContain($archiveB->id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,91 @@ it('renders published news across public discovery routes', function (): void {
|
||||
->assertSee('Skinbase Newsroom');
|
||||
});
|
||||
|
||||
it('renders news index breadcrumbs and item list schema', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'indexschemaauthor',
|
||||
'name' => 'Index Schema Author',
|
||||
]);
|
||||
|
||||
$category = newsCategory([
|
||||
'name' => 'Announcements',
|
||||
'slug' => 'announcements-index-schema',
|
||||
]);
|
||||
|
||||
$article = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Index Schema Article',
|
||||
'slug' => 'index-schema-article',
|
||||
]);
|
||||
|
||||
$this->get(route('news.index'))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Home', 'News'])
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('ItemList', false)
|
||||
->assertSee(route('news.show', ['slug' => $article->slug]), false);
|
||||
});
|
||||
|
||||
it('shows only popular news topics in the sidebar and links to the full tags page', function (): void {
|
||||
config()->set('news.sidebar_tags_limit', 2);
|
||||
|
||||
$author = User::factory()->create([
|
||||
'username' => 'sidebarauthor',
|
||||
'name' => 'Sidebar Author',
|
||||
]);
|
||||
$category = newsCategory([
|
||||
'name' => 'Sidebar Category',
|
||||
'slug' => 'sidebar-category',
|
||||
]);
|
||||
|
||||
$tagAlpha = newsTag([
|
||||
'name' => 'Alpha Topic',
|
||||
'slug' => 'alpha-topic',
|
||||
]);
|
||||
$tagBeta = newsTag([
|
||||
'name' => 'Beta Topic',
|
||||
'slug' => 'beta-topic',
|
||||
]);
|
||||
$tagGamma = newsTag([
|
||||
'name' => 'Gamma Topic',
|
||||
'slug' => 'gamma-topic',
|
||||
]);
|
||||
|
||||
$articleOne = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Sidebar article one',
|
||||
'slug' => 'sidebar-article-one',
|
||||
'is_featured' => false,
|
||||
'is_pinned' => false,
|
||||
]);
|
||||
$articleOne->tags()->sync([$tagAlpha->id, $tagBeta->id]);
|
||||
|
||||
$articleTwo = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Sidebar article two',
|
||||
'slug' => 'sidebar-article-two',
|
||||
'is_featured' => false,
|
||||
'is_pinned' => false,
|
||||
'published_at' => now()->subMinutes(30),
|
||||
]);
|
||||
$articleTwo->tags()->sync([$tagAlpha->id]);
|
||||
|
||||
$articleThree = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Sidebar article three',
|
||||
'slug' => 'sidebar-article-three',
|
||||
'is_featured' => false,
|
||||
'is_pinned' => false,
|
||||
'published_at' => now()->subMinutes(15),
|
||||
]);
|
||||
$articleThree->tags()->sync([$tagGamma->id]);
|
||||
|
||||
$this->get(route('news.index'))
|
||||
->assertOk()
|
||||
->assertSee('Popular Topics')
|
||||
->assertSee('All Tags')
|
||||
->assertSee(route('tags.index'), false)
|
||||
->assertSee('#Alpha Topic')
|
||||
->assertSee('#Beta Topic')
|
||||
->assertDontSee('#Gamma Topic');
|
||||
});
|
||||
|
||||
it('renders a public news article when anonymous sessions are skipped', function (): void {
|
||||
Config::set('skinbase-sessions.enabled', true);
|
||||
Config::set('skinbase-sessions.debug_header', true);
|
||||
@@ -128,4 +213,135 @@ it('renders a public news article when anonymous sessions are skipped', function
|
||||
->assertOk()
|
||||
->assertHeader('X-Skinbase-Session', 'skipped')
|
||||
->assertSee('Guest Sessionless News Page');
|
||||
});
|
||||
|
||||
it('renders article breadcrumbs with home news category hierarchy', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'breadcrumbauthor',
|
||||
'name' => 'Breadcrumb Author',
|
||||
]);
|
||||
|
||||
$category = newsCategory([
|
||||
'name' => 'Technology',
|
||||
'slug' => 'technology',
|
||||
]);
|
||||
|
||||
$article = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Breadcrumb Check Article',
|
||||
'slug' => 'breadcrumb-check-article',
|
||||
]);
|
||||
|
||||
$this->get(route('news.show', ['slug' => $article->slug]))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Home', 'News', 'Technology']);
|
||||
});
|
||||
|
||||
it('renders category breadcrumbs and item list schema', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'categoryschemaauthor',
|
||||
'name' => 'Category Schema Author',
|
||||
]);
|
||||
|
||||
$category = newsCategory([
|
||||
'name' => 'Technology',
|
||||
'slug' => 'technology-schema',
|
||||
]);
|
||||
|
||||
$article = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Category Schema Article',
|
||||
'slug' => 'category-schema-article',
|
||||
]);
|
||||
|
||||
$this->get(route('news.category', ['slug' => $category->slug]))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Home', 'News', 'Technology'])
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('ItemList', false)
|
||||
->assertSee(route('news.show', ['slug' => $article->slug]), false);
|
||||
});
|
||||
|
||||
it('renders structured data for public news pages', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'schemaauthor',
|
||||
'name' => 'Schema Author',
|
||||
]);
|
||||
|
||||
$category = newsCategory([
|
||||
'name' => 'Technology',
|
||||
'slug' => 'technology-structured-data',
|
||||
]);
|
||||
|
||||
$tag = newsTag([
|
||||
'name' => 'Game Art',
|
||||
'slug' => 'game-art-structured-data',
|
||||
]);
|
||||
|
||||
$article = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Structured Data Article',
|
||||
'slug' => 'structured-data-article',
|
||||
]);
|
||||
$article->tags()->sync([$tag->id]);
|
||||
|
||||
$this->get(route('news.index'))
|
||||
->assertOk()
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('BreadcrumbList', false)
|
||||
->assertSee('ItemList', false);
|
||||
|
||||
$this->get(route('news.category', ['slug' => $category->slug]))
|
||||
->assertOk()
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('BreadcrumbList', false)
|
||||
->assertSee('ItemList', false);
|
||||
|
||||
$this->get(route('news.tag', ['slug' => $tag->slug]))
|
||||
->assertOk()
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('BreadcrumbList', false);
|
||||
|
||||
$this->get(route('news.archive', ['year' => $article->published_at->year, 'month' => $article->published_at->month]))
|
||||
->assertOk()
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('BreadcrumbList', false);
|
||||
|
||||
$this->get(route('news.author', ['username' => $author->username]))
|
||||
->assertOk()
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('BreadcrumbList', false);
|
||||
|
||||
$this->get(route('news.show', ['slug' => $article->slug]))
|
||||
->assertOk()
|
||||
->assertSee('NewsArticle', false)
|
||||
->assertSee('ImageObject', false)
|
||||
->assertSee('BreadcrumbList', false);
|
||||
});
|
||||
|
||||
it('prioritizes the news hero image and skips the grid stylesheet on article pages', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'lcpauthor',
|
||||
'name' => 'LCP Author',
|
||||
]);
|
||||
|
||||
$category = newsCategory([
|
||||
'name' => 'Performance',
|
||||
'slug' => 'performance-news',
|
||||
]);
|
||||
|
||||
$article = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Performance News Article',
|
||||
'slug' => 'performance-news-article',
|
||||
'cover_image' => 'news/covers/ab/cd/abcdef1234567890.webp',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('news.show', ['slug' => $article->slug]));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('fetchpriority="high"', false)
|
||||
->assertSee('loading="eager"', false)
|
||||
->assertSee('imagesizes="(max-width: 767px) calc(100vw - 3rem), (max-width: 1279px) calc(100vw - 5rem), 768px"', false)
|
||||
->assertSee(' 768w', false)
|
||||
->assertSee('href="' . e($article->cover_desktop_url ?? $article->cover_url) . '"', false)
|
||||
->assertDontSee('id="news-cover-preview"', false)
|
||||
->assertDontSee('resources/css/nova-grid.css', false);
|
||||
});
|
||||
@@ -5,9 +5,16 @@ declare(strict_types=1);
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
use cPad\Plugins\News\Models\NewsCategory;
|
||||
use cPad\Plugins\News\Models\NewsTag;
|
||||
use App\Models\Artwork;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function studioNewsCategory(array $attributes = []): NewsCategory
|
||||
{
|
||||
@@ -298,4 +305,124 @@ it('soft deletes a newsroom article from studio', function (): void {
|
||||
$this->assertSoftDeleted('news_articles', [
|
||||
'id' => $article->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('uploads newsroom cover images with responsive variants and deletes them together', function (): void {
|
||||
Storage::fake('s3');
|
||||
|
||||
config()->set('uploads.object_storage.disk', 's3');
|
||||
config()->set('cdn.files_url', 'https://cdn.skinbase.test');
|
||||
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
]);
|
||||
|
||||
$uploadResponse = $this->actingAs($moderator)->postJson(route('api.studio.news.media.upload'), [
|
||||
'image' => UploadedFile::fake()->image('news-cover.jpg', 1600, 900),
|
||||
]);
|
||||
|
||||
$uploadResponse->assertOk();
|
||||
|
||||
$path = (string) $uploadResponse->json('path');
|
||||
$mobileUrl = (string) $uploadResponse->json('mobile_url');
|
||||
$desktopUrl = (string) $uploadResponse->json('desktop_url');
|
||||
$srcset = (string) $uploadResponse->json('srcset');
|
||||
|
||||
expect($path)->toMatch('#^news/covers/[a-f0-9]{2}/[a-f0-9]{2}/[a-f0-9]{64}\.webp$#');
|
||||
expect($mobileUrl)->toBe('https://cdn.skinbase.test/' . preg_replace('#\.webp$#', '-mobile.webp', $path));
|
||||
expect($desktopUrl)->toBe('https://cdn.skinbase.test/' . preg_replace('#\.webp$#', '-desktop.webp', $path));
|
||||
expect($srcset)->toContain($mobileUrl . ' 400w')
|
||||
->toContain($desktopUrl . ' 768w');
|
||||
|
||||
Storage::disk('s3')->assertExists($path);
|
||||
Storage::disk('s3')->assertExists(preg_replace('#\.webp$#', '-mobile.webp', $path));
|
||||
Storage::disk('s3')->assertExists(preg_replace('#\.webp$#', '-desktop.webp', $path));
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->deleteJson(route('api.studio.news.media.destroy'), ['path' => $path])
|
||||
->assertOk();
|
||||
|
||||
Storage::disk('s3')->assertMissing($path);
|
||||
Storage::disk('s3')->assertMissing(preg_replace('#\.webp$#', '-mobile.webp', $path));
|
||||
Storage::disk('s3')->assertMissing(preg_replace('#\.webp$#', '-desktop.webp', $path));
|
||||
});
|
||||
|
||||
it('backfills missing responsive variants for managed newsroom covers', function (): void {
|
||||
Storage::fake('s3');
|
||||
Http::fake([
|
||||
'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache' => Http::response(['success' => true], 200),
|
||||
]);
|
||||
|
||||
config()->set('uploads.object_storage.disk', 's3');
|
||||
config()->set('cdn.files_url', 'https://cdn.skinbase.test');
|
||||
config()->set('cdn.cloudflare.zone_id', 'test-zone');
|
||||
config()->set('cdn.cloudflare.api_token', 'test-token');
|
||||
|
||||
$author = User::factory()->create();
|
||||
$category = studioNewsCategory();
|
||||
$masterPath = 'news/covers/aa/bb/' . str_repeat('a', 64) . '.webp';
|
||||
|
||||
Storage::disk('s3')->put($masterPath, UploadedFile::fake()->image('source.jpg', 1600, 900)->get());
|
||||
|
||||
NewsArticle::query()->create([
|
||||
'title' => 'Backfill cover variants',
|
||||
'slug' => 'backfill-cover-variants',
|
||||
'excerpt' => 'Backfill test.',
|
||||
'content' => 'Backfill test body.',
|
||||
'author_id' => $author->id,
|
||||
'category_id' => $category->id,
|
||||
'cover_image' => $masterPath,
|
||||
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
||||
'status' => 'draft',
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
||||
]);
|
||||
|
||||
$this->artisan('news:generate-cover-thumbnails')
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('generated=1');
|
||||
|
||||
Storage::disk('s3')->assertExists('news/covers/aa/bb/' . str_repeat('a', 64) . '-mobile.webp');
|
||||
Storage::disk('s3')->assertExists('news/covers/aa/bb/' . str_repeat('a', 64) . '-desktop.webp');
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
$this->artisan('news:generate-cover-thumbnails', ['--force' => true])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('generated=1');
|
||||
|
||||
Http::assertSent(function ($request) use ($masterPath): bool {
|
||||
return $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache'
|
||||
&& $request->hasHeader('Authorization', 'Bearer test-token')
|
||||
&& $request['files'] === [
|
||||
'https://cdn.skinbase.test/' . preg_replace('#\.webp$#', '-mobile.webp', $masterPath),
|
||||
'https://cdn.skinbase.test/' . preg_replace('#\.webp$#', '-desktop.webp', $masterPath),
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
it('searches news artwork entities without relying on a top-level views column', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'title' => 'Entity Search Artwork',
|
||||
'slug' => 'entity-search-artwork',
|
||||
'artwork_status' => 'published',
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->getJson(route('studio.news.entity-search', [
|
||||
'type' => 'artwork',
|
||||
'q' => 'Entity Search',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonFragment([
|
||||
'id' => $artwork->id,
|
||||
'title' => 'Entity Search Artwork',
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user