Files
SkinbaseNova/app/Http/Controllers/StoryController.php
2026-03-17 18:34:26 +01:00

1354 lines
52 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use DOMDocument;
use DOMElement;
use DOMNode;
use App\Models\Artwork;
use App\Models\Story;
use App\Models\StoryTag;
use App\Models\StoryView;
use App\Models\User;
use App\Notifications\StoryStatusNotification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
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;
class StoryController extends Controller
{
public function index(Request $request): View
{
$featured = Cache::remember('stories:feed:featured', 300, function () {
return Story::published()
->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 = '<strong>' . $text . '</strong>';
} elseif ($markType === 'italic') {
$text = '<em>' . $text . '</em>';
} elseif ($markType === 'code') {
$text = '<code>' . $text . '</code>';
} elseif ($markType === 'link') {
$href = (string) (($mark['attrs']['href'] ?? '') ?: '');
if ($this->isSafeUrl($href)) {
$text = '<a href="' . e($href) . '" target="_blank" rel="nofollow ugc noopener">' . $text . '</a>';
}
}
}
return $text;
}
$inner = '';
foreach ($content as $child) {
if (is_array($child)) {
$inner .= $this->renderTipTapNode($child);
}
}
return match ($type) {
'paragraph' => '<p>' . $inner . '</p>',
'heading' => '<h' . max(1, min(4, (int) ($attrs['level'] ?? 2))) . '>' . $inner . '</h' . max(1, min(4, (int) ($attrs['level'] ?? 2))) . '>',
'blockquote' => '<blockquote>' . $inner . '</blockquote>',
'bulletList' => '<ul>' . $inner . '</ul>',
'orderedList' => '<ol>' . $inner . '</ol>',
'listItem' => '<li>' . $inner . '</li>',
'horizontalRule' => '<hr>',
'codeBlock' => '<pre><code>' . e($this->extractTipTapText($node)) . '</code></pre>',
'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 '<img src="' . e($src) . '" alt="' . e((string) ($attrs['alt'] ?? 'Story image')) . '" loading="lazy" />';
}
private function renderArtworkEmbedNode(array $attrs): string
{
$id = (int) ($attrs['artworkId'] ?? 0);
if ($id <= 0) {
return '';
}
$art = Artwork::query()->find($id);
if (! $art) {
return '<div class="my-6 rounded-xl border border-gray-700 bg-gray-900/70 p-4 text-sm text-gray-300">Embedded artwork #' . $id . ' is unavailable.</div>';
}
return '<div class="my-6 overflow-hidden rounded-xl border border-gray-700 bg-gray-900/70">'
. '<a class="block" href="' . e(route('art.show', ['id' => $art->id, 'slug' => $art->slug])) . '">'
. '<img class="h-52 w-full object-cover" loading="lazy" src="' . e($this->resolveArtworkThumbUrl($art, 'md') ?? '') . '" alt="' . e($art->title) . '" />'
. '<div class="p-4 text-sm text-gray-100"><span class="font-semibold">' . e($art->title) . '</span><span class="ml-2 text-sky-300">View artwork</span></div>'
. '</a></div>';
}
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) => '<img class="rounded-lg object-cover" loading="lazy" src="' . e($src) . '" alt="Gallery image" />')
->implode('');
if ($items === '') {
return '';
}
return '<div class="my-6 grid grid-cols-2 gap-3 rounded-xl border border-gray-700 bg-gray-900/60 p-3">' . $items . '</div>';
}
private function renderVideoEmbedNode(array $attrs): string
{
$src = (string) ($attrs['src'] ?? '');
if (! $this->isAllowedEmbedUrl($src)) {
return '';
}
$title = e((string) ($attrs['title'] ?? 'Embedded video'));
return '<figure class="my-6 overflow-hidden rounded-xl border border-gray-700 bg-gray-900/70">'
. '<iframe class="aspect-video w-full" src="' . e($src) . '" title="' . $title . '" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>'
. '</figure>';
}
private function renderDownloadAssetNode(array $attrs): string
{
$url = (string) ($attrs['url'] ?? '');
if (! $this->isSafeUrl($url)) {
return '';
}
$label = e((string) ($attrs['label'] ?? 'Download asset'));
return '<div class="my-6 rounded-xl border border-gray-700 bg-gray-900/70 p-4">'
. '<a class="inline-flex items-center rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200" href="' . e($url) . '" target="_blank" rel="nofollow ugc noopener" download>' . $label . '</a>'
. '</div>';
}
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('<?xml encoding="utf-8" ?><div id="story-sanitize-root">' . $html . '</div>', 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;
}
}