optimizations
This commit is contained in:
210
app/Services/NovaCards/NovaCardAiAssistService.php
Normal file
210
app/Services/NovaCards/NovaCardAiAssistService.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
/**
|
||||
* AI-assist hooks for Nova Cards v3.
|
||||
*
|
||||
* This service provides assistive suggestions — it does NOT auto-publish
|
||||
* or override the creator. All suggestions are optional and editable.
|
||||
*
|
||||
* Integration strategy: each suggest* method returns a plain array of
|
||||
* suggestions. If an AI/ML backend is configured (via config/nova_cards.php),
|
||||
* this service dispatches to it; otherwise it uses deterministic rule-based
|
||||
* fallbacks. This keeps the system functional without a live AI dependency.
|
||||
*/
|
||||
class NovaCardAiAssistService
|
||||
{
|
||||
public function suggestTags(NovaCard $card): array
|
||||
{
|
||||
$text = implode(' ', array_filter([
|
||||
$card->quote_text,
|
||||
$card->quote_author,
|
||||
$card->title,
|
||||
$card->description,
|
||||
]));
|
||||
|
||||
// Rule-based: extract meaningful words as tag suggestions.
|
||||
// In production, replace/extend with an actual NLP/AI call.
|
||||
$words = preg_split('/[\s\-_,\.]+/u', strtolower($text));
|
||||
$stopWords = ['the', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'of', 'and', 'or', 'is', 'it', 'be', 'by', 'that', 'this', 'with', 'you', 'your', 'we', 'our', 'not'];
|
||||
$filtered = array_values(array_filter(
|
||||
array_unique($words ?? []),
|
||||
fn ($w) => is_string($w) && mb_strlen($w) >= 4 && ! in_array($w, $stopWords, true),
|
||||
));
|
||||
|
||||
return array_slice($filtered, 0, 6);
|
||||
}
|
||||
|
||||
public function suggestMood(NovaCard $card): array
|
||||
{
|
||||
$text = strtolower((string) $card->quote_text . ' ' . (string) $card->title);
|
||||
$moods = [];
|
||||
|
||||
$moodMap = [
|
||||
'love|heart|romance|kiss|tender' => 'romantic',
|
||||
'dark|shadow|night|alone|silence|void|lost' => 'dark-poetry',
|
||||
'inspire|hope|dream|believe|courage|strength|rise' => 'inspirational',
|
||||
'morning|sunrise|calm|peace|gentle|soft|breeze' => 'soft-morning',
|
||||
'minimal|simple|quiet|still|breath|moment' => 'minimal',
|
||||
'power|bold|fierce|fire|warrior|fight|hustle' => 'motivational',
|
||||
'cyber|neon|digital|code|matrix|tech|signal' => 'cyber-mood',
|
||||
];
|
||||
|
||||
foreach ($moodMap as $pattern => $mood) {
|
||||
if (preg_match('/(' . $pattern . ')/i', $text)) {
|
||||
$moods[] = $mood;
|
||||
}
|
||||
}
|
||||
|
||||
return array_slice(array_unique($moods), 0, 3);
|
||||
}
|
||||
|
||||
public function suggestLayout(NovaCard $card): array
|
||||
{
|
||||
$project = is_array($card->project_json) ? $card->project_json : [];
|
||||
$quoteLength = mb_strlen((string) $card->quote_text);
|
||||
$hasAuthor = ! empty($card->quote_author);
|
||||
|
||||
// Heuristic layout suggestions based on text content.
|
||||
$suggestions = [];
|
||||
|
||||
if ($quoteLength < 80) {
|
||||
$suggestions[] = [
|
||||
'layout' => 'quote_centered',
|
||||
'reason' => 'Short quotes work well centered with generous padding.',
|
||||
];
|
||||
} elseif ($quoteLength < 200) {
|
||||
$suggestions[] = [
|
||||
'layout' => 'quote_heavy',
|
||||
'reason' => 'Medium quotes benefit from a focused heavy layout.',
|
||||
];
|
||||
} else {
|
||||
$suggestions[] = [
|
||||
'layout' => 'editorial',
|
||||
'reason' => 'Longer quotes fit editorial multi-column layouts.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($hasAuthor) {
|
||||
$suggestions[] = [
|
||||
'layout' => 'byline_bottom',
|
||||
'reason' => 'With an author, a bottom byline anchors attribution cleanly.',
|
||||
];
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
public function suggestBackground(NovaCard $card): array
|
||||
{
|
||||
$moods = $this->suggestMood($card);
|
||||
$suggestions = [];
|
||||
|
||||
$moodGradientMap = [
|
||||
'romantic' => ['gradient_preset' => 'rose-poetry', 'reason' => 'Warm rose tones suit romantic content.'],
|
||||
'dark-poetry' => ['gradient_preset' => 'midnight-nova', 'reason' => 'Deep dark gradients amplify dark poetry vibes.'],
|
||||
'inspirational' => ['gradient_preset' => 'golden-hour', 'reason' => 'Warm golden tones elevate inspirational messages.'],
|
||||
'soft-morning' => ['gradient_preset' => 'soft-dawn', 'reason' => 'Gentle pastels suit morning or calm content.'],
|
||||
'minimal' => ['gradient_preset' => 'carbon-minimal', 'reason' => 'Clean dark or light neutrals suit minimal style.'],
|
||||
'motivational' => ['gradient_preset' => 'bold-fire', 'reason' => 'Bold warm gradients energise motivational content.'],
|
||||
'cyber-mood' => ['gradient_preset' => 'cyber-pulse', 'reason' => 'Electric neon gradients suit cyber aesthetic.'],
|
||||
];
|
||||
|
||||
foreach ($moods as $mood) {
|
||||
if (isset($moodGradientMap[$mood])) {
|
||||
$suggestions[] = $moodGradientMap[$mood];
|
||||
}
|
||||
}
|
||||
|
||||
// Default if no match.
|
||||
if (empty($suggestions)) {
|
||||
$suggestions[] = ['gradient_preset' => 'midnight-nova', 'reason' => 'A versatile dark gradient that works for most content.'];
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
public function suggestFontPairing(NovaCard $card): array
|
||||
{
|
||||
$moods = $this->suggestMood($card);
|
||||
|
||||
$moodFontMap = [
|
||||
'romantic' => ['font_preset' => 'romantic-serif', 'reason' => 'Elegant serif pairs beautifully with romantic content.'],
|
||||
'dark-poetry' => ['font_preset' => 'dark-poetic', 'reason' => 'Strong contrast serif pairs with dark poetry style.'],
|
||||
'inspirational' => ['font_preset' => 'modern-sans', 'reason' => 'Clean modern sans feels energising and clear.'],
|
||||
'soft-morning' => ['font_preset' => 'soft-rounded', 'reason' => 'Rounded type has warmth and approachability.'],
|
||||
'minimal' => ['font_preset' => 'minimal-mono', 'reason' => 'Monospaced type enforces a minimalist aesthetic.'],
|
||||
'cyber-mood' => ['font_preset' => 'cyber-display', 'reason' => 'Tech display fonts suit cyber and digital themes.'],
|
||||
];
|
||||
|
||||
foreach ($moods as $mood) {
|
||||
if (isset($moodFontMap[$mood])) {
|
||||
return [$moodFontMap[$mood]];
|
||||
}
|
||||
}
|
||||
|
||||
return [['font_preset' => 'modern-sans', 'reason' => 'A clean, versatile sans-serif for most content.']];
|
||||
}
|
||||
|
||||
public function suggestReadabilityFixes(NovaCard $card): array
|
||||
{
|
||||
$issues = [];
|
||||
$project = is_array($card->project_json) ? $card->project_json : [];
|
||||
|
||||
$textColor = Arr::get($project, 'typography.text_color', '#ffffff');
|
||||
$bgType = Arr::get($project, 'background.type', 'gradient');
|
||||
$overlayStyle = Arr::get($project, 'background.overlay_style', 'dark-soft');
|
||||
$quoteSize = (int) Arr::get($project, 'typography.quote_size', 72);
|
||||
$lineHeight = (float) Arr::get($project, 'typography.line_height', 1.2);
|
||||
|
||||
// Detect light text without overlay on upload background.
|
||||
if ($bgType === 'upload' && in_array($overlayStyle, ['none', 'minimal'], true)) {
|
||||
$issues[] = [
|
||||
'field' => 'background.overlay_style',
|
||||
'message' => 'Adding a dark overlay improves text legibility on photo backgrounds.',
|
||||
'suggestion' => 'dark-soft',
|
||||
];
|
||||
}
|
||||
|
||||
// Detect very small quote text.
|
||||
if ($quoteSize < 40) {
|
||||
$issues[] = [
|
||||
'field' => 'typography.quote_size',
|
||||
'message' => 'Quote text may be too small to read on mobile.',
|
||||
'suggestion' => 52,
|
||||
];
|
||||
}
|
||||
|
||||
// Detect very tight line height on long text.
|
||||
if ($lineHeight < 1.1 && mb_strlen((string) $card->quote_text) > 100) {
|
||||
$issues[] = [
|
||||
'field' => 'typography.line_height',
|
||||
'message' => 'Increasing line height improves readability for longer quotes.',
|
||||
'suggestion' => 1.3,
|
||||
];
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all suggestions in one call, for the AI assist panel.
|
||||
*/
|
||||
public function allSuggestions(NovaCard $card): array
|
||||
{
|
||||
return [
|
||||
'tags' => $this->suggestTags($card),
|
||||
'moods' => $this->suggestMood($card),
|
||||
'layouts' => $this->suggestLayout($card),
|
||||
'backgrounds' => $this->suggestBackground($card),
|
||||
'font_pairings' => $this->suggestFontPairing($card),
|
||||
'readability_fixes' => $this->suggestReadabilityFixes($card),
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Services/NovaCards/NovaCardBackgroundService.php
Normal file
64
app/Services/NovaCards/NovaCardBackgroundService.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCardBackground;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
class NovaCardBackgroundService
|
||||
{
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
|
||||
} catch (\Throwable) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function storeUploadedBackground(User $user, UploadedFile $file): NovaCardBackground
|
||||
{
|
||||
if ($this->manager === null) {
|
||||
throw new RuntimeException('Nova card background processing requires Intervention Image.');
|
||||
}
|
||||
|
||||
$uuid = (string) Str::uuid();
|
||||
$extension = strtolower($file->getClientOriginalExtension() ?: 'jpg');
|
||||
$originalDisk = Storage::disk((string) config('nova_cards.storage.private_disk', 'local'));
|
||||
$processedDisk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
|
||||
|
||||
$originalPath = trim((string) config('nova_cards.storage.background_original_prefix', 'cards/backgrounds/original'), '/')
|
||||
. '/' . $user->id . '/' . $uuid . '.' . $extension;
|
||||
|
||||
$processedPath = trim((string) config('nova_cards.storage.background_processed_prefix', 'cards/backgrounds/processed'), '/')
|
||||
. '/' . $user->id . '/' . $uuid . '.webp';
|
||||
|
||||
$originalDisk->put($originalPath, file_get_contents($file->getRealPath()) ?: '');
|
||||
|
||||
$image = $this->manager->read($file->getRealPath())->scaleDown(width: 2200, height: 2200);
|
||||
$encoded = (string) $image->encode(new WebpEncoder(88));
|
||||
$processedDisk->put($processedPath, $encoded);
|
||||
|
||||
return NovaCardBackground::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'original_path' => $originalPath,
|
||||
'processed_path' => $processedPath,
|
||||
'width' => $image->width(),
|
||||
'height' => $image->height(),
|
||||
'mime_type' => (string) ($file->getMimeType() ?: 'image/jpeg'),
|
||||
'file_size' => (int) $file->getSize(),
|
||||
'sha256' => hash_file('sha256', $file->getRealPath()) ?: null,
|
||||
'visibility' => 'card-only',
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
app/Services/NovaCards/NovaCardChallengeService.php
Normal file
36
app/Services/NovaCards/NovaCardChallengeService.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardChallenge;
|
||||
use App\Models\NovaCardChallengeEntry;
|
||||
use App\Models\User;
|
||||
|
||||
class NovaCardChallengeService
|
||||
{
|
||||
public function submit(User $user, NovaCardChallenge $challenge, NovaCard $card, ?string $note = null): NovaCardChallengeEntry
|
||||
{
|
||||
$entry = NovaCardChallengeEntry::query()->updateOrCreate([
|
||||
'challenge_id' => $challenge->id,
|
||||
'card_id' => $card->id,
|
||||
], [
|
||||
'user_id' => $user->id,
|
||||
'status' => NovaCardChallengeEntry::STATUS_ACTIVE,
|
||||
'note' => $note,
|
||||
]);
|
||||
|
||||
$challenge->forceFill([
|
||||
'entries_count' => NovaCardChallengeEntry::query()->where('challenge_id', $challenge->id)->count(),
|
||||
])->save();
|
||||
|
||||
$card->forceFill([
|
||||
'challenge_entries_count' => NovaCardChallengeEntry::query()->where('card_id', $card->id)->count(),
|
||||
'last_engaged_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $entry;
|
||||
}
|
||||
}
|
||||
162
app/Services/NovaCards/NovaCardCollectionService.php
Normal file
162
app/Services/NovaCards/NovaCardCollectionService.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Jobs\UpdateNovaCardStatsJob;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardCollection;
|
||||
use App\Models\NovaCardCollectionItem;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NovaCardCollectionService
|
||||
{
|
||||
public function createCollection(User $user, array $attributes): NovaCardCollection
|
||||
{
|
||||
$name = trim((string) ($attributes['name'] ?? 'Saved Cards'));
|
||||
$slug = $this->uniqueSlug($user, Str::slug($attributes['slug'] ?? $name) ?: 'saved-cards');
|
||||
|
||||
return NovaCardCollection::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'slug' => $slug,
|
||||
'name' => $name,
|
||||
'description' => $attributes['description'] ?? null,
|
||||
'visibility' => $attributes['visibility'] ?? NovaCardCollection::VISIBILITY_PRIVATE,
|
||||
'official' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createManagedCollection(array $attributes): NovaCardCollection
|
||||
{
|
||||
$owner = User::query()->findOrFail((int) $attributes['user_id']);
|
||||
$name = trim((string) ($attributes['name'] ?? 'Untitled Collection'));
|
||||
$slug = $this->uniqueSlug($owner, Str::slug($attributes['slug'] ?? $name) ?: 'nova-collection');
|
||||
|
||||
return NovaCardCollection::query()->create([
|
||||
'user_id' => $owner->id,
|
||||
'slug' => $slug,
|
||||
'name' => $name,
|
||||
'description' => $attributes['description'] ?? null,
|
||||
'visibility' => $attributes['visibility'] ?? NovaCardCollection::VISIBILITY_PUBLIC,
|
||||
'official' => (bool) ($attributes['official'] ?? false),
|
||||
'featured' => (bool) ($attributes['featured'] ?? false),
|
||||
]);
|
||||
}
|
||||
|
||||
public function listCollections(User $user): array
|
||||
{
|
||||
return NovaCardCollection::query()
|
||||
->withCount('items')
|
||||
->where('user_id', $user->id)
|
||||
->orderByDesc('updated_at')
|
||||
->get()
|
||||
->map(fn (NovaCardCollection $collection): array => [
|
||||
'id' => (int) $collection->id,
|
||||
'slug' => (string) $collection->slug,
|
||||
'name' => (string) $collection->name,
|
||||
'description' => $collection->description,
|
||||
'visibility' => (string) $collection->visibility,
|
||||
'featured' => (bool) $collection->featured,
|
||||
'cards_count' => (int) $collection->cards_count,
|
||||
'items_count' => (int) $collection->items_count,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function saveCard(User $user, NovaCard $card, ?int $collectionId = null, ?string $note = null): NovaCardCollection
|
||||
{
|
||||
$collection = $collectionId
|
||||
? NovaCardCollection::query()->where('user_id', $user->id)->findOrFail($collectionId)
|
||||
: $this->defaultCollection($user);
|
||||
|
||||
$this->addCardToCollection($collection, $card, $note);
|
||||
|
||||
return $collection->refresh();
|
||||
}
|
||||
|
||||
public function addCardToCollection(NovaCardCollection $collection, NovaCard $card, ?string $note = null, ?int $sortOrder = null): NovaCardCollectionItem
|
||||
{
|
||||
$sortOrder ??= (int) NovaCardCollectionItem::query()->where('collection_id', $collection->id)->max('sort_order') + 1;
|
||||
|
||||
$item = NovaCardCollectionItem::query()->updateOrCreate([
|
||||
'collection_id' => $collection->id,
|
||||
'card_id' => $card->id,
|
||||
], [
|
||||
'note' => $note,
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
|
||||
$this->refreshCounts($collection, $card);
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
public function unsaveCard(User $user, NovaCard $card, ?int $collectionId = null): void
|
||||
{
|
||||
$query = NovaCardCollectionItem::query()
|
||||
->where('card_id', $card->id)
|
||||
->whereHas('collection', fn ($builder) => $builder->where('user_id', $user->id));
|
||||
|
||||
if ($collectionId !== null) {
|
||||
$query->where('collection_id', $collectionId);
|
||||
}
|
||||
|
||||
$collectionIds = $query->pluck('collection_id')->unique()->all();
|
||||
$query->delete();
|
||||
|
||||
foreach ($collectionIds as $id) {
|
||||
$collection = NovaCardCollection::query()->find($id);
|
||||
if ($collection) {
|
||||
$this->refreshCounts($collection, $card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function removeCardFromCollection(NovaCardCollection $collection, NovaCard $card): void
|
||||
{
|
||||
NovaCardCollectionItem::query()
|
||||
->where('collection_id', $collection->id)
|
||||
->where('card_id', $card->id)
|
||||
->delete();
|
||||
|
||||
$this->refreshCounts($collection, $card);
|
||||
}
|
||||
|
||||
public function defaultCollection(User $user): NovaCardCollection
|
||||
{
|
||||
return NovaCardCollection::query()->firstOrCreate([
|
||||
'user_id' => $user->id,
|
||||
'slug' => 'saved-cards',
|
||||
], [
|
||||
'name' => 'Saved Cards',
|
||||
'description' => 'Private library of Nova Cards saved for remixing, referencing, and future collections.',
|
||||
'visibility' => NovaCardCollection::VISIBILITY_PRIVATE,
|
||||
'official' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function refreshCounts(NovaCardCollection $collection, NovaCard $card): void
|
||||
{
|
||||
$collection->forceFill([
|
||||
'cards_count' => NovaCardCollectionItem::query()->where('collection_id', $collection->id)->count(),
|
||||
])->save();
|
||||
|
||||
UpdateNovaCardStatsJob::dispatch($card->id);
|
||||
}
|
||||
|
||||
private function uniqueSlug(User $user, string $base): string
|
||||
{
|
||||
$slug = $base;
|
||||
$suffix = 2;
|
||||
|
||||
while (NovaCardCollection::query()->where('user_id', $user->id)->where('slug', $slug)->exists()) {
|
||||
$slug = $base . '-' . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
117
app/Services/NovaCards/NovaCardCommentService.php
Normal file
117
app/Services/NovaCards/NovaCardCommentService.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Jobs\UpdateNovaCardStatsJob;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardComment;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class NovaCardCommentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationService $notifications,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(NovaCard $card, User $actor, string $body, ?NovaCardComment $parent = null): NovaCardComment
|
||||
{
|
||||
if (! $card->canReceiveCommentsFrom($actor)) {
|
||||
throw ValidationException::withMessages([
|
||||
'card' => 'Comments are unavailable for this card.',
|
||||
]);
|
||||
}
|
||||
|
||||
$comment = NovaCardComment::query()->create([
|
||||
'card_id' => $card->id,
|
||||
'user_id' => $actor->id,
|
||||
'parent_id' => $parent?->id,
|
||||
'body' => trim($body),
|
||||
'rendered_body' => nl2br(e(trim($body))),
|
||||
'status' => 'visible',
|
||||
]);
|
||||
|
||||
if (! $card->isOwnedBy($actor)) {
|
||||
$this->notifications->notifyNovaCardComment($card->user, $actor, $card, $comment);
|
||||
}
|
||||
|
||||
UpdateNovaCardStatsJob::dispatch($card->id);
|
||||
|
||||
return $comment->fresh(['user.profile', 'replies.user.profile', 'card.user']);
|
||||
}
|
||||
|
||||
public function delete(NovaCardComment $comment, User $actor): void
|
||||
{
|
||||
if ((int) $comment->user_id !== (int) $actor->id && ! $comment->card->isOwnedBy($actor) && ! $this->isModerator($actor)) {
|
||||
throw ValidationException::withMessages([
|
||||
'comment' => 'You are not allowed to remove this comment.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($comment->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$comment->delete();
|
||||
UpdateNovaCardStatsJob::dispatch($comment->card_id);
|
||||
}
|
||||
|
||||
public function mapComments(NovaCard $card, ?User $viewer = null): array
|
||||
{
|
||||
$comments = $card->comments()
|
||||
->whereNull('parent_id')
|
||||
->where('status', 'visible')
|
||||
->with(['user.profile', 'replies.user.profile', 'card.user'])
|
||||
->latest()
|
||||
->limit(30)
|
||||
->get();
|
||||
|
||||
return $comments->map(fn (NovaCardComment $comment) => $this->mapComment($comment, $viewer))->all();
|
||||
}
|
||||
|
||||
private function mapComment(NovaCardComment $comment, ?User $viewer = null): array
|
||||
{
|
||||
$user = $comment->user;
|
||||
|
||||
return [
|
||||
'id' => (int) $comment->id,
|
||||
'body' => (string) $comment->body,
|
||||
'rendered_content' => (string) $comment->rendered_body,
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'created_at' => $comment->created_at?->toISOString(),
|
||||
'can_delete' => $viewer !== null && ((int) $viewer->id === (int) $comment->user_id || $comment->card->isOwnedBy($viewer) || $this->isModerator($viewer)),
|
||||
'can_report' => $viewer !== null && (int) $viewer->id !== (int) $comment->user_id,
|
||||
'user' => [
|
||||
'id' => (int) $user->id,
|
||||
'display' => (string) ($user->name ?: $user->username),
|
||||
'username' => (string) $user->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64),
|
||||
'profile_url' => '/@' . Str::lower((string) $user->username),
|
||||
],
|
||||
'replies' => $comment->replies
|
||||
->where('status', 'visible')
|
||||
->map(fn (NovaCardComment $reply) => $this->mapComment($reply, $viewer))
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function isModerator(User $user): bool
|
||||
{
|
||||
if (method_exists($user, 'isModerator')) {
|
||||
return (bool) $user->isModerator();
|
||||
}
|
||||
|
||||
if (method_exists($user, 'hasRole')) {
|
||||
return (bool) $user->hasRole('moderator') || (bool) $user->hasRole('admin');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
235
app/Services/NovaCards/NovaCardCreatorPresetService.php
Normal file
235
app/Services/NovaCards/NovaCardCreatorPresetService.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardCreatorPreset;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class NovaCardCreatorPresetService
|
||||
{
|
||||
/** Maximum presets per user across all types. */
|
||||
public const MAX_TOTAL = 30;
|
||||
|
||||
/** Maximum presets per type per user. */
|
||||
public const MAX_PER_TYPE = 8;
|
||||
|
||||
public function listForUser(User $user, ?string $presetType = null): Collection
|
||||
{
|
||||
return NovaCardCreatorPreset::query()
|
||||
->where('user_id', $user->id)
|
||||
->when($presetType !== null, fn ($q) => $q->where('preset_type', $presetType))
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function create(User $user, array $data): NovaCardCreatorPreset
|
||||
{
|
||||
$type = (string) Arr::get($data, 'preset_type', NovaCardCreatorPreset::TYPE_STYLE);
|
||||
|
||||
// Enforce per-type limit.
|
||||
$typeCount = NovaCardCreatorPreset::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('preset_type', $type)
|
||||
->count();
|
||||
|
||||
if ($typeCount >= self::MAX_PER_TYPE) {
|
||||
abort(422, 'Maximum number of ' . $type . ' presets reached (' . self::MAX_PER_TYPE . ').');
|
||||
}
|
||||
|
||||
// Enforce total limit.
|
||||
$totalCount = NovaCardCreatorPreset::query()->where('user_id', $user->id)->count();
|
||||
if ($totalCount >= self::MAX_TOTAL) {
|
||||
abort(422, 'Maximum total presets reached (' . self::MAX_TOTAL . ').');
|
||||
}
|
||||
|
||||
$preset = NovaCardCreatorPreset::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => (string) Arr::get($data, 'name', 'My preset'),
|
||||
'preset_type' => $type,
|
||||
'config_json' => $this->sanitizeConfig((array) Arr::get($data, 'config_json', []), $type),
|
||||
'is_default' => false,
|
||||
]);
|
||||
|
||||
if ((bool) Arr::get($data, 'is_default', false)) {
|
||||
$this->setDefault($user, $preset);
|
||||
}
|
||||
|
||||
return $preset->refresh();
|
||||
}
|
||||
|
||||
public function update(User $user, NovaCardCreatorPreset $preset, array $data): NovaCardCreatorPreset
|
||||
{
|
||||
$this->authorizeOwner($user, $preset);
|
||||
|
||||
$preset->fill([
|
||||
'name' => (string) Arr::get($data, 'name', $preset->name),
|
||||
'config_json' => $this->sanitizeConfig(
|
||||
(array) Arr::get($data, 'config_json', $preset->config_json ?? []),
|
||||
$preset->preset_type,
|
||||
),
|
||||
]);
|
||||
$preset->save();
|
||||
|
||||
if (array_key_exists('is_default', $data) && (bool) $data['is_default']) {
|
||||
$this->setDefault($user, $preset);
|
||||
}
|
||||
|
||||
return $preset->refresh();
|
||||
}
|
||||
|
||||
public function delete(User $user, NovaCardCreatorPreset $preset): void
|
||||
{
|
||||
$this->authorizeOwner($user, $preset);
|
||||
$preset->delete();
|
||||
}
|
||||
|
||||
public function setDefault(User $user, NovaCardCreatorPreset|int $preset): void
|
||||
{
|
||||
if (is_int($preset)) {
|
||||
$preset = NovaCardCreatorPreset::query()->findOrFail($preset);
|
||||
}
|
||||
|
||||
$this->authorizeOwner($user, $preset);
|
||||
|
||||
// Clear existing defaults of the same type before setting new one.
|
||||
NovaCardCreatorPreset::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('preset_type', $preset->preset_type)
|
||||
->where('id', '!=', $preset->id)
|
||||
->update(['is_default' => false]);
|
||||
|
||||
$preset->update(['is_default' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a preset from a published or saved card project JSON.
|
||||
* Extracts only the fields relevant to the given preset type.
|
||||
*/
|
||||
public function captureFromCard(User $user, NovaCard $card, string $presetName, string $presetType): NovaCardCreatorPreset
|
||||
{
|
||||
$project = is_array($card->project_json) ? $card->project_json : [];
|
||||
$config = $this->extractFromProject($project, $presetType);
|
||||
|
||||
return $this->create($user, [
|
||||
'name' => $presetName,
|
||||
'preset_type' => $presetType,
|
||||
'config_json' => $config,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a preset config on top of the given project patch array.
|
||||
* Returns the merged patch to be passed into the draft autosave flow.
|
||||
*/
|
||||
public function applyToProjectPatch(NovaCardCreatorPreset $preset, array|NovaCard $currentProject): array
|
||||
{
|
||||
if ($currentProject instanceof NovaCard) {
|
||||
$currentProject = is_array($currentProject->project_json) ? $currentProject->project_json : [];
|
||||
}
|
||||
|
||||
$config = $preset->config_json ?? [];
|
||||
|
||||
return match ($preset->preset_type) {
|
||||
NovaCardCreatorPreset::TYPE_TYPOGRAPHY => [
|
||||
'typography' => array_merge(
|
||||
(array) Arr::get($currentProject, 'typography', []),
|
||||
(array) (Arr::get($config, 'typography', $config)),
|
||||
),
|
||||
],
|
||||
NovaCardCreatorPreset::TYPE_LAYOUT => [
|
||||
'layout' => array_merge(
|
||||
(array) Arr::get($currentProject, 'layout', []),
|
||||
(array) (Arr::get($config, 'layout', $config)),
|
||||
),
|
||||
],
|
||||
NovaCardCreatorPreset::TYPE_BACKGROUND => [
|
||||
'background' => array_merge(
|
||||
(array) Arr::get($currentProject, 'background', []),
|
||||
(array) (Arr::get($config, 'background', $config)),
|
||||
),
|
||||
],
|
||||
NovaCardCreatorPreset::TYPE_STYLE => array_filter([
|
||||
'typography' => isset($config['typography'])
|
||||
? array_merge((array) Arr::get($currentProject, 'typography', []), (array) $config['typography'])
|
||||
: null,
|
||||
'background' => isset($config['background'])
|
||||
? array_merge((array) Arr::get($currentProject, 'background', []), (array) $config['background'])
|
||||
: null,
|
||||
'effects' => isset($config['effects'])
|
||||
? array_merge((array) Arr::get($currentProject, 'effects', []), (array) $config['effects'])
|
||||
: null,
|
||||
]),
|
||||
NovaCardCreatorPreset::TYPE_STARTER => $config,
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
private function extractFromProject(array $project, string $presetType): array
|
||||
{
|
||||
return match ($presetType) {
|
||||
NovaCardCreatorPreset::TYPE_TYPOGRAPHY => array_intersect_key(
|
||||
(array) Arr::get($project, 'typography', []),
|
||||
array_flip(['font_preset', 'text_color', 'accent_color', 'quote_size', 'author_size', 'letter_spacing', 'line_height', 'shadow_preset', 'quote_mark_preset', 'text_panel_style', 'text_glow', 'text_stroke']),
|
||||
),
|
||||
NovaCardCreatorPreset::TYPE_LAYOUT => array_intersect_key(
|
||||
(array) Arr::get($project, 'layout', []),
|
||||
array_flip(['layout', 'position', 'alignment', 'padding', 'max_width']),
|
||||
),
|
||||
NovaCardCreatorPreset::TYPE_BACKGROUND => array_intersect_key(
|
||||
(array) Arr::get($project, 'background', []),
|
||||
array_flip(['type', 'gradient_preset', 'gradient_colors', 'solid_color', 'overlay_style', 'blur_level', 'opacity', 'brightness', 'contrast', 'texture_overlay', 'gradient_direction']),
|
||||
),
|
||||
NovaCardCreatorPreset::TYPE_STYLE => [
|
||||
'typography' => array_intersect_key(
|
||||
(array) Arr::get($project, 'typography', []),
|
||||
array_flip(['font_preset', 'text_color', 'accent_color', 'shadow_preset', 'quote_mark_preset', 'text_panel_style']),
|
||||
),
|
||||
'background' => array_intersect_key(
|
||||
(array) Arr::get($project, 'background', []),
|
||||
array_flip(['gradient_preset', 'gradient_colors', 'overlay_style']),
|
||||
),
|
||||
'effects' => (array) Arr::get($project, 'effects', []),
|
||||
],
|
||||
NovaCardCreatorPreset::TYPE_STARTER => array_intersect_key($project, array_flip([
|
||||
'template', 'layout', 'typography', 'background', 'decorations', 'effects', 'frame',
|
||||
])),
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
private function sanitizeConfig(array $config, string $type): array
|
||||
{
|
||||
// Strip deeply nested unknowns and limit total size.
|
||||
$encoded = json_encode($config);
|
||||
if ($encoded === false || mb_strlen($encoded) > 32_768) {
|
||||
abort(422, 'Preset config is too large.');
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function authorizeOwner(User $user, NovaCardCreatorPreset $preset): void
|
||||
{
|
||||
if ((int) $preset->user_id !== (int) $user->id) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
public function toArray(NovaCardCreatorPreset $preset): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $preset->id,
|
||||
'name' => (string) $preset->name,
|
||||
'preset_type' => (string) $preset->preset_type,
|
||||
'config_json' => $preset->config_json ?? [],
|
||||
'is_default' => (bool) $preset->is_default,
|
||||
'created_at' => optional($preset->created_at)?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
283
app/Services/NovaCards/NovaCardDraftService.php
Normal file
283
app/Services/NovaCards/NovaCardDraftService.php
Normal file
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Events\NovaCards\NovaCardAutosaved;
|
||||
use App\Events\NovaCards\NovaCardCreated;
|
||||
use App\Events\NovaCards\NovaCardTemplateSelected;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardCategory;
|
||||
use App\Models\NovaCardTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NovaCardDraftService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardTagService $tagService,
|
||||
private readonly NovaCardProjectNormalizer $normalizer,
|
||||
private readonly NovaCardVersionService $versions,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createDraft(User $user, array $attributes = []): NovaCard
|
||||
{
|
||||
$template = $this->resolveTemplate(Arr::get($attributes, 'template_id'));
|
||||
$category = $this->resolveCategory(Arr::get($attributes, 'category_id'));
|
||||
$title = trim((string) Arr::get($attributes, 'title', 'Untitled card'));
|
||||
$quote = trim((string) Arr::get($attributes, 'quote_text', 'Your next quote starts here.'));
|
||||
$project = $this->normalizer->upgradeToV2(null, $template, $attributes);
|
||||
$topLevel = $this->normalizer->syncTopLevelAttributes($project);
|
||||
$originalCardId = Arr::get($attributes, 'original_card_id');
|
||||
$rootCardId = Arr::get($attributes, 'root_card_id', $originalCardId);
|
||||
|
||||
$card = NovaCard::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $category?->id,
|
||||
'template_id' => $template?->id,
|
||||
'title' => Str::limit($topLevel['title'] ?: $title, (int) config('nova_cards.validation.title_max', 120), ''),
|
||||
'slug' => $this->generateUniqueSlug($title),
|
||||
'quote_text' => Str::limit($topLevel['quote_text'] ?: $quote, (int) config('nova_cards.validation.quote_max', 420), ''),
|
||||
'quote_author' => $topLevel['quote_author'],
|
||||
'quote_source' => $topLevel['quote_source'],
|
||||
'description' => Arr::get($attributes, 'description'),
|
||||
'format' => $this->resolveFormat((string) Arr::get($attributes, 'format', NovaCard::FORMAT_SQUARE)),
|
||||
'project_json' => $project,
|
||||
'schema_version' => (int) $topLevel['schema_version'],
|
||||
'visibility' => NovaCard::VISIBILITY_PRIVATE,
|
||||
'status' => NovaCard::STATUS_DRAFT,
|
||||
'moderation_status' => NovaCard::MOD_PENDING,
|
||||
'background_type' => $topLevel['background_type'],
|
||||
'background_image_id' => $topLevel['background_image_id'],
|
||||
'allow_download' => true,
|
||||
'allow_remix' => (bool) Arr::get($attributes, 'allow_remix', true),
|
||||
'allow_background_reuse' => (bool) Arr::get($attributes, 'allow_background_reuse', false),
|
||||
'allow_export' => (bool) Arr::get($attributes, 'allow_export', true),
|
||||
'style_family' => Arr::get($attributes, 'style_family'),
|
||||
'palette_family' => Arr::get($attributes, 'palette_family'),
|
||||
'original_card_id' => $originalCardId,
|
||||
'root_card_id' => $rootCardId,
|
||||
]);
|
||||
|
||||
$this->tagService->syncTags($card, Arr::wrap(Arr::get($attributes, 'tags', [])));
|
||||
$this->versions->snapshot($card->fresh(['template']), $user, $originalCardId ? 'Remix draft created' : 'Initial draft', true);
|
||||
|
||||
event(new NovaCardCreated($card->fresh()->load(['category', 'template', 'tags'])));
|
||||
|
||||
if ($card->template_id !== null) {
|
||||
event(new NovaCardTemplateSelected($card->fresh()->load(['category', 'template', 'tags']), null, (int) $card->template_id));
|
||||
}
|
||||
|
||||
return $card->load(['category', 'template', 'tags']);
|
||||
}
|
||||
|
||||
public function autosave(NovaCard $card, array $payload): NovaCard
|
||||
{
|
||||
$currentProject = $this->normalizer->upgradeToV2(is_array($card->project_json) ? $card->project_json : [], $card->template, [], $card);
|
||||
$template = $this->resolveTemplateId($payload, $card)
|
||||
? NovaCardTemplate::query()->find($this->resolveTemplateId($payload, $card))
|
||||
: $card->template;
|
||||
$projectPatch = is_array(Arr::get($payload, 'project_json')) ? Arr::get($payload, 'project_json') : [];
|
||||
$normalizedProject = $this->normalizer->upgradeToV2(
|
||||
array_replace_recursive($currentProject, $projectPatch),
|
||||
$template,
|
||||
array_merge($payload, [
|
||||
'title' => Arr::get($payload, 'title', $card->title),
|
||||
'quote_text' => Arr::get($payload, 'quote_text', $card->quote_text),
|
||||
'quote_author' => Arr::get($payload, 'quote_author', $card->quote_author),
|
||||
'quote_source' => Arr::get($payload, 'quote_source', $card->quote_source),
|
||||
'background_type' => Arr::get($payload, 'background_type', $card->background_type),
|
||||
'background_image_id' => Arr::get($payload, 'background_image_id', $card->background_image_id),
|
||||
'original_card_id' => $card->original_card_id,
|
||||
'root_card_id' => $card->root_card_id,
|
||||
]),
|
||||
$card,
|
||||
);
|
||||
$topLevel = $this->normalizer->syncTopLevelAttributes($normalizedProject);
|
||||
|
||||
if (array_key_exists('title', $payload)) {
|
||||
$topLevel['title'] = trim((string) $payload['title']);
|
||||
}
|
||||
|
||||
if (array_key_exists('quote_text', $payload)) {
|
||||
$topLevel['quote_text'] = trim((string) $payload['quote_text']);
|
||||
}
|
||||
|
||||
if (array_key_exists('quote_author', $payload)) {
|
||||
$topLevel['quote_author'] = (($value = trim((string) $payload['quote_author'])) !== '') ? $value : null;
|
||||
}
|
||||
|
||||
if (array_key_exists('quote_source', $payload)) {
|
||||
$topLevel['quote_source'] = (($value = trim((string) $payload['quote_source'])) !== '') ? $value : null;
|
||||
}
|
||||
|
||||
$previousTemplateId = $card->template_id ? (int) $card->template_id : null;
|
||||
|
||||
$card->fill([
|
||||
'title' => Str::limit($topLevel['title'], (int) config('nova_cards.validation.title_max', 120), ''),
|
||||
'quote_text' => Str::limit($topLevel['quote_text'], (int) config('nova_cards.validation.quote_max', 420), ''),
|
||||
'quote_author' => $topLevel['quote_author'],
|
||||
'quote_source' => $topLevel['quote_source'],
|
||||
'description' => Arr::get($payload, 'description', $card->description),
|
||||
'format' => $this->resolveFormat((string) Arr::get($payload, 'format', $card->format)),
|
||||
'project_json' => $normalizedProject,
|
||||
'schema_version' => (int) $topLevel['schema_version'],
|
||||
'background_type' => $topLevel['background_type'],
|
||||
'background_image_id' => $topLevel['background_image_id'],
|
||||
'template_id' => $this->resolveTemplateId($payload, $card),
|
||||
'category_id' => $this->resolveCategoryId($payload, $card),
|
||||
'visibility' => Arr::get($payload, 'visibility', $card->visibility),
|
||||
'allow_download' => (bool) Arr::get($payload, 'allow_download', $card->allow_download),
|
||||
'allow_remix' => (bool) Arr::get($payload, 'allow_remix', $card->allow_remix),
|
||||
'allow_background_reuse' => (bool) Arr::get($payload, 'allow_background_reuse', $card->allow_background_reuse ?? false),
|
||||
'allow_export' => (bool) Arr::get($payload, 'allow_export', $card->allow_export ?? true),
|
||||
'style_family' => Arr::get($payload, 'style_family', $card->style_family),
|
||||
'palette_family' => Arr::get($payload, 'palette_family', $card->palette_family),
|
||||
'editor_mode_last_used' => Arr::get($payload, 'editor_mode_last_used', $card->editor_mode_last_used),
|
||||
]);
|
||||
|
||||
if ($card->isDirty('title')) {
|
||||
$card->slug = $this->generateUniqueSlug($card->title, $card->id);
|
||||
}
|
||||
|
||||
$card->save();
|
||||
$changes = $card->getChanges();
|
||||
|
||||
if (array_key_exists('tags', $payload)) {
|
||||
$this->tagService->syncTags($card, Arr::wrap(Arr::get($payload, 'tags', [])));
|
||||
$changes['tags'] = true;
|
||||
}
|
||||
|
||||
if ($changes !== []) {
|
||||
$fresh = $card->fresh()->load(['category', 'template', 'tags']);
|
||||
$this->versions->snapshot($fresh->loadMissing('template'), $fresh->user, Arr::get($payload, 'version_label'));
|
||||
event(new NovaCardAutosaved($fresh, array_keys($changes)));
|
||||
|
||||
$currentTemplateId = $fresh->template_id ? (int) $fresh->template_id : null;
|
||||
if ($currentTemplateId !== $previousTemplateId && $currentTemplateId !== null) {
|
||||
event(new NovaCardTemplateSelected($fresh, $previousTemplateId, $currentTemplateId));
|
||||
}
|
||||
}
|
||||
|
||||
return $card->refresh()->load(['category', 'template', 'tags']);
|
||||
}
|
||||
|
||||
public function createRemix(User $user, NovaCard $source, array $attributes = []): NovaCard
|
||||
{
|
||||
$baseProject = $this->normalizer->normalizeForCard($source->loadMissing('template'));
|
||||
$payload = array_merge([
|
||||
'title' => 'Remix of ' . $source->title,
|
||||
'quote_text' => $source->quote_text,
|
||||
'quote_author' => $source->quote_author,
|
||||
'quote_source' => $source->quote_source,
|
||||
'description' => $source->description,
|
||||
'format' => $source->format,
|
||||
'background_type' => $source->background_type,
|
||||
'background_image_id' => $source->background_image_id,
|
||||
'template_id' => $source->template_id,
|
||||
'category_id' => $source->category_id,
|
||||
'tags' => $source->tags->pluck('name')->all(),
|
||||
'project_json' => $baseProject,
|
||||
'original_card_id' => $source->id,
|
||||
'root_card_id' => $source->root_card_id ?: $source->id,
|
||||
], $attributes);
|
||||
|
||||
return $this->createDraft($user, $payload);
|
||||
}
|
||||
|
||||
public function createDuplicate(User $user, NovaCard $source, array $attributes = []): NovaCard
|
||||
{
|
||||
abort_unless($source->isOwnedBy($user), 403);
|
||||
|
||||
$baseProject = $this->normalizer->normalizeForCard($source->loadMissing('template'));
|
||||
$payload = array_merge([
|
||||
'title' => 'Copy of ' . $source->title,
|
||||
'quote_text' => $source->quote_text,
|
||||
'quote_author' => $source->quote_author,
|
||||
'quote_source' => $source->quote_source,
|
||||
'description' => $source->description,
|
||||
'format' => $source->format,
|
||||
'background_type' => $source->background_type,
|
||||
'background_image_id' => $source->background_image_id,
|
||||
'template_id' => $source->template_id,
|
||||
'category_id' => $source->category_id,
|
||||
'tags' => $source->tags->pluck('name')->all(),
|
||||
'project_json' => $baseProject,
|
||||
'allow_remix' => $source->allow_remix,
|
||||
], $attributes);
|
||||
|
||||
return $this->createDraft($user, $payload);
|
||||
}
|
||||
|
||||
private function buildProjectPayload(?NovaCardTemplate $template, array $attributes): array
|
||||
{
|
||||
return $this->normalizer->normalize(null, $template, $attributes);
|
||||
}
|
||||
|
||||
private function generateUniqueSlug(string $title, ?int $ignoreId = null): string
|
||||
{
|
||||
$base = Str::slug($title);
|
||||
if ($base === '') {
|
||||
$base = 'nova-card';
|
||||
}
|
||||
|
||||
$slug = $base;
|
||||
$suffix = 2;
|
||||
|
||||
while (NovaCard::query()
|
||||
->when($ignoreId !== null, fn ($query) => $query->where('id', '!=', $ignoreId))
|
||||
->where('slug', $slug)
|
||||
->exists()) {
|
||||
$slug = $base . '-' . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
private function resolveTemplate(mixed $templateId): ?NovaCardTemplate
|
||||
{
|
||||
if ($templateId) {
|
||||
return NovaCardTemplate::query()->find($templateId);
|
||||
}
|
||||
|
||||
return NovaCardTemplate::query()->where('active', true)->orderBy('order_num')->first();
|
||||
}
|
||||
|
||||
private function resolveCategory(mixed $categoryId): ?NovaCardCategory
|
||||
{
|
||||
if ($categoryId) {
|
||||
return NovaCardCategory::query()->find($categoryId);
|
||||
}
|
||||
|
||||
return NovaCardCategory::query()->where('active', true)->orderBy('order_num')->first();
|
||||
}
|
||||
|
||||
private function resolveFormat(string $format): string
|
||||
{
|
||||
$formats = array_keys((array) config('nova_cards.formats', []));
|
||||
|
||||
return in_array($format, $formats, true) ? $format : NovaCard::FORMAT_SQUARE;
|
||||
}
|
||||
|
||||
private function resolveTemplateId(array $payload, NovaCard $card): ?int
|
||||
{
|
||||
if (! array_key_exists('template_id', $payload)) {
|
||||
return $card->template_id;
|
||||
}
|
||||
|
||||
return $this->resolveTemplate(Arr::get($payload, 'template_id'))?->id;
|
||||
}
|
||||
|
||||
private function resolveCategoryId(array $payload, NovaCard $card): ?int
|
||||
{
|
||||
if (! array_key_exists('category_id', $payload)) {
|
||||
return $card->category_id;
|
||||
}
|
||||
|
||||
return $this->resolveCategory(Arr::get($payload, 'category_id'))?->id;
|
||||
}
|
||||
}
|
||||
107
app/Services/NovaCards/NovaCardExportService.php
Normal file
107
app/Services/NovaCards/NovaCardExportService.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Jobs\NovaCards\GenerateNovaCardExportJob;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardExport;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Handles export request creation, status checking, and file resolution.
|
||||
* Actual rendering is delegated to GenerateNovaCardExportJob (queued).
|
||||
*/
|
||||
class NovaCardExportService
|
||||
{
|
||||
/** How long (in minutes) a generated export file is available. */
|
||||
private const EXPORT_TTL_MINUTES = 60;
|
||||
|
||||
/** Allowed export types and their canvas dimensions. */
|
||||
public const EXPORT_SPECS = [
|
||||
NovaCardExport::TYPE_PREVIEW => ['width' => 1080, 'height' => 1080, 'format' => 'webp'],
|
||||
NovaCardExport::TYPE_HIRES => ['width' => 2160, 'height' => 2160, 'format' => 'webp'],
|
||||
NovaCardExport::TYPE_SQUARE => ['width' => 1080, 'height' => 1080, 'format' => 'webp'],
|
||||
NovaCardExport::TYPE_STORY => ['width' => 1080, 'height' => 1920, 'format' => 'webp'],
|
||||
NovaCardExport::TYPE_WALLPAPER => ['width' => 2560, 'height' => 1440, 'format' => 'webp'],
|
||||
NovaCardExport::TYPE_OG => ['width' => 1200, 'height' => 630, 'format' => 'jpg'],
|
||||
];
|
||||
|
||||
public function requestExport(User $user, NovaCard $card, string $exportType, array $options = []): NovaCardExport
|
||||
{
|
||||
if (! $card->allow_export && ! $card->isOwnedBy($user)) {
|
||||
abort(403, 'This card does not allow exports.');
|
||||
}
|
||||
|
||||
$spec = self::EXPORT_SPECS[$exportType] ?? self::EXPORT_SPECS[NovaCardExport::TYPE_PREVIEW];
|
||||
|
||||
// Reuse a recent pending/ready non-expired export for the same type.
|
||||
$existing = NovaCardExport::query()
|
||||
->where('card_id', $card->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('export_type', $exportType)
|
||||
->whereIn('status', [NovaCardExport::STATUS_READY, NovaCardExport::STATUS_PENDING, NovaCardExport::STATUS_PROCESSING])
|
||||
->where('expires_at', '>', now())
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$export = NovaCardExport::query()->create([
|
||||
'card_id' => $card->id,
|
||||
'user_id' => $user->id,
|
||||
'export_type' => $exportType,
|
||||
'status' => NovaCardExport::STATUS_PENDING,
|
||||
'width' => $spec['width'],
|
||||
'height' => $spec['height'],
|
||||
'format' => $spec['format'],
|
||||
'options_json' => $options,
|
||||
'expires_at' => now()->addMinutes(self::EXPORT_TTL_MINUTES),
|
||||
]);
|
||||
|
||||
GenerateNovaCardExportJob::dispatch($export->id)
|
||||
->onQueue((string) config('nova_cards.render.queue', 'default'));
|
||||
|
||||
return $export->refresh();
|
||||
}
|
||||
|
||||
public function getStatus(NovaCardExport $export): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $export->id,
|
||||
'export_type' => (string) $export->export_type,
|
||||
'status' => (string) $export->status,
|
||||
'output_url' => $export->isReady() && ! $export->isExpired() ? $export->outputUrl() : null,
|
||||
'width' => $export->width,
|
||||
'height' => $export->height,
|
||||
'format' => (string) $export->format,
|
||||
'ready_at' => optional($export->ready_at)?->toISOString(),
|
||||
'expires_at' => optional($export->expires_at)?->toISOString(),
|
||||
];
|
||||
}
|
||||
|
||||
public function cleanupExpired(): int
|
||||
{
|
||||
$exports = NovaCardExport::query()
|
||||
->where('expires_at', '<', now())
|
||||
->whereNotNull('output_path')
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
foreach ($exports as $export) {
|
||||
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
|
||||
if ($export->output_path && $disk->exists($export->output_path)) {
|
||||
$disk->delete($export->output_path);
|
||||
}
|
||||
$export->delete();
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
50
app/Services/NovaCards/NovaCardLineageService.php
Normal file
50
app/Services/NovaCards/NovaCardLineageService.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\User;
|
||||
|
||||
class NovaCardLineageService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardPresenter $presenter,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(NovaCard $card, ?User $viewer = null): array
|
||||
{
|
||||
$trailCards = [];
|
||||
$cursor = $card;
|
||||
$visited = [];
|
||||
|
||||
while ($cursor && ! in_array($cursor->id, $visited, true)) {
|
||||
$visited[] = $cursor->id;
|
||||
$trailCards[] = $cursor;
|
||||
$cursor = $cursor->originalCard?->loadMissing(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard.user', 'rootCard.user']);
|
||||
}
|
||||
|
||||
$trailCards = array_reverse($trailCards);
|
||||
$rootCard = $card->rootCard ?? ($trailCards[0] ?? $card);
|
||||
|
||||
$family = NovaCard::query()
|
||||
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard', 'rootCard'])
|
||||
->where(function ($query) use ($rootCard): void {
|
||||
$query->where('id', $rootCard->id)
|
||||
->orWhere('root_card_id', $rootCard->id)
|
||||
->orWhere('original_card_id', $rootCard->id);
|
||||
})
|
||||
->published()
|
||||
->orderByDesc('published_at')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'card' => $this->presenter->card($card, false, $viewer),
|
||||
'trail' => $this->presenter->cards($trailCards, false, $viewer),
|
||||
'root_card' => $this->presenter->card($rootCard, false, $viewer),
|
||||
'family_cards' => $this->presenter->cards($family, false, $viewer),
|
||||
];
|
||||
}
|
||||
}
|
||||
355
app/Services/NovaCards/NovaCardPresenter.php
Normal file
355
app/Services/NovaCards/NovaCardPresenter.php
Normal file
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardAssetPack;
|
||||
use App\Models\NovaCardCategory;
|
||||
use App\Models\NovaCardCollection;
|
||||
use App\Models\NovaCardChallenge;
|
||||
use App\Models\NovaCardCreatorPreset;
|
||||
use App\Models\NovaCardReaction;
|
||||
use App\Models\NovaCardTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class NovaCardPresenter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardProjectNormalizer $normalizer,
|
||||
private readonly NovaCardPublishModerationService $moderation,
|
||||
) {
|
||||
}
|
||||
|
||||
public function options(): array
|
||||
{
|
||||
$assetPacks = collect((array) config('nova_cards.asset_packs', []))
|
||||
->map(fn (array $pack): array => ['id' => null, ...$pack])
|
||||
->values();
|
||||
$templatePacks = collect((array) config('nova_cards.template_packs', []))
|
||||
->map(fn (array $pack): array => ['id' => null, ...$pack])
|
||||
->values();
|
||||
|
||||
$databasePacks = NovaCardAssetPack::query()
|
||||
->where('active', true)
|
||||
->orderBy('order_num')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (NovaCardAssetPack $pack): array => [
|
||||
'id' => (int) $pack->id,
|
||||
'slug' => (string) $pack->slug,
|
||||
'name' => (string) $pack->name,
|
||||
'description' => $pack->description,
|
||||
'type' => (string) $pack->type,
|
||||
'preview_image' => $pack->preview_image,
|
||||
'manifest_json' => $pack->manifest_json,
|
||||
'official' => (bool) $pack->official,
|
||||
'active' => (bool) $pack->active,
|
||||
]);
|
||||
|
||||
return [
|
||||
'formats' => collect((array) config('nova_cards.formats', []))
|
||||
->map(fn (array $format, string $key): array => [
|
||||
'key' => $key,
|
||||
'label' => (string) ($format['label'] ?? ucfirst($key)),
|
||||
'width' => (int) ($format['width'] ?? 1080),
|
||||
'height' => (int) ($format['height'] ?? 1080),
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
'font_presets' => collect((array) config('nova_cards.font_presets', []))
|
||||
->map(fn (array $font, string $key): array => ['key' => $key, ...$font])
|
||||
->values()
|
||||
->all(),
|
||||
'gradient_presets' => collect((array) config('nova_cards.gradient_presets', []))
|
||||
->map(fn (array $gradient, string $key): array => ['key' => $key, ...$gradient])
|
||||
->values()
|
||||
->all(),
|
||||
'decor_presets' => array_values((array) config('nova_cards.decor_presets', [])),
|
||||
'background_modes' => array_values((array) config('nova_cards.background_modes', [])),
|
||||
'layout_presets' => array_values((array) config('nova_cards.layout_presets', [])),
|
||||
'alignment_presets' => array_values((array) config('nova_cards.alignment_presets', [])),
|
||||
'position_presets' => array_values((array) config('nova_cards.position_presets', [])),
|
||||
'padding_presets' => array_values((array) config('nova_cards.padding_presets', [])),
|
||||
'max_width_presets' => array_values((array) config('nova_cards.max_width_presets', [])),
|
||||
'line_height_presets' => array_values((array) config('nova_cards.line_height_presets', [])),
|
||||
'shadow_presets' => array_values((array) config('nova_cards.shadow_presets', [])),
|
||||
'focal_positions' => array_values((array) config('nova_cards.focal_positions', [])),
|
||||
'categories' => NovaCardCategory::query()
|
||||
->where('active', true)
|
||||
->orderBy('order_num')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (NovaCardCategory $category): array => [
|
||||
'id' => (int) $category->id,
|
||||
'slug' => (string) $category->slug,
|
||||
'name' => (string) $category->name,
|
||||
'description' => $category->description,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
'templates' => NovaCardTemplate::query()
|
||||
->where('active', true)
|
||||
->orderBy('order_num')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (NovaCardTemplate $template): array => [
|
||||
'id' => (int) $template->id,
|
||||
'slug' => (string) $template->slug,
|
||||
'name' => (string) $template->name,
|
||||
'description' => $template->description,
|
||||
'preview_image' => $template->preview_image,
|
||||
'supported_formats' => $template->supported_formats,
|
||||
'config_json' => $template->config_json,
|
||||
'official' => (bool) $template->official,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
'asset_packs' => $assetPacks
|
||||
->concat($databasePacks->where('type', NovaCardAssetPack::TYPE_ASSET)->values())
|
||||
->values()
|
||||
->all(),
|
||||
'template_packs' => $templatePacks
|
||||
->concat($databasePacks->where('type', NovaCardAssetPack::TYPE_TEMPLATE)->values())
|
||||
->values()
|
||||
->all(),
|
||||
'challenge_feed' => NovaCardChallenge::query()
|
||||
->whereIn('status', [NovaCardChallenge::STATUS_ACTIVE, NovaCardChallenge::STATUS_COMPLETED])
|
||||
->orderByDesc('featured')
|
||||
->orderBy('starts_at')
|
||||
->limit(8)
|
||||
->get()
|
||||
->map(fn (NovaCardChallenge $challenge): array => [
|
||||
'id' => (int) $challenge->id,
|
||||
'slug' => (string) $challenge->slug,
|
||||
'title' => (string) $challenge->title,
|
||||
'description' => $challenge->description,
|
||||
'prompt' => $challenge->prompt,
|
||||
'status' => (string) $challenge->status,
|
||||
'official' => (bool) $challenge->official,
|
||||
'featured' => (bool) $challenge->featured,
|
||||
'entries_count' => (int) $challenge->entries_count,
|
||||
'starts_at' => optional($challenge->starts_at)?->toISOString(),
|
||||
'ends_at' => optional($challenge->ends_at)?->toISOString(),
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
'validation' => (array) config('nova_cards.validation', []),
|
||||
// v3 additions
|
||||
'quote_mark_presets' => array_values((array) config('nova_cards.quote_mark_presets', [])),
|
||||
'text_panel_styles' => array_values((array) config('nova_cards.text_panel_styles', [])),
|
||||
'frame_presets' => array_values((array) config('nova_cards.frame_presets', [])),
|
||||
'color_grade_presets' => array_values((array) config('nova_cards.color_grade_presets', [])),
|
||||
'effect_presets' => array_values((array) config('nova_cards.effect_presets', [])),
|
||||
'style_families' => array_values((array) config('nova_cards.style_families', [])),
|
||||
'export_formats' => collect((array) config('nova_cards.export_formats', []))
|
||||
->map(fn ($fmt, $key) => array_merge(['key' => $key], (array) $fmt))
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
public function optionsWithPresets(array $options, User $user): array
|
||||
{
|
||||
$presets = NovaCardCreatorPreset::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->groupBy('preset_type')
|
||||
->map(fn ($group) => $group->map(fn ($p) => [
|
||||
'id' => (int) $p->id,
|
||||
'name' => (string) $p->name,
|
||||
'preset_type' => (string) $p->preset_type,
|
||||
'config_json' => $p->config_json ?? [],
|
||||
'is_default' => (bool) $p->is_default,
|
||||
])->values()->all())
|
||||
->all();
|
||||
|
||||
return array_merge($options, ['creator_presets' => $presets]);
|
||||
}
|
||||
|
||||
public function card(NovaCard $card, bool $withProject = false, ?User $viewer = null): array
|
||||
{
|
||||
$card->loadMissing(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard', 'rootCard']);
|
||||
$project = $this->normalizer->normalizeForCard($card);
|
||||
$viewerCollections = $viewer
|
||||
? NovaCardCollection::query()
|
||||
->where('user_id', $viewer->id)
|
||||
->whereHas('cards', fn ($query) => $query->where('nova_cards.id', $card->id))
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->values()
|
||||
->all()
|
||||
: [];
|
||||
$viewerReactions = $viewer
|
||||
? NovaCardReaction::query()
|
||||
->where('user_id', $viewer->id)
|
||||
->where('card_id', $card->id)
|
||||
->pluck('type')
|
||||
->all()
|
||||
: [];
|
||||
$moderationReasons = $this->moderation->storedReasons($card);
|
||||
$moderationOverride = $this->moderation->latestOverride($card);
|
||||
|
||||
return [
|
||||
'id' => (int) $card->id,
|
||||
'uuid' => (string) $card->uuid,
|
||||
'title' => (string) $card->title,
|
||||
'slug' => (string) $card->slug,
|
||||
'quote_text' => (string) $card->quote_text,
|
||||
'quote_author' => $card->quote_author,
|
||||
'quote_source' => $card->quote_source,
|
||||
'description' => $card->description,
|
||||
'format' => (string) $card->format,
|
||||
'visibility' => (string) $card->visibility,
|
||||
'status' => (string) $card->status,
|
||||
'moderation_status' => (string) $card->moderation_status,
|
||||
'moderation_reasons' => $moderationReasons,
|
||||
'moderation_reason_labels' => $this->moderation->labelsFor($moderationReasons),
|
||||
'moderation_source' => $this->moderation->storedSource($card),
|
||||
'moderation_override' => $moderationOverride,
|
||||
'moderation_override_history' => $this->moderation->overrideHistory($card),
|
||||
'featured' => (bool) $card->featured,
|
||||
'allow_download' => (bool) $card->allow_download,
|
||||
'background_type' => (string) $card->background_type,
|
||||
'background_image_id' => $card->background_image_id ? (int) $card->background_image_id : null,
|
||||
'template_id' => $card->template_id ? (int) $card->template_id : null,
|
||||
'category_id' => $card->category_id ? (int) $card->category_id : null,
|
||||
'preview_url' => $card->previewUrl(),
|
||||
'og_preview_url' => $card->ogPreviewUrl(),
|
||||
'public_url' => $card->publicUrl(),
|
||||
'published_at' => optional($card->published_at)?->toISOString(),
|
||||
'render_version' => (int) $card->render_version,
|
||||
'schema_version' => (int) $card->schema_version,
|
||||
'views_count' => (int) $card->views_count,
|
||||
'shares_count' => (int) $card->shares_count,
|
||||
'downloads_count' => (int) $card->downloads_count,
|
||||
'likes_count' => (int) $card->likes_count,
|
||||
'favorites_count' => (int) $card->favorites_count,
|
||||
'saves_count' => (int) $card->saves_count,
|
||||
'remixes_count' => (int) $card->remixes_count,
|
||||
'comments_count' => (int) $card->comments_count,
|
||||
'challenge_entries_count' => (int) $card->challenge_entries_count,
|
||||
'allow_remix' => (bool) $card->allow_remix,
|
||||
'allow_background_reuse' => (bool) $card->allow_background_reuse,
|
||||
'allow_export' => (bool) $card->allow_export,
|
||||
'style_family' => $card->style_family,
|
||||
'palette_family' => $card->palette_family,
|
||||
'editor_mode_last_used' => $card->editor_mode_last_used,
|
||||
'featured_score' => $card->featured_score !== null ? (float) $card->featured_score : null,
|
||||
'last_engaged_at' => optional($card->last_engaged_at)?->toISOString(),
|
||||
'last_ranked_at' => optional($card->last_ranked_at)?->toISOString(),
|
||||
'creator' => [
|
||||
'id' => (int) $card->user->id,
|
||||
'username' => (string) $card->user->username,
|
||||
'name' => $card->user->name,
|
||||
],
|
||||
'category' => $card->category ? [
|
||||
'id' => (int) $card->category->id,
|
||||
'slug' => (string) $card->category->slug,
|
||||
'name' => (string) $card->category->name,
|
||||
] : null,
|
||||
'template' => $card->template ? [
|
||||
'id' => (int) $card->template->id,
|
||||
'slug' => (string) $card->template->slug,
|
||||
'name' => (string) $card->template->name,
|
||||
'description' => $card->template->description,
|
||||
'config_json' => $card->template->config_json,
|
||||
'supported_formats' => $card->template->supported_formats,
|
||||
] : null,
|
||||
'background_image' => $card->backgroundImage ? [
|
||||
'id' => (int) $card->backgroundImage->id,
|
||||
'processed_url' => $card->backgroundImage->processedUrl(),
|
||||
'width' => (int) $card->backgroundImage->width,
|
||||
'height' => (int) $card->backgroundImage->height,
|
||||
] : null,
|
||||
'tags' => $card->tags->map(fn ($tag): array => [
|
||||
'id' => (int) $tag->id,
|
||||
'slug' => (string) $tag->slug,
|
||||
'name' => (string) $tag->name,
|
||||
])->values()->all(),
|
||||
'lineage' => [
|
||||
'original_card_id' => $card->original_card_id ? (int) $card->original_card_id : null,
|
||||
'root_card_id' => $card->root_card_id ? (int) $card->root_card_id : null,
|
||||
'original_card' => $card->originalCard ? [
|
||||
'id' => (int) $card->originalCard->id,
|
||||
'title' => (string) $card->originalCard->title,
|
||||
'slug' => (string) $card->originalCard->slug,
|
||||
] : null,
|
||||
'root_card' => $card->rootCard ? [
|
||||
'id' => (int) $card->rootCard->id,
|
||||
'title' => (string) $card->rootCard->title,
|
||||
'slug' => (string) $card->rootCard->slug,
|
||||
] : null,
|
||||
],
|
||||
'version_count' => $card->relationLoaded('versions') ? $card->versions->count() : $card->versions()->count(),
|
||||
'can_edit' => $viewer ? $card->isOwnedBy($viewer) : false,
|
||||
'viewer_state' => [
|
||||
'liked' => in_array(NovaCardReaction::TYPE_LIKE, $viewerReactions, true),
|
||||
'favorited' => in_array(NovaCardReaction::TYPE_FAVORITE, $viewerReactions, true),
|
||||
'saved_collection_ids' => $viewerCollections,
|
||||
],
|
||||
'project_json' => $withProject ? $project : null,
|
||||
];
|
||||
}
|
||||
|
||||
public function cards(iterable $cards, bool $withProject = false, ?User $viewer = null): array
|
||||
{
|
||||
return collect($cards)
|
||||
->map(fn (NovaCard $card): array => $this->card($card, $withProject, $viewer))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function collection(NovaCardCollection $collection, ?User $viewer = null, bool $withCards = false): array
|
||||
{
|
||||
$collection->loadMissing(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']);
|
||||
|
||||
$items = $collection->items
|
||||
->filter(fn ($item): bool => $item->card !== null && $item->card->canBeViewedBy($viewer))
|
||||
->sortBy('sort_order')
|
||||
->values();
|
||||
|
||||
return [
|
||||
'id' => (int) $collection->id,
|
||||
'slug' => (string) $collection->slug,
|
||||
'name' => (string) $collection->name,
|
||||
'description' => $collection->description,
|
||||
'visibility' => (string) $collection->visibility,
|
||||
'official' => (bool) $collection->official,
|
||||
'featured' => (bool) $collection->featured,
|
||||
'cards_count' => (int) $collection->cards_count,
|
||||
'public_url' => $collection->publicUrl(),
|
||||
'owner' => [
|
||||
'id' => (int) $collection->user->id,
|
||||
'username' => (string) $collection->user->username,
|
||||
'name' => $collection->user->name,
|
||||
],
|
||||
'cover_card' => $items->isNotEmpty() ? $this->card($items->first()->card, false, $viewer) : null,
|
||||
'items' => $withCards ? $items->map(fn ($item): array => [
|
||||
'id' => (int) $item->id,
|
||||
'note' => $item->note,
|
||||
'sort_order' => (int) $item->sort_order,
|
||||
'card' => $this->card($item->card, false, $viewer),
|
||||
])->values()->all() : [],
|
||||
];
|
||||
}
|
||||
|
||||
public function paginator(LengthAwarePaginator $paginator, bool $withProject = false, ?User $viewer = null): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->cards($paginator->items(), $withProject, $viewer),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'from' => $paginator->firstItem(),
|
||||
'to' => $paginator->lastItem(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
290
app/Services/NovaCards/NovaCardProjectNormalizer.php
Normal file
290
app/Services/NovaCards/NovaCardProjectNormalizer.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardTemplate;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class NovaCardProjectNormalizer
|
||||
{
|
||||
public function normalize(?array $project, ?NovaCardTemplate $template = null, array $attributes = [], ?NovaCard $card = null): array
|
||||
{
|
||||
return $this->upgradeToV3($project, $template, $attributes, $card);
|
||||
}
|
||||
|
||||
public function isLegacyProject(?array $project): bool
|
||||
{
|
||||
return (int) Arr::get($project, 'schema_version', 1) < 3;
|
||||
}
|
||||
|
||||
public function upgradeToV3(?array $project, ?NovaCardTemplate $template = null, array $attributes = [], ?NovaCard $card = null): array
|
||||
{
|
||||
// First normalize to v2 as the base, then layer on v3 additions.
|
||||
$v2 = $this->upgradeToV2($project, $template, $attributes, $card);
|
||||
|
||||
// v3: enrich background with v3-specific fields.
|
||||
$v3Background = array_merge($v2['background'], [
|
||||
'brightness' => (int) Arr::get($project, 'background.brightness', Arr::get($attributes, 'brightness', 0)),
|
||||
'contrast' => (int) Arr::get($project, 'background.contrast', Arr::get($attributes, 'contrast', 0)),
|
||||
'texture_overlay' => (string) Arr::get($project, 'background.texture_overlay', Arr::get($attributes, 'texture_overlay', '')),
|
||||
'gradient_direction' => (string) Arr::get($project, 'background.gradient_direction', Arr::get($attributes, 'gradient_direction', 'to-bottom')),
|
||||
]);
|
||||
|
||||
// v3: enrich typography with v3-specific fields.
|
||||
$v3Typography = array_merge($v2['typography'], [
|
||||
'quote_mark_preset' => (string) Arr::get($project, 'typography.quote_mark_preset', Arr::get($attributes, 'quote_mark_preset', 'none')),
|
||||
'text_panel_style' => (string) Arr::get($project, 'typography.text_panel_style', Arr::get($attributes, 'text_panel_style', 'none')),
|
||||
'text_glow' => (bool) Arr::get($project, 'typography.text_glow', Arr::get($attributes, 'text_glow', false)),
|
||||
'text_stroke' => (bool) Arr::get($project, 'typography.text_stroke', Arr::get($attributes, 'text_stroke', false)),
|
||||
]);
|
||||
|
||||
// v3: canvas safe zones and layout anchors.
|
||||
$v3Canvas = [
|
||||
'snap_guides' => (bool) Arr::get($project, 'canvas.snap_guides', true),
|
||||
'safe_zones' => (bool) Arr::get($project, 'canvas.safe_zones', true),
|
||||
];
|
||||
|
||||
// v3: frame pack reference.
|
||||
$v3Frame = [
|
||||
'frame_preset' => (string) Arr::get($project, 'frame.frame_preset', Arr::get($attributes, 'frame_preset', 'none')),
|
||||
'frame_color' => (string) Arr::get($project, 'frame.frame_color', Arr::get($attributes, 'frame_color', '')),
|
||||
];
|
||||
|
||||
// v3: effects layer (glow overlays, vignette, etc.)
|
||||
$v3Effects = [
|
||||
'vignette' => (bool) Arr::get($project, 'effects.vignette', Arr::get($attributes, 'vignette', false)),
|
||||
'vignette_strength' => (string) Arr::get($project, 'effects.vignette_strength', 'soft'),
|
||||
'color_grade' => (string) Arr::get($project, 'effects.color_grade', Arr::get($attributes, 'color_grade', 'none')),
|
||||
'glow_overlay' => (bool) Arr::get($project, 'effects.glow_overlay', Arr::get($attributes, 'glow_overlay', false)),
|
||||
];
|
||||
|
||||
// v3: export preferences (what the creator last chose).
|
||||
$v3ExportPrefs = [
|
||||
'preferred_format' => (string) Arr::get($project, 'export_preferences.preferred_format', Arr::get($attributes, 'export_preferred_format', 'preview')),
|
||||
'include_watermark' => (bool) Arr::get($project, 'export_preferences.include_watermark', true),
|
||||
];
|
||||
|
||||
// v3: source/attribution context.
|
||||
$v3SourceContext = [
|
||||
'original_card_id' => Arr::get($v2, 'meta.remix.original_card_id'),
|
||||
'root_card_id' => Arr::get($v2, 'meta.remix.root_card_id'),
|
||||
'original_creator_id' => Arr::get($attributes, 'original_creator_id', $card?->original_creator_id),
|
||||
'preset_id' => Arr::get($attributes, 'preset_id', Arr::get($project, 'source_context.preset_id')),
|
||||
];
|
||||
|
||||
return array_merge($v2, [
|
||||
'schema_version' => 3,
|
||||
'meta' => array_merge($v2['meta'], [
|
||||
'editor' => 'nova-cards-v3',
|
||||
]),
|
||||
'canvas' => $v3Canvas,
|
||||
'background' => $v3Background,
|
||||
'typography' => $v3Typography,
|
||||
'frame' => $v3Frame,
|
||||
'effects' => $v3Effects,
|
||||
'export_preferences' => $v3ExportPrefs,
|
||||
'source_context' => $v3SourceContext,
|
||||
]);
|
||||
}
|
||||
|
||||
public function upgradeToV2(?array $project, ?NovaCardTemplate $template = null, array $attributes = [], ?NovaCard $card = null): array
|
||||
{
|
||||
$project = is_array($project) ? $project : [];
|
||||
$templateConfig = is_array($template?->config_json) ? $template->config_json : [];
|
||||
$existingContent = is_array(Arr::get($project, 'content')) ? Arr::get($project, 'content') : [];
|
||||
|
||||
$title = trim((string) Arr::get($attributes, 'title', Arr::get($existingContent, 'title', $card?->title ?? 'Untitled card')));
|
||||
$quoteText = trim((string) Arr::get($attributes, 'quote_text', Arr::get($existingContent, 'quote_text', $card?->quote_text ?? 'Your next quote starts here.')));
|
||||
$quoteAuthor = trim((string) Arr::get($attributes, 'quote_author', Arr::get($existingContent, 'quote_author', $card?->quote_author ?? '')));
|
||||
$quoteSource = trim((string) Arr::get($attributes, 'quote_source', Arr::get($existingContent, 'quote_source', $card?->quote_source ?? '')));
|
||||
$textBlocks = $this->normalizeTextBlocks(Arr::wrap(Arr::get($project, 'text_blocks', [])), [
|
||||
'title' => $title,
|
||||
'quote_text' => $quoteText,
|
||||
'quote_author' => $quoteAuthor,
|
||||
'quote_source' => $quoteSource,
|
||||
]);
|
||||
|
||||
[$syncedTitle, $syncedQuote, $syncedAuthor, $syncedSource] = $this->syncLegacyContent($textBlocks, [
|
||||
'title' => $title,
|
||||
'quote_text' => $quoteText,
|
||||
'quote_author' => $quoteAuthor,
|
||||
'quote_source' => $quoteSource,
|
||||
]);
|
||||
|
||||
if (array_key_exists('title', $attributes)) {
|
||||
$syncedTitle = $title;
|
||||
}
|
||||
|
||||
if (array_key_exists('quote_text', $attributes)) {
|
||||
$syncedQuote = $quoteText;
|
||||
}
|
||||
|
||||
if (array_key_exists('quote_author', $attributes)) {
|
||||
$syncedAuthor = $quoteAuthor;
|
||||
}
|
||||
|
||||
if (array_key_exists('quote_source', $attributes)) {
|
||||
$syncedSource = $quoteSource;
|
||||
}
|
||||
|
||||
$gradientKey = (string) Arr::get($project, 'background.gradient_preset', Arr::get($attributes, 'gradient_preset', Arr::get($templateConfig, 'gradient_preset', 'midnight-nova')));
|
||||
$fontKey = (string) Arr::get($project, 'typography.font_preset', Arr::get($attributes, 'font_preset', Arr::get($templateConfig, 'font_preset', 'modern-sans')));
|
||||
$sourceLineEnabled = collect($textBlocks)->contains(fn (array $block): bool => ($block['type'] ?? null) === 'source' && (bool) ($block['enabled'] ?? true) && trim((string) ($block['text'] ?? '')) !== '');
|
||||
$authorLineEnabled = collect($textBlocks)->contains(fn (array $block): bool => ($block['type'] ?? null) === 'author' && (bool) ($block['enabled'] ?? true) && trim((string) ($block['text'] ?? '')) !== '');
|
||||
|
||||
return [
|
||||
'schema_version' => 2,
|
||||
'template' => [
|
||||
'id' => $template?->id ?? Arr::get($project, 'template.id'),
|
||||
'slug' => $template?->slug ?? Arr::get($project, 'template.slug'),
|
||||
],
|
||||
'meta' => [
|
||||
'editor' => 'nova-cards-v2',
|
||||
'remix' => [
|
||||
'original_card_id' => Arr::get($attributes, 'original_card_id', $card?->original_card_id),
|
||||
'root_card_id' => Arr::get($attributes, 'root_card_id', $card?->root_card_id),
|
||||
],
|
||||
],
|
||||
'content' => [
|
||||
'title' => $syncedTitle,
|
||||
'quote_text' => $syncedQuote,
|
||||
'quote_author' => $syncedAuthor,
|
||||
'quote_source' => $syncedSource,
|
||||
],
|
||||
'text_blocks' => $textBlocks,
|
||||
'layout' => [
|
||||
'layout' => (string) Arr::get($project, 'layout.layout', Arr::get($attributes, 'layout', Arr::get($templateConfig, 'layout', 'quote_heavy'))),
|
||||
'position' => (string) Arr::get($project, 'layout.position', Arr::get($attributes, 'position', 'center')),
|
||||
'alignment' => (string) Arr::get($project, 'layout.alignment', Arr::get($attributes, 'alignment', Arr::get($templateConfig, 'text_align', 'center'))),
|
||||
'padding' => (string) Arr::get($project, 'layout.padding', Arr::get($attributes, 'padding', 'comfortable')),
|
||||
'max_width' => (string) Arr::get($project, 'layout.max_width', Arr::get($attributes, 'max_width', 'balanced')),
|
||||
],
|
||||
'typography' => [
|
||||
'font_preset' => $fontKey,
|
||||
'text_color' => (string) Arr::get($project, 'typography.text_color', Arr::get($attributes, 'text_color', Arr::get($templateConfig, 'text_color', '#ffffff'))),
|
||||
'accent_color' => (string) Arr::get($project, 'typography.accent_color', Arr::get($attributes, 'accent_color', '#e0f2fe')),
|
||||
'quote_size' => (int) Arr::get($project, 'typography.quote_size', Arr::get($attributes, 'quote_size', 72)),
|
||||
'author_size' => (int) Arr::get($project, 'typography.author_size', Arr::get($attributes, 'author_size', 28)),
|
||||
'letter_spacing' => (int) Arr::get($project, 'typography.letter_spacing', Arr::get($attributes, 'letter_spacing', 0)),
|
||||
'line_height' => (float) Arr::get($project, 'typography.line_height', Arr::get($attributes, 'line_height', 1.2)),
|
||||
'shadow_preset' => (string) Arr::get($project, 'typography.shadow_preset', Arr::get($attributes, 'shadow_preset', 'soft')),
|
||||
'author_line_enabled' => $authorLineEnabled,
|
||||
'source_line_enabled' => $sourceLineEnabled,
|
||||
],
|
||||
'background' => [
|
||||
'type' => (string) Arr::get($project, 'background.type', Arr::get($attributes, 'background_type', $card?->background_type ?? 'gradient')),
|
||||
'gradient_preset' => $gradientKey,
|
||||
'gradient_colors' => array_values(Arr::wrap(Arr::get($project, 'background.gradient_colors', Arr::get($attributes, 'gradient_colors', Arr::get(config('nova_cards.gradient_presets'), $gradientKey . '.colors', ['#0f172a', '#1d4ed8']))))),
|
||||
'solid_color' => (string) Arr::get($project, 'background.solid_color', Arr::get($attributes, 'solid_color', '#111827')),
|
||||
'background_image_id' => Arr::get($attributes, 'background_image_id', Arr::get($project, 'background.background_image_id', $card?->background_image_id)),
|
||||
'overlay_style' => (string) Arr::get($project, 'background.overlay_style', Arr::get($attributes, 'overlay_style', Arr::get($templateConfig, 'overlay_style', 'dark-soft'))),
|
||||
'focal_position' => (string) Arr::get($project, 'background.focal_position', Arr::get($attributes, 'focal_position', 'center')),
|
||||
'blur_level' => (int) Arr::get($project, 'background.blur_level', Arr::get($attributes, 'blur_level', 0)),
|
||||
'opacity' => (int) Arr::get($project, 'background.opacity', Arr::get($attributes, 'opacity', 50)),
|
||||
],
|
||||
'decorations' => array_values(Arr::wrap(Arr::get($project, 'decorations', Arr::get($attributes, 'decorations', [])))),
|
||||
'assets' => [
|
||||
'pack_ids' => array_values(array_filter(
|
||||
array_map('intval', Arr::wrap(Arr::get($project, 'assets.pack_ids', Arr::get($attributes, 'asset_pack_ids', []))))
|
||||
)),
|
||||
'template_pack_ids' => array_values(array_filter(
|
||||
array_map('intval', Arr::wrap(Arr::get($project, 'assets.template_pack_ids', Arr::get($attributes, 'template_pack_ids', []))))
|
||||
)),
|
||||
'items' => array_values(Arr::wrap(Arr::get($project, 'assets.items', []))),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizeForCard(NovaCard $card): array
|
||||
{
|
||||
return $this->normalize($card->project_json, $card->template, [
|
||||
'title' => $card->title,
|
||||
'quote_text' => $card->quote_text,
|
||||
'quote_author' => $card->quote_author,
|
||||
'quote_source' => $card->quote_source,
|
||||
'background_type' => $card->background_type,
|
||||
'background_image_id' => $card->background_image_id,
|
||||
'original_card_id' => $card->original_card_id,
|
||||
'root_card_id' => $card->root_card_id,
|
||||
], $card);
|
||||
}
|
||||
|
||||
public function syncTopLevelAttributes(array $project): array
|
||||
{
|
||||
[$title, $quoteText, $quoteAuthor, $quoteSource] = $this->syncLegacyContent(Arr::wrap(Arr::get($project, 'text_blocks', [])), Arr::get($project, 'content', []));
|
||||
|
||||
return [
|
||||
'schema_version' => (int) Arr::get($project, 'schema_version', 3),
|
||||
'title' => $title,
|
||||
'quote_text' => $quoteText,
|
||||
'quote_author' => $quoteAuthor !== '' ? $quoteAuthor : null,
|
||||
'quote_source' => $quoteSource !== '' ? $quoteSource : null,
|
||||
'background_type' => (string) Arr::get($project, 'background.type', 'gradient'),
|
||||
'background_image_id' => Arr::get($project, 'background.background_image_id'),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeTextBlocks(array $blocks, array $fallback): array
|
||||
{
|
||||
$normalized = collect($blocks)
|
||||
->map(function ($block, int $index): array {
|
||||
$block = is_array($block) ? $block : [];
|
||||
|
||||
return [
|
||||
'key' => (string) Arr::get($block, 'key', 'block-' . ($index + 1)),
|
||||
'type' => (string) Arr::get($block, 'type', 'body'),
|
||||
'text' => (string) Arr::get($block, 'text', ''),
|
||||
'enabled' => ! array_key_exists('enabled', $block) || (bool) Arr::get($block, 'enabled', true),
|
||||
'style' => is_array(Arr::get($block, 'style')) ? Arr::get($block, 'style') : [],
|
||||
];
|
||||
})
|
||||
->filter(fn (array $block): bool => $block['type'] !== '' && $block['key'] !== '')
|
||||
->values();
|
||||
|
||||
if ($normalized->isEmpty()) {
|
||||
$normalized = collect([
|
||||
['key' => 'title', 'type' => 'title', 'text' => (string) ($fallback['title'] ?? ''), 'enabled' => true, 'style' => ['role' => 'eyebrow']],
|
||||
['key' => 'quote', 'type' => 'quote', 'text' => (string) ($fallback['quote_text'] ?? ''), 'enabled' => true, 'style' => ['role' => 'headline']],
|
||||
['key' => 'author', 'type' => 'author', 'text' => (string) ($fallback['quote_author'] ?? ''), 'enabled' => (string) ($fallback['quote_author'] ?? '') !== '', 'style' => ['role' => 'byline']],
|
||||
['key' => 'source', 'type' => 'source', 'text' => (string) ($fallback['quote_source'] ?? ''), 'enabled' => (string) ($fallback['quote_source'] ?? '') !== '', 'style' => ['role' => 'caption']],
|
||||
]);
|
||||
}
|
||||
|
||||
return $normalized->take((int) config('nova_cards.validation.max_text_blocks', 8))->values()->all();
|
||||
}
|
||||
|
||||
private function syncLegacyContent(array $blocks, array $fallback): array
|
||||
{
|
||||
$title = trim((string) ($fallback['title'] ?? 'Untitled card'));
|
||||
$quoteText = trim((string) ($fallback['quote_text'] ?? 'Your next quote starts here.'));
|
||||
$quoteAuthor = trim((string) ($fallback['quote_author'] ?? ''));
|
||||
$quoteSource = trim((string) ($fallback['quote_source'] ?? ''));
|
||||
|
||||
foreach ($blocks as $block) {
|
||||
if (! is_array($block) || ! ($block['enabled'] ?? true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = trim((string) ($block['text'] ?? ''));
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = (string) ($block['type'] ?? '');
|
||||
if ($type === 'title') {
|
||||
$title = $text;
|
||||
} elseif ($type === 'quote') {
|
||||
$quoteText = $text;
|
||||
} elseif ($type === 'author') {
|
||||
$quoteAuthor = $text;
|
||||
} elseif ($type === 'source') {
|
||||
$quoteSource = $text;
|
||||
}
|
||||
}
|
||||
|
||||
return [$title !== '' ? $title : 'Untitled card', $quoteText !== '' ? $quoteText : 'Your next quote starts here.', $quoteAuthor, $quoteSource];
|
||||
}
|
||||
}
|
||||
231
app/Services/NovaCards/NovaCardPublishModerationService.php
Normal file
231
app/Services/NovaCards/NovaCardPublishModerationService.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\User;
|
||||
|
||||
class NovaCardPublishModerationService
|
||||
{
|
||||
private const REASON_LABELS = [
|
||||
'duplicate_content' => 'Duplicate content',
|
||||
'self_remix_loop' => 'Self-remix loop',
|
||||
];
|
||||
|
||||
public const DISPOSITION_LABELS = [
|
||||
'cleared_after_review' => 'Cleared after review',
|
||||
'approved_with_watch' => 'Approved with watch',
|
||||
'escalated_for_review' => 'Escalated for review',
|
||||
'rights_review_required' => 'Rights review required',
|
||||
'rejected_after_review' => 'Rejected after review',
|
||||
'returned_to_pending' => 'Returned to pending',
|
||||
];
|
||||
|
||||
public function evaluate(NovaCard $card): array
|
||||
{
|
||||
$card->loadMissing(['originalCard.user', 'rootCard.user']);
|
||||
|
||||
$reasons = [];
|
||||
|
||||
if ($this->hasDuplicateContent($card)) {
|
||||
$reasons[] = 'duplicate_content';
|
||||
}
|
||||
|
||||
if ($this->hasSelfRemixLoop($card)) {
|
||||
$reasons[] = 'self_remix_loop';
|
||||
}
|
||||
|
||||
return [
|
||||
'flagged' => $reasons !== [],
|
||||
'reasons' => $reasons,
|
||||
];
|
||||
}
|
||||
|
||||
public function moderationStatus(NovaCard $card): string
|
||||
{
|
||||
return $this->evaluate($card)['flagged'] ? NovaCard::MOD_FLAGGED : NovaCard::MOD_APPROVED;
|
||||
}
|
||||
|
||||
public function applyPublishOutcome(NovaCard $card, array $evaluation): NovaCard
|
||||
{
|
||||
$project = (array) ($card->project_json ?? []);
|
||||
$project['moderation'] = [
|
||||
'source' => 'publish_heuristics',
|
||||
'flagged' => (bool) ($evaluation['flagged'] ?? false),
|
||||
'reasons' => $this->normalizeReasons($evaluation['reasons'] ?? []),
|
||||
'updated_at' => now()->toISOString(),
|
||||
];
|
||||
|
||||
$card->forceFill([
|
||||
'project_json' => $project,
|
||||
'status' => NovaCard::STATUS_PUBLISHED,
|
||||
'moderation_status' => (bool) ($evaluation['flagged'] ?? false) ? NovaCard::MOD_FLAGGED : NovaCard::MOD_APPROVED,
|
||||
])->save();
|
||||
|
||||
return $card->refresh();
|
||||
}
|
||||
|
||||
public function storedReasons(NovaCard $card): array
|
||||
{
|
||||
return $this->normalizeReasons(((array) (($card->project_json ?? [])['moderation'] ?? []))['reasons'] ?? []);
|
||||
}
|
||||
|
||||
public function storedReasonLabels(NovaCard $card): array
|
||||
{
|
||||
return $this->labelsFor($this->storedReasons($card));
|
||||
}
|
||||
|
||||
public function storedSource(NovaCard $card): ?string
|
||||
{
|
||||
$source = ((array) (($card->project_json ?? [])['moderation'] ?? []))['source'] ?? null;
|
||||
|
||||
return is_string($source) && $source !== '' ? $source : null;
|
||||
}
|
||||
|
||||
public function latestOverride(NovaCard $card): ?array
|
||||
{
|
||||
$override = ((array) (($card->project_json ?? [])['moderation'] ?? []))['override'] ?? null;
|
||||
|
||||
return is_array($override) && $override !== [] ? $override : null;
|
||||
}
|
||||
|
||||
public function dispositionOptions(?string $moderationStatus = null): array
|
||||
{
|
||||
$keys = match ($moderationStatus) {
|
||||
NovaCard::MOD_APPROVED => ['cleared_after_review', 'approved_with_watch'],
|
||||
NovaCard::MOD_FLAGGED => ['escalated_for_review', 'rights_review_required'],
|
||||
NovaCard::MOD_REJECTED => ['rejected_after_review'],
|
||||
NovaCard::MOD_PENDING => ['returned_to_pending'],
|
||||
default => array_keys(self::DISPOSITION_LABELS),
|
||||
};
|
||||
|
||||
return array_values(array_map(fn (string $key): array => [
|
||||
'value' => $key,
|
||||
'label' => self::DISPOSITION_LABELS[$key] ?? ucwords(str_replace('_', ' ', $key)),
|
||||
], $keys));
|
||||
}
|
||||
|
||||
public function overrideHistory(NovaCard $card): array
|
||||
{
|
||||
$history = ((array) (($card->project_json ?? [])['moderation'] ?? []))['override_history'] ?? [];
|
||||
|
||||
return array_values(array_filter($history, fn ($entry): bool => is_array($entry) && $entry !== []));
|
||||
}
|
||||
|
||||
public function recordStaffOverride(
|
||||
NovaCard $card,
|
||||
string $moderationStatus,
|
||||
?User $actor,
|
||||
string $source,
|
||||
array $context = [],
|
||||
): NovaCard {
|
||||
$project = (array) ($card->project_json ?? []);
|
||||
$moderation = (array) ($project['moderation'] ?? []);
|
||||
$disposition = $this->normalizeDisposition(
|
||||
$context['disposition'] ?? $this->defaultDispositionForStatus($moderationStatus),
|
||||
$moderationStatus,
|
||||
);
|
||||
$override = array_filter([
|
||||
'moderation_status' => $moderationStatus,
|
||||
'previous_status' => (string) $card->moderation_status,
|
||||
'disposition' => $disposition,
|
||||
'disposition_label' => self::DISPOSITION_LABELS[$disposition] ?? ucwords(str_replace('_', ' ', $disposition)),
|
||||
'source' => $source,
|
||||
'actor_user_id' => $actor?->id,
|
||||
'actor_username' => $actor?->username,
|
||||
'note' => isset($context['note']) && is_string($context['note']) && trim($context['note']) !== '' ? trim($context['note']) : null,
|
||||
'report_id' => isset($context['report_id']) ? (int) $context['report_id'] : null,
|
||||
'updated_at' => now()->toISOString(),
|
||||
], fn ($value): bool => $value !== null);
|
||||
|
||||
$history = $this->overrideHistory($card);
|
||||
array_unshift($history, $override);
|
||||
|
||||
$moderation['override'] = $override;
|
||||
$moderation['override_history'] = array_slice($history, 0, 10);
|
||||
$project['moderation'] = $moderation;
|
||||
|
||||
$card->forceFill([
|
||||
'moderation_status' => $moderationStatus,
|
||||
'project_json' => $project,
|
||||
])->save();
|
||||
|
||||
return $card->refresh();
|
||||
}
|
||||
|
||||
public function labelsFor(array $reasons): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
fn (string $reason): string => self::REASON_LABELS[$reason] ?? ucwords(str_replace('_', ' ', $reason)),
|
||||
$this->normalizeReasons($reasons),
|
||||
));
|
||||
}
|
||||
|
||||
private function normalizeReasons(array $reasons): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
fn ($reason): string => is_string($reason) ? trim($reason) : '',
|
||||
$reasons,
|
||||
))));
|
||||
}
|
||||
|
||||
private function normalizeDisposition(mixed $disposition, string $moderationStatus): string
|
||||
{
|
||||
$value = is_string($disposition) ? trim($disposition) : '';
|
||||
|
||||
return $value !== '' && array_key_exists($value, self::DISPOSITION_LABELS)
|
||||
? $value
|
||||
: $this->defaultDispositionForStatus($moderationStatus);
|
||||
}
|
||||
|
||||
private function defaultDispositionForStatus(string $moderationStatus): string
|
||||
{
|
||||
return match ($moderationStatus) {
|
||||
NovaCard::MOD_APPROVED => 'cleared_after_review',
|
||||
NovaCard::MOD_FLAGGED => 'escalated_for_review',
|
||||
NovaCard::MOD_REJECTED => 'rejected_after_review',
|
||||
default => 'returned_to_pending',
|
||||
};
|
||||
}
|
||||
|
||||
private function hasDuplicateContent(NovaCard $card): bool
|
||||
{
|
||||
$title = mb_strtolower(trim((string) $card->title));
|
||||
$quote = mb_strtolower(trim((string) $card->quote_text));
|
||||
|
||||
if ($title === '' || $quote === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return NovaCard::query()
|
||||
->where('id', '!=', $card->id)
|
||||
->where('status', NovaCard::STATUS_PUBLISHED)
|
||||
->whereNotIn('moderation_status', [NovaCard::MOD_FLAGGED, NovaCard::MOD_REJECTED])
|
||||
->whereRaw('LOWER(title) = ?', [$title])
|
||||
->whereRaw('LOWER(quote_text) = ?', [$quote])
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function hasSelfRemixLoop(NovaCard $card): bool
|
||||
{
|
||||
if (! $card->originalCard || ! $card->rootCard) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$depth = 0;
|
||||
$cursor = $card;
|
||||
$visited = [];
|
||||
|
||||
while ($cursor->originalCard && ! in_array($cursor->id, $visited, true)) {
|
||||
$visited[] = $cursor->id;
|
||||
$cursor = $cursor->originalCard;
|
||||
$depth++;
|
||||
}
|
||||
|
||||
return $depth >= 3
|
||||
&& (int) $card->user_id === (int) ($card->originalCard->user_id ?? 0)
|
||||
&& (int) $card->user_id === (int) ($card->rootCard->user_id ?? 0);
|
||||
}
|
||||
}
|
||||
55
app/Services/NovaCards/NovaCardPublishService.php
Normal file
55
app/Services/NovaCards/NovaCardPublishService.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Jobs\NovaCards\RenderNovaCardPreviewJob;
|
||||
use App\Models\NovaCard;
|
||||
use App\Services\NovaCards\NovaCardPublishModerationService;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class NovaCardPublishService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardRenderService $renderService,
|
||||
private readonly NovaCardVersionService $versions,
|
||||
private readonly NovaCardPublishModerationService $moderation,
|
||||
) {
|
||||
}
|
||||
|
||||
public function queuePublish(NovaCard $card): NovaCard
|
||||
{
|
||||
$card->forceFill([
|
||||
'status' => NovaCard::STATUS_PROCESSING,
|
||||
'moderation_status' => NovaCard::MOD_PENDING,
|
||||
'published_at' => $card->published_at ?? Carbon::now(),
|
||||
'render_version' => (int) $card->render_version + 1,
|
||||
])->save();
|
||||
|
||||
RenderNovaCardPreviewJob::dispatch($card->id)
|
||||
->onQueue((string) config('nova_cards.render.queue', 'default'));
|
||||
|
||||
return $card->refresh();
|
||||
}
|
||||
|
||||
public function publishNow(NovaCard $card): NovaCard
|
||||
{
|
||||
$card->forceFill([
|
||||
'status' => NovaCard::STATUS_PROCESSING,
|
||||
'moderation_status' => NovaCard::MOD_PENDING,
|
||||
'published_at' => $card->published_at ?? Carbon::now(),
|
||||
'render_version' => (int) $card->render_version + 1,
|
||||
])->save();
|
||||
|
||||
$this->renderService->render($card->refresh());
|
||||
|
||||
$evaluation = $this->moderation->evaluate($card->fresh()->loadMissing(['originalCard.user', 'rootCard.user']));
|
||||
|
||||
$card = $this->moderation->applyPublishOutcome($card->fresh(), $evaluation);
|
||||
|
||||
$this->versions->snapshot($card->refresh()->loadMissing('template'), $card->user, 'Published version', true);
|
||||
|
||||
return $card->refresh()->load(['category', 'template', 'tags', 'backgroundImage']);
|
||||
}
|
||||
}
|
||||
45
app/Services/NovaCards/NovaCardReactionService.php
Normal file
45
app/Services/NovaCards/NovaCardReactionService.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Jobs\UpdateNovaCardStatsJob;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardReaction;
|
||||
use App\Models\User;
|
||||
|
||||
class NovaCardReactionService
|
||||
{
|
||||
public function setReaction(User $user, NovaCard $card, string $type, bool $active): array
|
||||
{
|
||||
$existing = NovaCardReaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('card_id', $card->id)
|
||||
->where('type', $type)
|
||||
->first();
|
||||
|
||||
if ($active && ! $existing) {
|
||||
NovaCardReaction::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'card_id' => $card->id,
|
||||
'type' => $type,
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $active && $existing) {
|
||||
$existing->delete();
|
||||
}
|
||||
|
||||
UpdateNovaCardStatsJob::dispatch($card->id);
|
||||
|
||||
$card->refresh();
|
||||
|
||||
return [
|
||||
'liked' => NovaCardReaction::query()->where('user_id', $user->id)->where('card_id', $card->id)->where('type', NovaCardReaction::TYPE_LIKE)->exists(),
|
||||
'favorited' => NovaCardReaction::query()->where('user_id', $user->id)->where('card_id', $card->id)->where('type', NovaCardReaction::TYPE_FAVORITE)->exists(),
|
||||
'likes_count' => (int) $card->fresh()->likes_count,
|
||||
'favorites_count' => (int) $card->fresh()->favorites_count,
|
||||
];
|
||||
}
|
||||
}
|
||||
129
app/Services/NovaCards/NovaCardRelatedCardsService.php
Normal file
129
app/Services/NovaCards/NovaCardRelatedCardsService.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Computes and returns related cards for a given card using multiple
|
||||
* similarity signals: template family, category, mood tags, format,
|
||||
* style family, palette family, and creator.
|
||||
*/
|
||||
class NovaCardRelatedCardsService
|
||||
{
|
||||
private const CACHE_TTL = 600;
|
||||
|
||||
private const LIMIT = 8;
|
||||
|
||||
public function related(NovaCard $card, int $limit = self::LIMIT, bool $cached = true): Collection
|
||||
{
|
||||
if ($cached) {
|
||||
return Cache::remember(
|
||||
'nova_cards.related.' . $card->id . '.' . $limit,
|
||||
self::CACHE_TTL,
|
||||
fn () => $this->compute($card, $limit),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->compute($card, $limit);
|
||||
}
|
||||
|
||||
public function invalidateForCard(NovaCard $card): void
|
||||
{
|
||||
foreach ([4, 6, 8, 12] as $limit) {
|
||||
Cache::forget('nova_cards.related.' . $card->id . '.' . $limit);
|
||||
}
|
||||
}
|
||||
|
||||
private function compute(NovaCard $card, int $limit): Collection
|
||||
{
|
||||
$card->loadMissing(['tags', 'category', 'template']);
|
||||
|
||||
$tagIds = $card->tags->pluck('id')->all();
|
||||
$templateId = $card->template_id;
|
||||
$categoryId = $card->category_id;
|
||||
$format = $card->format;
|
||||
$styleFamily = $card->style_family;
|
||||
$paletteFamily = $card->palette_family;
|
||||
$creatorId = $card->user_id;
|
||||
|
||||
$query = NovaCard::query()
|
||||
->publiclyVisible()
|
||||
->where('nova_cards.id', '!=', $card->id)
|
||||
->select(['nova_cards.*'])
|
||||
->selectRaw('0 AS relevance_score');
|
||||
|
||||
// We build a union-ranked set via scored sub-queries, then re-aggregate
|
||||
// in PHP (simpler than scoring in MySQL without a full-text index).
|
||||
$candidates = NovaCard::query()
|
||||
->publiclyVisible()
|
||||
->where('nova_cards.id', '!=', $card->id)
|
||||
->where(function ($q) use ($tagIds, $templateId, $categoryId, $format, $styleFamily, $paletteFamily, $creatorId): void {
|
||||
$q->whereHas('tags', fn ($tq) => $tq->whereIn('nova_card_tags.id', $tagIds))
|
||||
->orWhere('template_id', $templateId)
|
||||
->orWhere('category_id', $categoryId)
|
||||
->orWhere('format', $format)
|
||||
->orWhere('style_family', $styleFamily)
|
||||
->orWhere('palette_family', $paletteFamily)
|
||||
->orWhere('user_id', $creatorId);
|
||||
})
|
||||
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
|
||||
->limit(80)
|
||||
->get();
|
||||
|
||||
// Score in PHP — lightweight for this candidate set size.
|
||||
$scored = $candidates->map(function (NovaCard $c) use ($tagIds, $templateId, $categoryId, $format, $styleFamily, $paletteFamily, $creatorId): array {
|
||||
$score = 0;
|
||||
|
||||
// Tag overlap: up to 10 points
|
||||
$overlap = count(array_intersect($c->tags->pluck('id')->all(), $tagIds));
|
||||
$score += min($overlap * 2, 10);
|
||||
|
||||
// Same template: 5 pts
|
||||
if ($templateId && $c->template_id === $templateId) {
|
||||
$score += 5;
|
||||
}
|
||||
|
||||
// Same category: 3 pts
|
||||
if ($categoryId && $c->category_id === $categoryId) {
|
||||
$score += 3;
|
||||
}
|
||||
|
||||
// Same format: 2 pts
|
||||
if ($c->format === $format) {
|
||||
$score += 2;
|
||||
}
|
||||
|
||||
// Same style family: 4 pts
|
||||
if ($styleFamily && $c->style_family === $styleFamily) {
|
||||
$score += 4;
|
||||
}
|
||||
|
||||
// Same palette: 3 pts
|
||||
if ($paletteFamily && $c->palette_family === $paletteFamily) {
|
||||
$score += 3;
|
||||
}
|
||||
|
||||
// Same creator (more cards by creator): 1 pt
|
||||
if ($c->user_id === $creatorId) {
|
||||
$score += 1;
|
||||
}
|
||||
|
||||
// Engagement quality boost (saves + remixes weighted)
|
||||
$engagementBoost = min(($c->saves_count + $c->remixes_count * 2) * 0.1, 3.0);
|
||||
$score += $engagementBoost;
|
||||
|
||||
return ['card' => $c, 'score' => $score];
|
||||
});
|
||||
|
||||
return $scored
|
||||
->sortByDesc('score')
|
||||
->take($limit)
|
||||
->pluck('card')
|
||||
->values();
|
||||
}
|
||||
}
|
||||
350
app/Services/NovaCards/NovaCardRenderService.php
Normal file
350
app/Services/NovaCards/NovaCardRenderService.php
Normal file
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class NovaCardRenderService
|
||||
{
|
||||
public function render(NovaCard $card): array
|
||||
{
|
||||
if (! function_exists('imagecreatetruecolor')) {
|
||||
throw new RuntimeException('Nova card rendering requires the GD extension.');
|
||||
}
|
||||
|
||||
$format = Arr::get(config('nova_cards.formats'), $card->format, config('nova_cards.formats.square'));
|
||||
$width = (int) Arr::get($format, 'width', 1080);
|
||||
$height = (int) Arr::get($format, 'height', 1080);
|
||||
$project = is_array($card->project_json) ? $card->project_json : [];
|
||||
|
||||
$image = imagecreatetruecolor($width, $height);
|
||||
imagealphablending($image, true);
|
||||
imagesavealpha($image, true);
|
||||
|
||||
$this->paintBackground($image, $card, $width, $height, $project);
|
||||
$this->paintOverlay($image, $project, $width, $height);
|
||||
$this->paintText($image, $card, $project, $width, $height);
|
||||
$this->paintDecorations($image, $project, $width, $height);
|
||||
$this->paintAssets($image, $project, $width, $height);
|
||||
|
||||
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
|
||||
$basePath = trim((string) config('nova_cards.storage.preview_prefix', 'cards/previews'), '/') . '/' . $card->user_id;
|
||||
$previewPath = $basePath . '/' . $card->uuid . '.webp';
|
||||
$ogPath = $basePath . '/' . $card->uuid . '-og.jpg';
|
||||
|
||||
ob_start();
|
||||
imagewebp($image, null, (int) config('nova_cards.render.preview_quality', 86));
|
||||
$webpBinary = (string) ob_get_clean();
|
||||
|
||||
ob_start();
|
||||
imagejpeg($image, null, (int) config('nova_cards.render.og_quality', 88));
|
||||
$jpgBinary = (string) ob_get_clean();
|
||||
|
||||
imagedestroy($image);
|
||||
|
||||
$disk->put($previewPath, $webpBinary);
|
||||
$disk->put($ogPath, $jpgBinary);
|
||||
|
||||
$card->forceFill([
|
||||
'preview_path' => $previewPath,
|
||||
'preview_width' => $width,
|
||||
'preview_height' => $height,
|
||||
])->save();
|
||||
|
||||
return [
|
||||
'preview_path' => $previewPath,
|
||||
'og_path' => $ogPath,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
];
|
||||
}
|
||||
|
||||
private function paintBackground($image, NovaCard $card, int $width, int $height, array $project): void
|
||||
{
|
||||
$background = Arr::get($project, 'background', []);
|
||||
$type = (string) Arr::get($background, 'type', $card->background_type ?: 'gradient');
|
||||
|
||||
if ($type === 'solid') {
|
||||
$color = $this->allocateHex($image, (string) Arr::get($background, 'solid_color', '#111827'));
|
||||
imagefilledrectangle($image, 0, 0, $width, $height, $color);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'upload' && $card->backgroundImage?->processed_path) {
|
||||
$this->paintImageBackground($image, $card->backgroundImage->processed_path, $width, $height);
|
||||
} else {
|
||||
$colors = Arr::wrap(Arr::get($background, 'gradient_colors', ['#0f172a', '#1d4ed8']));
|
||||
$from = (string) Arr::get($colors, 0, '#0f172a');
|
||||
$to = (string) Arr::get($colors, 1, '#1d4ed8');
|
||||
$this->paintVerticalGradient($image, $width, $height, $from, $to);
|
||||
}
|
||||
}
|
||||
|
||||
private function paintImageBackground($image, string $path, int $width, int $height): void
|
||||
{
|
||||
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
|
||||
if (! $disk->exists($path)) {
|
||||
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$blob = $disk->get($path);
|
||||
$background = @imagecreatefromstring($blob);
|
||||
if ($background === false) {
|
||||
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$focalPosition = (string) Arr::get($card->project_json, 'background.focal_position', 'center');
|
||||
[$srcX, $srcY] = $this->resolveFocalSourceOrigin($focalPosition, imagesx($background), imagesy($background));
|
||||
|
||||
imagecopyresampled(
|
||||
$image,
|
||||
$background,
|
||||
0,
|
||||
0,
|
||||
$srcX,
|
||||
$srcY,
|
||||
$width,
|
||||
$height,
|
||||
max(1, imagesx($background) - $srcX),
|
||||
max(1, imagesy($background) - $srcY)
|
||||
);
|
||||
|
||||
$blurLevel = (int) Arr::get($card->project_json, 'background.blur_level', 0);
|
||||
for ($index = 0; $index < (int) floor($blurLevel / 4); $index++) {
|
||||
imagefilter($image, IMG_FILTER_GAUSSIAN_BLUR);
|
||||
}
|
||||
|
||||
imagedestroy($background);
|
||||
}
|
||||
|
||||
private function paintOverlay($image, array $project, int $width, int $height): void
|
||||
{
|
||||
$style = (string) Arr::get($project, 'background.overlay_style', 'dark-soft');
|
||||
$alpha = match ($style) {
|
||||
'dark-strong' => 72,
|
||||
'dark-soft' => 92,
|
||||
'light-soft' => 108,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($alpha === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rgb = $style === 'light-soft' ? [255, 255, 255] : [0, 0, 0];
|
||||
$overlay = imagecolorallocatealpha($image, $rgb[0], $rgb[1], $rgb[2], $alpha);
|
||||
imagefilledrectangle($image, 0, 0, $width, $height, $overlay);
|
||||
}
|
||||
|
||||
private function paintText($image, NovaCard $card, array $project, int $width, int $height): void
|
||||
{
|
||||
$textColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.text_color', '#ffffff'));
|
||||
$authorColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', Arr::get($project, 'typography.text_color', '#ffffff')));
|
||||
$alignment = (string) Arr::get($project, 'layout.alignment', 'center');
|
||||
$lineHeightMultiplier = (float) Arr::get($project, 'typography.line_height', 1.2);
|
||||
$shadowPreset = (string) Arr::get($project, 'typography.shadow_preset', 'soft');
|
||||
$paddingRatio = match ((string) Arr::get($project, 'layout.padding', 'comfortable')) {
|
||||
'tight' => 0.08,
|
||||
'airy' => 0.15,
|
||||
default => 0.11,
|
||||
};
|
||||
$xPadding = (int) round($width * $paddingRatio);
|
||||
$maxLineWidth = match ((string) Arr::get($project, 'layout.max_width', 'balanced')) {
|
||||
'compact' => (int) round($width * 0.5),
|
||||
'wide' => (int) round($width * 0.78),
|
||||
default => (int) round($width * 0.64),
|
||||
};
|
||||
|
||||
$textBlocks = $this->resolveTextBlocks($card, $project);
|
||||
$charWidth = imagefontwidth(5);
|
||||
$lineHeight = max(imagefontheight(5) + 4, (int) round((imagefontheight(5) + 2) * $lineHeightMultiplier));
|
||||
$charsPerLine = max(14, (int) floor($maxLineWidth / max(1, $charWidth)));
|
||||
$textBlockHeight = 0;
|
||||
foreach ($textBlocks as $block) {
|
||||
$font = $this->fontForBlockType((string) ($block['type'] ?? 'body'));
|
||||
$wrapped = preg_split('/\r\n|\r|\n/', wordwrap((string) ($block['text'] ?? ''), max(10, $charsPerLine - ($font === 3 ? 4 : 0)), "\n", true)) ?: [(string) ($block['text'] ?? '')];
|
||||
$textBlockHeight += count($wrapped) * max(imagefontheight($font) + 4, (int) round((imagefontheight($font) + 2) * $lineHeightMultiplier));
|
||||
$textBlockHeight += 18;
|
||||
}
|
||||
$position = (string) Arr::get($project, 'layout.position', 'center');
|
||||
$startY = match ($position) {
|
||||
'top' => (int) round($height * 0.14),
|
||||
'upper-middle' => (int) round($height * 0.26),
|
||||
'lower-middle' => (int) round($height * 0.58),
|
||||
'bottom' => max($xPadding, $height - $textBlockHeight - (int) round($height * 0.12)),
|
||||
default => (int) round(($height - $textBlockHeight) / 2),
|
||||
};
|
||||
|
||||
foreach ($textBlocks as $block) {
|
||||
$type = (string) ($block['type'] ?? 'body');
|
||||
$font = $this->fontForBlockType($type);
|
||||
$color = in_array($type, ['author', 'source', 'title'], true) ? $authorColor : $textColor;
|
||||
$prefix = $type === 'author' ? '— ' : '';
|
||||
$value = $prefix . (string) ($block['text'] ?? '');
|
||||
$wrapped = preg_split('/\r\n|\r|\n/', wordwrap($type === 'title' ? strtoupper($value) : $value, max(10, $charsPerLine - ($font === 3 ? 4 : 0)), "\n", true)) ?: [$value];
|
||||
$blockLineHeight = max(imagefontheight($font) + 4, (int) round((imagefontheight($font) + 2) * $lineHeightMultiplier));
|
||||
|
||||
foreach ($wrapped as $line) {
|
||||
$lineWidth = imagefontwidth($font) * strlen($line);
|
||||
$x = $this->resolveAlignedX($alignment, $width, $xPadding, $lineWidth);
|
||||
$this->drawText($image, $font, $x, $startY, $line, $color, $shadowPreset);
|
||||
$startY += $blockLineHeight;
|
||||
}
|
||||
|
||||
$startY += 18;
|
||||
}
|
||||
}
|
||||
|
||||
private function paintDecorations($image, array $project, int $width, int $height): void
|
||||
{
|
||||
$decorations = Arr::wrap(Arr::get($project, 'decorations', []));
|
||||
$accent = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', '#ffffff'));
|
||||
|
||||
foreach (array_slice($decorations, 0, (int) config('nova_cards.validation.max_decorations', 6)) as $index => $decoration) {
|
||||
$x = (int) Arr::get($decoration, 'x', ($index % 2 === 0 ? 0.12 : 0.82) * $width);
|
||||
$y = (int) Arr::get($decoration, 'y', (0.14 + ($index * 0.1)) * $height);
|
||||
$size = max(2, (int) Arr::get($decoration, 'size', 6));
|
||||
imagefilledellipse($image, $x, $y, $size, $size, $accent);
|
||||
}
|
||||
}
|
||||
|
||||
private function paintAssets($image, array $project, int $width, int $height): void
|
||||
{
|
||||
$items = Arr::wrap(Arr::get($project, 'assets.items', []));
|
||||
if ($items === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$accent = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', '#ffffff'));
|
||||
|
||||
foreach (array_slice($items, 0, 6) as $index => $item) {
|
||||
$type = (string) Arr::get($item, 'type', 'glyph');
|
||||
if ($type === 'glyph') {
|
||||
$glyph = (string) Arr::get($item, 'glyph', Arr::get($item, 'label', '✦'));
|
||||
imagestring($image, 5, (int) round($width * (0.08 + (($index % 3) * 0.28))), (int) round($height * (0.08 + (intdiv($index, 3) * 0.74))), $glyph, $accent);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'frame') {
|
||||
$y = $index % 2 === 0 ? (int) round($height * 0.08) : (int) round($height * 0.92);
|
||||
imageline($image, (int) round($width * 0.12), $y, (int) round($width * 0.88), $y, $accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveTextBlocks(NovaCard $card, array $project): array
|
||||
{
|
||||
$blocks = collect(Arr::wrap(Arr::get($project, 'text_blocks', [])))
|
||||
->filter(fn ($block): bool => is_array($block) && (bool) Arr::get($block, 'enabled', true) && trim((string) Arr::get($block, 'text', '')) !== '')
|
||||
->values();
|
||||
|
||||
if ($blocks->isNotEmpty()) {
|
||||
return $blocks->all();
|
||||
}
|
||||
|
||||
return [
|
||||
['type' => 'title', 'text' => trim((string) $card->title)],
|
||||
['type' => 'quote', 'text' => trim((string) $card->quote_text)],
|
||||
['type' => 'author', 'text' => trim((string) $card->quote_author)],
|
||||
['type' => 'source', 'text' => trim((string) $card->quote_source)],
|
||||
];
|
||||
}
|
||||
|
||||
private function fontForBlockType(string $type): int
|
||||
{
|
||||
return match ($type) {
|
||||
'title', 'source' => 3,
|
||||
'author', 'body' => 4,
|
||||
'caption' => 2,
|
||||
default => 5,
|
||||
};
|
||||
}
|
||||
|
||||
private function paintVerticalGradient($image, int $width, int $height, string $fromHex, string $toHex): void
|
||||
{
|
||||
[$r1, $g1, $b1] = $this->hexToRgb($fromHex);
|
||||
[$r2, $g2, $b2] = $this->hexToRgb($toHex);
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$ratio = $height > 1 ? $y / ($height - 1) : 0;
|
||||
$red = (int) round($r1 + (($r2 - $r1) * $ratio));
|
||||
$green = (int) round($g1 + (($g2 - $g1) * $ratio));
|
||||
$blue = (int) round($b1 + (($b2 - $b1) * $ratio));
|
||||
$color = imagecolorallocate($image, $red, $green, $blue);
|
||||
imageline($image, 0, $y, $width, $y, $color);
|
||||
}
|
||||
}
|
||||
|
||||
private function allocateHex($image, string $hex)
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
|
||||
return imagecolorallocate($image, $r, $g, $b);
|
||||
}
|
||||
|
||||
private function hexToRgb(string $hex): array
|
||||
{
|
||||
$normalized = ltrim($hex, '#');
|
||||
if (strlen($normalized) === 3) {
|
||||
$normalized = preg_replace('/(.)/', '$1$1', $normalized) ?: 'ffffff';
|
||||
}
|
||||
|
||||
if (strlen($normalized) !== 6) {
|
||||
$normalized = 'ffffff';
|
||||
}
|
||||
|
||||
return [
|
||||
hexdec(substr($normalized, 0, 2)),
|
||||
hexdec(substr($normalized, 2, 2)),
|
||||
hexdec(substr($normalized, 4, 2)),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveAlignedX(string $alignment, int $width, int $padding, int $lineWidth): int
|
||||
{
|
||||
return match ($alignment) {
|
||||
'left' => $padding,
|
||||
'right' => max($padding, $width - $padding - $lineWidth),
|
||||
default => max($padding, (int) round(($width - $lineWidth) / 2)),
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveFocalSourceOrigin(string $focalPosition, int $sourceWidth, int $sourceHeight): array
|
||||
{
|
||||
$x = match ($focalPosition) {
|
||||
'left', 'top-left', 'bottom-left' => 0,
|
||||
'right', 'top-right', 'bottom-right' => max(0, (int) round($sourceWidth * 0.18)),
|
||||
default => max(0, (int) round($sourceWidth * 0.09)),
|
||||
};
|
||||
|
||||
$y = match ($focalPosition) {
|
||||
'top', 'top-left', 'top-right' => 0,
|
||||
'bottom', 'bottom-left', 'bottom-right' => max(0, (int) round($sourceHeight * 0.18)),
|
||||
default => max(0, (int) round($sourceHeight * 0.09)),
|
||||
};
|
||||
|
||||
return [$x, $y];
|
||||
}
|
||||
|
||||
private function drawText($image, int $font, int $x, int $y, string $text, int $color, string $shadowPreset): void
|
||||
{
|
||||
if ($shadowPreset !== 'none') {
|
||||
$offset = $shadowPreset === 'strong' ? 3 : 1;
|
||||
$shadow = imagecolorallocatealpha($image, 2, 6, 23, $shadowPreset === 'strong' ? 46 : 78);
|
||||
imagestring($image, $font, $x + $offset, $y + $offset, $text, $shadow);
|
||||
}
|
||||
|
||||
imagestring($image, $font, $x, $y, $text, $color);
|
||||
}
|
||||
}
|
||||
81
app/Services/NovaCards/NovaCardRisingService.php
Normal file
81
app/Services/NovaCards/NovaCardRisingService.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Surfaces "rising" cards — recently published cards that are gaining
|
||||
* engagement faster than average, weighted to balance novelty creators.
|
||||
*/
|
||||
class NovaCardRisingService
|
||||
{
|
||||
/** Number of hours a card is eligible for "rising" feed. */
|
||||
private const WINDOW_HOURS = 96;
|
||||
|
||||
/** Cache TTL in seconds. */
|
||||
private const CACHE_TTL = 300;
|
||||
|
||||
public function risingCards(int $limit = 18, bool $cached = true): Collection
|
||||
{
|
||||
if ($cached) {
|
||||
return Cache::remember(
|
||||
'nova_cards.rising.' . $limit,
|
||||
self::CACHE_TTL,
|
||||
fn () => $this->queryRising($limit),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->queryRising($limit);
|
||||
}
|
||||
|
||||
public function invalidateCache(): void
|
||||
{
|
||||
foreach ([6, 18, 24, 36] as $limit) {
|
||||
Cache::forget('nova_cards.rising.' . $limit);
|
||||
}
|
||||
}
|
||||
|
||||
private function queryRising(int $limit): Collection
|
||||
{
|
||||
$cutoff = Carbon::now()->subHours(self::WINDOW_HOURS);
|
||||
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
|
||||
$ageHoursExpression = $isSqlite
|
||||
? "CASE WHEN ((julianday('now') - julianday(published_at)) * 24.0) < 1 THEN 1 ELSE ((julianday('now') - julianday(published_at)) * 24.0) END"
|
||||
: 'GREATEST(1, TIMESTAMPDIFF(HOUR, published_at, NOW()))';
|
||||
$decayExpression = $isSqlite
|
||||
? $ageHoursExpression
|
||||
: 'POWER(' . $ageHoursExpression . ', 0.7)';
|
||||
$risingMomentumExpression = '(
|
||||
(saves_count * 5.0 + remixes_count * 6.0 + likes_count * 4.0 + favorites_count * 2.5 + comments_count * 2.0 + challenge_entries_count * 4.0)
|
||||
/ ' . $decayExpression . '
|
||||
) AS rising_momentum';
|
||||
|
||||
return NovaCard::query()
|
||||
->publiclyVisible()
|
||||
->where('published_at', '>=', $cutoff)
|
||||
// Must have at least one meaningful engagement signal.
|
||||
->where(function (Builder $q): void {
|
||||
$q->where('saves_count', '>', 0)
|
||||
->orWhere('remixes_count', '>', 0)
|
||||
->orWhere('likes_count', '>', 1);
|
||||
})
|
||||
->select([
|
||||
'nova_cards.*',
|
||||
// Rising score: weight recent engagement, penalise by sqrt(age hours) to let novelty show
|
||||
DB::raw($risingMomentumExpression),
|
||||
])
|
||||
->orderByDesc('rising_momentum')
|
||||
->orderByDesc('published_at')
|
||||
->limit($limit)
|
||||
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
|
||||
->get();
|
||||
}
|
||||
}
|
||||
43
app/Services/NovaCards/NovaCardTagService.php
Normal file
43
app/Services/NovaCards/NovaCardTagService.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardTag;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NovaCardTagService
|
||||
{
|
||||
public function syncTags(NovaCard $card, array $tags): Collection
|
||||
{
|
||||
$limit = (int) config('nova_cards.validation.max_tags', 8);
|
||||
|
||||
$normalized = collect($tags)
|
||||
->map(static fn ($tag) => trim((string) $tag))
|
||||
->filter(static fn (string $tag): bool => $tag !== '')
|
||||
->map(static fn (string $tag): array => [
|
||||
'name' => Str::headline(Str::lower($tag)),
|
||||
'slug' => Str::slug($tag),
|
||||
])
|
||||
->filter(static fn (array $tag): bool => $tag['slug'] !== '')
|
||||
->unique('slug')
|
||||
->take($limit)
|
||||
->values();
|
||||
|
||||
$tagIds = $normalized->map(function (array $tag): int {
|
||||
$model = NovaCardTag::query()->firstOrCreate(
|
||||
['slug' => $tag['slug']],
|
||||
['name' => $tag['name']]
|
||||
);
|
||||
|
||||
return (int) $model->id;
|
||||
})->all();
|
||||
|
||||
$card->tags()->sync($tagIds);
|
||||
|
||||
return NovaCardTag::query()->whereIn('id', $tagIds)->get();
|
||||
}
|
||||
}
|
||||
90
app/Services/NovaCards/NovaCardTrendingService.php
Normal file
90
app/Services/NovaCards/NovaCardTrendingService.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardChallengeEntry;
|
||||
use App\Models\NovaCardComment;
|
||||
use App\Models\NovaCardCollectionItem;
|
||||
use App\Models\NovaCardReaction;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
class NovaCardTrendingService
|
||||
{
|
||||
public function refreshCard(NovaCard $card): NovaCard
|
||||
{
|
||||
$likes = NovaCardReaction::query()->where('card_id', $card->id)->where('type', NovaCardReaction::TYPE_LIKE)->count();
|
||||
$favorites = NovaCardReaction::query()->where('card_id', $card->id)->where('type', NovaCardReaction::TYPE_FAVORITE)->count();
|
||||
$saves = NovaCardCollectionItem::query()->where('card_id', $card->id)->count();
|
||||
$remixes = NovaCard::query()->where('original_card_id', $card->id)->count();
|
||||
$comments = NovaCardComment::query()->where('card_id', $card->id)->where('status', 'visible')->count();
|
||||
$challengeEntries = NovaCardChallengeEntry::query()->where('card_id', $card->id)->count();
|
||||
$lastEngagedAt = $this->lastEngagedAt($card);
|
||||
|
||||
$card->forceFill([
|
||||
'likes_count' => $likes,
|
||||
'favorites_count' => $favorites,
|
||||
'saves_count' => $saves,
|
||||
'remixes_count' => $remixes,
|
||||
'comments_count' => $comments,
|
||||
'challenge_entries_count' => $challengeEntries,
|
||||
'last_engaged_at' => $lastEngagedAt,
|
||||
'trending_score' => $this->score($card, $likes, $favorites, $saves, $remixes, $comments, $challengeEntries, $lastEngagedAt),
|
||||
])->save();
|
||||
|
||||
return $card->refresh();
|
||||
}
|
||||
|
||||
public function rebuildAll(): void
|
||||
{
|
||||
NovaCard::query()->select('id')->orderBy('id')->chunkById(100, function ($cards): void {
|
||||
foreach ($cards as $card) {
|
||||
$this->refreshCard(NovaCard::query()->findOrFail($card->id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function score(NovaCard $card, int $likes, int $favorites, int $saves, int $remixes, int $comments, int $challengeEntries, ?CarbonInterface $lastEngagedAt): float
|
||||
{
|
||||
$base = ($likes * 4.0)
|
||||
+ ($favorites * 2.5)
|
||||
+ ($saves * 5.0)
|
||||
+ ($remixes * 6.0)
|
||||
+ ($comments * 2.0)
|
||||
+ ($challengeEntries * 4.0)
|
||||
+ ($card->shares_count * 3.0)
|
||||
+ ($card->downloads_count * 2.0)
|
||||
+ ($card->views_count * 0.25);
|
||||
|
||||
$engagedAt = $lastEngagedAt ?? $card->published_at ?? now();
|
||||
$ageHours = max(1.0, (float) $engagedAt->diffInHours(now()));
|
||||
$decay = max(0.2, 1 / (1 + ($ageHours / 72)));
|
||||
|
||||
return round($base * $decay, 4);
|
||||
}
|
||||
|
||||
private function lastEngagedAt(NovaCard $card): ?CarbonInterface
|
||||
{
|
||||
$timestamps = array_filter([
|
||||
NovaCardReaction::query()->where('card_id', $card->id)->max('created_at'),
|
||||
NovaCardCollectionItem::query()->where('card_id', $card->id)->max('created_at'),
|
||||
NovaCard::query()->where('original_card_id', $card->id)->max('created_at'),
|
||||
NovaCardComment::query()->where('card_id', $card->id)->max('created_at'),
|
||||
NovaCardChallengeEntry::query()->where('card_id', $card->id)->max('created_at'),
|
||||
$card->updated_at?->toDateTimeString(),
|
||||
$card->published_at?->toDateTimeString(),
|
||||
]);
|
||||
|
||||
if ($timestamps === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return collect($timestamps)
|
||||
->map(fn ($timestamp) => Carbon::parse($timestamp))
|
||||
->sortDesc()
|
||||
->first();
|
||||
}
|
||||
}
|
||||
58
app/Services/NovaCards/NovaCardVersionService.php
Normal file
58
app/Services/NovaCards/NovaCardVersionService.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\NovaCards;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardVersion;
|
||||
use App\Models\User;
|
||||
|
||||
class NovaCardVersionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardProjectNormalizer $normalizer,
|
||||
) {
|
||||
}
|
||||
|
||||
public function snapshot(NovaCard $card, ?User $actor = null, ?string $label = null, bool $force = false): NovaCardVersion
|
||||
{
|
||||
$project = $this->normalizer->normalizeForCard($card->fresh(['template']));
|
||||
$hash = hash('sha256', json_encode($project, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
$latest = $card->versions()->latest('version_number')->first();
|
||||
|
||||
if (! $force && $latest && $latest->snapshot_hash === $hash) {
|
||||
return $latest;
|
||||
}
|
||||
|
||||
return $card->versions()->create([
|
||||
'user_id' => $actor?->id,
|
||||
'version_number' => (int) ($latest?->version_number ?? 0) + 1,
|
||||
'label' => $label,
|
||||
'snapshot_hash' => $hash,
|
||||
'snapshot_json' => $project,
|
||||
]);
|
||||
}
|
||||
|
||||
public function restore(NovaCard $card, NovaCardVersion $version, ?User $actor = null): NovaCard
|
||||
{
|
||||
$project = $this->normalizer->normalize($version->snapshot_json, $card->template, [], $card);
|
||||
$topLevel = $this->normalizer->syncTopLevelAttributes($project);
|
||||
|
||||
$card->forceFill([
|
||||
'project_json' => $project,
|
||||
'schema_version' => $topLevel['schema_version'],
|
||||
'title' => $topLevel['title'],
|
||||
'quote_text' => $topLevel['quote_text'],
|
||||
'quote_author' => $topLevel['quote_author'],
|
||||
'quote_source' => $topLevel['quote_source'],
|
||||
'background_type' => $topLevel['background_type'],
|
||||
'background_image_id' => $topLevel['background_image_id'],
|
||||
'render_version' => (int) $card->render_version + 1,
|
||||
])->save();
|
||||
|
||||
$this->snapshot($card->refresh(['template']), $actor, 'Restored from version ' . $version->version_number, true);
|
||||
|
||||
return $card->refresh()->load(['category', 'template', 'tags', 'backgroundImage', 'versions']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user