feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
This commit is contained in:
122
app/Console/Commands/PublishScheduledArtworksCommand.php
Normal file
122
app/Console/Commands/PublishScheduledArtworksCommand.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ActivityEvent;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* PublishScheduledArtworksCommand
|
||||
*
|
||||
* Runs every minute (via Kernel schedule).
|
||||
* Finds artworks with:
|
||||
* - artwork_status = 'scheduled'
|
||||
* - publish_at <= now() (UTC)
|
||||
* - is_approved = true (respect moderation gate)
|
||||
*
|
||||
* Publishes each one:
|
||||
* - sets is_public = true
|
||||
* - sets published_at = now()
|
||||
* - sets artwork_status = 'published'
|
||||
* - dispatches Meilisearch reindex (via Scout)
|
||||
* - records activity event
|
||||
*
|
||||
* Safe to run concurrently (DB row lock prevents double-publish).
|
||||
*/
|
||||
class PublishScheduledArtworksCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:publish-scheduled
|
||||
{--dry-run : List candidate artworks without publishing}
|
||||
{--limit=100 : Max artworks to process per run}';
|
||||
|
||||
protected $description = 'Publish scheduled artworks whose publish_at datetime has passed.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = (int) $this->option('limit');
|
||||
|
||||
$now = now()->utc();
|
||||
|
||||
$candidates = Artwork::query()
|
||||
->where('artwork_status', 'scheduled')
|
||||
->where('publish_at', '<=', $now)
|
||||
->where('is_approved', true)
|
||||
->orderBy('publish_at')
|
||||
->limit($limit)
|
||||
->get(['id', 'user_id', 'title', 'publish_at', 'artwork_status']);
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->line('No scheduled artworks due for publishing.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$candidates->count()} artwork(s) to publish." . ($dryRun ? ' [DRY RUN]' : ''));
|
||||
|
||||
$published = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would publish artwork #{$candidate->id}: \"{$candidate->title}\"");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($candidate, $now, &$published) {
|
||||
// Re-fetch with lock to avoid double-publish in concurrent runs
|
||||
$artwork = Artwork::query()
|
||||
->lockForUpdate()
|
||||
->where('id', $candidate->id)
|
||||
->where('artwork_status', 'scheduled')
|
||||
->first();
|
||||
|
||||
if (! $artwork) {
|
||||
// Already published or status changed – skip
|
||||
return;
|
||||
}
|
||||
|
||||
$artwork->is_public = true;
|
||||
$artwork->published_at = $now;
|
||||
$artwork->artwork_status = 'published';
|
||||
$artwork->save();
|
||||
|
||||
// Trigger Meilisearch reindex via Scout (if searchable trait present)
|
||||
if (method_exists($artwork, 'searchable')) {
|
||||
try {
|
||||
$artwork->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
// Record activity event
|
||||
try {
|
||||
ActivityEvent::record(
|
||||
actorId: (int) $artwork->user_id,
|
||||
type: ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: (int) $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
$published++;
|
||||
$this->line(" Published artwork #{$artwork->id}: \"{$artwork->title}\"");
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$errors++;
|
||||
Log::error("PublishScheduledArtworksCommand: failed to publish artwork #{$candidate->id}: {$e->getMessage()}");
|
||||
$this->error(" Failed to publish #{$candidate->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->info("Done. Published: {$published}, Errors: {$errors}.");
|
||||
}
|
||||
|
||||
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
46
app/Console/Commands/PublishScheduledPostsCommand.php
Normal file
46
app/Console/Commands/PublishScheduledPostsCommand.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Publishes posts whose publish_at timestamp has passed.
|
||||
* Scheduled every minute via console/kernel.
|
||||
*/
|
||||
class PublishScheduledPostsCommand extends Command
|
||||
{
|
||||
protected $signature = 'posts:publish-scheduled';
|
||||
protected $description = 'Publish all scheduled posts whose publish_at time has been reached.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$count = Post::where('status', Post::STATUS_SCHEDULED)
|
||||
->where('publish_at', '<=', now())
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
$this->line('No scheduled posts to publish.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$published = 0;
|
||||
|
||||
Post::where('status', Post::STATUS_SCHEDULED)
|
||||
->where('publish_at', '<=', now())
|
||||
->chunkById(100, function ($posts) use (&$published) {
|
||||
foreach ($posts as $post) {
|
||||
DB::transaction(function () use ($post) {
|
||||
$post->update(['status' => Post::STATUS_PUBLISHED]);
|
||||
});
|
||||
$published++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Published {$published} scheduled post(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReindexRecentPublishedArtworksCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:search-reindex-recent
|
||||
{--hours=72 : Reindex artworks published in the last N hours}
|
||||
{--limit=1000 : Maximum artworks to process in this run}
|
||||
{--id=* : Specific artwork IDs to reindex (overrides --hours window)}
|
||||
{--dry-run : Show candidates without dispatching index jobs}';
|
||||
|
||||
protected $description = 'Reindex recently published public artworks to recover missed search indexing.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$hours = max(1, (int) $this->option('hours'));
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', (array) $this->option('id')), static fn (int $id): bool => $id > 0)));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$since = now()->subHours($hours);
|
||||
|
||||
$query = Artwork::query()
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at');
|
||||
|
||||
if ($ids !== []) {
|
||||
$query->whereIn('id', $ids)->orderBy('id');
|
||||
} else {
|
||||
$query->where('published_at', '>=', $since)
|
||||
->orderByDesc('published_at');
|
||||
}
|
||||
|
||||
$candidates = $query->limit($limit)->get(['id', 'title', 'slug', 'published_at']);
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
if ($ids !== []) {
|
||||
$this->line('No matching published artworks found for the provided --id values.');
|
||||
} else {
|
||||
$this->line("No published artworks found in the last {$hours} hour(s).");
|
||||
}
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($ids !== []) {
|
||||
$this->info('Found ' . $candidates->count() . ' target artwork(s) by --id.' . ($dryRun ? ' [DRY RUN]' : ''));
|
||||
} else {
|
||||
$this->info("Found {$candidates->count()} artwork(s) published in the last {$hours} hour(s)." . ($dryRun ? ' [DRY RUN]' : ''));
|
||||
}
|
||||
|
||||
foreach ($candidates as $artwork) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would reindex #{$artwork->id} ({$artwork->slug})");
|
||||
continue;
|
||||
}
|
||||
|
||||
IndexArtworkJob::dispatchSync((int) $artwork->id);
|
||||
$this->line(" Reindexed #{$artwork->id} ({$artwork->slug})");
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->info('Done. Recent published artworks were reindexed.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
28
app/Console/Commands/WarmPostTrendingCommand.php
Normal file
28
app/Console/Commands/WarmPostTrendingCommand.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Posts\PostTrendingService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Warms the post trending cache so requests are fast.
|
||||
* Scheduled every 2 minutes to match the cache TTL.
|
||||
*/
|
||||
class WarmPostTrendingCommand extends Command
|
||||
{
|
||||
protected $signature = 'posts:warm-trending';
|
||||
protected $description = 'Refresh the post trending feed cache.';
|
||||
|
||||
public function __construct(private PostTrendingService $trending)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$ids = $this->trending->refresh();
|
||||
$this->info('Trending feed cache refreshed. ' . count($ids) . ' post(s) ranked.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use App\Console\Commands\RecalculateHeatCommand;
|
||||
use App\Jobs\RankComputeArtworkScoresJob;
|
||||
use App\Jobs\RankBuildListsJob;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
use App\Console\Commands\PublishScheduledArtworksCommand;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
@@ -35,6 +36,7 @@ class Kernel extends ConsoleKernel
|
||||
\App\Console\Commands\AvatarsMigrate::class,
|
||||
\App\Console\Commands\ResetAllUserPasswords::class,
|
||||
CleanupUploadsCommand::class,
|
||||
PublishScheduledArtworksCommand::class,
|
||||
BackfillArtworkEmbeddingsCommand::class,
|
||||
AggregateSimilarArtworkAnalyticsCommand::class,
|
||||
AggregateFeedAnalyticsCommand::class,
|
||||
@@ -54,6 +56,13 @@ class Kernel extends ConsoleKernel
|
||||
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
|
||||
{
|
||||
$schedule->command('uploads:cleanup')->dailyAt('03:00');
|
||||
|
||||
// Publish artworks whose scheduled publish_at has passed
|
||||
$schedule->command('artworks:publish-scheduled')
|
||||
->everyMinute()
|
||||
->name('publish-scheduled-artworks')
|
||||
->withoutOverlapping(2) // prevent overlap up to 2 minutes
|
||||
->runInBackground();
|
||||
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
|
||||
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
|
||||
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
|
||||
|
||||
20
app/Events/Posts/ArtworkShared.php
Normal file
20
app/Events/Posts/ArtworkShared.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Posts;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ArtworkShared
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Post $post,
|
||||
public readonly Artwork $artwork,
|
||||
public readonly User $sharer,
|
||||
) {}
|
||||
}
|
||||
20
app/Events/Posts/PostCommented.php
Normal file
20
app/Events/Posts/PostCommented.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PostCommented
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Post $post,
|
||||
public readonly PostComment $comment,
|
||||
public readonly User $commenter,
|
||||
) {}
|
||||
}
|
||||
223
app/Http/Controllers/Api/LinkPreviewController.php
Normal file
223
app/Http/Controllers/Api/LinkPreviewController.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\TransferException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LinkPreviewController extends Controller
|
||||
{
|
||||
private const TIMEOUT = 8; // seconds
|
||||
private const MAX_BYTES = 524_288; // 512 KB – enough to get the <head>
|
||||
private const USER_AGENT = 'Skinbase-LinkPreview/1.0 (+https://skinbase.org)';
|
||||
|
||||
/** Blocked IP ranges (SSRF protection). */
|
||||
private const BLOCKED_CIDRS = [
|
||||
'0.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'100.64.0.0/10',
|
||||
'127.0.0.0/8',
|
||||
'169.254.0.0/16',
|
||||
'172.16.0.0/12',
|
||||
'192.0.0.0/24',
|
||||
'192.168.0.0/16',
|
||||
'198.18.0.0/15',
|
||||
'198.51.100.0/24',
|
||||
'203.0.113.0/24',
|
||||
'240.0.0.0/4',
|
||||
'::1/128',
|
||||
'fc00::/7',
|
||||
'fe80::/10',
|
||||
];
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'url' => ['required', 'string', 'max:2048'],
|
||||
]);
|
||||
|
||||
$rawUrl = trim((string) $request->input('url'));
|
||||
|
||||
// Must be http(s)
|
||||
if (! preg_match('#^https?://#i', $rawUrl)) {
|
||||
return response()->json(['error' => 'Invalid URL scheme.'], 422);
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$host = $parsed['host'] ?? '';
|
||||
|
||||
if (empty($host)) {
|
||||
return response()->json(['error' => 'Invalid URL.'], 422);
|
||||
}
|
||||
|
||||
// Resolve hostname and block private/loopback IPs (SSRF protection)
|
||||
$resolved = gethostbyname($host);
|
||||
if ($this->isBlockedIp($resolved)) {
|
||||
return response()->json(['error' => 'URL not allowed.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = new Client([
|
||||
'timeout' => self::TIMEOUT,
|
||||
'connect_timeout' => 4,
|
||||
'allow_redirects' => ['max' => 5, 'strict' => false],
|
||||
'headers' => [
|
||||
'User-Agent' => self::USER_AGENT,
|
||||
'Accept' => 'text/html,application/xhtml+xml',
|
||||
],
|
||||
'verify' => true,
|
||||
]);
|
||||
|
||||
$response = $client->get($rawUrl);
|
||||
$status = $response->getStatusCode();
|
||||
|
||||
if ($status < 200 || $status >= 400) {
|
||||
return response()->json(['error' => 'Could not fetch URL.'], 422);
|
||||
}
|
||||
|
||||
// Read up to MAX_BYTES – we only need the HTML <head>
|
||||
$body = '';
|
||||
$stream = $response->getBody();
|
||||
while (! $stream->eof() && strlen($body) < self::MAX_BYTES) {
|
||||
$body .= $stream->read(4096);
|
||||
}
|
||||
$stream->close();
|
||||
|
||||
} catch (TransferException $e) {
|
||||
return response()->json(['error' => 'Could not reach URL.'], 422);
|
||||
}
|
||||
|
||||
$preview = $this->extractMeta($body, $rawUrl);
|
||||
|
||||
return response()->json($preview);
|
||||
}
|
||||
|
||||
/** Extract OG / Twitter / fallback meta tags. */
|
||||
private function extractMeta(string $html, string $originalUrl): array
|
||||
{
|
||||
// Limit to roughly the <head> block for speed
|
||||
$head = substr($html, 0, 50_000);
|
||||
|
||||
$og = [];
|
||||
|
||||
// OG / Twitter meta tags
|
||||
preg_match_all(
|
||||
'/<meta\s[^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*>/i',
|
||||
$head,
|
||||
$m1,
|
||||
PREG_SET_ORDER,
|
||||
);
|
||||
preg_match_all(
|
||||
'/<meta\s[^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*>/i',
|
||||
$head,
|
||||
$m2,
|
||||
PREG_SET_ORDER,
|
||||
);
|
||||
|
||||
$allMeta = array_merge(
|
||||
array_map(fn ($r) => ['key' => strtolower($r[1]), 'value' => $r[2]], $m1),
|
||||
array_map(fn ($r) => ['key' => strtolower($r[2]), 'value' => $r[1]], $m2),
|
||||
);
|
||||
|
||||
$map = [];
|
||||
foreach ($allMeta as $entry) {
|
||||
$map[$entry['key']] ??= $entry['value'];
|
||||
}
|
||||
|
||||
// Canonical URL
|
||||
$canonical = $originalUrl;
|
||||
if (preg_match('/<link[^>]+rel\s*=\s*["\']canonical["\'][^>]+href\s*=\s*["\']([^"\']+)["\'][^>]*>/i', $head, $mc)) {
|
||||
$canonical = $mc[1];
|
||||
} elseif (preg_match('/<link[^>]+href\s*=\s*["\']([^"\']+)["\'][^>]+rel\s*=\s*["\']canonical["\'][^>]*>/i', $head, $mc)) {
|
||||
$canonical = $mc[1];
|
||||
}
|
||||
|
||||
// Title
|
||||
$title = $map['og:title']
|
||||
?? $map['twitter:title']
|
||||
?? null;
|
||||
if (! $title && preg_match('/<title[^>]*>([^<]+)<\/title>/i', $head, $mt)) {
|
||||
$title = trim(html_entity_decode($mt[1]));
|
||||
}
|
||||
|
||||
// Description
|
||||
$description = $map['og:description']
|
||||
?? $map['twitter:description']
|
||||
?? $map['description']
|
||||
?? null;
|
||||
|
||||
// Image
|
||||
$image = $map['og:image']
|
||||
?? $map['twitter:image']
|
||||
?? $map['twitter:image:src']
|
||||
?? null;
|
||||
|
||||
// Resolve relative image URL
|
||||
if ($image && ! preg_match('#^https?://#i', $image)) {
|
||||
$parsed = parse_url($originalUrl);
|
||||
$base = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
|
||||
$image = $base . '/' . ltrim($image, '/');
|
||||
}
|
||||
|
||||
// Site name
|
||||
$siteName = $map['og:site_name'] ?? parse_url($originalUrl, PHP_URL_HOST) ?? null;
|
||||
|
||||
return [
|
||||
'url' => $canonical,
|
||||
'title' => $title ? html_entity_decode($title) : null,
|
||||
'description' => $description ? html_entity_decode($description) : null,
|
||||
'image' => $image,
|
||||
'site_name' => $siteName,
|
||||
];
|
||||
}
|
||||
|
||||
private function isBlockedIp(string $ip): bool
|
||||
{
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return true; // could not resolve
|
||||
}
|
||||
foreach (self::BLOCKED_CIDRS as $cidr) {
|
||||
if ($this->ipInCidr($ip, $cidr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function ipInCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $cidr) + [1 => 32];
|
||||
|
||||
// IPv6
|
||||
if (str_contains($cidr, ':')) {
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
return false;
|
||||
}
|
||||
$ipBin = inet_pton($ip);
|
||||
$subnetBin = inet_pton($subnet);
|
||||
if ($ipBin === false || $subnetBin === false) {
|
||||
return false;
|
||||
}
|
||||
$bits = (int) $bits;
|
||||
$mask = str_repeat("\xff", (int) ($bits / 8));
|
||||
$remain = $bits % 8;
|
||||
if ($remain) {
|
||||
$mask .= chr(0xff << (8 - $remain));
|
||||
}
|
||||
$mask = str_pad($mask, strlen($subnetBin), "\x00");
|
||||
return ($ipBin & $mask) === ($subnetBin & $mask);
|
||||
}
|
||||
|
||||
// IPv4
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
$ipLong = ip2long($ip);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$maskLong = $bits == 32 ? -1 : ~((1 << (32 - (int) $bits)) - 1);
|
||||
return ($ipLong & $maskLong) === ($subnetLong & $maskLong);
|
||||
}
|
||||
}
|
||||
61
app/Http/Controllers/Api/NotificationController.php
Normal file
61
app/Http/Controllers/Api/NotificationController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Services\Posts\NotificationDigestService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
/**
|
||||
* GET /api/notifications — digestd notification list
|
||||
* POST /api/notifications/read-all — mark all unread as read
|
||||
* POST /api/notifications/{id}/read — mark single as read
|
||||
*/
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function __construct(private NotificationDigestService $digest) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$notifications = $user->notifications()
|
||||
->latest()
|
||||
->limit(200) // aggregate from last 200 raw notifs
|
||||
->get();
|
||||
|
||||
$digested = $this->digest->aggregate($notifications);
|
||||
|
||||
// Simple manual pagination on the digested array
|
||||
$perPage = 20;
|
||||
$total = count($digested);
|
||||
$sliced = array_slice($digested, ($page - 1) * $perPage, $perPage);
|
||||
$unread = $user->unreadNotifications()->count();
|
||||
|
||||
return response()->json([
|
||||
'data' => array_values($sliced),
|
||||
'unread_count' => $unread,
|
||||
'meta' => [
|
||||
'total' => $total,
|
||||
'current_page' => $page,
|
||||
'last_page' => (int) ceil($total / $perPage) ?: 1,
|
||||
'per_page' => $perPage,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function readAll(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
return response()->json(['message' => 'All notifications marked as read.']);
|
||||
}
|
||||
|
||||
public function markRead(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$notif = $request->user()->notifications()->findOrFail($id);
|
||||
$notif->markAsRead();
|
||||
return response()->json(['message' => 'Notification marked as read.']);
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Api/Posts/PostAnalyticsController.php
Normal file
44
app/Http/Controllers/Api/Posts/PostAnalyticsController.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Services\Posts\PostAnalyticsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/impression — record an impression (throttled)
|
||||
* GET /api/posts/{id}/analytics — owner analytics summary
|
||||
*/
|
||||
class PostAnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(private PostAnalyticsService $analytics) {}
|
||||
|
||||
public function impression(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
|
||||
|
||||
// Session key: authenticated user ID or hashed IP
|
||||
$sessionKey = $request->user()
|
||||
? 'u:' . $request->user()->id
|
||||
: 'ip:' . md5($request->ip());
|
||||
|
||||
$counted = $this->analytics->trackImpression($post, $sessionKey);
|
||||
|
||||
return response()->json(['counted' => $counted]);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
// Only the post owner can view analytics
|
||||
if ($request->user()?->id !== $post->user_id) {
|
||||
abort(403, 'You do not own this post.');
|
||||
}
|
||||
|
||||
return response()->json(['data' => $this->analytics->getSummary($post)]);
|
||||
}
|
||||
}
|
||||
122
app/Http/Controllers/Api/Posts/PostCommentController.php
Normal file
122
app/Http/Controllers/Api/Posts/PostCommentController.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Events\Posts\PostCommented;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Posts\CreateCommentRequest;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Posts\PostCountersService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostCommentController extends Controller
|
||||
{
|
||||
public function __construct(private PostCountersService $counters) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// List
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function index(Request $request, int $postId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$comments = PostComment::with(['user', 'user.profile'])
|
||||
->where('post_id', $post->id)
|
||||
->orderByDesc('is_highlighted') // highlighted first
|
||||
->orderBy('created_at')
|
||||
->paginate(20, ['*'], 'page', $page);
|
||||
|
||||
$formatted = $comments->getCollection()->map(fn ($c) => $this->formatComment($c));
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'meta' => [
|
||||
'total' => $comments->total(),
|
||||
'current_page' => $comments->currentPage(),
|
||||
'last_page' => $comments->lastPage(),
|
||||
'per_page' => $comments->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Store
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function store(CreateCommentRequest $request, int $postId): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Rate limit: 30 comments per hour
|
||||
$key = 'comment_post:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 30)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
return response()->json([
|
||||
'message' => "You're commenting too quickly. Please wait {$seconds} seconds.",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$post = Post::findOrFail($postId);
|
||||
$body = ContentSanitizer::render($request->input('body'));
|
||||
|
||||
$comment = PostComment::create([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$this->counters->incrementComments($post);
|
||||
|
||||
// Fire event for notification
|
||||
if ($post->user_id !== $user->id) {
|
||||
event(new PostCommented($post, $comment, $user));
|
||||
}
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
|
||||
return response()->json(['comment' => $this->formatComment($comment)], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Destroy
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function destroy(Request $request, int $postId, int $commentId): JsonResponse
|
||||
{
|
||||
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
|
||||
Gate::authorize('delete', $comment);
|
||||
|
||||
$comment->delete();
|
||||
$this->counters->decrementComments(Post::findOrFail($postId));
|
||||
|
||||
return response()->json(['message' => 'Comment deleted.']);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Format
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function formatComment(PostComment $comment): array
|
||||
{
|
||||
return [
|
||||
'id' => $comment->id,
|
||||
'body' => $comment->body,
|
||||
'is_highlighted' => (bool) $comment->is_highlighted,
|
||||
'created_at' => $comment->created_at->toISOString(),
|
||||
'author' => [
|
||||
'id' => $comment->user->id,
|
||||
'username' => $comment->user->username,
|
||||
'name' => $comment->user->name,
|
||||
'avatar' => $comment->user->profile?->avatar_url ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{post_id}/comments/{comment_id}/highlight
|
||||
* DELETE /api/posts/{post_id}/comments/{comment_id}/highlight
|
||||
*
|
||||
* Only the post owner may highlight/un-highlight.
|
||||
* Only 1 highlighted comment per post is allowed at a time.
|
||||
*/
|
||||
class PostCommentHighlightController extends Controller
|
||||
{
|
||||
public function highlight(Request $request, int $postId, int $commentId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
|
||||
|
||||
if ($request->user()->id !== $post->user_id) {
|
||||
abort(403, 'Only the post owner can highlight comments.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($post, $comment) {
|
||||
// Remove any existing highlight on this post
|
||||
PostComment::where('post_id', $post->id)
|
||||
->where('is_highlighted', true)
|
||||
->update(['is_highlighted' => false]);
|
||||
|
||||
$comment->update(['is_highlighted' => true]);
|
||||
});
|
||||
|
||||
return response()->json(['message' => 'Comment highlighted.', 'comment_id' => $comment->id]);
|
||||
}
|
||||
|
||||
public function unhighlight(Request $request, int $postId, int $commentId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
|
||||
|
||||
if ($request->user()->id !== $post->user_id) {
|
||||
abort(403, 'Only the post owner can remove comment highlights.');
|
||||
}
|
||||
|
||||
$comment->update(['is_highlighted' => false]);
|
||||
|
||||
return response()->json(['message' => 'Highlight removed.', 'comment_id' => $comment->id]);
|
||||
}
|
||||
}
|
||||
92
app/Http/Controllers/Api/Posts/PostController.php
Normal file
92
app/Http/Controllers/Api/Posts/PostController.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Posts\CreatePostRequest;
|
||||
use App\Http\Requests\Posts\UpdatePostRequest;
|
||||
use App\Models\Post;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostService $postService,
|
||||
private PostFeedService $feedService,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Create
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function store(CreatePostRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Rate limit: 10 post creations per hour
|
||||
$key = 'create_post:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
return response()->json([
|
||||
'message' => "You're posting too quickly. Please wait {$seconds} seconds.",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
Gate::authorize('create', Post::class);
|
||||
|
||||
$post = $this->postService->createPost(
|
||||
user: $user,
|
||||
type: $request->input('type', Post::TYPE_TEXT),
|
||||
visibility: $request->input('visibility', Post::VISIBILITY_PUBLIC),
|
||||
body: $request->input('body'),
|
||||
targets: $request->input('targets', []),
|
||||
linkPreview: $request->input('link_preview'),
|
||||
taggedUsers: $request->input('tagged_users'), publishAt: $request->filled('publish_at') ? Carbon::parse($request->input('publish_at')) : null, );
|
||||
|
||||
$post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']);
|
||||
|
||||
return response()->json([
|
||||
'post' => $this->feedService->formatPost($post, $user->id),
|
||||
], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Update
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function update(UpdatePostRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$updated = $this->postService->updatePost(
|
||||
post: $post,
|
||||
body: $request->input('body'),
|
||||
visibility: $request->input('visibility'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'post' => $this->feedService->formatPost($updated->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']), $request->user()?->id),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Delete
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
Gate::authorize('delete', $post);
|
||||
|
||||
$this->postService->deletePost($post);
|
||||
|
||||
return response()->json(['message' => 'Post deleted.']);
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Api/Posts/PostFeedController.php
Normal file
60
app/Http/Controllers/Api/Posts/PostFeedController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PostFeedController extends Controller
|
||||
{
|
||||
public function __construct(private PostFeedService $feedService) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Profile feed — GET /api/posts/profile/{username}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function profile(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$profileUser = User::where('username', $username)->firstOrFail();
|
||||
$viewerId = $request->user()?->id;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$paginated = $this->feedService->getProfileFeed($profileUser, $viewerId, $page);
|
||||
|
||||
$formatted = collect($paginated['data'])
|
||||
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'meta' => $paginated['meta'],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Following feed — GET /api/posts/following
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function following(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$filter = $request->query('filter', 'all');
|
||||
|
||||
$result = $this->feedService->getFollowingFeed($user, $page, $filter);
|
||||
|
||||
$viewerId = $user->id;
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewerId),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/Api/Posts/PostPinController.php
Normal file
67
app/Http/Controllers/Api/Posts/PostPinController.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/pin
|
||||
* DELETE /api/posts/{id}/pin
|
||||
*/
|
||||
class PostPinController extends Controller
|
||||
{
|
||||
private const MAX_PINNED = 3;
|
||||
|
||||
public function pin(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Count existing pinned posts
|
||||
$pinnedCount = Post::where('user_id', $user->id)
|
||||
->where('is_pinned', true)
|
||||
->count();
|
||||
|
||||
if ($post->is_pinned) {
|
||||
return response()->json(['message' => 'Post is already pinned.'], 409);
|
||||
}
|
||||
|
||||
if ($pinnedCount >= self::MAX_PINNED) {
|
||||
return response()->json([
|
||||
'message' => 'You can pin a maximum of ' . self::MAX_PINNED . ' posts.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$nextOrder = Post::where('user_id', $user->id)
|
||||
->where('is_pinned', true)
|
||||
->max('pinned_order') ?? 0;
|
||||
|
||||
$post->update([
|
||||
'is_pinned' => true,
|
||||
'pinned_order' => $nextOrder + 1,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Post pinned.', 'post_id' => $post->id]);
|
||||
}
|
||||
|
||||
public function unpin(int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
if (! $post->is_pinned) {
|
||||
return response()->json(['message' => 'Post is not pinned.'], 409);
|
||||
}
|
||||
|
||||
$post->update(['is_pinned' => false, 'pinned_order' => null]);
|
||||
|
||||
return response()->json(['message' => 'Post unpinned.', 'post_id' => $post->id]);
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Api/Posts/PostReactionController.php
Normal file
75
app/Http/Controllers/Api/Posts/PostReactionController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostReaction;
|
||||
use App\Services\Posts\PostCountersService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostReactionController extends Controller
|
||||
{
|
||||
public function __construct(private PostCountersService $counters) {}
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/reactions
|
||||
* payload: { reaction: 'like' }
|
||||
*/
|
||||
public function store(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$key = 'react_post:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 60)) {
|
||||
return response()->json(['message' => 'Too many reactions. Please slow down.'], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$post = Post::findOrFail($id);
|
||||
$reaction = $request->input('reaction', 'like');
|
||||
|
||||
$existing = PostReaction::where('post_id', $post->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('reaction', $reaction)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['message' => 'Already reacted.', 'reactions_count' => $post->reactions_count], 200);
|
||||
}
|
||||
|
||||
PostReaction::create([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
'reaction' => $reaction,
|
||||
]);
|
||||
|
||||
$this->counters->incrementReactions($post);
|
||||
$post->refresh();
|
||||
|
||||
return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => true], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/posts/{id}/reactions/{reaction}
|
||||
*/
|
||||
public function destroy(Request $request, int $id, string $reaction = 'like'): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
$deleted = PostReaction::where('post_id', $post->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('reaction', $reaction)
|
||||
->delete();
|
||||
|
||||
if ($deleted) {
|
||||
$this->counters->decrementReactions($post);
|
||||
$post->refresh();
|
||||
}
|
||||
|
||||
return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => false]);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/Api/Posts/PostReportController.php
Normal file
49
app/Http/Controllers/Api/Posts/PostReportController.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostReport;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class PostReportController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /api/posts/{id}/report
|
||||
* payload: { reason, message? }
|
||||
*/
|
||||
public function store(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
Gate::authorize('report', $post);
|
||||
|
||||
$request->validate([
|
||||
'reason' => ['required', 'string', 'max:64'],
|
||||
'message' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
// Unique report per user+post
|
||||
$existing = PostReport::where('post_id', $post->id)
|
||||
->where('reporter_user_id', $user->id)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['message' => 'You have already reported this post.'], 409);
|
||||
}
|
||||
|
||||
PostReport::create([
|
||||
'post_id' => $post->id,
|
||||
'reporter_user_id' => $user->id,
|
||||
'reason' => $request->input('reason'),
|
||||
'message' => $request->input('message'),
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Report submitted. Thank you for helping keep Skinbase safe.'], 201);
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Api/Posts/PostSaveController.php
Normal file
69
app/Http/Controllers/Api/Posts/PostSaveController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostSave;
|
||||
use App\Services\Posts\PostCountersService;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/save
|
||||
* DELETE /api/posts/{id}/save
|
||||
* GET /api/posts/saved
|
||||
*/
|
||||
class PostSaveController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostCountersService $counters,
|
||||
private PostFeedService $feedService,
|
||||
) {}
|
||||
|
||||
public function save(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
|
||||
$user = $request->user();
|
||||
|
||||
if (PostSave::where('post_id', $post->id)->where('user_id', $user->id)->exists()) {
|
||||
return response()->json(['message' => 'Already saved.', 'saved' => true], 200);
|
||||
}
|
||||
|
||||
PostSave::create(['post_id' => $post->id, 'user_id' => $user->id]);
|
||||
$this->counters->incrementSaves($post);
|
||||
|
||||
return response()->json(['message' => 'Post saved.', 'saved' => true, 'saves_count' => $post->fresh()->saves_count]);
|
||||
}
|
||||
|
||||
public function unsave(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
$user = $request->user();
|
||||
$save = PostSave::where('post_id', $post->id)->where('user_id', $user->id)->first();
|
||||
|
||||
if (! $save) {
|
||||
return response()->json(['message' => 'Not saved.', 'saved' => false], 200);
|
||||
}
|
||||
|
||||
$save->delete();
|
||||
$this->counters->decrementSaves($post);
|
||||
|
||||
return response()->json(['message' => 'Post unsaved.', 'saved' => false, 'saves_count' => $post->fresh()->saves_count]);
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$result = $this->feedService->getSavedFeed($user, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $user->id),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
|
||||
}
|
||||
}
|
||||
85
app/Http/Controllers/Api/Posts/PostSearchController.php
Normal file
85
app/Http/Controllers/Api/Posts/PostSearchController.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* GET /api/feed/search?q=...
|
||||
*
|
||||
* Searches posts body + hashtags via Meilisearch (Laravel Scout).
|
||||
* Falls back to a simple LIKE query if Scout is unavailable.
|
||||
*/
|
||||
class PostSearchController extends Controller
|
||||
{
|
||||
public function __construct(private PostFeedService $feedService) {}
|
||||
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'q' => ['required', 'string', 'min:2', 'max:100'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
$query = trim($request->input('q'));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = 20;
|
||||
$viewerId = $request->user()?->id;
|
||||
|
||||
// Scout search (Meilisearch)
|
||||
try {
|
||||
$results = Post::search($query)
|
||||
->where('visibility', Post::VISIBILITY_PUBLIC)
|
||||
->where('status', Post::STATUS_PUBLISHED)
|
||||
->paginate($perPage, 'page', $page);
|
||||
|
||||
// Load relations
|
||||
$results->load($this->feedService->publicEagerLoads());
|
||||
|
||||
$formatted = $results->getCollection()
|
||||
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'query' => $query,
|
||||
'meta' => [
|
||||
'total' => $results->total(),
|
||||
'current_page' => $results->currentPage(),
|
||||
'last_page' => $results->lastPage(),
|
||||
'per_page' => $results->perPage(),
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Fallback: basic LIKE search on body
|
||||
$paginated = Post::with($this->feedService->publicEagerLoads())
|
||||
->where('status', Post::STATUS_PUBLISHED)
|
||||
->where('visibility', Post::VISIBILITY_PUBLIC)
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('body', 'like', '%' . $query . '%')
|
||||
->orWhereHas('hashtags', fn ($hq) => $hq->where('tag', 'like', '%' . mb_strtolower($query) . '%'));
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$formatted = $paginated->getCollection()
|
||||
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'query' => $query,
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Api/Posts/PostShareController.php
Normal file
58
app/Http/Controllers/Api/Posts/PostShareController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Events\Posts\ArtworkShared;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Posts\ShareArtworkRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostShareService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostShareController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostShareService $shareService,
|
||||
private PostFeedService $feedService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/posts/share/artwork/{artwork_id}
|
||||
* payload: { body?, visibility }
|
||||
*/
|
||||
public function shareArtwork(ShareArtworkRequest $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = Artwork::findOrFail($artworkId);
|
||||
|
||||
// Rate limit: 10 artwork shares per hour
|
||||
$key = 'share_artwork:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
return response()->json([
|
||||
'message' => "You're sharing too quickly. Please wait {$seconds} seconds.",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$post = $this->shareService->shareArtwork(
|
||||
user: $user,
|
||||
artwork: $artwork,
|
||||
body: $request->input('body'),
|
||||
visibility: $request->input('visibility', 'public'),
|
||||
);
|
||||
|
||||
$post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']);
|
||||
|
||||
// Notify original artwork owner (unless self-share)
|
||||
if ($artwork->user_id !== $user->id) {
|
||||
event(new ArtworkShared($post, $artwork, $user));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'post' => $this->feedService->formatPost($post, $user->id),
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use App\Services\Posts\PostTrendingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* GET /api/feed/trending
|
||||
* GET /api/feed/hashtag/{tag}
|
||||
* GET /api/feed/hashtags/trending
|
||||
*/
|
||||
class PostTrendingFeedController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostTrendingService $trendingService,
|
||||
private PostFeedService $feedService,
|
||||
private PostHashtagService $hashtagService,
|
||||
) {}
|
||||
|
||||
public function trending(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$viewer = $request->user()?->id;
|
||||
|
||||
$result = $this->trendingService->getTrending($viewer, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewer),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
|
||||
}
|
||||
|
||||
public function hashtag(Request $request, string $tag): JsonResponse
|
||||
{
|
||||
$tag = mb_strtolower(preg_replace('/[^A-Za-z0-9_]/', '', $tag));
|
||||
if (strlen($tag) < 2 || strlen($tag) > 64) {
|
||||
return response()->json(['message' => 'Invalid hashtag.'], 422);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$viewer = $request->user()?->id;
|
||||
|
||||
$result = $this->feedService->getHashtagFeed($tag, $viewer, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewer),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'tag' => $tag,
|
||||
'data' => array_values($formatted),
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function trendingHashtags(): JsonResponse
|
||||
{
|
||||
$tags = Cache::remember('trending_hashtags', 300, function () {
|
||||
return $this->hashtagService->trending(10, 24);
|
||||
});
|
||||
|
||||
return response()->json(['hashtags' => $tags]);
|
||||
}
|
||||
}
|
||||
177
app/Http/Controllers/Api/ProfileApiController.php
Normal file
177
app/Http/Controllers/Api/ProfileApiController.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* ProfileApiController
|
||||
* JSON API endpoints for Profile page v2 tabs.
|
||||
*/
|
||||
final class ProfileApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/profile/{username}/artworks
|
||||
* Returns cursor-paginated artworks for the profile page tabs.
|
||||
* Supports: sort=latest|trending|rising|views|favs, cursor=...
|
||||
*/
|
||||
public function artworks(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$sort = $request->input('sort', 'latest');
|
||||
|
||||
$query = Artwork::with('user:id,name,username')
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at');
|
||||
|
||||
if (! $isOwner) {
|
||||
$query->where('is_public', true)->where('is_approved', true)->whereNotNull('published_at');
|
||||
}
|
||||
|
||||
$query = match ($sort) {
|
||||
'trending' => $query->orderByDesc('ranking_score'),
|
||||
'rising' => $query->orderByDesc('heat_score'),
|
||||
'views' => $query->orderByDesc('view_count'),
|
||||
'favs' => $query->orderByDesc('favourite_count'),
|
||||
default => $query->orderByDesc('published_at'),
|
||||
};
|
||||
|
||||
$perPage = 24;
|
||||
$paginator = $query->cursorPaginate($perPage);
|
||||
|
||||
$data = collect($paginator->items())->map(function (Artwork $art) {
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
return [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'username' => $art->user->username ?? null,
|
||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
||||
'published_at' => $art->published_at,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'next_cursor' => $paginator->nextCursor()?->encode(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/profile/{username}/favourites
|
||||
* Returns cursor-paginated favourites for the profile.
|
||||
*/
|
||||
public function favourites(Request $request, string $username): JsonResponse
|
||||
{
|
||||
if (! Schema::hasTable('user_favorites')) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$perPage = 24;
|
||||
$cursor = $request->input('cursor');
|
||||
|
||||
$favIds = DB::table('user_favorites as uf')
|
||||
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
|
||||
->where('uf.user_id', $user->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->orderByDesc('uf.created_at')
|
||||
->offset($cursor ? (int) base64_decode($cursor) : 0)
|
||||
->limit($perPage + 1)
|
||||
->pluck('a.id');
|
||||
|
||||
$hasMore = $favIds->count() > $perPage;
|
||||
$favIds = $favIds->take($perPage);
|
||||
|
||||
if ($favIds->isEmpty()) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$indexed = Artwork::with('user:id,name,username')
|
||||
->whereIn('id', $favIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$data = $favIds->filter(fn ($id) => $indexed->has($id))->map(function ($id) use ($indexed) {
|
||||
$art = $indexed[$id];
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
return [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'username' => $art->user->username ?? null,
|
||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'next_cursor' => null, // Simple offset pagination for now
|
||||
'has_more' => $hasMore,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/profile/{username}/stats
|
||||
* Returns profile statistics.
|
||||
*/
|
||||
public function stats(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$stats = null;
|
||||
if (Schema::hasTable('user_statistics')) {
|
||||
$stats = DB::table('user_statistics')->where('user_id', $user->id)->first();
|
||||
}
|
||||
|
||||
$followerCount = 0;
|
||||
if (Schema::hasTable('user_followers')) {
|
||||
$followerCount = DB::table('user_followers')->where('user_id', $user->id)->count();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'stats' => $stats,
|
||||
'follower_count' => $followerCount,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveUser(string $username): ?User
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,14 @@ final class UserSearchController extends Controller
|
||||
->where('is_active', 1)
|
||||
->whereNull('deleted_at')
|
||||
->where(function ($qb) use ($q) {
|
||||
$qb->whereRaw('LOWER(username) LIKE ?', ['%' . strtolower($q) . '%']);
|
||||
$qb->whereRaw('LOWER(username) LIKE ?', ['%' . strtolower($q) . '%'])
|
||||
->orWhereRaw('LOWER(name) LIKE ?', ['%' . strtolower($q) . '%']);
|
||||
})
|
||||
->with(['profile', 'statistics'])
|
||||
->orderByRaw('LOWER(username) = ? DESC', [strtolower($q)]) // exact match first
|
||||
->orderBy('username')
|
||||
->limit($perPage)
|
||||
->get(['id', 'username']);
|
||||
->get(['id', 'username', 'name']);
|
||||
|
||||
$data = $users->map(function (User $user) {
|
||||
$username = strtolower((string) ($user->username ?? ''));
|
||||
@@ -48,6 +49,7 @@ final class UserSearchController extends Controller
|
||||
'id' => $user->id,
|
||||
'type' => 'user',
|
||||
'username' => $username,
|
||||
'name' => $user->name ?? $username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $user->id, $avatarHash, 64),
|
||||
'uploads_count' => $uploadsCount,
|
||||
'profile_url' => '/@' . $username,
|
||||
|
||||
@@ -81,6 +81,7 @@ final class UploadController extends Controller
|
||||
$user = $request->user();
|
||||
$sessionId = (string) $request->validated('session_id');
|
||||
$artworkId = (int) $request->validated('artwork_id');
|
||||
$originalFileName = $request->validated('file_name');
|
||||
|
||||
$session = $sessions->getOrFail($sessionId);
|
||||
|
||||
@@ -94,6 +95,14 @@ final class UploadController extends Controller
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
if ($pipeline->originalHashExists($validated->hash)) {
|
||||
return response()->json([
|
||||
'message' => 'Duplicate upload is not allowed. This file already exists.',
|
||||
'reason' => 'duplicate_hash',
|
||||
'hash' => $validated->hash,
|
||||
], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
$scan = $pipeline->scan($sessionId);
|
||||
if (! $scan->ok) {
|
||||
return response()->json([
|
||||
@@ -103,13 +112,13 @@ final class UploadController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId) {
|
||||
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName) {
|
||||
if ((bool) config('uploads.queue_derivatives', false)) {
|
||||
GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId)->afterCommit();
|
||||
GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId, is_string($originalFileName) ? $originalFileName : null)->afterCommit();
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
$pipeline->processAndPublish($sessionId, $validated->hash, $artworkId);
|
||||
$pipeline->processAndPublish($sessionId, $validated->hash, $artworkId, is_string($originalFileName) ? $originalFileName : null);
|
||||
|
||||
// Derivatives are available now; dispatch AI auto-tagging.
|
||||
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
|
||||
@@ -476,10 +485,34 @@ final class UploadController extends Controller
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'description' => ['nullable', 'string'],
|
||||
// Scheduled-publishing fields
|
||||
'mode' => ['nullable', 'string', 'in:now,schedule'],
|
||||
'publish_at' => ['nullable', 'string', 'date'],
|
||||
'timezone' => ['nullable', 'string', 'max:64'],
|
||||
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||
]);
|
||||
|
||||
$mode = $validated['mode'] ?? 'now';
|
||||
$visibility = $validated['visibility'] ?? 'public';
|
||||
|
||||
// Resolve the UTC publish_at datetime for schedule mode
|
||||
$publishAt = null;
|
||||
if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
|
||||
try {
|
||||
$publishAt = \Carbon\Carbon::parse($validated['publish_at'])->utc();
|
||||
// Must be at least 1 minute in the future (server-side guard)
|
||||
if ($publishAt->lte(now()->addMinute())) {
|
||||
return response()->json([
|
||||
'message' => 'Scheduled publish time must be at least 1 minute in the future.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Invalid publish_at datetime.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
if (ctype_digit($id)) {
|
||||
$artworkId = (int) $id;
|
||||
$artwork = Artwork::query()->find($artworkId);
|
||||
@@ -512,12 +545,58 @@ final class UploadController extends Controller
|
||||
if (array_key_exists('description', $validated)) {
|
||||
$artwork->description = $validated['description'];
|
||||
}
|
||||
$artwork->slug = $slug;
|
||||
$artwork->is_public = true;
|
||||
$artwork->is_approved = true;
|
||||
$artwork->published_at = now();
|
||||
$artwork->slug = $slug;
|
||||
$artwork->artwork_timezone = $validated['timezone'] ?? null;
|
||||
|
||||
if ($mode === 'schedule' && $publishAt) {
|
||||
// Scheduled: store publish_at but don't make public yet
|
||||
$artwork->is_public = false;
|
||||
$artwork->is_approved = true;
|
||||
$artwork->publish_at = $publishAt;
|
||||
$artwork->artwork_status = 'scheduled';
|
||||
$artwork->published_at = null;
|
||||
$artwork->save();
|
||||
|
||||
try {
|
||||
$artwork->unsearchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to remove scheduled artwork from search index', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'scheduled',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'publish_at' => $publishAt->toISOString(),
|
||||
'published_at' => null,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
// Publish immediately
|
||||
$artwork->is_public = ($visibility !== 'private');
|
||||
$artwork->is_approved = true;
|
||||
$artwork->published_at = now();
|
||||
$artwork->artwork_status = 'published';
|
||||
$artwork->publish_at = null;
|
||||
$artwork->save();
|
||||
|
||||
try {
|
||||
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && !empty($artwork->published_at)) {
|
||||
$artwork->searchable();
|
||||
} else {
|
||||
$artwork->unsearchable();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to sync artwork search index after publish', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Record upload activity event
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
@@ -529,10 +608,10 @@ final class UploadController extends Controller
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'published',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'published',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'published_at' => optional($artwork->published_at)->toISOString(),
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
@@ -541,11 +620,11 @@ final class UploadController extends Controller
|
||||
$upload = $publishService->publish($id, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'upload_id' => (string) $upload->id,
|
||||
'status' => (string) $upload->status,
|
||||
'success' => true,
|
||||
'upload_id' => (string) $upload->id,
|
||||
'status' => (string) $upload->status,
|
||||
'published_at' => optional($upload->published_at)->toISOString(),
|
||||
'final_path' => (string) ($upload->final_path ?? ''),
|
||||
'final_path' => (string) ($upload->final_path ?? ''),
|
||||
], Response::HTTP_OK);
|
||||
} catch (UploadOwnershipException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);
|
||||
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\View\View;
|
||||
use App\Support\AvatarUrl;
|
||||
|
||||
class FavoriteController extends Controller
|
||||
{
|
||||
@@ -36,16 +37,41 @@ class FavoriteController extends Controller
|
||||
|
||||
$artworks = collect();
|
||||
if ($slice !== []) {
|
||||
$arts = Artwork::query()->whereIn('id', $slice)->with('user')->get()->keyBy('id');
|
||||
$arts = Artwork::query()
|
||||
->whereIn('id', $slice)
|
||||
->with(['user.profile', 'categories'])
|
||||
->withCount(['favourites', 'comments'])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($slice as $id) {
|
||||
$a = $arts->get($id);
|
||||
if (! $a) continue;
|
||||
|
||||
$primaryCategory = $a->categories->sortBy('sort_order')->first();
|
||||
$username = $a->user?->username ?? $a->user?->name ?? '';
|
||||
|
||||
$artworks->push((object) [
|
||||
'id' => $a->id,
|
||||
'name' => $a->title,
|
||||
'title' => $a->title,
|
||||
'thumb' => $a->thumbUrl('md') ?? $a->thumbnail_url ?? null,
|
||||
'thumb_url' => $a->thumbUrl('md') ?? $a->thumbnail_url ?? null,
|
||||
'slug' => $a->slug,
|
||||
'author' => $a->user?->username ?? $a->user?->name,
|
||||
'author' => $username,
|
||||
'uname' => $username,
|
||||
'username' => $a->user?->username ?? '',
|
||||
'avatar_url' => AvatarUrl::forUser(
|
||||
(int) ($a->user_id ?? 0),
|
||||
$a->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
),
|
||||
'category_name' => $primaryCategory->name ?? '',
|
||||
'category_slug' => $primaryCategory->slug ?? '',
|
||||
'width' => $a->width,
|
||||
'height' => $a->height,
|
||||
'likes' => (int) ($a->favourites_count ?? $a->likes ?? 0),
|
||||
'comments_count' => (int) ($a->comments_count ?? 0),
|
||||
'published_at' => $a->published_at,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ class ForumController extends Controller
|
||||
'last_update' => $item->last_post_at ?? $item->created_at,
|
||||
'uname' => $item->user?->name,
|
||||
'num_posts' => (int) ($item->posts_count ?? 0),
|
||||
'is_pinned' => (bool) $item->is_pinned,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Services\Studio\StudioBulkActionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
|
||||
@@ -318,15 +319,17 @@ final class StudioArtworksApiController extends Controller
|
||||
$storage = app(\App\Services\Uploads\UploadStorageService::class);
|
||||
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
|
||||
|
||||
// 1. Store original on disk
|
||||
// 1. Store original on disk (preserve extension when possible)
|
||||
$originalPath = $derivatives->storeOriginal($tempPath, $hash);
|
||||
$originalRelative = $storage->sectionRelativePath('originals', $hash, 'orig.webp');
|
||||
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath));
|
||||
$origFilename = basename($originalPath);
|
||||
$originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename);
|
||||
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
|
||||
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
|
||||
|
||||
// 2. Generate public derivatives (thumbnails)
|
||||
// 2. Generate thumbnails (xs/sm/md/lg/xl)
|
||||
$publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash);
|
||||
foreach ($publicAbsolute as $variant => $absolutePath) {
|
||||
$relativePath = $storage->publicRelativePath($hash, $variant . '.webp');
|
||||
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp');
|
||||
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
|
||||
}
|
||||
|
||||
@@ -337,13 +340,14 @@ final class StudioArtworksApiController extends Controller
|
||||
$size = (int) filesize($originalPath);
|
||||
|
||||
// 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
|
||||
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
|
||||
$artwork->update([
|
||||
'file_name' => 'orig.webp',
|
||||
'file_name' => $origFilename,
|
||||
'file_path' => '',
|
||||
'file_size' => $size,
|
||||
'mime_type' => 'image/webp',
|
||||
'mime_type' => $origMime,
|
||||
'hash' => $hash,
|
||||
'file_ext' => 'webp',
|
||||
'file_ext' => $origExt,
|
||||
'thumb_ext' => 'webp',
|
||||
'width' => max(1, $width),
|
||||
'height' => max(1, $height),
|
||||
|
||||
@@ -25,6 +25,7 @@ use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
@@ -220,6 +221,10 @@ class ProfileController extends Controller
|
||||
$profileUpdates['friend_upload_notice'] = filter_var($validated['notify'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (array_key_exists('auto_post_upload', $validated)) {
|
||||
$profileUpdates['auto_post_upload'] = filter_var($validated['auto_post_upload'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($validated['signature'])) $profileUpdates['signature'] = $validated['signature'];
|
||||
if (isset($validated['description'])) $profileUpdates['description'] = $validated['description'];
|
||||
|
||||
@@ -498,24 +503,70 @@ class ProfileController extends Controller
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
return response()->view('legacy::profile', [
|
||||
'user' => $user,
|
||||
'profile' => $profile,
|
||||
'artworks' => $artworks,
|
||||
'featuredArtworks' => $featuredArtworks,
|
||||
'favourites' => $favourites,
|
||||
// ── Normalise artworks for JSON serialisation ────────────────────
|
||||
$artworkItems = collect($artworks->items())->values();
|
||||
$artworkPayload = [
|
||||
'data' => $artworkItems,
|
||||
'next_cursor' => $artworks->nextCursor()?->encode(),
|
||||
'has_more' => $artworks->hasMorePages(),
|
||||
];
|
||||
|
||||
// ── Avatar URL on user object ────────────────────────────────────
|
||||
$avatarUrl = AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 128);
|
||||
|
||||
// ── Auth context for JS ───────────────────────────────────────────
|
||||
$authData = null;
|
||||
if (Auth::check()) {
|
||||
/** @var \App\Models\User $authUser */
|
||||
$authUser = Auth::user();
|
||||
$authAvatarUrl = AvatarUrl::forUser((int) $authUser->id, $authUser->profile?->avatar_hash, 64);
|
||||
$authData = [
|
||||
'user' => [
|
||||
'id' => $authUser->id,
|
||||
'username' => $authUser->username,
|
||||
'name' => $authUser->name,
|
||||
'avatar' => $authAvatarUrl,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$canonical = url('/@' . strtolower((string) ($user->username ?? '')));
|
||||
|
||||
return Inertia::render('Profile/ProfileShow', [
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'name' => $user->name,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'created_at' => $user->created_at?->toISOString(),
|
||||
'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null,
|
||||
],
|
||||
'profile' => $profile ? [
|
||||
'about' => $profile->about ?? null,
|
||||
'website' => $profile->website ?? null,
|
||||
'country_code' => $profile->country_code ?? null,
|
||||
'gender' => $profile->gender ?? null,
|
||||
'birthdate' => $profile->birthdate ?? null,
|
||||
'cover_image' => $profile->cover_image ?? null,
|
||||
] : null,
|
||||
'artworks' => $artworkPayload,
|
||||
'featuredArtworks' => $featuredArtworks->values(),
|
||||
'favourites' => $favourites->values(),
|
||||
'stats' => $stats,
|
||||
'socialLinks' => $socialLinks,
|
||||
'followerCount' => $followerCount,
|
||||
'recentFollowers' => $recentFollowers,
|
||||
'recentFollowers' => $recentFollowers->values(),
|
||||
'viewerIsFollowing' => $viewerIsFollowing,
|
||||
'heroBgUrl' => $heroBgUrl,
|
||||
'profileComments' => $profileComments,
|
||||
'profileComments' => $profileComments->values(),
|
||||
'countryName' => $countryName,
|
||||
'isOwner' => $isOwner,
|
||||
'page_title' => 'Profile: ' . ($user->username ?? $user->name ?? ''),
|
||||
'page_canonical' => url('/@' . strtolower((string) ($user->username ?? ''))),
|
||||
'auth' => $authData,
|
||||
])->withViewData([
|
||||
'page_title' => ($user->username ?? $user->name ?? 'User') . ' on Skinbase',
|
||||
'page_canonical' => $canonical,
|
||||
'page_meta_description' => 'View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.',
|
||||
'og_image' => $avatarUrl,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
30
app/Http/Controllers/Web/Posts/FollowingFeedController.php
Normal file
30
app/Http/Controllers/Web/Posts/FollowingFeedController.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class FollowingFeedController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /feed/following
|
||||
* Renders the Following Feed Inertia page.
|
||||
* Actual data is loaded client-side via GET /api/posts/following
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Feed/FollowingFeed', [
|
||||
'auth' => [
|
||||
'user' => $request->user() ? [
|
||||
'id' => $request->user()->id,
|
||||
'username' => $request->user()->username,
|
||||
'name' => $request->user()->name,
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
] : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/Web/Posts/HashtagFeedController.php
Normal file
27
app/Http/Controllers/Web/Posts/HashtagFeedController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class HashtagFeedController extends Controller
|
||||
{
|
||||
/** GET /tags/{tag} */
|
||||
public function index(Request $request, string $tag): Response
|
||||
{
|
||||
return Inertia::render('Feed/HashtagFeed', [
|
||||
'auth' => $request->user() ? [
|
||||
'user' => [
|
||||
'id' => $request->user()->id,
|
||||
'username' => $request->user()->username,
|
||||
'name' => $request->user()->name,
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
] : null,
|
||||
'tag' => strtolower($tag),
|
||||
]);
|
||||
}
|
||||
}
|
||||
26
app/Http/Controllers/Web/Posts/SavedFeedController.php
Normal file
26
app/Http/Controllers/Web/Posts/SavedFeedController.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SavedFeedController extends Controller
|
||||
{
|
||||
/** GET /feed/saved */
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Feed/SavedFeed', [
|
||||
'auth' => [
|
||||
'user' => [
|
||||
'id' => $request->user()->id,
|
||||
'username' => $request->user()->username,
|
||||
'name' => $request->user()->name,
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Web/Posts/SearchFeedController.php
Normal file
38
app/Http/Controllers/Web/Posts/SearchFeedController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SearchFeedController extends Controller
|
||||
{
|
||||
public function __construct(private PostHashtagService $hashtagService) {}
|
||||
|
||||
/** GET /feed/search */
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$trendingHashtags = Cache::remember(
|
||||
'trending_hashtags',
|
||||
300,
|
||||
fn () => $this->hashtagService->trending(10, 24)
|
||||
);
|
||||
|
||||
return Inertia::render('Feed/SearchFeed', [
|
||||
'auth' => $request->user() ? [
|
||||
'user' => [
|
||||
'id' => $request->user()->id,
|
||||
'username' => $request->user()->username,
|
||||
'name' => $request->user()->name,
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
] : null,
|
||||
'initialQuery' => $request->query('q', ''),
|
||||
'trendingHashtags' => $trendingHashtags,
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
app/Http/Controllers/Web/Posts/TrendingFeedController.php
Normal file
33
app/Http/Controllers/Web/Posts/TrendingFeedController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class TrendingFeedController extends Controller
|
||||
{
|
||||
public function __construct(private PostHashtagService $hashtagService) {}
|
||||
|
||||
/** GET /feed/trending */
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$trendingHashtags = Cache::remember('trending_hashtags', 300, fn () => $this->hashtagService->trending(10, 24));
|
||||
|
||||
return Inertia::render('Feed/TrendingFeed', [
|
||||
'auth' => $request->user() ? [
|
||||
'user' => [
|
||||
'id' => $request->user()->id,
|
||||
'username' => $request->user()->username,
|
||||
'name' => $request->user()->name,
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
] : null,
|
||||
'trendingHashtags' => $trendingHashtags,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,29 @@ final class HandleInertiaRequests extends Middleware
|
||||
return 'studio';
|
||||
}
|
||||
|
||||
// Profile pages: /@{username}
|
||||
if (str_starts_with($request->path(), '@')) {
|
||||
return 'profile.show';
|
||||
}
|
||||
|
||||
// Feed pages — ordered most-specific first
|
||||
if ($request->path() === 'feed/trending') {
|
||||
return 'feed.trending';
|
||||
}
|
||||
|
||||
if ($request->path() === 'feed/saved') {
|
||||
return 'feed.saved';
|
||||
}
|
||||
|
||||
if (str_starts_with($request->path(), 'feed')) {
|
||||
return 'feed.following';
|
||||
}
|
||||
|
||||
// Hashtag pages: /tags/{tag}
|
||||
if (str_starts_with($request->path(), 'tags/')) {
|
||||
return 'feed.hashtag';
|
||||
}
|
||||
|
||||
return $this->rootView;
|
||||
}
|
||||
|
||||
|
||||
27
app/Http/Requests/Posts/CreateCommentRequest.php
Normal file
27
app/Http/Requests/Posts/CreateCommentRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Posts;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CreateCommentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'body' => ['required', 'string', 'min:1', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'body.max' => 'Comment cannot exceed 1,000 characters.',
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Http/Requests/Posts/CreatePostRequest.php
Normal file
43
app/Http/Requests/Posts/CreatePostRequest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Posts;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CreatePostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', 'string', 'in:text,artwork_share,upload,achievement'],
|
||||
'visibility' => ['required', 'string', 'in:public,followers,private'],
|
||||
'body' => ['nullable', 'string', 'max:2000'],
|
||||
'targets' => ['nullable', 'array', 'max:1'],
|
||||
'targets.*.type' => ['required_with:targets', 'string', 'in:artwork'],
|
||||
'targets.*.id' => ['required_with:targets', 'integer', 'min:1'],
|
||||
'link_preview' => ['nullable', 'array'],
|
||||
'link_preview.url' => ['nullable', 'string', 'url', 'max:2048'],
|
||||
'link_preview.title' => ['nullable', 'string', 'max:300'],
|
||||
'link_preview.description' => ['nullable', 'string', 'max:500'],
|
||||
'link_preview.image' => ['nullable', 'string', 'url', 'max:2048'],
|
||||
'link_preview.site_name' => ['nullable', 'string', 'max:100'],
|
||||
'tagged_users' => ['nullable', 'array', 'max:10'],
|
||||
'tagged_users.*.id' => ['required_with:tagged_users', 'integer', 'min:1'],
|
||||
'tagged_users.*.username' => ['required_with:tagged_users', 'string', 'max:50'],
|
||||
'tagged_users.*.name' => ['nullable', 'string', 'max:100'],
|
||||
'publish_at' => ['nullable', 'date', 'after:now'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'body.max' => 'Post body cannot exceed 2,000 characters.',
|
||||
];
|
||||
}
|
||||
}
|
||||
21
app/Http/Requests/Posts/ShareArtworkRequest.php
Normal file
21
app/Http/Requests/Posts/ShareArtworkRequest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Posts;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ShareArtworkRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'body' => ['nullable', 'string', 'max:2000'],
|
||||
'visibility' => ['required', 'string', 'in:public,followers,private'],
|
||||
];
|
||||
}
|
||||
}
|
||||
21
app/Http/Requests/Posts/UpdatePostRequest.php
Normal file
21
app/Http/Requests/Posts/UpdatePostRequest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Posts;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdatePostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'body' => ['nullable', 'string', 'max:2000'],
|
||||
'visibility' => ['nullable', 'string', 'in:public,followers,private'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class ProfileUpdateRequest extends FormRequest
|
||||
'country' => ['nullable', 'string', 'max:10'],
|
||||
'mailing' => ['nullable', 'boolean'],
|
||||
'notify' => ['nullable', 'boolean'],
|
||||
'auto_post_upload' => ['nullable', 'boolean'],
|
||||
'about' => ['nullable', 'string'],
|
||||
'signature' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string'],
|
||||
|
||||
@@ -78,6 +78,7 @@ final class UploadFinishRequest extends FormRequest
|
||||
'session_id' => 'required|uuid',
|
||||
'artwork_id' => 'required|integer',
|
||||
'upload_token' => 'nullable|string|min:40|max:200',
|
||||
'file_name' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -23,13 +23,14 @@ final class GenerateDerivativesJob implements ShouldQueue
|
||||
public function __construct(
|
||||
private readonly string $sessionId,
|
||||
private readonly string $hash,
|
||||
private readonly int $artworkId
|
||||
private readonly int $artworkId,
|
||||
private readonly ?string $originalFileName = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(UploadPipelineService $pipeline): void
|
||||
{
|
||||
$pipeline->processAndPublish($this->sessionId, $this->hash, $this->artworkId);
|
||||
$pipeline->processAndPublish($this->sessionId, $this->hash, $this->artworkId, $this->originalFileName);
|
||||
|
||||
// Auto-tagging is async and must never block publish.
|
||||
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
||||
|
||||
70
app/Jobs/Posts/AutoUploadPostJob.php
Normal file
70
app/Jobs/Posts/AutoUploadPostJob.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Posts;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTarget;
|
||||
use App\Models\User;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Creates a feed post of type=upload when an artwork is published.
|
||||
* Dispatched from ArtworkObserver when auto_post_upload is enabled for the user.
|
||||
*/
|
||||
class AutoUploadPostJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $artworkId,
|
||||
public readonly int $userId,
|
||||
) {}
|
||||
|
||||
public function handle(PostHashtagService $hashtagService): void
|
||||
{
|
||||
$artwork = Artwork::find($this->artworkId);
|
||||
$user = User::find($this->userId);
|
||||
|
||||
if (! $artwork || ! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If post already exists for this artwork, skip (idempotent)
|
||||
$exists = Post::where('user_id', $user->id)
|
||||
->where('type', Post::TYPE_UPLOAD)
|
||||
->whereHas('targets', fn ($q) => $q->where('target_type', 'artwork')->where('target_id', $artwork->id))
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($artwork, $user, $hashtagService) {
|
||||
$post = Post::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => Post::TYPE_UPLOAD,
|
||||
'visibility' => Post::VISIBILITY_PUBLIC,
|
||||
'body' => null,
|
||||
'status' => Post::STATUS_PUBLISHED,
|
||||
]);
|
||||
|
||||
PostTarget::create([
|
||||
'post_id' => $post->id,
|
||||
'target_type' => 'artwork',
|
||||
'target_id' => $artwork->id,
|
||||
]);
|
||||
});
|
||||
|
||||
Log::info("AutoUploadPostJob: created upload post for artwork #{$this->artworkId} by user #{$this->userId}");
|
||||
}
|
||||
}
|
||||
27
app/Listeners/Posts/SendArtworkSharedNotification.php
Normal file
27
app/Listeners/Posts/SendArtworkSharedNotification.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Posts;
|
||||
|
||||
use App\Events\Posts\ArtworkShared;
|
||||
use App\Notifications\ArtworkSharedNotification;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
||||
class SendArtworkSharedNotification implements ShouldQueue
|
||||
{
|
||||
public function handle(ArtworkShared $event): void
|
||||
{
|
||||
// Notify the artwork's original owner
|
||||
$originalOwner = $event->artwork->user;
|
||||
|
||||
// Don't notify if sharer is the owner
|
||||
if ($originalOwner->id === $event->sharer->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$originalOwner->notify(new ArtworkSharedNotification(
|
||||
post: $event->post,
|
||||
artwork: $event->artwork,
|
||||
sharer: $event->sharer,
|
||||
));
|
||||
}
|
||||
}
|
||||
27
app/Listeners/Posts/SendPostCommentedNotification.php
Normal file
27
app/Listeners/Posts/SendPostCommentedNotification.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Posts;
|
||||
|
||||
use App\Events\Posts\PostCommented;
|
||||
use App\Notifications\PostCommentedNotification;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
||||
class SendPostCommentedNotification implements ShouldQueue
|
||||
{
|
||||
public function handle(PostCommented $event): void
|
||||
{
|
||||
// Notify the post owner
|
||||
$postOwner = $event->post->user;
|
||||
|
||||
// Don't notify for self-comments
|
||||
if ($postOwner->id === $event->commenter->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$postOwner->notify(new PostCommentedNotification(
|
||||
post: $event->post,
|
||||
comment: $event->comment,
|
||||
commenter: $event->commenter,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -54,12 +54,17 @@ class Artwork extends Model
|
||||
'version_count',
|
||||
'version_updated_at',
|
||||
'requires_reapproval',
|
||||
// Scheduled publishing
|
||||
'publish_at',
|
||||
'artwork_status',
|
||||
'artwork_timezone',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_public' => 'boolean',
|
||||
'is_approved' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'publish_at' => 'datetime',
|
||||
'version_updated_at' => 'datetime',
|
||||
'requires_reapproval' => 'boolean',
|
||||
];
|
||||
|
||||
185
app/Models/Post.php
Normal file
185
app/Models/Post.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $type text | artwork_share | upload | achievement
|
||||
* @property string $visibility public | followers | private
|
||||
* @property string|null $body
|
||||
* @property array|null $meta
|
||||
* @property int $reactions_count
|
||||
* @property int $comments_count
|
||||
* @property string $status draft | scheduled | published
|
||||
* @property bool $is_pinned
|
||||
* @property int|null $pinned_order
|
||||
* @property \Carbon\Carbon|null $publish_at
|
||||
* @property int $impressions_count
|
||||
* @property float $engagement_score
|
||||
* @property int $saves_count
|
||||
*/
|
||||
class Post extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, Searchable;
|
||||
|
||||
protected $table = 'posts';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'type', 'visibility', 'body', 'meta',
|
||||
'reactions_count', 'comments_count',
|
||||
'is_pinned', 'pinned_order',
|
||||
'publish_at', 'status',
|
||||
'impressions_count', 'engagement_score', 'saves_count',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'meta' => 'array',
|
||||
'reactions_count' => 'integer',
|
||||
'comments_count' => 'integer',
|
||||
'impressions_count' => 'integer',
|
||||
'saves_count' => 'integer',
|
||||
'engagement_score' => 'float',
|
||||
'is_pinned' => 'boolean',
|
||||
'pinned_order' => 'integer',
|
||||
'publish_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public const TYPE_TEXT = 'text';
|
||||
public const TYPE_ARTWORK_SHARE = 'artwork_share';
|
||||
public const TYPE_UPLOAD = 'upload';
|
||||
public const TYPE_ACHIEVEMENT = 'achievement';
|
||||
|
||||
public const VISIBILITY_PUBLIC = 'public';
|
||||
public const VISIBILITY_FOLLOWERS = 'followers';
|
||||
public const VISIBILITY_PRIVATE = 'private';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_SCHEDULED = 'scheduled';
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Relationships
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function targets(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostTarget::class);
|
||||
}
|
||||
|
||||
/** Convenience: single artwork target for artwork_share posts */
|
||||
public function artworkTarget(): HasOne
|
||||
{
|
||||
return $this->hasOne(PostTarget::class)->where('target_type', 'artwork');
|
||||
}
|
||||
|
||||
public function reactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostReaction::class);
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostComment::class)->orderBy('created_at');
|
||||
}
|
||||
|
||||
public function saves(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostSave::class);
|
||||
}
|
||||
|
||||
public function reports(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostReport::class);
|
||||
}
|
||||
|
||||
public function hashtags(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostHashtag::class);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Scopes
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Posts visible to any public (non-authenticated) visitor */
|
||||
public function scopePublic($query)
|
||||
{
|
||||
return $query->where('visibility', self::VISIBILITY_PUBLIC);
|
||||
}
|
||||
|
||||
/** Only published posts */
|
||||
public function scopePublished($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PUBLISHED);
|
||||
}
|
||||
|
||||
/** Only scheduled posts */
|
||||
public function scopeScheduled($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_SCHEDULED);
|
||||
}
|
||||
|
||||
/** Posts visible to the given viewer (respects followers-only AND published status) */
|
||||
public function scopeVisibleTo($query, ?int $viewerId)
|
||||
{
|
||||
$query->where('status', self::STATUS_PUBLISHED);
|
||||
|
||||
if (! $viewerId) {
|
||||
return $query->where('visibility', self::VISIBILITY_PUBLIC);
|
||||
}
|
||||
|
||||
return $query->where(function ($q) use ($viewerId) {
|
||||
$q->where('visibility', self::VISIBILITY_PUBLIC)
|
||||
->orWhere('user_id', $viewerId)
|
||||
->orWhere(function ($q2) use ($viewerId) {
|
||||
$q2->where('visibility', self::VISIBILITY_FOLLOWERS)
|
||||
->whereIn('user_id', function ($sub) use ($viewerId) {
|
||||
$sub->select('user_id')
|
||||
->from('user_followers')
|
||||
->where('follower_id', $viewerId);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Scout (Meilisearch)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'body' => strip_tags($this->body ?? ''),
|
||||
'hashtags' => $this->hashtags->pluck('tag')->toArray(),
|
||||
'user_id' => $this->user_id,
|
||||
'type' => $this->type,
|
||||
'visibility' => $this->visibility,
|
||||
'created_at' => $this->created_at?->timestamp,
|
||||
];
|
||||
}
|
||||
|
||||
public function shouldBeSearchable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PUBLISHED
|
||||
&& $this->visibility === self::VISIBILITY_PUBLIC;
|
||||
}
|
||||
}
|
||||
35
app/Models/PostComment.php
Normal file
35
app/Models/PostComment.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PostComment extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'post_comments';
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'user_id',
|
||||
'body',
|
||||
'is_highlighted',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_highlighted' => 'boolean',
|
||||
];
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
31
app/Models/PostHashtag.php
Normal file
31
app/Models/PostHashtag.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PostHashtag extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'post_hashtags';
|
||||
|
||||
protected $fillable = ['post_id', 'user_id', 'tag', 'created_at'];
|
||||
|
||||
// Store tag always lowercase for consistency
|
||||
public function setTagAttribute(string $value): void
|
||||
{
|
||||
$this->attributes['tag'] = mb_strtolower($value);
|
||||
}
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
27
app/Models/PostReaction.php
Normal file
27
app/Models/PostReaction.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PostReaction extends Model
|
||||
{
|
||||
protected $table = 'post_reactions';
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'user_id',
|
||||
'reaction',
|
||||
];
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
29
app/Models/PostReport.php
Normal file
29
app/Models/PostReport.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PostReport extends Model
|
||||
{
|
||||
protected $table = 'post_reports';
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'reporter_user_id',
|
||||
'reason',
|
||||
'message',
|
||||
'status',
|
||||
];
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function reporter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reporter_user_id');
|
||||
}
|
||||
}
|
||||
35
app/Models/PostSave.php
Normal file
35
app/Models/PostSave.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PostSave extends Model
|
||||
{
|
||||
protected $table = 'post_saves';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
const CREATED_AT = 'created_at';
|
||||
const UPDATED_AT = null;
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
41
app/Models/PostTarget.php
Normal file
41
app/Models/PostTarget.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Polymorphic-style target attached to a Post.
|
||||
* For v1: target_type = 'artwork', target_id = artworks.id
|
||||
*/
|
||||
class PostTarget extends Model
|
||||
{
|
||||
protected $table = 'post_targets';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'target_type',
|
||||
'target_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
const CREATED_AT = 'created_at';
|
||||
const UPDATED_AT = null;
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
/** Resolved Artwork when target_type = 'artwork' */
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class, 'target_id');
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,11 @@ class User extends Authenticatable
|
||||
return $this->hasRole('moderator');
|
||||
}
|
||||
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Post::class)->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
// ─── Meilisearch ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,11 +29,13 @@ class UserProfile extends Model
|
||||
'birthdate',
|
||||
'gender',
|
||||
'website',
|
||||
'auto_post_upload',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'birthdate' => 'date',
|
||||
'avatar_updated_at' => 'datetime',
|
||||
'birthdate' => 'date',
|
||||
'avatar_updated_at'=> 'datetime',
|
||||
'auto_post_upload' => 'boolean',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
41
app/Notifications/ArtworkSharedNotification.php
Normal file
41
app/Notifications/ArtworkSharedNotification.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ArtworkSharedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Post $post,
|
||||
public readonly Artwork $artwork,
|
||||
public readonly User $sharer,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'type' => 'artwork_shared',
|
||||
'post_id' => $this->post->id,
|
||||
'artwork_id' => $this->artwork->id,
|
||||
'artwork_title' => $this->artwork->title,
|
||||
'sharer_id' => $this->sharer->id,
|
||||
'sharer_name' => $this->sharer->name,
|
||||
'sharer_username' => $this->sharer->username,
|
||||
'message' => "{$this->sharer->name} shared your artwork "{$this->artwork->title}"",
|
||||
'url' => "/@{$this->sharer->username}?tab=posts",
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Notifications/PostCommentedNotification.php
Normal file
40
app/Notifications/PostCommentedNotification.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class PostCommentedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Post $post,
|
||||
public readonly PostComment $comment,
|
||||
public readonly User $commenter,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'type' => 'post_commented',
|
||||
'post_id' => $this->post->id,
|
||||
'comment_id' => $this->comment->id,
|
||||
'commenter_id' => $this->commenter->id,
|
||||
'commenter_name' => $this->commenter->name,
|
||||
'commenter_username' => $this->commenter->username,
|
||||
'message' => "{$this->commenter->name} commented on your post",
|
||||
'url' => "/@{$this->post->user->username}?tab=posts",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Observers;
|
||||
use App\Models\Artwork;
|
||||
use App\Jobs\RecComputeSimilarByTagsJob;
|
||||
use App\Jobs\RecComputeSimilarHybridJob;
|
||||
use App\Jobs\Posts\AutoUploadPostJob;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\UserStatsService;
|
||||
|
||||
@@ -48,6 +49,16 @@ class ArtworkObserver
|
||||
if ($artwork->is_public && $artwork->published_at) {
|
||||
RecComputeSimilarByTagsJob::dispatch($artwork->id)->delay(now()->addSeconds(30));
|
||||
RecComputeSimilarHybridJob::dispatch($artwork->id)->delay(now()->addMinutes(1));
|
||||
|
||||
// Auto-upload post: fire only when artwork transitions to published for the first time
|
||||
if ($artwork->wasChanged('published_at') && $artwork->published_at !== null) {
|
||||
$user = $artwork->user;
|
||||
$autoPost = $user?->profile?->auto_post_upload ?? true;
|
||||
if ($autoPost) {
|
||||
AutoUploadPostJob::dispatch($artwork->id, $artwork->user_id)
|
||||
->delay(now()->addSeconds(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
app/Policies/PostCommentPolicy.php
Normal file
16
app/Policies/PostCommentPolicy.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\PostComment;
|
||||
use App\Models\User;
|
||||
|
||||
class PostCommentPolicy
|
||||
{
|
||||
public function delete(User $user, PostComment $comment): bool
|
||||
{
|
||||
return $user->id === $comment->user_id
|
||||
|| $user->isAdmin()
|
||||
|| $user->isModerator();
|
||||
}
|
||||
}
|
||||
75
app/Policies/PostPolicy.php
Normal file
75
app/Policies/PostPolicy.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
|
||||
class PostPolicy
|
||||
{
|
||||
/** Any authenticated user can create posts */
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Only the post author can update */
|
||||
public function update(User $user, Post $post): bool
|
||||
{
|
||||
return $user->id === $post->user_id;
|
||||
}
|
||||
|
||||
/** Author or admin/moderator can delete */
|
||||
public function delete(User $user, Post $post): bool
|
||||
{
|
||||
return $user->id === $post->user_id
|
||||
|| $user->isAdmin()
|
||||
|| $user->isModerator();
|
||||
}
|
||||
|
||||
/** Anyone can view public posts; followers-only requires following */
|
||||
public function view(?User $user, Post $post): bool
|
||||
{
|
||||
if ($post->visibility === Post::VISIBILITY_PUBLIC) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->id === $post->user_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($post->visibility === Post::VISIBILITY_FOLLOWERS) {
|
||||
return $post->user->isFollowedBy($user->id);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Only the author can report their own posts */
|
||||
public function report(User $user, Post $post): bool
|
||||
{
|
||||
return $user->id !== $post->user_id;
|
||||
}
|
||||
|
||||
/** Only the post owner can pin/unpin their own post */
|
||||
public function pin(User $user, Post $post): bool
|
||||
{
|
||||
return $user->id === $post->user_id;
|
||||
}
|
||||
|
||||
/** Any authenticated user can save a post (own or others') */
|
||||
public function save(User $user, Post $post): bool
|
||||
{
|
||||
return $post->status === Post::STATUS_PUBLISHED;
|
||||
}
|
||||
|
||||
/** Only post owner may highlight a comment */
|
||||
public function highlightComment(User $user, Post $post): bool
|
||||
{
|
||||
return $user->id === $post->user_id;
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,16 @@ class AppServiceProvider extends ServiceProvider
|
||||
ArtworkComment::observe(ArtworkCommentObserver::class);
|
||||
ArtworkReaction::observe(ArtworkReactionObserver::class);
|
||||
|
||||
// ── Posts / Feed System Events ──────────────────────────────────────
|
||||
Event::listen(
|
||||
\App\Events\Posts\ArtworkShared::class,
|
||||
\App\Listeners\Posts\SendArtworkSharedNotification::class,
|
||||
);
|
||||
Event::listen(
|
||||
\App\Events\Posts\PostCommented::class,
|
||||
\App\Listeners\Posts\SendPostCommentedNotification::class,
|
||||
);
|
||||
|
||||
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
|
||||
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
|
||||
$uploadCount = $favCount = $msgCount = $noticeCount = 0;
|
||||
|
||||
@@ -6,21 +6,22 @@ use Illuminate\Support\Facades\Gate;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Policies\ArtworkPolicy;
|
||||
use App\Policies\ArtworkAwardPolicy;
|
||||
use App\Policies\ArtworkCommentPolicy;
|
||||
use App\Policies\PostPolicy;
|
||||
use App\Policies\PostCommentPolicy;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The policy mappings for the application.
|
||||
*
|
||||
* @var array<class-string, class-string>
|
||||
*/
|
||||
protected $policies = [
|
||||
Artwork::class => ArtworkPolicy::class,
|
||||
ArtworkAward::class => ArtworkAwardPolicy::class,
|
||||
ArtworkComment::class => ArtworkCommentPolicy::class,
|
||||
Post::class => PostPolicy::class,
|
||||
PostComment::class => PostCommentPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
115
app/Services/Posts/NotificationDigestService.php
Normal file
115
app/Services/Posts/NotificationDigestService.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Collection as BaseCollection;
|
||||
|
||||
/**
|
||||
* Aggregates database notification records into digest groups
|
||||
* so the UI shows "12 people liked your post" instead of 12 separate entries.
|
||||
*
|
||||
* Grouping key: (notifiable_id, data->type, data->post_id, 1-hour bucket)
|
||||
*/
|
||||
class NotificationDigestService
|
||||
{
|
||||
/**
|
||||
* Aggregate a raw notification collection into digest entries.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Collection $notifications
|
||||
* @return array<array{
|
||||
* type: string,
|
||||
* count: int,
|
||||
* actors: array,
|
||||
* post_id: int|null,
|
||||
* message: string,
|
||||
* url: string|null,
|
||||
* latest_at: string,
|
||||
* read: bool,
|
||||
* ids: int[],
|
||||
* }>
|
||||
*/
|
||||
public function aggregate(Collection $notifications): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($notifications as $notif) {
|
||||
$data = is_array($notif->data) ? $notif->data : json_decode($notif->data, true);
|
||||
$type = $data['type'] ?? 'unknown';
|
||||
$postId = $data['post_id'] ?? null;
|
||||
|
||||
// 1-hour bucket
|
||||
$bucket = $notif->created_at->format('Y-m-d H');
|
||||
|
||||
$key = "{$type}:{$postId}:{$bucket}";
|
||||
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'type' => $type,
|
||||
'count' => 0,
|
||||
'actors' => [],
|
||||
'post_id' => $postId,
|
||||
'url' => $data['url'] ?? null,
|
||||
'latest_at' => $notif->created_at->toISOString(),
|
||||
'read' => $notif->read_at !== null,
|
||||
'ids' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$groups[$key]['count']++;
|
||||
$groups[$key]['ids'][] = $notif->id;
|
||||
|
||||
// Collect unique actors (up to 5 for display)
|
||||
$actorId = $data['commenter_id'] ?? $data['reactor_id'] ?? $data['actor_id'] ?? null;
|
||||
if ($actorId && count($groups[$key]['actors']) < 5) {
|
||||
$alreadyAdded = collect($groups[$key]['actors'])->contains('id', $actorId);
|
||||
if (! $alreadyAdded) {
|
||||
$groups[$key]['actors'][] = [
|
||||
'id' => $actorId,
|
||||
'name' => $data['commenter_name'] ?? $data['actor_name'] ?? null,
|
||||
'username' => $data['commenter_username'] ?? $data['actor_username'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($notif->read_at === null) {
|
||||
$groups[$key]['read'] = false; // group is unread if any item is unread
|
||||
}
|
||||
}
|
||||
|
||||
// Build readable message for each group
|
||||
$result = [];
|
||||
foreach ($groups as $group) {
|
||||
$group['message'] = $this->buildMessage($group);
|
||||
$result[] = $group;
|
||||
}
|
||||
|
||||
// Sort by latest_at descending
|
||||
usort($result, fn ($a, $b) => strcmp($b['latest_at'], $a['latest_at']));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function buildMessage(array $group): string
|
||||
{
|
||||
$count = $group['count'];
|
||||
$actors = $group['actors'];
|
||||
$first = $actors[0]['name'] ?? 'Someone';
|
||||
$others = $count - 1;
|
||||
|
||||
return match ($group['type']) {
|
||||
'post_commented' => $count === 1
|
||||
? "{$first} commented on your post"
|
||||
: "{$first} and {$others} other(s) commented on your post",
|
||||
'post_liked' => $count === 1
|
||||
? "{$first} liked your post"
|
||||
: "{$first} and {$others} other(s) liked your post",
|
||||
'post_shared' => $count === 1
|
||||
? "{$first} shared your artwork"
|
||||
: "{$first} and {$others} other(s) shared your artwork",
|
||||
default => $count === 1
|
||||
? "{$first} interacted with your post"
|
||||
: "{$count} people interacted with your post",
|
||||
};
|
||||
}
|
||||
}
|
||||
109
app/Services/Posts/PostAchievementService.php
Normal file
109
app/Services/Posts/PostAchievementService.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTarget;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Creates achievement posts for significant milestones.
|
||||
*
|
||||
* Achievement types stored in post.meta.achievement_type:
|
||||
* - follower_100 / follower_500 / follower_1000 / follower_5000 / follower_10000
|
||||
* - artwork_100_views / artwork_1000_views / artwork_1000_favs
|
||||
* - award_received
|
||||
*/
|
||||
class PostAchievementService
|
||||
{
|
||||
private const FOLLOWER_MILESTONES = [100, 500, 1_000, 5_000, 10_000, 50_000];
|
||||
private const VIEW_MILESTONES = [100, 1_000, 10_000, 100_000];
|
||||
private const FAV_MILESTONES = [10, 100, 500, 1_000];
|
||||
|
||||
/**
|
||||
* Check if a follower count crosses a milestone and create an achievement post.
|
||||
*/
|
||||
public function maybeFollowerMilestone(User $user, int $newFollowerCount): void
|
||||
{
|
||||
foreach (self::FOLLOWER_MILESTONES as $milestone) {
|
||||
if ($newFollowerCount === $milestone) {
|
||||
$this->createAchievementPost($user, "follower_{$milestone}", [
|
||||
'milestone' => $milestone,
|
||||
'message' => "🎉 Just reached {$milestone} followers! Thank you all!",
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an artwork's view count crosses a milestone.
|
||||
*/
|
||||
public function maybeArtworkViewMilestone(User $user, int $artworkId, int $newViewCount): void
|
||||
{
|
||||
foreach (self::VIEW_MILESTONES as $milestone) {
|
||||
if ($newViewCount === $milestone) {
|
||||
$this->createAchievementPost($user, "artwork_{$milestone}_views", [
|
||||
'artwork_id' => $artworkId,
|
||||
'milestone' => $milestone,
|
||||
'message' => "🎨 One of my artworks just hit {$milestone} views!",
|
||||
], $artworkId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an achievement post for receiving an award.
|
||||
*/
|
||||
public function awardReceived(User $user, string $awardName, ?int $artworkId = null): void
|
||||
{
|
||||
$this->createAchievementPost($user, 'award_received', [
|
||||
'award_name' => $awardName,
|
||||
'artwork_id' => $artworkId,
|
||||
'message' => "🏆 Just received the \"{$awardName}\" award!",
|
||||
], $artworkId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function createAchievementPost(
|
||||
User $user,
|
||||
string $achievementType,
|
||||
array $meta,
|
||||
?int $artworkId = null,
|
||||
): void {
|
||||
// Deduplicate: don't create the same achievement post twice
|
||||
$exists = Post::where('user_id', $user->id)
|
||||
->where('type', Post::TYPE_ACHIEVEMENT)
|
||||
->whereJsonContains('meta->achievement_type', $achievementType)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $achievementType, $meta, $artworkId) {
|
||||
$post = Post::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => Post::TYPE_ACHIEVEMENT,
|
||||
'visibility' => Post::VISIBILITY_PUBLIC,
|
||||
'body' => $meta['message'] ?? null,
|
||||
'status' => Post::STATUS_PUBLISHED,
|
||||
'meta' => array_merge($meta, ['achievement_type' => $achievementType]),
|
||||
]);
|
||||
|
||||
if ($artworkId) {
|
||||
PostTarget::create([
|
||||
'post_id' => $post->id,
|
||||
'target_type' => 'artwork',
|
||||
'target_id' => $artworkId,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
Log::info("PostAchievementService: created '{$achievementType}' post for user #{$user->id}");
|
||||
}
|
||||
}
|
||||
81
app/Services/Posts/PostAnalyticsService.php
Normal file
81
app/Services/Posts/PostAnalyticsService.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Tracks post impressions (throttled per session) and computes engagement score.
|
||||
*
|
||||
* Impression throttle: 1 impression per post per session-key per hour.
|
||||
* Engagement score: (reactions*2 + comments*3 + saves) / max(impressions, 1)
|
||||
*/
|
||||
class PostAnalyticsService
|
||||
{
|
||||
/**
|
||||
* Record a post impression, throttled by a session key.
|
||||
* Returns true if impression was counted, false if throttled.
|
||||
*/
|
||||
public function trackImpression(Post $post, string $sessionKey): bool
|
||||
{
|
||||
$cacheKey = "impression:{$post->id}:{$sessionKey}";
|
||||
|
||||
if (Cache::has($cacheKey)) {
|
||||
return false; // already counted this hour
|
||||
}
|
||||
|
||||
Cache::put($cacheKey, 1, now()->addHour());
|
||||
|
||||
Post::withoutTimestamps(function () use ($post) {
|
||||
DB::table('posts')
|
||||
->where('id', $post->id)
|
||||
->increment('impressions_count');
|
||||
});
|
||||
|
||||
// Recompute engagement score asynchronously via a quick DB update
|
||||
$this->refreshEngagementScore($post->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the cached engagement_score = (reactions*2 + comments*3 + saves) / max(impressions, 1)
|
||||
*/
|
||||
public function refreshEngagementScore(int $postId): void
|
||||
{
|
||||
Post::withoutTimestamps(function () use ($postId) {
|
||||
DB::table('posts')
|
||||
->where('id', $postId)
|
||||
->update([
|
||||
'engagement_score' => DB::raw(
|
||||
'(reactions_count * 2 + comments_count * 3 + saves_count) / GREATEST(impressions_count, 1)'
|
||||
),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return analytics summary for a post (owner view).
|
||||
*/
|
||||
public function getSummary(Post $post): array
|
||||
{
|
||||
$reactions = $post->reactions_count;
|
||||
$comments = $post->comments_count;
|
||||
$saves = $post->saves_count;
|
||||
$impressions = $post->impressions_count;
|
||||
$rate = $impressions > 0
|
||||
? round((($reactions + $comments + $saves) / $impressions) * 100, 2)
|
||||
: 0.0;
|
||||
|
||||
return [
|
||||
'impressions' => $impressions,
|
||||
'reactions' => $reactions,
|
||||
'comments' => $comments,
|
||||
'saves' => $saves,
|
||||
'engagement_rate' => $rate, // percentage
|
||||
'engagement_score' => round($post->engagement_score, 4),
|
||||
];
|
||||
}
|
||||
}
|
||||
68
app/Services/Posts/PostCountersService.php
Normal file
68
app/Services/Posts/PostCountersService.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Models\PostReaction;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Maintains cached counter columns on the posts table.
|
||||
* Called from controllers when reactions/comments are created or deleted.
|
||||
*/
|
||||
class PostCountersService
|
||||
{
|
||||
public function incrementReactions(Post $post): void
|
||||
{
|
||||
$post->increment('reactions_count');
|
||||
}
|
||||
|
||||
public function decrementReactions(Post $post): void
|
||||
{
|
||||
DB::table('posts')
|
||||
->where('id', $post->id)
|
||||
->where('reactions_count', '>', 0)
|
||||
->decrement('reactions_count');
|
||||
}
|
||||
|
||||
public function incrementComments(Post $post): void
|
||||
{
|
||||
$post->increment('comments_count');
|
||||
}
|
||||
|
||||
public function decrementComments(Post $post): void
|
||||
{
|
||||
DB::table('posts')
|
||||
->where('id', $post->id)
|
||||
->where('comments_count', '>', 0)
|
||||
->decrement('comments_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute both counters from scratch (repair tool).
|
||||
*/
|
||||
public function recompute(Post $post): void
|
||||
{
|
||||
Post::withoutTimestamps(function () use ($post) {
|
||||
$post->update([
|
||||
'reactions_count' => PostReaction::where('post_id', $post->id)->count(),
|
||||
'comments_count' => PostComment::where('post_id', $post->id)->whereNull('deleted_at')->count(),
|
||||
'saves_count' => \App\Models\PostSave::where('post_id', $post->id)->count(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function incrementSaves(Post $post): void
|
||||
{
|
||||
$post->increment('saves_count');
|
||||
}
|
||||
|
||||
public function decrementSaves(Post $post): void
|
||||
{
|
||||
DB::table('posts')
|
||||
->where('id', $post->id)
|
||||
->where('saves_count', '>', 0)
|
||||
->decrement('saves_count');
|
||||
}
|
||||
}
|
||||
262
app/Services/Posts/PostFeedService.php
Normal file
262
app/Services/Posts/PostFeedService.php
Normal file
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostSave;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PostFeedService
|
||||
{
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Profile feed — pinned posts first, then chronological
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function getProfileFeed(
|
||||
User $profileUser,
|
||||
?int $viewerId,
|
||||
int $page = 1,
|
||||
): array {
|
||||
$baseQuery = Post::with($this->eagerLoads())
|
||||
->where('user_id', $profileUser->id)
|
||||
->visibleTo($viewerId);
|
||||
|
||||
// Pinned posts (always on page 1, regardless of pagination)
|
||||
$pinned = (clone $baseQuery)
|
||||
->where('is_pinned', true)
|
||||
->orderBy('pinned_order')
|
||||
->get();
|
||||
|
||||
$paginated = (clone $baseQuery)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(self::PER_PAGE, ['*'], 'page', $page);
|
||||
|
||||
// On page 1, prepend pinned posts (deduplicated)
|
||||
$paginatedCollection = $paginated->getCollection();
|
||||
if ($page === 1 && $pinned->isNotEmpty()) {
|
||||
$pinnedIds = $pinned->pluck('id');
|
||||
$rest = $paginatedCollection->reject(fn ($p) => $pinnedIds->contains($p->id));
|
||||
$combined = $pinned->concat($rest);
|
||||
} else {
|
||||
$combined = $paginatedCollection->reject(fn ($p) => $p->is_pinned && $page === 1);
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $combined->values()->all(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Following feed (ranked + diversity-limited)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function getFollowingFeed(
|
||||
User $viewer,
|
||||
int $page = 1,
|
||||
string $filter = 'all',
|
||||
): array {
|
||||
$followingIds = DB::table('user_followers')
|
||||
->where('follower_id', $viewer->id)
|
||||
->pluck('user_id')
|
||||
->toArray();
|
||||
|
||||
if (empty($followingIds)) {
|
||||
return ['data' => [], 'meta' => ['total' => 0, 'current_page' => $page, 'last_page' => 1, 'per_page' => self::PER_PAGE]];
|
||||
}
|
||||
|
||||
$query = Post::with($this->eagerLoads())
|
||||
->whereIn('user_id', $followingIds)
|
||||
->visibleTo($viewer->id)
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($filter === 'shares') $query->where('type', Post::TYPE_ARTWORK_SHARE);
|
||||
elseif ($filter === 'text') $query->where('type', Post::TYPE_TEXT);
|
||||
elseif ($filter === 'uploads') $query->where('type', Post::TYPE_UPLOAD);
|
||||
|
||||
$paginated = $query->paginate(self::PER_PAGE, ['*'], 'page', $page);
|
||||
$diversified = $this->applyDiversityPass($paginated->getCollection());
|
||||
|
||||
return [
|
||||
'data' => $diversified->values()->all(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Hashtag feed
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function getHashtagFeed(
|
||||
string $tag,
|
||||
?int $viewerId,
|
||||
int $page = 1,
|
||||
): array {
|
||||
$tag = mb_strtolower($tag);
|
||||
|
||||
$paginated = Post::with($this->eagerLoads())
|
||||
->whereHas('hashtags', fn ($q) => $q->where('tag', $tag))
|
||||
->visibleTo($viewerId)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(self::PER_PAGE, ['*'], 'page', $page);
|
||||
|
||||
return [
|
||||
'data' => $paginated->getCollection()->values()->all(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Saved posts feed
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function getSavedFeed(User $viewer, int $page = 1): array
|
||||
{
|
||||
$paginated = Post::with($this->eagerLoads())
|
||||
->whereHas('saves', fn ($q) => $q->where('user_id', $viewer->id))
|
||||
->where('status', Post::STATUS_PUBLISHED)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(self::PER_PAGE, ['*'], 'page', $page);
|
||||
|
||||
return [
|
||||
'data' => $paginated->getCollection()->values()->all(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Eager loads for authenticated/profile feeds. */
|
||||
private function eagerLoads(): array
|
||||
{
|
||||
return [
|
||||
'user',
|
||||
'user.profile',
|
||||
'targets',
|
||||
'targets.artwork',
|
||||
'targets.artwork.user',
|
||||
'targets.artwork.user.profile',
|
||||
'reactions',
|
||||
'hashtags',
|
||||
];
|
||||
}
|
||||
|
||||
/** Eager loads safe for public (trending) feed calls from PostTrendingService. */
|
||||
public function publicEagerLoads(): array
|
||||
{
|
||||
return $this->eagerLoads();
|
||||
}
|
||||
|
||||
/**
|
||||
* Penalize runs of 5+ posts from the same author by deferring them to the end.
|
||||
*/
|
||||
public function applyDiversityPass(Collection $posts): Collection
|
||||
{
|
||||
$result = collect(); $deferred = collect(); $runCounts = [];
|
||||
|
||||
foreach ($posts as $post) {
|
||||
$uid = $post->user_id;
|
||||
$runCounts[$uid] = ($runCounts[$uid] ?? 0) + 1;
|
||||
($runCounts[$uid] <= 5 ? $result : $deferred)->push($post);
|
||||
}
|
||||
|
||||
return $result->merge($deferred);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Formatter
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialize a Post into a JSON-safe array for API responses.
|
||||
*/
|
||||
public function formatPost(Post $post, ?int $viewerId): array
|
||||
{
|
||||
$artworkData = null;
|
||||
|
||||
if ($post->type === Post::TYPE_ARTWORK_SHARE) {
|
||||
$target = $post->targets->firstWhere('target_type', 'artwork');
|
||||
$artwork = $target?->artwork;
|
||||
if ($artwork) {
|
||||
$artworkData = [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumb_url ?? null,
|
||||
'author' => [
|
||||
'id' => $artwork->user->id,
|
||||
'username' => $artwork->user->username,
|
||||
'name' => $artwork->user->name,
|
||||
'avatar' => $artwork->user->profile?->avatar_url ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$viewerLiked = $viewerSaved = false;
|
||||
if ($viewerId) {
|
||||
$viewerLiked = $post->reactions->where('user_id', $viewerId)->where('reaction', 'like')->isNotEmpty();
|
||||
// saves are lazy-loaded only when needed; check if relation is loaded
|
||||
if ($post->relationLoaded('saves')) {
|
||||
$viewerSaved = $post->saves->where('user_id', $viewerId)->isNotEmpty();
|
||||
} else {
|
||||
$viewerSaved = $post->saves()->where('user_id', $viewerId)->exists();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $post->id,
|
||||
'type' => $post->type,
|
||||
'visibility' => $post->visibility,
|
||||
'status' => $post->status,
|
||||
'body' => $post->body,
|
||||
'reactions_count' => $post->reactions_count,
|
||||
'comments_count' => $post->comments_count,
|
||||
'saves_count' => $post->saves_count,
|
||||
'impressions_count'=> $post->impressions_count,
|
||||
'is_pinned' => (bool) $post->is_pinned,
|
||||
'pinned_order' => $post->pinned_order,
|
||||
'publish_at' => $post->publish_at?->toISOString(),
|
||||
'viewer_liked' => $viewerLiked,
|
||||
'viewer_saved' => $viewerSaved,
|
||||
'artwork' => $artworkData,
|
||||
'author' => [
|
||||
'id' => $post->user->id,
|
||||
'username' => $post->user->username,
|
||||
'name' => $post->user->name,
|
||||
'avatar' => $post->user->profile?->avatar_url ?? null,
|
||||
],
|
||||
'hashtags' => $post->relationLoaded('hashtags') ? $post->hashtags->pluck('tag')->toArray() : [],
|
||||
'meta' => $post->meta,
|
||||
'created_at' => $post->created_at->toISOString(),
|
||||
'updated_at' => $post->updated_at->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
86
app/Services/Posts/PostHashtagService.php
Normal file
86
app/Services/Posts/PostHashtagService.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostHashtag;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Parses hashtags from post body and syncs them to the post_hashtags table.
|
||||
*/
|
||||
class PostHashtagService
|
||||
{
|
||||
/** Regex: #word (letters/numbers/underscore, 2–64 chars, no leading digit) */
|
||||
private const HASHTAG_RE = '/#([A-Za-z][A-Za-z0-9_]{1,63})/u';
|
||||
|
||||
/**
|
||||
* Extract unique lowercase hashtag strings from raw or HTML body.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function parseHashtags(string $body): array
|
||||
{
|
||||
$plainText = strip_tags($body);
|
||||
preg_match_all(self::HASHTAG_RE, $plainText, $matches);
|
||||
|
||||
return array_values(array_unique(
|
||||
array_map('mb_strtolower', $matches[1] ?? [])
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync hashtags for a post: insert new, delete removed.
|
||||
*/
|
||||
public function sync(Post $post, string $body): void
|
||||
{
|
||||
$tags = $this->parseHashtags($body);
|
||||
|
||||
DB::transaction(function () use ($post, $tags) {
|
||||
// Remove tags no longer in the body
|
||||
PostHashtag::where('post_id', $post->id)
|
||||
->whereNotIn('tag', $tags)
|
||||
->delete();
|
||||
|
||||
// Insert new tags (ignore duplicates)
|
||||
foreach ($tags as $tag) {
|
||||
PostHashtag::firstOrCreate([
|
||||
'post_id' => $post->id,
|
||||
'tag' => $tag,
|
||||
], [
|
||||
'user_id' => $post->user_id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending hashtags in the last $hours hours (top $limit by post count).
|
||||
*
|
||||
* @return array<array{tag: string, post_count: int, author_count: int}>
|
||||
*/
|
||||
public function trending(int $limit = 10, int $hours = 24): array
|
||||
{
|
||||
return DB::table('post_hashtags')
|
||||
->join('posts', 'post_hashtags.post_id', '=', 'posts.id')
|
||||
->where('post_hashtags.created_at', '>=', now()->subHours($hours))
|
||||
->where('posts.status', Post::STATUS_PUBLISHED)
|
||||
->where('posts.visibility', Post::VISIBILITY_PUBLIC)
|
||||
->whereNull('posts.deleted_at')
|
||||
->groupBy('post_hashtags.tag')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->limit($limit)
|
||||
->get([
|
||||
'post_hashtags.tag',
|
||||
DB::raw('COUNT(*) as post_count'),
|
||||
DB::raw('COUNT(DISTINCT post_hashtags.user_id) as author_count'),
|
||||
])
|
||||
->map(fn ($row) => [
|
||||
'tag' => $row->tag,
|
||||
'post_count' => (int) $row->post_count,
|
||||
'author_count' => (int) $row->author_count,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
115
app/Services/Posts/PostService.php
Normal file
115
app/Services/Posts/PostService.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTarget;
|
||||
use App\Models\User;
|
||||
use App\Services\ContentSanitizer;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PostService
|
||||
{
|
||||
public function __construct(
|
||||
private PostCountersService $counters,
|
||||
private PostHashtagService $hashtags,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new text post.
|
||||
*
|
||||
* @param User $user
|
||||
* @param string $visibility
|
||||
* @param string|null $body
|
||||
* @param array $targets [['type'=>'artwork','id'=>123], ...]
|
||||
* @return Post
|
||||
*/
|
||||
public function createPost(
|
||||
User $user,
|
||||
string $type,
|
||||
string $visibility,
|
||||
?string $body,
|
||||
array $targets = [],
|
||||
?array $linkPreview = null,
|
||||
?array $taggedUsers = null,
|
||||
?Carbon $publishAt = null,
|
||||
): Post {
|
||||
$sanitizedBody = $body ? ContentSanitizer::render($body) : null;
|
||||
|
||||
$status = ($publishAt && $publishAt->isFuture())
|
||||
? Post::STATUS_SCHEDULED
|
||||
: Post::STATUS_PUBLISHED;
|
||||
|
||||
$meta = [];
|
||||
if ($linkPreview && ! empty($linkPreview['url'])) {
|
||||
$meta['link_preview'] = array_intersect_key($linkPreview, array_flip(['url', 'title', 'description', 'image', 'site_name']));
|
||||
}
|
||||
if ($taggedUsers && count($taggedUsers) > 0) {
|
||||
$meta['tagged_users'] = array_map(
|
||||
fn ($u) => ['id' => (int) $u['id'], 'username' => (string) $u['username'], 'name' => (string) ($u['name'] ?? $u['username'])],
|
||||
array_slice($taggedUsers, 0, 10),
|
||||
);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $type, $visibility, $sanitizedBody, $targets, $meta, $status, $publishAt) {
|
||||
$post = Post::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => $type,
|
||||
'visibility' => $visibility,
|
||||
'body' => $sanitizedBody,
|
||||
'meta' => $meta ?: null,
|
||||
'status' => $status,
|
||||
'publish_at' => $publishAt,
|
||||
]);
|
||||
|
||||
foreach ($targets as $target) {
|
||||
PostTarget::create([
|
||||
'post_id' => $post->id,
|
||||
'target_type' => $target['type'],
|
||||
'target_id' => $target['id'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Sync hashtags extracted from the body
|
||||
if ($sanitizedBody) {
|
||||
$this->hashtags->sync($post, $sanitizedBody);
|
||||
}
|
||||
|
||||
return $post;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update body/visibility of an existing post.
|
||||
*/
|
||||
public function updatePost(Post $post, ?string $body, ?string $visibility): Post
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
if ($body !== null) {
|
||||
$updates['body'] = ContentSanitizer::render($body);
|
||||
}
|
||||
|
||||
if ($visibility !== null) {
|
||||
$updates['visibility'] = $visibility;
|
||||
}
|
||||
|
||||
$post->update($updates);
|
||||
|
||||
// Re-sync hashtags whenever body changes
|
||||
if (isset($updates['body'])) {
|
||||
$this->hashtags->sync($post, $updates['body']);
|
||||
}
|
||||
|
||||
return $post->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a post (cascades to targets/reactions/comments via DB).
|
||||
*/
|
||||
public function deletePost(Post $post): void
|
||||
{
|
||||
$post->delete();
|
||||
}
|
||||
}
|
||||
72
app/Services/Posts/PostShareService.php
Normal file
72
app/Services/Posts/PostShareService.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTarget;
|
||||
use App\Models\User;
|
||||
use App\Services\ContentSanitizer;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PostShareService
|
||||
{
|
||||
/**
|
||||
* Share an artwork to a user's profile feed.
|
||||
*
|
||||
* Enforces:
|
||||
* - artwork must be public
|
||||
* - no duplicate share within 24 hours (for same user+artwork)
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function shareArtwork(
|
||||
User $user,
|
||||
Artwork $artwork,
|
||||
?string $body,
|
||||
string $visibility = Post::VISIBILITY_PUBLIC,
|
||||
): Post {
|
||||
// Ensure artwork is shareable
|
||||
if (! $artwork->is_public || ! $artwork->is_approved) {
|
||||
throw ValidationException::withMessages([
|
||||
'artwork_id' => ['This artwork cannot be shared because it is not publicly available.'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Duplicate share prevention: same user + same artwork within 24h
|
||||
$alreadyShared = Post::where('user_id', $user->id)
|
||||
->where('type', Post::TYPE_ARTWORK_SHARE)
|
||||
->where('created_at', '>=', Carbon::now()->subHours(24))
|
||||
->whereHas('targets', function ($q) use ($artwork) {
|
||||
$q->where('target_type', 'artwork')->where('target_id', $artwork->id);
|
||||
})
|
||||
->exists();
|
||||
|
||||
if ($alreadyShared) {
|
||||
throw ValidationException::withMessages([
|
||||
'artwork_id' => ['You already shared this artwork recently. Please wait 24 hours before sharing it again.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$sanitizedBody = $body ? ContentSanitizer::render($body) : null;
|
||||
|
||||
return DB::transaction(function () use ($user, $artwork, $sanitizedBody, $visibility) {
|
||||
$post = Post::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => Post::TYPE_ARTWORK_SHARE,
|
||||
'visibility' => $visibility,
|
||||
'body' => $sanitizedBody,
|
||||
]);
|
||||
|
||||
PostTarget::create([
|
||||
'post_id' => $post->id,
|
||||
'target_type' => 'artwork',
|
||||
'target_id' => $artwork->id,
|
||||
]);
|
||||
|
||||
return $post;
|
||||
});
|
||||
}
|
||||
}
|
||||
146
app/Services/Posts/PostTrendingService.php
Normal file
146
app/Services/Posts/PostTrendingService.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Computes the trending post feed with Wilson/decay scoring and author diversity.
|
||||
*
|
||||
* Score formula (per spec):
|
||||
* base = (likes * 3) + (comments * 5) + (shares * 6) + (unique_reactors * 4)
|
||||
* score = base * exp(-hours_since_post / 24)
|
||||
*
|
||||
* Diversity rule: max 2 posts per author in the top N results.
|
||||
* Cache TTL: 2 minutes.
|
||||
*/
|
||||
class PostTrendingService
|
||||
{
|
||||
private const CACHE_KEY = 'feed:trending';
|
||||
private const CACHE_TTL = 120; // seconds
|
||||
private const WINDOW_DAYS = 7;
|
||||
private const MAX_PER_AUTHOR = 2;
|
||||
|
||||
private PostFeedService $feedService;
|
||||
|
||||
public function __construct(PostFeedService $feedService)
|
||||
{
|
||||
$this->feedService = $feedService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return trending posts for the given viewer.
|
||||
*
|
||||
* @param int|null $viewerId
|
||||
* @param int $page
|
||||
* @param int $perPage
|
||||
* @return array{data: array, meta: array}
|
||||
*/
|
||||
public function getTrending(?int $viewerId, int $page = 1, int $perPage = 20): array
|
||||
{
|
||||
$rankedIds = $this->getRankedIds();
|
||||
|
||||
// Paginate from the ranked ID list
|
||||
$total = count($rankedIds);
|
||||
$pageIds = array_slice($rankedIds, ($page - 1) * $perPage, $perPage);
|
||||
|
||||
if (empty($pageIds)) {
|
||||
return ['data' => [], 'meta' => ['total' => $total, 'current_page' => $page, 'last_page' => (int) ceil($total / $perPage) ?: 1, 'per_page' => $perPage]];
|
||||
}
|
||||
|
||||
// Load posts preserving ranked order
|
||||
$posts = Post::with($this->feedService->publicEagerLoads())
|
||||
->whereIn('id', $pageIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$ordered = array_filter(array_map(fn ($id) => $posts->get($id), $pageIds));
|
||||
$data = array_values(array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewerId),
|
||||
$ordered,
|
||||
));
|
||||
|
||||
return [
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'total' => $total,
|
||||
'current_page' => $page,
|
||||
'last_page' => (int) ceil($total / $perPage) ?: 1,
|
||||
'per_page' => $perPage,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or compute the ranked post-ID list from cache.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public function getRankedIds(): array
|
||||
{
|
||||
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () {
|
||||
return $this->computeRankedIds();
|
||||
});
|
||||
}
|
||||
|
||||
/** Force a cache refresh (called by the CLI command). */
|
||||
public function refresh(): array
|
||||
{
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
return $this->getRankedIds();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function computeRankedIds(): array
|
||||
{
|
||||
$cutoff = now()->subDays(self::WINDOW_DAYS);
|
||||
|
||||
$rows = DB::table('posts')
|
||||
->leftJoin(
|
||||
DB::raw('(SELECT post_id, COUNT(*) as unique_reactors FROM post_reactions GROUP BY post_id) pr'),
|
||||
'posts.id', '=', 'pr.post_id',
|
||||
)
|
||||
->where('posts.status', Post::STATUS_PUBLISHED)
|
||||
->where('posts.visibility', Post::VISIBILITY_PUBLIC)
|
||||
->where('posts.created_at', '>=', $cutoff)
|
||||
->whereNull('posts.deleted_at')
|
||||
->select([
|
||||
'posts.id',
|
||||
'posts.user_id',
|
||||
'posts.reactions_count',
|
||||
'posts.comments_count',
|
||||
'posts.created_at',
|
||||
DB::raw('COALESCE(pr.unique_reactors, 0) as unique_reactors'),
|
||||
])
|
||||
->get();
|
||||
|
||||
$now = now()->timestamp;
|
||||
|
||||
$scored = $rows->map(function ($row) use ($now) {
|
||||
$hoursSince = ($now - strtotime($row->created_at)) / 3600;
|
||||
$base = ($row->reactions_count * 3)
|
||||
+ ($row->comments_count * 5)
|
||||
+ ($row->unique_reactors * 4);
|
||||
$score = $base * exp(-$hoursSince / 24);
|
||||
|
||||
return ['id' => $row->id, 'user_id' => $row->user_id, 'score' => $score];
|
||||
})->sortByDesc('score');
|
||||
|
||||
// Apply author diversity: max MAX_PER_AUTHOR posts per author
|
||||
$authorCount = [];
|
||||
$result = [];
|
||||
|
||||
foreach ($scored as $item) {
|
||||
$uid = $item['user_id'];
|
||||
$authorCount[$uid] = ($authorCount[$uid] ?? 0) + 1;
|
||||
if ($authorCount[$uid] <= self::MAX_PER_AUTHOR) {
|
||||
$result[] = $item['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -29,20 +29,31 @@ final class StudioArtworkQueryService
|
||||
*/
|
||||
public function list(int $userId, array $filters = [], int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
// Skip Meilisearch when driver is null (e.g. in tests)
|
||||
// Studio is a management dashboard — DB is always authoritative.
|
||||
// Draft/unpublished artworks are never indexed in Meilisearch, so using
|
||||
// Meili as the primary source would silently hide them.
|
||||
//
|
||||
// Meilisearch is only used when the user submits a free-text query (`q`),
|
||||
// since it can provide relevance-ranked full-text search across many docs.
|
||||
// Even then, we fall back to DB on any Meili error.
|
||||
|
||||
$hasTextQuery = !empty($filters['q']);
|
||||
$driver = config('scout.driver');
|
||||
if (empty($driver) || $driver === 'null') {
|
||||
return $this->listViaDatabase($userId, $filters, $perPage);
|
||||
$useMeili = $hasTextQuery && !empty($driver) && $driver !== 'null';
|
||||
|
||||
if ($useMeili) {
|
||||
try {
|
||||
return $this->listViaMeilisearch($userId, $filters, $perPage);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Studio: Meilisearch unavailable during text search, falling back to DB', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
// fall through to DB
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->listViaMeilisearch($userId, $filters, $perPage);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Studio: Meilisearch unavailable, falling back to DB', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return $this->listViaDatabase($userId, $filters, $perPage);
|
||||
}
|
||||
return $this->listViaDatabase($userId, $filters, $perPage);
|
||||
}
|
||||
|
||||
private function listViaMeilisearch(int $userId, array $filters, int $perPage): LengthAwarePaginator
|
||||
|
||||
@@ -27,34 +27,81 @@ final class UploadDerivativesService
|
||||
}
|
||||
}
|
||||
|
||||
public function storeOriginal(string $sourcePath, string $hash): string
|
||||
public function storeOriginal(string $sourcePath, string $hash, ?string $originalFileName = null): string
|
||||
{
|
||||
$this->assertImageAvailable();
|
||||
// Preserve original file extension and store with filename = <hash>.<ext>
|
||||
$dir = $this->storage->ensureHashDirectory('original', $hash);
|
||||
|
||||
$dir = $this->storage->ensureHashDirectory('originals', $hash);
|
||||
$target = $dir . DIRECTORY_SEPARATOR . 'orig.webp';
|
||||
$quality = (int) config('uploads.quality', 85);
|
||||
$origExt = $this->resolveOriginalExtension($sourcePath, $originalFileName);
|
||||
$target = $dir . DIRECTORY_SEPARATOR . $hash . ($origExt !== '' ? '.' . $origExt : '');
|
||||
|
||||
/** @var InterventionImageInterface $img */
|
||||
$img = $this->manager->read($sourcePath);
|
||||
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
|
||||
$encoded = (string) $img->encode($encoder);
|
||||
File::put($target, $encoded);
|
||||
// Try a direct copy first (works for images and archives). If that fails,
|
||||
// fall back to re-encoding image to webp as a last resort.
|
||||
try {
|
||||
if (! File::copy($sourcePath, $target)) {
|
||||
throw new \RuntimeException('Copy failed');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback: encode to webp
|
||||
$quality = (int) config('uploads.quality', 85);
|
||||
/** @var InterventionImageInterface $img */
|
||||
$img = $this->manager->read($sourcePath);
|
||||
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
|
||||
$encoded = (string) $img->encode($encoder);
|
||||
$target = $dir . DIRECTORY_SEPARATOR . $hash . '.webp';
|
||||
File::put($target, $encoded);
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
private function resolveOriginalExtension(string $sourcePath, ?string $originalFileName): string
|
||||
{
|
||||
$fromClientName = strtolower((string) pathinfo((string) $originalFileName, PATHINFO_EXTENSION));
|
||||
if ($fromClientName !== '' && preg_match('/^[a-z0-9]{1,12}$/', $fromClientName) === 1) {
|
||||
return $fromClientName;
|
||||
}
|
||||
|
||||
$fromSource = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION));
|
||||
if ($fromSource !== '' && $fromSource !== 'upload' && preg_match('/^[a-z0-9]{1,12}$/', $fromSource) === 1) {
|
||||
return $fromSource;
|
||||
}
|
||||
|
||||
$mime = File::exists($sourcePath) ? (string) (File::mimeType($sourcePath) ?? '') : '';
|
||||
return $this->extensionFromMime($mime);
|
||||
}
|
||||
|
||||
private function extensionFromMime(string $mime): string
|
||||
{
|
||||
return match (strtolower($mime)) {
|
||||
'image/jpeg', 'image/jpg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'image/gif' => 'gif',
|
||||
'image/bmp' => 'bmp',
|
||||
'image/tiff' => 'tif',
|
||||
'application/zip', 'application/x-zip-compressed' => 'zip',
|
||||
'application/x-rar-compressed', 'application/vnd.rar' => 'rar',
|
||||
'application/x-7z-compressed' => '7z',
|
||||
'application/x-tar' => 'tar',
|
||||
'application/gzip', 'application/x-gzip' => 'gz',
|
||||
default => 'bin',
|
||||
};
|
||||
}
|
||||
|
||||
public function generatePublicDerivatives(string $sourcePath, string $hash): array
|
||||
{
|
||||
$this->assertImageAvailable();
|
||||
$quality = (int) config('uploads.quality', 85);
|
||||
$variants = (array) config('uploads.derivatives', []);
|
||||
$dir = $this->storage->publicHashDirectory($hash);
|
||||
$written = [];
|
||||
|
||||
foreach ($variants as $variant => $options) {
|
||||
$variant = (string) $variant;
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $variant . '.webp';
|
||||
$dir = $this->storage->ensureHashDirectory($variant, $hash);
|
||||
// store derivative filename as <hash>.webp per variant directory
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $hash . '.webp';
|
||||
|
||||
/** @var InterventionImageInterface $img */
|
||||
$img = $this->manager->read($sourcePath);
|
||||
|
||||
@@ -104,20 +104,22 @@ final class UploadPipelineService
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function processAndPublish(string $sessionId, string $hash, int $artworkId): array
|
||||
public function processAndPublish(string $sessionId, string $hash, int $artworkId, ?string $originalFileName = null): array
|
||||
{
|
||||
$session = $this->sessions->getOrFail($sessionId);
|
||||
|
||||
$originalPath = $this->derivatives->storeOriginal($session->tempPath, $hash);
|
||||
$originalRelative = $this->storage->sectionRelativePath('originals', $hash, 'orig.webp');
|
||||
$this->artworkFiles->upsert($artworkId, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath));
|
||||
$originalPath = $this->derivatives->storeOriginal($session->tempPath, $hash, $originalFileName);
|
||||
$origFilename = basename($originalPath);
|
||||
$originalRelative = $this->storage->sectionRelativePath('original', $hash, $origFilename);
|
||||
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
|
||||
$this->artworkFiles->upsert($artworkId, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
|
||||
|
||||
$publicAbsolute = $this->derivatives->generatePublicDerivatives($session->tempPath, $hash);
|
||||
$publicRelative = [];
|
||||
|
||||
foreach ($publicAbsolute as $variant => $absolutePath) {
|
||||
$filename = $variant . '.webp';
|
||||
$relativePath = $this->storage->publicRelativePath($hash, $filename);
|
||||
$filename = $hash . '.webp';
|
||||
$relativePath = $this->storage->sectionRelativePath($variant, $hash, $filename);
|
||||
$this->artworkFiles->upsert($artworkId, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
|
||||
$publicRelative[$variant] = $relativePath;
|
||||
}
|
||||
@@ -126,13 +128,14 @@ final class UploadPipelineService
|
||||
$width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : 1;
|
||||
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : 1;
|
||||
|
||||
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
|
||||
Artwork::query()->whereKey($artworkId)->update([
|
||||
'file_name' => basename($originalRelative),
|
||||
'file_name' => $origFilename,
|
||||
'file_path' => '',
|
||||
'file_size' => (int) filesize($originalPath),
|
||||
'mime_type' => 'image/webp',
|
||||
'mime_type' => $origMime,
|
||||
'hash' => $hash,
|
||||
'file_ext' => 'webp',
|
||||
'file_ext' => $origExt,
|
||||
'thumb_ext' => 'webp',
|
||||
'width' => max(1, $width),
|
||||
'height' => max(1, $height),
|
||||
@@ -152,6 +155,11 @@ final class UploadPipelineService
|
||||
];
|
||||
}
|
||||
|
||||
public function originalHashExists(string $hash): bool
|
||||
{
|
||||
return $this->storage->originalHashExists($hash);
|
||||
}
|
||||
|
||||
private function quarantine(UploadSessionData $session, string $reason): void
|
||||
{
|
||||
$newPath = $this->storage->moveToSection($session->tempPath, 'quarantine');
|
||||
|
||||
@@ -76,33 +76,6 @@ final class UploadStorageService
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function publicHashDirectory(string $hash): string
|
||||
{
|
||||
$prefix = trim((string) config('uploads.public_img_prefix', 'img'), DIRECTORY_SEPARATOR);
|
||||
$base = $this->sectionPath('public') . DIRECTORY_SEPARATOR . $prefix;
|
||||
|
||||
if (! File::exists($base)) {
|
||||
File::makeDirectory($base, 0755, true);
|
||||
}
|
||||
|
||||
$segments = $this->hashSegments($hash);
|
||||
$dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
|
||||
|
||||
if (! File::exists($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function publicRelativePath(string $hash, string $filename): string
|
||||
{
|
||||
$prefix = trim((string) config('uploads.public_img_prefix', 'img'), DIRECTORY_SEPARATOR);
|
||||
$segments = $this->hashSegments($hash);
|
||||
|
||||
return $prefix . '/' . implode('/', $segments) . '/' . ltrim($filename, '/');
|
||||
}
|
||||
|
||||
public function sectionRelativePath(string $section, string $hash, string $filename): string
|
||||
{
|
||||
$segments = $this->hashSegments($hash);
|
||||
@@ -111,6 +84,24 @@ final class UploadStorageService
|
||||
return $section . '/' . implode('/', $segments) . '/' . ltrim($filename, '/');
|
||||
}
|
||||
|
||||
public function originalHashExists(string $hash): bool
|
||||
{
|
||||
$segments = $this->hashSegments($hash);
|
||||
$dir = $this->sectionPath('original') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
|
||||
|
||||
if (! File::isDirectory($dir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalizedHash = strtolower(preg_replace('/[^a-z0-9]/', '', $hash) ?? '');
|
||||
if ($normalizedHash === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$matches = File::glob($dir . DIRECTORY_SEPARATOR . $normalizedHash . '.*');
|
||||
return is_array($matches) && count($matches) > 0;
|
||||
}
|
||||
|
||||
private function safeExtension(UploadedFile $file): string
|
||||
{
|
||||
$extension = (string) $file->guessExtension();
|
||||
@@ -125,10 +116,11 @@ final class UploadStorageService
|
||||
$hash = preg_replace('/[^a-z0-9]/', '', $hash) ?? '';
|
||||
$hash = str_pad($hash, 6, '0');
|
||||
|
||||
// Use two 2-char segments for directory sharding: first two chars, next two chars.
|
||||
// Result: <section>/<aa>/<bb>/<filename>
|
||||
$segments = [
|
||||
substr($hash, 0, 2),
|
||||
substr($hash, 2, 2),
|
||||
substr($hash, 4, 2),
|
||||
];
|
||||
|
||||
return array_map(static fn (string $part): string => $part === '' ? '00' : $part, $segments);
|
||||
|
||||
@@ -6,14 +6,16 @@ return [
|
||||
'storage_root' => env('SKINBASE_STORAGE_ROOT', storage_path('app/artworks')),
|
||||
|
||||
'paths' => [
|
||||
'tmp' => 'tmp',
|
||||
'tmp' => 'tmp',
|
||||
'quarantine' => 'quarantine',
|
||||
'originals' => 'originals',
|
||||
'public' => 'public',
|
||||
'original' => 'original',
|
||||
'xs' => 'xs',
|
||||
'sm' => 'sm',
|
||||
'md' => 'md',
|
||||
'lg' => 'lg',
|
||||
'xl' => 'xl',
|
||||
],
|
||||
|
||||
'public_img_prefix' => 'img',
|
||||
|
||||
'max_size_mb' => 50,
|
||||
'max_pixels' => 12000,
|
||||
|
||||
@@ -26,8 +28,8 @@ return [
|
||||
'allow_gif' => env('UPLOAD_ALLOW_GIF', false),
|
||||
|
||||
'derivatives' => [
|
||||
'thumb' => ['max' => 320],
|
||||
'sq' => ['size' => 512],
|
||||
'xs' => ['max' => 320],
|
||||
'sm' => ['max' => 680],
|
||||
'md' => ['max' => 1024],
|
||||
'lg' => ['max' => 1920],
|
||||
'xl' => ['max' => 2560],
|
||||
|
||||
44
database/factories/PostFactory.php
Normal file
44
database/factories/PostFactory.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Post>
|
||||
*/
|
||||
class PostFactory extends Factory
|
||||
{
|
||||
protected $model = Post::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'type' => Post::TYPE_TEXT,
|
||||
'visibility' => Post::VISIBILITY_PUBLIC,
|
||||
'status' => Post::STATUS_PUBLISHED,
|
||||
'body' => fake()->paragraph(),
|
||||
'meta' => null,
|
||||
'reactions_count' => 0,
|
||||
'comments_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function artworkShare(): static
|
||||
{
|
||||
return $this->state(['type' => Post::TYPE_ARTWORK_SHARE]);
|
||||
}
|
||||
|
||||
public function followersOnly(): static
|
||||
{
|
||||
return $this->state(['visibility' => Post::VISIBILITY_FOLLOWERS]);
|
||||
}
|
||||
|
||||
public function private(): static
|
||||
{
|
||||
return $this->state(['visibility' => Post::VISIBILITY_PRIVATE]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Add scheduled-publishing columns to artworks table.
|
||||
*
|
||||
* Fields added:
|
||||
* - publish_at Nullable UTC datetime – when the artwork becomes public (scheduled publish)
|
||||
* - artwork_status Enum string: draft | scheduled | published | archived
|
||||
* - artwork_timezone Nullable IANA timezone string for display purposes only
|
||||
*
|
||||
* Note: we use `artwork_status` to avoid collision with any reserved word `status`
|
||||
* across DB adapters. A DB-level check constraint enforces valid values.
|
||||
*
|
||||
* Rules:
|
||||
* - publish_at set in the future → artwork_status = 'scheduled'
|
||||
* - scheduler job publishes when publish_at <= now():
|
||||
* sets published_at = now(), artwork_status = 'published'
|
||||
* - Immediate publish keeps existing flow (published_at = now(), artwork_status = 'published')
|
||||
* - Draft state is pre-publish (artwork_status = 'draft', published_at = null)
|
||||
*/
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('artworks', function (Blueprint $table) {
|
||||
// When this artwork should become publicly visible (UTC)
|
||||
$table->dateTime('publish_at')->nullable()->after('published_at');
|
||||
|
||||
// Lifecycle status
|
||||
$table->string('artwork_status', 20)->default('draft')->after('publish_at');
|
||||
|
||||
// User's display timezone (IANA), stored for UX display only
|
||||
$table->string('artwork_timezone', 50)->nullable()->after('artwork_status');
|
||||
|
||||
// Index for scheduler job: find artworks whose publish_at has passed
|
||||
$table->index(['artwork_status', 'publish_at'], 'idx_artworks_scheduled_publish');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('artworks', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_artworks_scheduled_publish');
|
||||
$table->dropColumn(['publish_at', 'artwork_status', 'artwork_timezone']);
|
||||
});
|
||||
}
|
||||
};
|
||||
33
database/migrations/2026_03_02_000001_create_posts_table.php
Normal file
33
database/migrations/2026_03_02_000001_create_posts_table.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('posts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('type', 32)->default('text'); // text | artwork_share | upload | achievement
|
||||
$table->string('visibility', 16)->default('public'); // public | followers | private
|
||||
$table->longText('body')->nullable();
|
||||
$table->json('meta')->nullable();
|
||||
$table->unsignedInteger('reactions_count')->default(0);
|
||||
$table->unsignedInteger('comments_count')->default(0);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['user_id', 'created_at']);
|
||||
$table->index(['type', 'created_at']);
|
||||
$table->index(['visibility', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('posts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('post_targets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('target_type', 32); // artwork | collection
|
||||
$table->unsignedBigInteger('target_id');
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index('post_id');
|
||||
$table->index(['target_type', 'target_id']);
|
||||
$table->unique(['post_id', 'target_type', 'target_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('post_targets');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('post_reactions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('reaction', 16)->default('like');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('post_id');
|
||||
$table->index('user_id');
|
||||
$table->unique(['post_id', 'user_id', 'reaction']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('post_reactions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('post_comments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->text('body');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['post_id', 'created_at']);
|
||||
$table->index('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('post_comments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('post_saves', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index('post_id');
|
||||
$table->index('user_id');
|
||||
$table->unique(['post_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('post_saves');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('post_reports', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('reporter_user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('reason', 64);
|
||||
$table->text('message')->nullable();
|
||||
$table->string('status', 16)->default('open'); // open | reviewed | actioned
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('post_id');
|
||||
$table->index('reporter_user_id');
|
||||
$table->index('status');
|
||||
$table->unique(['post_id', 'reporter_user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('post_reports');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
// Pinned posts
|
||||
$table->boolean('is_pinned')->default(false)->after('meta');
|
||||
$table->unsignedTinyInteger('pinned_order')->nullable()->after('is_pinned');
|
||||
|
||||
// Scheduled posts
|
||||
$table->timestamp('publish_at')->nullable()->after('pinned_order');
|
||||
$table->string('status', 16)->default('published')->after('publish_at'); // draft | scheduled | published
|
||||
|
||||
// Analytics
|
||||
$table->unsignedBigInteger('impressions_count')->default(0)->after('comments_count');
|
||||
$table->float('engagement_score')->default(0.0)->after('impressions_count');
|
||||
$table->unsignedInteger('saves_count')->default(0)->after('engagement_score');
|
||||
|
||||
$table->index(['is_pinned', 'user_id', 'pinned_order']);
|
||||
$table->index(['status', 'publish_at']);
|
||||
$table->index(['status', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
$table->dropIndex(['is_pinned', 'user_id', 'pinned_order']);
|
||||
$table->dropIndex(['status', 'publish_at']);
|
||||
$table->dropIndex(['status', 'created_at']);
|
||||
$table->dropColumn(['is_pinned', 'pinned_order', 'publish_at', 'status',
|
||||
'impressions_count', 'engagement_score', 'saves_count']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('post_hashtags', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); // denormalised for fast author diversity queries
|
||||
$table->string('tag', 64)->index();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['tag', 'created_at']); // trending query
|
||||
$table->index(['post_id', 'tag']);
|
||||
$table->unique(['post_id', 'tag']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('post_hashtags');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('post_comments', function (Blueprint $table) {
|
||||
$table->boolean('is_highlighted')->default(false)->after('body');
|
||||
$table->index(['post_id', 'is_highlighted']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('post_comments', function (Blueprint $table) {
|
||||
$table->dropIndex(['post_id', 'is_highlighted']);
|
||||
$table->dropColumn('is_highlighted');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('user_profiles', function (Blueprint $table) {
|
||||
$table->boolean('auto_post_upload')->default(true)->after('website');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('user_profiles', function (Blueprint $table) {
|
||||
$table->dropColumn('auto_post_upload');
|
||||
});
|
||||
}
|
||||
};
|
||||
1066
package-lock.json
generated
1066
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -16,6 +16,7 @@
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"alpinejs": "^3.4.2",
|
||||
@@ -35,10 +36,21 @@
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@inertiajs/core": "^1.0.4",
|
||||
"@inertiajs/react": "^1.0.4",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.20.0",
|
||||
"@tiptap/extension-image": "^3.20.0",
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-mention": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.20.0",
|
||||
"@tiptap/extension-underline": "^3.20.0",
|
||||
"@tiptap/react": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.0",
|
||||
"@tiptap/suggestion": "^3.20.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"framer-motion": "^12.34.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0"
|
||||
"react-markdown": "^10.1.0",
|
||||
"tippy.js": "^6.3.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,3 +87,107 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── TipTap rich text editor ─── */
|
||||
.tiptap {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: theme('colors.zinc.600');
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.tiptap img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.tiptap pre {
|
||||
background: theme('colors.white / 4%');
|
||||
border: 1px solid theme('colors.white / 6%');
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tiptap pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: theme('colors.zinc.300');
|
||||
}
|
||||
|
||||
.tiptap blockquote {
|
||||
border-left: 3px solid theme('colors.sky.500 / 40%');
|
||||
padding-left: 1rem;
|
||||
margin-left: 0;
|
||||
color: theme('colors.zinc.400');
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tiptap hr {
|
||||
border: none;
|
||||
border-top: 1px solid theme('colors.white / 10%');
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.tiptap ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.tiptap ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.tiptap li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tiptap a {
|
||||
color: theme('colors.sky.300');
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.tiptap a:hover {
|
||||
color: theme('colors.sky.200');
|
||||
}
|
||||
|
||||
/* ─── @mention pills ─── */
|
||||
.tiptap .mention,
|
||||
.mention {
|
||||
background: theme('colors.sky.500 / 15%');
|
||||
color: theme('colors.sky.300');
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tiptap .mention:hover {
|
||||
background: theme('colors.sky.500 / 25%');
|
||||
color: theme('colors.sky.200');
|
||||
}
|
||||
|
||||
/* ─── Tippy.js mention dropdown theme ─── */
|
||||
.tippy-box[data-theme~='mention'] {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='mention'] .tippy-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
154
resources/js/Pages/Feed/FollowingFeed.jsx
Normal file
154
resources/js/Pages/Feed/FollowingFeed.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'shares', label: 'Artwork Shares' },
|
||||
{ value: 'uploads', label: 'New Uploads' },
|
||||
{ value: 'text', label: 'Text Posts' },
|
||||
]
|
||||
|
||||
function EmptyFollowingState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-600">
|
||||
<i className="fa-solid fa-users text-3xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">Nothing here yet</h2>
|
||||
<p className="text-slate-500 text-sm max-w-sm leading-relaxed">
|
||||
Follow some creators to see their posts here. Discover amazing artwork on{' '}
|
||||
<a href="/discover/trending" className="text-sky-400 hover:underline">Trending</a>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FollowingFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [filter, setFilter] = useState('all')
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1, f = filter) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/posts/following', {
|
||||
params: { page: p, filter: f },
|
||||
})
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeed(1, filter)
|
||||
}, [filter])
|
||||
|
||||
const handleFilterChange = (f) => {
|
||||
if (f === filter) return
|
||||
setFilter(f)
|
||||
setPosts([])
|
||||
setLoaded(false)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleDeleted = useCallback((postId) => {
|
||||
setPosts((prev) => prev.filter((p) => p.id !== postId))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
{/* ── Page header ────────────────────────────────────────────────────── */}
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-users-rays mr-2 text-sky-400 opacity-80" />
|
||||
Following Feed
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Posts from creators you follow</p>
|
||||
</div>
|
||||
<a
|
||||
href="/discover/trending"
|
||||
className="text-xs text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
Discover creators →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{FILTER_OPTIONS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => handleFilterChange(f.value)}
|
||||
className={`px-3.5 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all border ${
|
||||
filter === f.value
|
||||
? 'bg-sky-600/20 border-sky-500/40 text-sky-300'
|
||||
: 'bg-white/[0.03] border-white/[0.06] text-slate-400 hover:text-white hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Feed ────────────────────────────────────────────────────────────── */}
|
||||
<div className="max-w-2xl mx-auto px-4 pb-16 space-y-4">
|
||||
{/* Loading skeletons */}
|
||||
{!loaded && loading && (
|
||||
<>
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{loaded && !loading && posts.length === 0 && <EmptyFollowingState />}
|
||||
|
||||
{/* Posts */}
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Load more */}
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
resources/js/Pages/Feed/HashtagFeed.jsx
Normal file
114
resources/js/Pages/Feed/HashtagFeed.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
export default function HashtagFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth, tag } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [totalPosts, setTotalPosts] = useState(null)
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/feed/hashtag/${encodeURIComponent(tag)}`, {
|
||||
params: { page: p },
|
||||
})
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setTotalPosts(data.meta.total ?? null)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [tag])
|
||||
|
||||
useEffect(() => { fetchFeed(1) }, [tag])
|
||||
|
||||
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-16">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="inline-flex items-center justify-center w-10 h-10 rounded-xl bg-sky-500/15 text-sky-400 text-lg font-bold">
|
||||
#
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">#{tag}</h1>
|
||||
{totalPosts !== null && (
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
{totalPosts.toLocaleString()} post{totalPosts !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<a
|
||||
href="/feed/trending"
|
||||
className="text-xs text-slate-500 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
← Trending
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
<div className="space-y-4">
|
||||
{!loaded && loading && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-hashtag text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">No posts yet</h2>
|
||||
<p className="text-slate-500 text-sm max-w-xs">
|
||||
No posts tagged <span className="text-sky-400">#{tag}</span> yet. Be the first!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
resources/js/Pages/Feed/SavedFeed.jsx
Normal file
105
resources/js/Pages/Feed/SavedFeed.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
export default function SavedFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/posts/saved', { params: { page: p } })
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchFeed(1) }, [])
|
||||
|
||||
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
// When a post is unsaved, remove it from the list too
|
||||
const handleUnsaved = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-16">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-bookmark mr-2 text-amber-400 opacity-80" />
|
||||
Saved Posts
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Posts you've bookmarked</p>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
<div className="space-y-4">
|
||||
{!loaded && loading && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-bookmark text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">Nothing saved yet</h2>
|
||||
<p className="text-slate-500 text-sm max-w-xs leading-relaxed">
|
||||
Bookmark posts to read later. Look for the{' '}
|
||||
<i className="fa-regular fa-bookmark text-amber-400" /> icon on any post.
|
||||
</p>
|
||||
<a
|
||||
href="/feed/trending"
|
||||
className="mt-4 text-sm text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
Browse trending posts →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
onUnsaved={handleUnsaved}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
resources/js/Pages/Feed/SearchFeed.jsx
Normal file
255
resources/js/Pages/Feed/SearchFeed.jsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
/* ── Trending hashtags sidebar ─────────────────────────────────────────────── */
|
||||
function TrendingHashtagsSidebar({ hashtags }) {
|
||||
if (!hashtags || hashtags.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
|
||||
<i className="fa-solid fa-hashtag text-slate-500 fa-fw text-[13px]" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">
|
||||
Trending Tags
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
{hashtags.map((h) => (
|
||||
<a
|
||||
key={h.tag}
|
||||
href={`/feed/search?q=%23${h.tag}`}
|
||||
className="flex items-center justify-between group px-2 py-1.5 rounded-lg transition-colors hover:bg-white/5 text-slate-400 hover:text-white"
|
||||
>
|
||||
<span className="text-sm font-medium">#{h.tag}</span>
|
||||
<span className="text-[11px] text-slate-600 group-hover:text-slate-500 tabular-nums">
|
||||
{h.post_count}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main page ─────────────────────────────────────────────────────────────── */
|
||||
export default function SearchFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth, initialQuery, trendingHashtags } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [query, setQuery] = useState(initialQuery ?? '')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [meta, setMeta] = useState(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const debounceRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
/* ── Push query into URL without reload ──────────────────────────────────── */
|
||||
const pushUrl = useCallback((q) => {
|
||||
const url = q.trim()
|
||||
? `/feed/search?q=${encodeURIComponent(q.trim())}`
|
||||
: '/feed/search'
|
||||
window.history.replaceState({}, '', url)
|
||||
}, [])
|
||||
|
||||
/* ── Fetch results ───────────────────────────────────────────────────────── */
|
||||
const fetchResults = useCallback(async (q, p = 1) => {
|
||||
if (!q.trim() || q.trim().length < 2) {
|
||||
setResults([])
|
||||
setMeta(null)
|
||||
setSearched(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/feed/search', {
|
||||
params: { q: q.trim(), page: p },
|
||||
})
|
||||
setResults((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setMeta(data.meta)
|
||||
setPage(p)
|
||||
setSearched(true)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── Debounce typing ─────────────────────────────────────────────────────── */
|
||||
const handleChange = useCallback((e) => {
|
||||
const q = e.target.value
|
||||
setQuery(q)
|
||||
pushUrl(q)
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchResults(q, 1)
|
||||
}, 350)
|
||||
}, [fetchResults, pushUrl])
|
||||
|
||||
const handleSubmit = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
clearTimeout(debounceRef.current)
|
||||
fetchResults(query, 1)
|
||||
}, [fetchResults, query])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setMeta(null)
|
||||
setSearched(false)
|
||||
pushUrl('')
|
||||
inputRef.current?.focus()
|
||||
}, [pushUrl])
|
||||
|
||||
/* ── Run initial query if pre-filled from URL ────────────────────────────── */
|
||||
useEffect(() => {
|
||||
if (initialQuery?.trim().length >= 2) {
|
||||
fetchResults(initialQuery.trim(), 1)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleDeleted = useCallback((id) => {
|
||||
setResults((prev) => prev.filter((p) => p.id !== id))
|
||||
}, [])
|
||||
|
||||
const hasMore = meta ? meta.current_page < meta.last_page : false
|
||||
const noResults = searched && !loading && results.length === 0
|
||||
const hasResults = results.length > 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-5xl mx-auto px-4 pt-8 pb-16">
|
||||
<div className="flex gap-8">
|
||||
|
||||
{/* ── Main ─────────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-5">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-magnifying-glass mr-2 text-slate-400/80" />
|
||||
Search Posts
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
Search by keywords, hashtags, or phrases
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search box */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
placeholder="Search posts…"
|
||||
className="w-full bg-white/[0.05] border border-white/[0.08] rounded-xl pl-10 pr-10 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-sky-500/40 focus:ring-1 focus:ring-sky-500/30 transition-colors"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-500 hover:text-white transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Skeletons while first load */}
|
||||
{loading && !hasResults && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{/* Idle / too short */}
|
||||
{!searched && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-magnifying-glass text-2xl" />
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Type at least 2 characters to search posts
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{noResults && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-face-rolling-eyes text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-1">No results</h2>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Nothing matched <span className="text-slate-300">“{query}”</span>.
|
||||
Try different keywords or a hashtag.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results meta */}
|
||||
{hasResults && meta && (
|
||||
<p className="text-[11px] text-slate-600 px-1">
|
||||
{meta.total.toLocaleString()} result{meta.total !== 1 ? 's' : ''} for{' '}
|
||||
<span className="text-slate-400">“{query}”</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Post cards */}
|
||||
{results.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Loading more indicator */}
|
||||
{loading && hasResults && (
|
||||
<div className="flex justify-center py-4">
|
||||
<i className="fa-solid fa-spinner fa-spin text-slate-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{!loading && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchResults(query, page + 1)}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Sidebar ──────────────────────────────────────────────────── */}
|
||||
<aside className="hidden lg:block w-64 shrink-0 space-y-4 pt-14">
|
||||
<TrendingHashtagsSidebar hashtags={trendingHashtags} />
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] px-4 py-4 text-center">
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
Tip: search <span className="text-sky-400/80">#hashtag</span> to find
|
||||
posts by topic.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
133
resources/js/Pages/Feed/TrendingFeed.jsx
Normal file
133
resources/js/Pages/Feed/TrendingFeed.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
function TrendingHashtagsSidebar({ hashtags, activeTag = null }) {
|
||||
if (!hashtags || hashtags.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
|
||||
<i className="fa-solid fa-hashtag text-slate-500 fa-fw text-[13px]" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">Trending Tags</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-1.5">
|
||||
{hashtags.map((h) => (
|
||||
<a
|
||||
key={h.tag}
|
||||
href={`/tags/${h.tag}`}
|
||||
className={`flex items-center justify-between group px-2 py-1.5 rounded-lg transition-colors ${
|
||||
activeTag === h.tag
|
||||
? 'bg-sky-500/15 text-sky-400'
|
||||
: 'hover:bg-white/5 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">#{h.tag}</span>
|
||||
<span className="text-[11px] text-slate-600 group-hover:text-slate-500 tabular-nums">
|
||||
{h.post_count} posts
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TrendingFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth, trendingHashtags } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/feed/trending', { params: { page: p } })
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchFeed(1) }, [])
|
||||
|
||||
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-5xl mx-auto px-4 pt-8 pb-16">
|
||||
<div className="flex gap-8">
|
||||
{/* ── Main feed ──────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-fire mr-2 text-orange-400 opacity-80" />
|
||||
Trending
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Most engaging posts right now</p>
|
||||
</div>
|
||||
|
||||
{!loaded && loading && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-fire text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">Nothing trending yet</h2>
|
||||
<p className="text-slate-500 text-sm">Check back soon — posts are ranked by engagement.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Sidebar ────────────────────────────────────────────────── */}
|
||||
<aside className="hidden lg:block w-64 shrink-0 space-y-4 pt-14">
|
||||
<TrendingHashtagsSidebar hashtags={trendingHashtags} />
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] px-4 py-4 text-center">
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
Posts are ranked by likes, comments & engagement over the last 7 days.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
resources/js/Pages/Forum/ForumCategory.jsx
Normal file
81
resources/js/Pages/Forum/ForumCategory.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import ThreadRow from '../../components/forum/ThreadRow'
|
||||
import Pagination from '../../components/forum/Pagination'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
export default function ForumCategory({ category, threads = [], pagination = {}, isAuthenticated = false }) {
|
||||
const name = category?.name ?? 'Category'
|
||||
const slug = category?.slug
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: name },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-5 mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Forum</p>
|
||||
<h1 className="text-3xl font-bold text-white leading-tight">{name}</h1>
|
||||
</div>
|
||||
{isAuthenticated && slug && (
|
||||
<a href={`/forum/${slug}/new`}>
|
||||
<Button variant="primary" size="sm"
|
||||
leftIcon={
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
New thread
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thread list */}
|
||||
<section className="overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 backdrop-blur">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-4 border-b border-white/[0.06] px-5 py-3">
|
||||
<span className="flex-1 text-xs font-semibold uppercase tracking-widest text-white/30">Threads</span>
|
||||
<span className="w-12 text-center text-xs font-semibold uppercase tracking-widest text-white/30">Posts</span>
|
||||
</div>
|
||||
|
||||
{threads.length === 0 ? (
|
||||
<div className="px-5 py-12 text-center">
|
||||
<svg className="mx-auto mb-4 text-zinc-600" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
<p className="text-sm text-zinc-500">No threads in this section yet.</p>
|
||||
{isAuthenticated && slug && (
|
||||
<a href={`/forum/${slug}/new`} className="mt-3 inline-block text-sm text-sky-300 hover:text-sky-200">
|
||||
Be the first to start a discussion →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{threads.map((thread, i) => (
|
||||
<ThreadRow key={thread.topic_id ?? thread.id ?? i} thread={thread} isFirst={i === 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination?.last_page > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination meta={pagination} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
resources/js/Pages/Forum/ForumEditPost.jsx
Normal file
74
resources/js/Pages/Forum/ForumEditPost.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import Button from '../../components/ui/Button'
|
||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||
|
||||
export default function ForumEditPost({ post, thread, csrfToken, errors = {} }) {
|
||||
const [content, setContent] = useState(post?.content ?? '')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: thread?.title ?? 'Thread', href: thread?.id ? `/forum/thread/${thread.id}-${thread.slug ?? ''}` : '/forum' },
|
||||
{ label: 'Edit post' },
|
||||
]
|
||||
|
||||
const handleSubmit = useCallback((e) => {
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
// Let the form submit normally for PRG
|
||||
}, [submitting])
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-5 mb-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Edit</p>
|
||||
<h1 className="text-2xl font-bold text-white leading-tight">Edit post</h1>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
method="POST"
|
||||
action={`/forum/post/${post?.id}`}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
|
||||
>
|
||||
<input type="hidden" name="_token" value={csrfToken} />
|
||||
<input type="hidden" name="_method" value="PUT" />
|
||||
|
||||
{/* Rich text editor */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-white/85">
|
||||
Content
|
||||
</label>
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Edit your post…"
|
||||
error={errors.content}
|
||||
minHeight={14}
|
||||
autofocus={false}
|
||||
/>
|
||||
<input type="hidden" name="content" value={content} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<a
|
||||
href={thread?.id ? `/forum/thread/${thread.id}-${thread.slug ?? ''}` : '/forum'}
|
||||
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
← Cancel
|
||||
</a>
|
||||
<Button type="submit" variant="primary" size="md" loading={submitting}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
resources/js/Pages/Forum/ForumIndex.jsx
Normal file
31
resources/js/Pages/Forum/ForumIndex.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import CategoryCard from '../../components/forum/CategoryCard'
|
||||
|
||||
export default function ForumIndex({ categories = [] }) {
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
|
||||
<h1 className="text-3xl font-bold text-white leading-tight">Forum</h1>
|
||||
<p className="mt-1.5 text-sm text-white/50">Browse forum sections and join the conversation.</p>
|
||||
</div>
|
||||
|
||||
{/* Category grid */}
|
||||
{categories.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/50 p-12 text-center">
|
||||
<svg className="mx-auto mb-4 text-zinc-600" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
<p className="text-sm text-zinc-500">No forum categories available yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{categories.map((cat) => (
|
||||
<CategoryCard key={cat.id ?? cat.slug} category={cat} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
resources/js/Pages/Forum/ForumNewThread.jsx
Normal file
91
resources/js/Pages/Forum/ForumNewThread.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import Button from '../../components/ui/Button'
|
||||
import TextInput from '../../components/ui/TextInput'
|
||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||
|
||||
export default function ForumNewThread({ category, csrfToken, errors = {}, oldValues = {} }) {
|
||||
const [title, setTitle] = useState(oldValues.title ?? '')
|
||||
const [content, setContent] = useState(oldValues.content ?? '')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const slug = category?.slug
|
||||
const categoryName = category?.name ?? 'Category'
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: categoryName, href: slug ? `/forum/${slug}` : '/forum' },
|
||||
{ label: 'New thread' },
|
||||
]
|
||||
|
||||
const handleSubmit = useCallback(async (e) => {
|
||||
e.preventDefault()
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
|
||||
// Standard form submission to keep server-side validation + redirect
|
||||
e.target.submit()
|
||||
}, [submitting])
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-5 mb-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">New thread</p>
|
||||
<h1 className="text-2xl font-bold text-white leading-tight">
|
||||
Create thread in {categoryName}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
method="POST"
|
||||
action={`/forum/${slug}/new`}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
|
||||
>
|
||||
<input type="hidden" name="_token" value={csrfToken} />
|
||||
|
||||
<TextInput
|
||||
label="Title"
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Thread title…"
|
||||
error={errors.title}
|
||||
/>
|
||||
|
||||
{/* Rich text editor */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-white/85">
|
||||
Content <span className="text-red-400 ml-1">*</span>
|
||||
</label>
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Write your post…"
|
||||
error={errors.content}
|
||||
minHeight={14}
|
||||
autofocus={false}
|
||||
/>
|
||||
<input type="hidden" name="content" value={content} />
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<a href={`/forum/${slug}`} className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
← Cancel
|
||||
</a>
|
||||
<Button type="submit" variant="primary" size="md" loading={submitting}>
|
||||
Publish thread
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
200
resources/js/Pages/Forum/ForumThread.jsx
Normal file
200
resources/js/Pages/Forum/ForumThread.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import PostCard from '../../components/forum/PostCard'
|
||||
import ReplyForm from '../../components/forum/ReplyForm'
|
||||
import Pagination from '../../components/forum/Pagination'
|
||||
|
||||
export default function ForumThread({
|
||||
thread,
|
||||
category,
|
||||
author,
|
||||
opPost,
|
||||
posts = [],
|
||||
pagination = {},
|
||||
replyCount = 0,
|
||||
sort = 'asc',
|
||||
quotedPost = null,
|
||||
replyPrefill = '',
|
||||
isAuthenticated = false,
|
||||
canModerate = false,
|
||||
csrfToken = '',
|
||||
status = null,
|
||||
}) {
|
||||
const [currentSort, setCurrentSort] = useState(sort)
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: category?.name ?? 'Category', href: category?.slug ? `/forum/${category.slug}` : '/forum' },
|
||||
{ label: thread?.title ?? 'Thread' },
|
||||
]
|
||||
|
||||
const handleSortToggle = useCallback(() => {
|
||||
const newSort = currentSort === 'asc' ? 'desc' : 'asc'
|
||||
setCurrentSort(newSort)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('sort', newSort)
|
||||
window.location.href = url.toString()
|
||||
}, [currentSort])
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto space-y-5">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Status flash */}
|
||||
{status && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thread header card */}
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-nova-800/50 p-5 backdrop-blur">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold text-white leading-snug">{thread?.title}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||
<span>By {author?.name ?? 'Unknown'}</span>
|
||||
<span className="text-zinc-700">•</span>
|
||||
{thread?.created_at && (
|
||||
<time dateTime={thread.created_at}>{formatDate(thread.created_at)}</time>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-sky-500/15 px-2.5 py-1 text-sky-300">
|
||||
{number(thread?.views ?? 0)} views
|
||||
</span>
|
||||
<span className="rounded-full bg-cyan-500/15 px-2.5 py-1 text-cyan-300">
|
||||
{number(replyCount)} replies
|
||||
</span>
|
||||
{thread?.is_pinned && (
|
||||
<span className="rounded-full bg-amber-500/15 px-2.5 py-1 text-amber-300">Pinned</span>
|
||||
)}
|
||||
{thread?.is_locked && (
|
||||
<span className="rounded-full bg-red-500/15 px-2.5 py-1 text-red-300">Locked</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Moderation tools */}
|
||||
{canModerate && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-white/[0.06] pt-3">
|
||||
{thread?.is_locked ? (
|
||||
<ModForm action={`/forum/thread/${thread.id}/unlock`} csrf={csrfToken} label="Unlock" variant="danger" />
|
||||
) : (
|
||||
<ModForm action={`/forum/thread/${thread.id}/lock`} csrf={csrfToken} label="Lock" variant="danger" />
|
||||
)}
|
||||
{thread?.is_pinned ? (
|
||||
<ModForm action={`/forum/thread/${thread.id}/unpin`} csrf={csrfToken} label="Unpin" variant="warning" />
|
||||
) : (
|
||||
<ModForm action={`/forum/thread/${thread.id}/pin`} csrf={csrfToken} label="Pin" variant="warning" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Sort toggle + reply count */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-500">{number(replyCount)} {replyCount === 1 ? 'reply' : 'replies'}</p>
|
||||
<button
|
||||
onClick={handleSortToggle}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points={currentSort === 'asc' ? '18 15 12 21 6 15' : '18 9 12 3 6 9'} />
|
||||
<line x1="12" y1="3" x2="12" y2="21" />
|
||||
</svg>
|
||||
{currentSort === 'asc' ? 'Oldest first' : 'Newest first'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OP Post */}
|
||||
{opPost && (
|
||||
<PostCard
|
||||
post={opPost}
|
||||
thread={thread}
|
||||
isOp
|
||||
isAuthenticated={isAuthenticated}
|
||||
canModerate={canModerate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reply list */}
|
||||
<section className="space-y-4" aria-label="Replies">
|
||||
{posts.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/40 px-5 py-8 text-center text-zinc-500 text-sm">
|
||||
No replies yet. Be the first to respond!
|
||||
</div>
|
||||
) : (
|
||||
posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
thread={thread}
|
||||
isAuthenticated={isAuthenticated}
|
||||
canModerate={canModerate}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination?.last_page > 1 && (
|
||||
<div className="sticky bottom-3 z-10 rounded-xl border border-white/[0.06] bg-nova-800/80 p-2 backdrop-blur">
|
||||
<Pagination meta={pagination} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply form or locked / auth prompt */}
|
||||
{isAuthenticated ? (
|
||||
thread?.is_locked ? (
|
||||
<div className="rounded-2xl border border-red-500/20 bg-red-500/5 px-5 py-4 text-sm text-red-300">
|
||||
This thread is locked. Replies are disabled.
|
||||
</div>
|
||||
) : (
|
||||
<ReplyForm
|
||||
threadId={thread?.id}
|
||||
prefill={replyPrefill}
|
||||
quotedAuthor={quotedPost?.user?.name}
|
||||
csrfToken={csrfToken}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/40 px-5 py-5 text-sm text-zinc-400">
|
||||
<a href="/login" className="text-sky-300 hover:text-sky-200 font-medium">Sign in</a> to post a reply.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModForm({ action, csrf, label, variant }) {
|
||||
const colors = variant === 'danger'
|
||||
? 'bg-red-500/15 text-red-300 hover:bg-red-500/25 border-red-500/20'
|
||||
: 'bg-amber-500/15 text-amber-300 hover:bg-amber-500/25 border-amber-500/20'
|
||||
|
||||
return (
|
||||
<form method="POST" action={action}>
|
||||
<input type="hidden" name="_token" value={csrf} />
|
||||
<button type="submit" className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${colors}`}>
|
||||
{label}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function number(n) {
|
||||
return (n ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
+ ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user