Add tests for featured thumbnail generation; apply Pint formatting and related edits
This commit is contained in:
+2
-1
@@ -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
+545
-522
File diff suppressed because it is too large
Load Diff
+5096
-3014
File diff suppressed because one or more lines are too long
+4
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+78
-196
@@ -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",
|
||||
|
||||
+879
-3
@@ -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) {
|
||||
|
||||
+10
-3
@@ -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,
|
||||
])
|
||||
|
||||
+19
-12
@@ -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 () {
|
||||
|
||||
+22
-10
@@ -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