This commit is contained in:
2026-03-20 21:17:26 +01:00
parent 1a62fcb81d
commit 29c3ff8572
229 changed files with 13147 additions and 2577 deletions

View File

@@ -8,18 +8,20 @@ use DOMDocument;
use DOMElement;
use DOMNode;
use App\Models\Artwork;
use App\Models\StoryComment;
use App\Models\Story;
use App\Models\StoryTag;
use App\Models\StoryView;
use App\Models\User;
use App\Notifications\StoryStatusNotification;
use App\Services\SocialService;
use App\Services\StoryPublicationService;
use App\Support\AvatarUrl;
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;
@@ -88,7 +90,7 @@ class StoryController extends Controller
public function show(Request $request, string $slug): View
{
$story = Story::published()
->with(['creator.profile', 'tags'])
->with(['creator.profile', 'creator.statistics', 'tags'])
->where('slug', $slug)
->firstOrFail();
@@ -127,28 +129,49 @@ class StoryController extends Controller
->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',
]);
}
$social = app(SocialService::class);
$initialComments = Schema::hasTable('story_comments')
? StoryComment::query()
->with(['user.profile', 'approvedReplies'])
->where('story_id', $story->id)
->where('is_approved', true)
->whereNull('parent_id')
->whereNull('deleted_at')
->latest('created_at')
->limit(10)
->get()
->map(fn (StoryComment $comment) => $social->formatComment($comment, $request->user()?->id, true))
->values()
->all()
: [];
$storyState = $social->storyStateFor($request->user(), $story);
$storySocialProps = [
'story' => [
'id' => (int) $story->id,
'slug' => (string) $story->slug,
'title' => (string) $story->title,
],
'creator' => $story->creator ? [
'id' => (int) $story->creator->id,
'username' => (string) ($story->creator->username ?? ''),
'display_name' => (string) ($story->creator->name ?: $story->creator->username ?: 'Creator'),
'avatar_url' => AvatarUrl::forUser((int) $story->creator->id, $story->creator->profile?->avatar_hash, 128),
'followers_count' => (int) ($story->creator->statistics?->followers_count ?? 0),
'profile_url' => $story->creator->username ? '/@' . $story->creator->username : null,
] : null,
'state' => $storyState,
'comments' => $initialComments,
'is_authenticated' => $request->user() !== null,
];
return view('web.stories.show', [
'story' => $story,
'safeContent' => $storyContentHtml,
'relatedStories' => $relatedStories,
'relatedArtworks' => $relatedArtworks,
'comments' => $discussionComments,
'storySocialProps' => $storySocialProps,
'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),
@@ -212,6 +235,10 @@ class StoryController extends Controller
$story->tags()->sync($this->resolveTagIds($validated));
if ($resolved['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
if ($resolved['status'] === 'published') {
return redirect()->route('stories.show', ['slug' => $story->slug])
->with('status', 'Story published.');
@@ -275,6 +302,8 @@ class StoryController extends Controller
{
abort_unless($this->canManageStory($request, $story), 403);
$wasPublished = $story->published_at !== null || $story->status === 'published';
$validated = $this->validateStoryPayload($request);
$resolved = $this->resolveWorkflowState($request, $validated, false);
$serializedContent = $this->normalizeStoryContent($validated['content'] ?? []);
@@ -302,6 +331,10 @@ class StoryController extends Controller
$story->tags()->sync($this->resolveTagIds($validated));
if (! $wasPublished && $resolved['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
return back()->with('status', 'Story updated.');
}
@@ -370,14 +403,10 @@ class StoryController extends Controller
{
abort_unless($this->canManageStory($request, $story), 403);
$story->update([
'status' => 'published',
app(StoryPublicationService::class)->publish($story, '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.');
}
@@ -512,11 +541,19 @@ class StoryController extends Controller
$story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']]));
}
if ($workflow['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
return response()->json([
'ok' => true,
'story_id' => (int) $story->id,
'status' => $story->status,
'message' => 'Story created.',
'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
'public_url' => route('stories.show', ['slug' => $story->slug]),
]);
}
@@ -540,6 +577,7 @@ class StoryController extends Controller
$story = Story::query()->findOrFail((int) $validated['story_id']);
abort_unless($this->canManageStory($request, $story), 403);
$wasPublished = $story->published_at !== null || $story->status === 'published';
$workflow = $this->resolveWorkflowState($request, array_merge([
'status' => $story->status,
@@ -576,11 +614,19 @@ class StoryController extends Controller
$story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']]));
}
if (! $wasPublished && $workflow['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
return response()->json([
'ok' => true,
'story_id' => (int) $story->id,
'status' => $story->status,
'message' => 'Story updated.',
'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
'public_url' => route('stories.show', ['slug' => $story->slug]),
]);
}
@@ -631,6 +677,7 @@ class StoryController extends Controller
'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
]);
} else {
$wasPublished = $story->published_at !== null || $story->status === 'published';
$nextContent = array_key_exists('content', $validated)
? $this->normalizeStoryContent($validated['content'])
: (string) $story->content;
@@ -655,6 +702,14 @@ class StoryController extends Controller
'scheduled_for' => ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : $story->scheduled_for,
]);
$story->save();
if (! $wasPublished && $story->status === 'published') {
if ($story->published_at === null) {
$story->forceFill(['published_at' => now()])->save();
}
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
}
if (! empty($validated['tags_csv'])) {
@@ -666,6 +721,10 @@ class StoryController extends Controller
'story_id' => (int) $story->id,
'saved_at' => now()->toIso8601String(),
'message' => 'Saved just now',
'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
'public_url' => route('stories.show', ['slug' => $story->slug]),
]);
}
@@ -1047,7 +1106,7 @@ class StoryController extends Controller
'orderedList' => '<ol>' . $inner . '</ol>',
'listItem' => '<li>' . $inner . '</li>',
'horizontalRule' => '<hr>',
'codeBlock' => '<pre><code>' . e($this->extractTipTapText($node)) . '</code></pre>',
'codeBlock' => $this->renderCodeBlockNode($attrs, $node),
'image' => $this->renderImageNode($attrs),
'artworkEmbed' => $this->renderArtworkEmbedNode($attrs),
'galleryBlock' => $this->renderGalleryBlockNode($attrs),
@@ -1057,6 +1116,23 @@ class StoryController extends Controller
};
}
private function renderCodeBlockNode(array $attrs, array $node): string
{
$language = strtolower(trim((string) ($attrs['language'] ?? '')));
$language = preg_match('/^[a-z0-9_+-]+$/', $language) === 1 ? $language : '';
$escapedCode = e($this->extractTipTapText($node));
$preAttributes = $language !== ''
? ' data-language="' . e($language) . '"'
: '';
$codeAttributes = $language !== ''
? ' class="language-' . e($language) . '" data-language="' . e($language) . '"'
: '';
return '<pre' . $preAttributes . '><code' . $codeAttributes . '>' . $escapedCode . '</code></pre>';
}
private function renderImageNode(array $attrs): string
{
$src = (string) ($attrs['src'] ?? '');