with(['creator.profile', 'tags']) ->orderByDesc('likes_count') ->orderByDesc('published_at') ->first(); }); $trendingStories = Cache::remember('stories:feed:trending', 300, function () { $now = now(); return Story::published() ->with(['creator.profile', 'tags']) ->latest('published_at') ->limit(60) ->get() ->sortByDesc(function (Story $story) use ($now): int { $daysOld = (int) ($story->published_at?->diffInDays($now) ?? 30); $recentBonus = max(0, 30 - $daysOld); return ((int) $story->views) + ((int) $story->likes_count * 3) + ((int) $story->comments_count * 4) + min(20, max(1, (int) $story->reading_time)) + $recentBonus; }) ->take(6) ->values(); }); $latestStories = Story::published() ->with(['creator.profile', 'tags']) ->latest('published_at') ->paginate(12) ->withQueryString(); return view('web.stories.index', [ 'featured' => $featured, 'trendingStories' => $trendingStories, 'latestStories' => $latestStories, 'categories' => $this->storyCategories(), 'page_title' => 'Creator Stories - Skinbase', 'page_meta_description' => 'Long-form creator stories, tutorials, interviews and project breakdowns on Skinbase.', 'page_canonical' => route('stories.index'), 'page_robots' => 'index,follow', 'breadcrumbs' => collect([ (object) ['name' => 'Stories', 'url' => route('stories.index')], ]), ]); } public function show(Request $request, string $slug): View { $story = Story::published() ->with(['creator.profile', 'tags']) ->where('slug', $slug) ->firstOrFail(); $storyContentHtml = $this->renderStoryContent((string) $story->content); StoryView::query()->create([ 'story_id' => $story->id, 'user_id' => $request->user()?->id, 'ip_address' => (string) $request->ip(), 'created_at' => now(), ]); $story->increment('views'); $relatedStories = Story::published() ->with(['creator.profile', 'tags']) ->where('id', '!=', $story->id) ->where(function ($query) use ($story): void { $query->where('creator_id', $story->creator_id) ->orWhereHas('tags', function ($tagsQuery) use ($story): void { $tagsQuery->whereIn('story_tags.id', $story->tags->pluck('id')->all()); }); }) ->latest('published_at') ->limit(6) ->get(); $relatedArtworks = collect(); if ($story->creator_id !== null) { $relatedArtworks = Artwork::query() ->where('user_id', $story->creator_id) ->where('is_public', true) ->where('is_approved', true) ->latest('published_at') ->limit(4) ->get(['id', 'title', 'slug']); } $discussionComments = collect(); if ($story->creator_id !== null && Schema::hasTable('profile_comments')) { $discussionComments = DB::table('profile_comments as pc') ->join('users as u', 'u.id', '=', 'pc.author_user_id') ->where('pc.profile_user_id', $story->creator_id) ->where('pc.is_active', true) ->orderByDesc('pc.created_at') ->limit(8) ->get([ 'pc.id', 'pc.body', 'pc.created_at', 'u.username as author_username', ]); } return view('web.stories.show', [ 'story' => $story, 'safeContent' => $storyContentHtml, 'relatedStories' => $relatedStories, 'relatedArtworks' => $relatedArtworks, 'comments' => $discussionComments, 'page_title' => $story->title . ' - Skinbase Stories', 'page_meta_description' => $story->excerpt ?: Str::limit(strip_tags((string) $story->content), 160), 'page_canonical' => route('stories.show', $story->slug), 'page_robots' => 'index,follow', ]); } public function create(Request $request): View { $tags = StoryTag::query()->orderBy('name')->limit(80)->get(['id', 'name', 'slug']); return view('web.stories.editor', [ 'story' => new Story([ 'status' => 'draft', 'story_type' => 'creator_story', 'content' => '', ]), 'mode' => 'create', 'tags' => $tags, 'storyTypes' => $this->storyCategories(), 'page_title' => 'Create Story - Skinbase', 'page_meta_description' => 'Write and publish a creator story on Skinbase.', 'page_robots' => 'noindex,nofollow', ]); } public function store(Request $request): RedirectResponse { $validated = $this->validateStoryPayload($request); $resolved = $this->resolveWorkflowState($request, $validated, true); $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []); $baseSlug = Str::slug($validated['title']); $slug = $baseSlug; $suffix = 2; while (Story::query()->where('slug', $slug)->exists()) { $slug = $baseSlug . '-' . $suffix; $suffix++; } $readingTime = $this->estimateReadingTimeFromSerializedContent($serializedContent); $story = Story::query()->create([ 'creator_id' => (int) $request->user()->id, 'title' => $validated['title'], 'slug' => $slug, 'cover_image' => $validated['cover_image'] ?? null, 'excerpt' => $validated['excerpt'] ?? null, 'content' => $serializedContent, 'story_type' => $validated['story_type'], 'reading_time' => $readingTime, 'status' => $resolved['status'], 'published_at' => $resolved['published_at'], 'scheduled_for' => $resolved['scheduled_for'], 'meta_title' => $validated['meta_title'] ?? $validated['title'], 'meta_description' => $validated['meta_description'] ?? Str::limit(strip_tags((string) $validated['excerpt']), 160), 'canonical_url' => $validated['canonical_url'] ?? null, 'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null), 'submitted_for_review_at' => $resolved['status'] === 'pending_review' ? now() : null, ]); $story->tags()->sync($this->resolveTagIds($validated)); if ($resolved['status'] === 'published') { return redirect()->route('stories.show', ['slug' => $story->slug]) ->with('status', 'Story published.'); } return redirect()->route('creator.stories.edit', ['story' => $story->id]) ->with('status', $resolved['status'] === 'pending_review' ? 'Story submitted for review.' : 'Draft saved.'); } public function dashboard(Request $request): View { $creatorId = (int) $request->user()->id; $drafts = Story::query() ->where('creator_id', $creatorId) ->whereIn('status', ['draft', 'pending_review', 'rejected']) ->latest('updated_at') ->limit(20) ->get(); $published = Story::query() ->where('creator_id', $creatorId) ->whereIn('status', ['published', 'scheduled']) ->latest('published_at') ->limit(20) ->get(); $archived = Story::query() ->where('creator_id', $creatorId) ->where('status', 'archived') ->latest('updated_at') ->limit(20) ->get(); return view('web.stories.dashboard', [ 'drafts' => $drafts, 'publishedStories' => $published, 'archivedStories' => $archived, 'page_title' => 'My Stories - Skinbase', 'page_meta_description' => 'Manage your drafts, published stories, and archived stories.', 'page_robots' => 'noindex,nofollow', ]); } public function edit(Request $request, Story $story): View { abort_unless($this->canManageStory($request, $story), 403); return view('web.stories.editor', [ 'story' => $story->load('tags'), 'mode' => 'edit', 'tags' => StoryTag::query()->orderBy('name')->limit(120)->get(['id', 'name', 'slug']), 'storyTypes' => $this->storyCategories(), 'page_title' => 'Edit Story - Skinbase', 'page_meta_description' => 'Update your story draft and publishing settings.', 'page_robots' => 'noindex,nofollow', ]); } public function update(Request $request, Story $story): RedirectResponse { abort_unless($this->canManageStory($request, $story), 403); $validated = $this->validateStoryPayload($request); $resolved = $this->resolveWorkflowState($request, $validated, false); $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []); $story->update([ 'title' => $validated['title'], 'excerpt' => $validated['excerpt'] ?? null, 'cover_image' => $validated['cover_image'] ?? null, 'content' => $serializedContent, 'story_type' => $validated['story_type'], 'reading_time' => $this->estimateReadingTimeFromSerializedContent($serializedContent), 'status' => $resolved['status'], 'published_at' => $resolved['published_at'], 'scheduled_for' => $resolved['scheduled_for'], 'meta_title' => $validated['meta_title'] ?? $validated['title'], 'meta_description' => $validated['meta_description'] ?? Str::limit(strip_tags((string) $validated['excerpt']), 160), 'canonical_url' => $validated['canonical_url'] ?? null, 'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null), 'submitted_for_review_at' => $resolved['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at, ]); if ($story->slug === '' || Str::slug($story->title) !== $story->slug) { $story->update(['slug' => $this->uniqueSlug($story->title, $story->id)]); } $story->tags()->sync($this->resolveTagIds($validated)); return back()->with('status', 'Story updated.'); } public function destroy(Request $request, Story $story): RedirectResponse { abort_unless($this->canManageStory($request, $story), 403); $story->delete(); return redirect()->route('creator.stories.index')->with('status', 'Story deleted.'); } public function autosave(Request $request, Story $story): JsonResponse { abort_unless($this->canManageStory($request, $story), 403); $validated = $request->validate([ 'title' => ['nullable', 'string', 'max:255'], 'excerpt' => ['nullable', 'string', 'max:500'], 'cover_image' => ['nullable', 'string', 'max:500'], 'content' => ['nullable'], 'story_type' => ['nullable', Rule::in($this->storyCategories()->pluck('slug')->all())], 'tags_csv' => ['nullable', 'string', 'max:500'], ]); $nextContent = array_key_exists('content', $validated) ? $this->normalizeStoryContent($validated['content']) : (string) $story->content; $story->fill([ 'title' => $validated['title'] ?? $story->title, 'excerpt' => $validated['excerpt'] ?? $story->excerpt, 'cover_image' => $validated['cover_image'] ?? $story->cover_image, 'content' => $nextContent, 'story_type' => $validated['story_type'] ?? $story->story_type, 'reading_time' => $this->estimateReadingTimeFromSerializedContent($nextContent), 'status' => in_array($story->status, ['pending_review', 'published', 'scheduled'], true) ? $story->status : 'draft', ]); $story->save(); if (! empty($validated['tags_csv'])) { $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']])); } return response()->json([ 'ok' => true, 'saved_at' => now()->toIso8601String(), 'message' => 'Saved just now', ]); } public function submitForReview(Request $request, Story $story): RedirectResponse { abort_unless($this->canManageStory($request, $story), 403); $story->update([ 'status' => 'pending_review', 'submitted_for_review_at' => now(), 'rejected_reason' => null, ]); return back()->with('status', 'Story submitted for review.'); } public function publishNow(Request $request, Story $story): RedirectResponse { abort_unless($this->canManageStory($request, $story), 403); $story->update([ 'status' => 'published', 'published_at' => now(), 'scheduled_for' => null, ]); $story->creator?->notify(new StoryStatusNotification($story, 'published')); return redirect()->route('stories.show', ['slug' => $story->slug])->with('status', 'Story published.'); } public function preview(Request $request, Story $story): View { abort_unless($this->canManageStory($request, $story), 403); return view('web.stories.preview', [ 'story' => $story->load(['creator.profile', 'tags']), 'safeContent' => $this->renderStoryContent((string) $story->content), 'page_title' => 'Preview: ' . $story->title, 'page_robots' => 'noindex,nofollow', ]); } public function analytics(Request $request, Story $story): View { abort_unless($this->canManageStory($request, $story), 403); $viewsLast7 = StoryView::query() ->where('story_id', $story->id) ->where('created_at', '>=', now()->subDays(7)) ->count(); $viewsLast30 = StoryView::query() ->where('story_id', $story->id) ->where('created_at', '>=', now()->subDays(30)) ->count(); return view('web.stories.analytics', [ 'story' => $story, 'metrics' => [ 'views' => (int) $story->views, 'likes' => (int) $story->likes_count, 'comments' => (int) $story->comments_count, 'read_time' => (int) $story->reading_time, 'views_last_7_days' => $viewsLast7, 'views_last_30_days' => $viewsLast30, 'estimated_total_read_minutes' => (int) $story->views * max(1, (int) $story->reading_time), ], 'page_title' => 'Story Analytics - ' . $story->title, 'page_robots' => 'noindex,nofollow', ]); } public function searchArtworks(Request $request): JsonResponse { $q = trim((string) $request->query('q', '')); $artworks = Artwork::query() ->where('user_id', (int) $request->user()->id) ->when($q !== '', function ($query) use ($q): void { $query->where('title', 'like', '%' . $q . '%'); }) ->latest('id') ->limit(20) ->get(['id', 'title', 'slug', 'hash', 'thumb_ext']) ->map(function (Artwork $art): array { $thumbs = [ 'xs' => $this->resolveArtworkThumbUrl($art, 'xs'), 'sm' => $this->resolveArtworkThumbUrl($art, 'sm'), 'md' => $this->resolveArtworkThumbUrl($art, 'md'), 'lg' => $this->resolveArtworkThumbUrl($art, 'lg'), 'xl' => $this->resolveArtworkThumbUrl($art, 'xl'), ]; return [ 'id' => $art->id, 'title' => $art->title, 'url' => route('art.show', ['id' => $art->id, 'slug' => $art->slug]), 'thumb' => $thumbs['sm'] ?? $thumbs['md'] ?? '', 'thumbs' => $thumbs, ]; }) ->values(); return response()->json(['artworks' => $artworks]); } public function apiCreate(Request $request): JsonResponse { $validated = $request->validate([ 'title' => ['nullable', 'string', 'max:255'], 'cover_image' => ['nullable', 'string', 'max:500'], 'excerpt' => ['nullable', 'string', 'max:500'], 'story_type' => ['nullable', Rule::in($this->storyCategories()->pluck('slug')->all())], 'content' => ['nullable'], 'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])], 'scheduled_for' => ['nullable', 'date'], 'tags_csv' => ['nullable', 'string', 'max:500'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string', 'max:300'], 'canonical_url' => ['nullable', 'url', 'max:500'], 'og_image' => ['nullable', 'string', 'max:500'], ]); $workflow = $this->resolveWorkflowState($request, array_merge([ 'status' => 'draft', 'story_type' => 'creator_story', 'title' => 'Untitled Story', 'content' => ['type' => 'doc', 'content' => [['type' => 'paragraph']]], ], $validated), true); $title = trim((string) ($validated['title'] ?? 'Untitled Story')); if ($title === '') { $title = 'Untitled Story'; } $slug = $this->uniqueSlug($title); $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []); $story = Story::query()->create([ 'creator_id' => (int) $request->user()->id, 'title' => $title, 'slug' => $slug, 'cover_image' => $validated['cover_image'] ?? null, 'excerpt' => $validated['excerpt'] ?? null, 'content' => $serializedContent, 'story_type' => $validated['story_type'] ?? 'creator_story', 'reading_time' => $this->estimateReadingTimeFromSerializedContent($serializedContent), 'status' => $workflow['status'], 'published_at' => $workflow['published_at'], 'scheduled_for' => $workflow['scheduled_for'], 'meta_title' => $validated['meta_title'] ?? $title, 'meta_description' => $validated['meta_description'] ?? Str::limit((string) ($validated['excerpt'] ?? ''), 160), 'canonical_url' => $validated['canonical_url'] ?? null, 'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null), 'submitted_for_review_at' => $workflow['status'] === 'pending_review' ? now() : null, ]); if (! empty($validated['tags_csv'])) { $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']])); } return response()->json([ 'ok' => true, 'story_id' => (int) $story->id, 'status' => $story->status, 'message' => 'Story created.', ]); } public function apiUpdate(Request $request): JsonResponse { $validated = $request->validate([ 'story_id' => ['required', 'integer', 'exists:stories,id'], 'title' => ['nullable', 'string', 'max:255'], 'cover_image' => ['nullable', 'string', 'max:500'], 'excerpt' => ['nullable', 'string', 'max:500'], 'story_type' => ['nullable', Rule::in($this->storyCategories()->pluck('slug')->all())], 'content' => ['nullable'], 'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])], 'scheduled_for' => ['nullable', 'date'], 'tags_csv' => ['nullable', 'string', 'max:500'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string', 'max:300'], 'canonical_url' => ['nullable', 'url', 'max:500'], 'og_image' => ['nullable', 'string', 'max:500'], ]); $story = Story::query()->findOrFail((int) $validated['story_id']); abort_unless($this->canManageStory($request, $story), 403); $workflow = $this->resolveWorkflowState($request, array_merge([ 'status' => $story->status, ], $validated), false); $title = trim((string) ($validated['title'] ?? $story->title)); if ($title === '') { $title = 'Untitled Story'; } $serializedContent = array_key_exists('content', $validated) ? $this->normalizeStoryContent($validated['content']) : (string) $story->content; $story->update([ 'title' => $title, 'slug' => $story->slug !== '' ? $story->slug : $this->uniqueSlug($title, (int) $story->id), 'cover_image' => $validated['cover_image'] ?? $story->cover_image, 'excerpt' => $validated['excerpt'] ?? $story->excerpt, 'content' => $serializedContent, 'story_type' => $validated['story_type'] ?? $story->story_type, 'reading_time' => $this->estimateReadingTimeFromSerializedContent($serializedContent), 'status' => $workflow['status'], 'published_at' => $workflow['published_at'] ?? $story->published_at, 'scheduled_for' => $workflow['scheduled_for'], 'meta_title' => $validated['meta_title'] ?? $story->meta_title ?? $title, 'meta_description' => $validated['meta_description'] ?? $story->meta_description, 'canonical_url' => $validated['canonical_url'] ?? $story->canonical_url, 'og_image' => $validated['og_image'] ?? $story->og_image, 'submitted_for_review_at' => $workflow['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at, ]); if (! empty($validated['tags_csv'])) { $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']])); } return response()->json([ 'ok' => true, 'story_id' => (int) $story->id, 'status' => $story->status, 'message' => 'Story updated.', ]); } public function apiAutosave(Request $request): JsonResponse { $validated = $request->validate([ 'story_id' => ['nullable', 'integer', 'exists:stories,id'], 'title' => ['nullable', 'string', 'max:255'], 'excerpt' => ['nullable', 'string', 'max:500'], 'cover_image' => ['nullable', 'string', 'max:500'], 'content' => ['nullable'], 'story_type' => ['nullable', Rule::in($this->storyCategories()->pluck('slug')->all())], 'tags_csv' => ['nullable', 'string', 'max:500'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string', 'max:300'], 'canonical_url' => ['nullable', 'url', 'max:500'], 'og_image' => ['nullable', 'string', 'max:500'], 'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])], 'scheduled_for' => ['nullable', 'date'], ]); $story = null; if (! empty($validated['story_id'])) { $story = Story::query()->findOrFail((int) $validated['story_id']); abort_unless($this->canManageStory($request, $story), 403); } if (! $story) { $title = trim((string) ($validated['title'] ?? 'Untitled Story')); if ($title === '') { $title = 'Untitled Story'; } $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []); $story = Story::query()->create([ 'creator_id' => (int) $request->user()->id, 'title' => $title, 'slug' => $this->uniqueSlug($title), 'excerpt' => $validated['excerpt'] ?? null, 'cover_image' => $validated['cover_image'] ?? null, 'content' => $serializedContent, 'story_type' => $validated['story_type'] ?? 'creator_story', 'reading_time' => $this->estimateReadingTimeFromSerializedContent($serializedContent), 'status' => 'draft', 'meta_title' => $validated['meta_title'] ?? $title, 'meta_description' => $validated['meta_description'] ?? Str::limit((string) ($validated['excerpt'] ?? ''), 160), 'canonical_url' => $validated['canonical_url'] ?? null, 'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null), ]); } else { $nextContent = array_key_exists('content', $validated) ? $this->normalizeStoryContent($validated['content']) : (string) $story->content; $nextStatus = $validated['status'] ?? $story->status; if (! in_array($nextStatus, ['pending_review', 'published', 'scheduled', 'archived', 'rejected'], true)) { $nextStatus = 'draft'; } $story->fill([ 'title' => trim((string) ($validated['title'] ?? $story->title)) ?: 'Untitled Story', 'excerpt' => $validated['excerpt'] ?? $story->excerpt, 'cover_image' => $validated['cover_image'] ?? $story->cover_image, 'content' => $nextContent, 'story_type' => $validated['story_type'] ?? $story->story_type, 'reading_time' => $this->estimateReadingTimeFromSerializedContent($nextContent), 'status' => $nextStatus, 'meta_title' => $validated['meta_title'] ?? $story->meta_title, 'meta_description' => $validated['meta_description'] ?? $story->meta_description, 'canonical_url' => $validated['canonical_url'] ?? $story->canonical_url, 'og_image' => $validated['og_image'] ?? $story->og_image, 'scheduled_for' => ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : $story->scheduled_for, ]); $story->save(); } if (! empty($validated['tags_csv'])) { $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']])); } return response()->json([ 'ok' => true, 'story_id' => (int) $story->id, 'saved_at' => now()->toIso8601String(), 'message' => 'Saved just now', ]); } public function apiArtworks(Request $request): JsonResponse { return $this->searchArtworks($request); } public function apiUploadImage(Request $request): JsonResponse { return $this->uploadImage($request); } public function uploadImage(Request $request): JsonResponse { $validated = $request->validate([ 'image' => ['required', 'image', 'max:10240'], ]); /** @var UploadedFile $file */ $file = $validated['image']; $sourcePath = $file->getRealPath() ?: $file->getPathname(); if ($sourcePath === '' || ! is_file($sourcePath)) { return response()->json([ 'message' => 'Unable to read uploaded image. Please try again.', ], 422); } $disk = Storage::disk('public'); $base = 'stories/' . now()->format('Y/m') . '/' . Str::uuid(); $extension = strtolower((string) ($file->guessExtension() ?: $file->extension() ?: 'jpg')); $originalPath = $base . '/original.' . $extension; $thumbnailPath = $base . '/thumbnail.webp'; $mediumPath = $base . '/medium.webp'; $stream = fopen($sourcePath, 'rb'); if ($stream === false) { return response()->json([ 'message' => 'Unable to process uploaded image. Please try again.', ], 422); } try { $disk->put($originalPath, $stream); } finally { fclose($stream); } $storedThumbnails = false; if (class_exists(ImageManager::class)) { try { $manager = extension_loaded('gd') ? new ImageManager(new GdDriver()) : new ImageManager(new ImagickDriver()); $image = $manager->read($sourcePath); $thumb = $image->scaleDown(width: 420); $disk->put($thumbnailPath, (string) $thumb->encode(new WebpEncoder(82))); $medium = $image->scaleDown(width: 1200); $disk->put($mediumPath, (string) $medium->encode(new WebpEncoder(85))); $storedThumbnails = true; } catch (\Throwable) { $storedThumbnails = false; } } if (! $storedThumbnails) { $disk->copy($originalPath, $thumbnailPath); $disk->copy($originalPath, $mediumPath); } return response()->json([ 'thumbnail_url' => $disk->url($thumbnailPath), 'medium_url' => $disk->url($mediumPath), 'original_url' => $disk->url($originalPath), ]); } public function tag(string $tag): View { $storyTag = StoryTag::query()->where('slug', $tag)->firstOrFail(); $stories = Story::published() ->with(['creator.profile', 'tags']) ->whereHas('tags', fn ($query) => $query->where('story_tags.id', $storyTag->id)) ->latest('published_at') ->paginate(12) ->withQueryString(); return view('web.stories.tag', [ 'storyTag' => $storyTag, 'stories' => $stories, 'page_title' => '#' . $storyTag->name . ' Stories - Skinbase', 'page_meta_description' => 'Creator stories tagged with ' . $storyTag->name . '.', 'page_canonical' => route('stories.tag', $storyTag->slug), ]); } public function creator(string $username): View { $creator = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)])->firstOrFail(); $stories = Story::published() ->with(['creator.profile', 'tags']) ->where('creator_id', $creator->id) ->latest('published_at') ->paginate(12) ->withQueryString(); return view('web.stories.creator', [ 'creator' => $creator, 'stories' => $stories, 'page_title' => 'Stories by @' . $creator->username . ' - Skinbase', 'page_meta_description' => 'Read stories published by @' . $creator->username . '.', 'page_canonical' => route('stories.creator', $creator->username), ]); } public function category(string $category): View { $normalized = strtolower($category); $valid = $this->storyCategories()->pluck('slug')->all(); abort_unless(in_array($normalized, $valid, true), 404); $stories = Story::published() ->with(['creator.profile', 'tags']) ->where('story_type', $normalized) ->latest('published_at') ->paginate(12) ->withQueryString(); return view('web.stories.category', [ 'category' => $normalized, 'stories' => $stories, 'categories' => $this->storyCategories(), 'page_title' => ucfirst(str_replace('_', ' ', $normalized)) . ' Stories - Skinbase', 'page_meta_description' => 'Browse ' . str_replace('_', ' ', $normalized) . ' stories on Skinbase.', 'page_canonical' => route('stories.category', $normalized), ]); } private function storyCategories(): Collection { return collect([ ['slug' => 'tutorial', 'name' => 'Tutorials'], ['slug' => 'creator_story', 'name' => 'Creator Stories'], ['slug' => 'interview', 'name' => 'Interviews'], ['slug' => 'announcement', 'name' => 'Announcements'], ['slug' => 'resource', 'name' => 'Resources'], ['slug' => 'project_breakdown', 'name' => 'Project Breakdowns'], ]); } private function validateStoryPayload(Request $request): array { return $request->validate([ 'title' => ['required', 'string', 'max:255'], 'cover_image' => ['nullable', 'string', 'max:500'], 'excerpt' => ['nullable', 'string', 'max:500'], 'story_type' => ['required', Rule::in($this->storyCategories()->pluck('slug')->all())], 'content' => ['required'], 'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])], 'scheduled_for' => ['nullable', 'date'], 'tags' => ['nullable', 'array'], 'tags.*' => ['integer', 'exists:story_tags,id'], 'tags_csv' => ['nullable', 'string', 'max:500'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string', 'max:300'], 'canonical_url' => ['nullable', 'url', 'max:500'], 'og_image' => ['nullable', 'string', 'max:500'], ]); } private function resolveWorkflowState(Request $request, array $validated, bool $isCreate): array { $action = (string) $request->input('submit_action', 'save_draft'); $status = (string) ($validated['status'] ?? 'draft'); $scheduledFor = ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : null; $publishedAt = null; if ($action === 'submit_review') { $status = 'pending_review'; } elseif ($action === 'publish_now') { $status = 'published'; $publishedAt = now(); } elseif ($status === 'scheduled' && $scheduledFor !== null) { $publishedAt = $scheduledFor; } if (! $isCreate && $status === 'published' && $publishedAt === null) { $publishedAt = now(); } return [ 'status' => $status, 'published_at' => $publishedAt, 'scheduled_for' => $status === 'scheduled' ? $scheduledFor : null, ]; } private function resolveTagIds(array $validated): array { $tagIds = collect($validated['tags'] ?? [])->map(fn ($id) => (int) $id)->filter()->values(); $csv = (string) ($validated['tags_csv'] ?? ''); if ($csv !== '') { $names = collect(explode(',', $csv)) ->map(fn ($part) => trim($part)) ->filter() ->unique() ->take(12) ->values(); $extraIds = $names->map(function (string $name): int { $slug = Str::slug($name); $tag = StoryTag::query()->firstOrCreate( ['slug' => $slug], ['name' => Str::title($name)] ); return (int) $tag->id; }); $tagIds = $tagIds->merge($extraIds)->unique()->values(); } return $tagIds->all(); } private function renderStoryContent(string $raw): string { $decoded = json_decode($raw, true); if (is_array($decoded) && ($decoded['type'] ?? null) === 'doc') { return $this->renderTipTapDocument($decoded); } return $this->sanitizeStoryContent($raw); } private function normalizeStoryContent(mixed $content): string { if (is_array($content)) { if (($content['type'] ?? null) !== 'doc') { $content = [ 'type' => 'doc', 'content' => [ ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => trim((string) json_encode($content))]]], ], ]; } return (string) json_encode($content, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } if (is_string($content)) { $decoded = json_decode($content, true); if (is_array($decoded) && ($decoded['type'] ?? null) === 'doc') { return (string) json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } $text = trim(strip_tags($content)); return (string) json_encode([ 'type' => 'doc', 'content' => [ ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => $text]]], ], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } return (string) json_encode([ 'type' => 'doc', 'content' => [['type' => 'paragraph']], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } private function estimateReadingTimeFromSerializedContent(string $serializedContent): int { $decoded = json_decode($serializedContent, true); if (is_array($decoded)) { $text = trim($this->extractTipTapText($decoded)); if ($text !== '') { return max(1, (int) ceil(str_word_count($text) / 200)); } } return max(1, (int) ceil(str_word_count(strip_tags($serializedContent)) / 200)); } private function extractTipTapText(array $node): string { $type = (string) ($node['type'] ?? ''); if ($type === 'text') { return (string) ($node['text'] ?? ''); } $content = $node['content'] ?? []; if (! is_array($content)) { return ''; } $chunks = []; foreach ($content as $child) { if (is_array($child)) { $chunks[] = $this->extractTipTapText($child); } } $joined = implode(' ', array_filter($chunks, fn ($part) => $part !== '')); return in_array($type, ['paragraph', 'heading', 'blockquote', 'codeBlock', 'listItem'], true) ? $joined . "\n" : $joined; } private function renderTipTapDocument(array $doc): string { $content = $doc['content'] ?? []; if (! is_array($content)) { return ''; } $html = ''; foreach ($content as $node) { if (is_array($node)) { $html .= $this->renderTipTapNode($node); } } return $this->sanitizeStoryContent($html); } private function renderTipTapNode(array $node): string { $type = (string) ($node['type'] ?? ''); $attrs = is_array($node['attrs'] ?? null) ? $node['attrs'] : []; $content = is_array($node['content'] ?? null) ? $node['content'] : []; if ($type === 'text') { $text = e((string) ($node['text'] ?? '')); $marks = is_array($node['marks'] ?? null) ? $node['marks'] : []; foreach ($marks as $mark) { $markType = (string) ($mark['type'] ?? ''); if ($markType === 'bold') { $text = '' . $text . ''; } elseif ($markType === 'italic') { $text = '' . $text . ''; } elseif ($markType === 'code') { $text = '' . $text . ''; } elseif ($markType === 'link') { $href = (string) (($mark['attrs']['href'] ?? '') ?: ''); if ($this->isSafeUrl($href)) { $text = '' . $text . ''; } } } return $text; } $inner = ''; foreach ($content as $child) { if (is_array($child)) { $inner .= $this->renderTipTapNode($child); } } return match ($type) { 'paragraph' => '

' . $inner . '

', 'heading' => '' . $inner . '', 'blockquote' => '
' . $inner . '
', 'bulletList' => '', 'orderedList' => '
    ' . $inner . '
', 'listItem' => '
  • ' . $inner . '
  • ', 'horizontalRule' => '
    ', 'codeBlock' => '
    ' . e($this->extractTipTapText($node)) . '
    ', 'image' => $this->renderImageNode($attrs), 'artworkEmbed' => $this->renderArtworkEmbedNode($attrs), 'galleryBlock' => $this->renderGalleryBlockNode($attrs), 'videoEmbed' => $this->renderVideoEmbedNode($attrs), 'downloadAsset' => $this->renderDownloadAssetNode($attrs), default => $inner, }; } private function renderImageNode(array $attrs): string { $src = (string) ($attrs['src'] ?? ''); if (! $this->isSafeUrl($src)) { return ''; } return '' . e((string) ($attrs['alt'] ?? 'Story image')) . ''; } private function renderArtworkEmbedNode(array $attrs): string { $id = (int) ($attrs['artworkId'] ?? 0); if ($id <= 0) { return ''; } $art = Artwork::query()->find($id); if (! $art) { return '
    Embedded artwork #' . $id . ' is unavailable.
    '; } return '
    ' . '' . '' . e($art->title) . '' . '
    ' . e($art->title) . 'View artwork
    ' . '
    '; } private function resolveArtworkThumbUrl(Artwork $art, string $size): ?string { $sized = $art->thumbUrl($size); if (is_string($sized) && $sized !== '') { return $sized; } $fallback = $art->thumb_url; if (! is_string($fallback) || $fallback === '') { return null; } return preg_replace('#/(thumb|xs|sm|md|lg|xl)/#', '/' . $size . '/', $fallback) ?: $fallback; } private function renderGalleryBlockNode(array $attrs): string { $images = $attrs['images'] ?? []; if (! is_array($images) || $images === []) { return ''; } $items = collect($images) ->filter(fn ($src) => is_string($src) && $this->isSafeUrl($src)) ->take(8) ->map(fn (string $src) => 'Gallery image') ->implode(''); if ($items === '') { return ''; } return '
    ' . $items . '
    '; } private function renderVideoEmbedNode(array $attrs): string { $src = (string) ($attrs['src'] ?? ''); if (! $this->isAllowedEmbedUrl($src)) { return ''; } $title = e((string) ($attrs['title'] ?? 'Embedded video')); return '
    ' . '' . '
    '; } private function renderDownloadAssetNode(array $attrs): string { $url = (string) ($attrs['url'] ?? ''); if (! $this->isSafeUrl($url)) { return ''; } $label = e((string) ($attrs['label'] ?? 'Download asset')); return '
    ' . '' . $label . '' . '
    '; } private function sanitizeStoryContent(string $raw): string { $html = trim($raw); if ($html === '') { return ''; } $html = preg_replace('/<(script|style)\\b[^>]*>.*?<\\/\\1>/is', '', $html) ?? ''; libxml_use_internal_errors(true); $document = new DOMDocument('1.0', 'UTF-8'); $document->loadHTML('
    ' . $html . '
    ', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $root = $document->getElementById('story-sanitize-root'); if (! $root instanceof DOMElement) { libxml_clear_errors(); return strip_tags($html); } $allowedTags = [ 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'strong', 'em', 'b', 'i', 'u', 'blockquote', 'pre', 'code', 'hr', 'a', 'img', 'div', 'span', 'figure', 'figcaption', 'iframe', ]; $this->sanitizeDomNode($root, $allowedTags); $clean = ''; foreach ($root->childNodes as $child) { $clean .= $document->saveHTML($child); } libxml_clear_errors(); return $clean; } private function sanitizeDomNode(DOMNode $node, array $allowedTags): void { $children = []; foreach ($node->childNodes as $child) { $children[] = $child; } foreach ($children as $child) { if ($child instanceof DOMElement) { $tag = strtolower($child->tagName); if (! in_array($tag, $allowedTags, true)) { $this->unwrapNode($child); continue; } $this->sanitizeElementAttributes($child); } if ($child->parentNode !== null) { $this->sanitizeDomNode($child, $allowedTags); } } } private function sanitizeElementAttributes(DOMElement $element): void { $tag = strtolower($element->tagName); $allowedByTag = [ 'a' => ['href', 'title', 'target', 'rel'], 'img' => ['src', 'alt', 'title', 'loading'], 'iframe' => ['src', 'title', 'allow', 'allowfullscreen', 'frameborder', 'referrerpolicy'], 'div' => ['class'], 'span' => ['class'], 'p' => ['class'], 'pre' => ['class'], 'code' => ['class'], 'figure' => ['class'], 'figcaption' => ['class'], 'h1' => ['class'], 'h2' => ['class'], 'h3' => ['class'], 'h4' => ['class'], 'ul' => ['class'], 'ol' => ['class'], 'li' => ['class'], 'blockquote' => ['class'], ]; $allowed = $allowedByTag[$tag] ?? []; $toRemove = []; foreach ($element->attributes as $attribute) { $name = strtolower($attribute->name); $value = trim((string) $attribute->value); if (str_starts_with($name, 'on') || $name === 'style' || ! in_array($name, $allowed, true)) { $toRemove[] = $attribute->name; continue; } if (($name === 'href' || $name === 'src') && ! $this->isSafeUrl($value)) { $toRemove[] = $attribute->name; continue; } if ($tag === 'iframe' && $name === 'src' && ! $this->isAllowedEmbedUrl($value)) { $toRemove[] = $attribute->name; } } foreach ($toRemove as $name) { $element->removeAttribute($name); } if ($tag === 'a' && $element->hasAttribute('href')) { $element->setAttribute('rel', 'nofollow ugc noopener'); if ($element->getAttribute('target') === '') { $element->setAttribute('target', '_blank'); } } if ($tag === 'img' && ! $element->hasAttribute('loading')) { $element->setAttribute('loading', 'lazy'); } if ($tag === 'iframe' && ! $element->hasAttribute('src')) { $this->unwrapNode($element); } } private function isSafeUrl(string $value): bool { if ($value === '') { return false; } $lower = strtolower($value); if (str_starts_with($lower, 'javascript:') || str_starts_with($lower, 'data:')) { return false; } return str_starts_with($lower, 'http://') || str_starts_with($lower, 'https://') || str_starts_with($lower, '/') || str_starts_with($lower, '#'); } private function isAllowedEmbedUrl(string $value): bool { if (! $this->isSafeUrl($value)) { return false; } $host = strtolower((string) (parse_url($value, PHP_URL_HOST) ?? '')); return $host === 'www.youtube.com' || $host === 'youtube.com' || $host === 'youtu.be' || $host === 'player.vimeo.com' || $host === 'vimeo.com'; } private function unwrapNode(DOMNode $node): void { $parent = $node->parentNode; if ($parent === null) { return; } while ($node->firstChild !== null) { $parent->insertBefore($node->firstChild, $node); } $parent->removeChild($node); } private function canManageStory(Request $request, Story $story): bool { $user = $request->user(); if ($user === null) { return false; } return (int) $story->creator_id === (int) $user->id || $user->isAdmin() || $user->isModerator(); } private function uniqueSlug(string $title, ?int $ignoreId = null): string { $baseSlug = Str::slug($title); $slug = $baseSlug; $suffix = 2; while (Story::query() ->when($ignoreId !== null, fn ($q) => $q->where('id', '!=', $ignoreId)) ->where('slug', $slug) ->exists()) { $slug = $baseSlug . '-' . $suffix; $suffix++; } return $slug; } }