Featured artworks thumbnails

This commit is contained in:
2026-05-06 19:11:31 +02:00
parent 82f2b1f660
commit 0c5dde9b22
36 changed files with 55994 additions and 30 deletions

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\News\NewsCoverImageService;
use App\Support\News\NewsCoverImage;
use Illuminate\Console\Command;
use cPad\Plugins\News\Models\NewsArticle;
final class GenerateNewsCoverThumbnailsCommand extends Command
{
protected $signature = 'news:generate-cover-thumbnails {--id=* : Restrict to one or more news article IDs} {--force : Regenerate variants even when they already exist}';
protected $description = 'Generate missing responsive cover thumbnails for managed news cover images';
public function __construct(
private readonly NewsCoverImageService $covers,
private readonly ArtworkCdnPurgeService $cdnPurge,
)
{
parent::__construct();
}
public function handle(): int
{
$ids = collect((array) $this->option('id'))
->map(static fn (mixed $id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
$force = (bool) $this->option('force');
$query = NewsArticle::query()
->select(['id', 'title', 'cover_image'])
->whereNotNull('cover_image')
->where('cover_image', '!=', '');
if ($ids !== []) {
$query->whereIn('id', $ids);
}
$generated = 0;
$skipped = 0;
$failed = 0;
$purged = 0;
$query->orderBy('id')->chunkById(100, function ($articles) use (&$generated, &$skipped, &$failed, &$purged, $force): void {
foreach ($articles as $article) {
$path = trim((string) $article->cover_image);
if (! NewsCoverImage::isManagedPath($path)) {
$skipped++;
continue;
}
try {
$result = $this->covers->ensureVariants($path, $force);
} catch (\Throwable $e) {
$failed++;
$this->warn(sprintf('Article %d failed: %s', (int) $article->id, $e->getMessage()));
continue;
}
if (($result['generated'] ?? 0) > 0) {
$generated++;
if ($force && $this->purgeVariantCache($path, (int) $article->id)) {
$purged++;
}
continue;
}
$skipped++;
}
});
$this->info(sprintf('News cover thumbnail generation complete: generated=%d skipped=%d failed=%d purged=%d', $generated, $skipped, $failed, $purged));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function purgeVariantCache(string $path, int $articleId): bool
{
$variantPaths = array_values(array_map(
static fn (string $variant): string => NewsCoverImage::variantPath($path, $variant),
array_keys(NewsCoverImage::VARIANTS),
));
return $this->cdnPurge->purgeArtworkObjectPaths($variantPaths, [
'article_id' => $articleId,
'reason' => 'news_cover_thumbnails_regenerated',
]);
}
}

View File

@@ -0,0 +1,502 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Repositories\Uploads\ArtworkFileRepository;
use App\Services\ArtworkOriginalFileLocator;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\Uploads\UploadDerivativesService;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class RepairArtworkThumbnailsCommand extends Command
{
private const SOURCE_IMAGE_EXTENSIONS = [
'avif',
'bmp',
'gif',
'jpg',
'jpeg',
'png',
'tif',
'tiff',
'webp',
];
protected $signature = 'artworks:repair-missing-thumbnails
{--id= : Repair only this artwork ID}
{--limit= : Stop after processing this many artworks}
{--chunk=200 : Number of artworks to scan per batch}
{--variant=* : Specific thumbnail variants to repair (defaults to all configured derivatives)}
{--only-missing-flagged : Scan only artworks already marked with has_missing_thumbnails=1}
{--csv= : Optional path to write a CSV report}
{--force : Regenerate the selected variants even when they already exist}
{--dry-run : Report repairs without writing files}';
protected $description = 'Scan artworks from newest to oldest, detect missing CDN thumbnails, and rebuild only the missing derivatives from local source files.';
public function handle(
UploadStorageService $storage,
UploadDerivativesService $derivatives,
ArtworkFileRepository $artworkFiles,
ArtworkOriginalFileLocator $locator,
ArtworkCdnPurgeService $cdnPurge,
): int {
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
$onlyMissingFlagged = (bool) $this->option('only-missing-flagged');
$csvPath = trim((string) $this->option('csv'));
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$allVariants = $this->resolveConfiguredVariants();
$selectedVariants = $this->resolveSelectedVariants($allVariants);
if ($selectedVariants === []) {
return self::FAILURE;
}
$auditColumnsAvailable = Schema::hasColumns('artworks', [
'has_missing_thumbnails',
'missing_thumbnail_variants_json',
'thumbnails_checked_at',
]);
if ($onlyMissingFlagged && ! $auditColumnsAvailable) {
$this->error('The --only-missing-flagged option requires thumbnail audit columns on the artworks table.');
return self::FAILURE;
}
$diskName = $storage->objectDiskName();
$disk = Storage::disk($diskName);
$csvHandle = $this->openCsvHandle($csvPath);
$baseQuery = $this->baseQuery($onlyMissingFlagged);
$totalCandidates = $this->resolveTotalCandidates($baseQuery, $artworkId, $limit);
$progressBar = $totalCandidates > 0 ? $this->output->createProgressBar($totalCandidates) : null;
$this->info(sprintf(
'Starting thumbnail repair. order=id_desc include_trashed=yes disk=%s variants=%s chunk=%d limit=%s flagged_only=%s force=%s dry_run=%s csv=%s',
$diskName,
implode(',', $selectedVariants),
$chunkSize,
$limit !== null ? (string) $limit : 'all',
$onlyMissingFlagged ? 'yes' : 'no',
$force ? 'yes' : 'no',
$dryRun ? 'yes' : 'no',
$csvPath !== '' ? $csvPath : 'off',
));
if ($progressBar !== null) {
$progressBar->start();
}
$processed = 0;
$healthy = 0;
$planned = 0;
$repaired = 0;
$failed = 0;
$lastSeenId = null;
try {
do {
$artworks = $this->nextChunk($baseQuery, $artworkId, $chunkSize, $lastSeenId);
if ($artworks->isEmpty()) {
break;
}
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
break 2;
}
try {
$targetVariants = $force
? $selectedVariants
: $this->resolveMissingVariants($artwork, $selectedVariants, $storage, $disk);
if ($targetVariants === []) {
$healthy++;
$processed++;
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'healthy',
'variants' => '',
'source_file' => '',
'message' => '',
]);
$progressBar?->advance();
continue;
}
$sourcePath = $this->resolveLocalSourcePath($artwork, $locator);
if ($sourcePath === '') {
throw new \RuntimeException('No local original source file was found in the configured artwork roots.');
}
if ($dryRun) {
$planned++;
$this->line(sprintf(
'Artwork %d would repair thumbnails: %s',
(int) $artwork->id,
implode(',', $targetVariants),
));
$this->line(' source_file: ' . $sourcePath);
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'planned',
'variants' => implode(',', $targetVariants),
'source_file' => $sourcePath,
'message' => '',
]);
$processed++;
$progressBar?->advance();
continue;
}
$assets = $derivatives->generateSelectedPublicDerivatives($sourcePath, (string) $artwork->hash, $targetVariants);
if ($assets === []) {
throw new \RuntimeException('No thumbnail assets were generated for the requested variants.');
}
DB::transaction(function () use ($artwork, $assets, $artworkFiles, $storage, $disk, $allVariants, $auditColumnsAvailable): void {
foreach ($assets as $variant => $asset) {
$artworkFiles->upsert((int) $artwork->id, (string) $variant, $asset['path'], $asset['mime'], $asset['size']);
}
$update = [
'thumb_ext' => 'webp',
];
if ($auditColumnsAvailable) {
$remainingMissing = $this->resolveMissingVariants($artwork, $allVariants, $storage, $disk);
$update['has_missing_thumbnails'] = $remainingMissing !== [];
$update['missing_thumbnail_variants_json'] = $remainingMissing === []
? null
: json_encode(array_values($remainingMissing), JSON_UNESCAPED_SLASHES);
$update['thumbnails_checked_at'] = now();
}
Artwork::query()->withTrashed()->whereKey($artwork->id)->update($update);
});
$cdnPurge->purgeArtworkObjectPaths(array_map(
static fn (array $asset): string => (string) $asset['path'],
array_values($assets),
), [
'artwork_id' => (int) $artwork->id,
'reason' => 'thumbnail_repair',
]);
$repaired++;
$this->info(sprintf(
'Artwork %d repaired thumbnails: %s',
(int) $artwork->id,
implode(',', array_keys($assets)),
));
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'repaired',
'variants' => implode(',', array_keys($assets)),
'source_file' => $sourcePath,
'message' => '',
]);
} catch (Throwable $exception) {
$failed++;
$this->warn(sprintf('Artwork %d repair failed: %s', (int) $artwork->id, $exception->getMessage()));
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'failed',
'variants' => isset($targetVariants) && is_array($targetVariants) ? implode(',', $targetVariants) : '',
'source_file' => isset($sourcePath) ? (string) $sourcePath : '',
'message' => $exception->getMessage(),
]);
}
$processed++;
$progressBar?->advance();
}
$lastSeenId = (int) $artworks->last()->id;
} while (true);
} finally {
if ($progressBar !== null) {
$progressBar->finish();
$this->newLine(2);
}
if (is_resource($csvHandle)) {
fclose($csvHandle);
}
}
$this->info(sprintf(
'Thumbnail repair complete. processed=%d healthy=%d planned=%d repaired=%d failed=%d',
$processed,
$healthy,
$planned,
$repaired,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return Collection<int, Artwork>
*/
private function nextChunk(mixed $baseQuery, ?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
{
$query = clone $baseQuery;
if ($artworkId !== null) {
$query->whereKey($artworkId);
} elseif ($lastSeenId !== null) {
$query->where('id', '<', $lastSeenId);
}
return $query->limit($chunkSize)->get();
}
private function baseQuery(bool $onlyMissingFlagged): mixed
{
$query = Artwork::query()
->withTrashed()
->select(['id', 'slug', 'hash', 'file_path', 'file_ext', 'thumb_ext'])
->whereNotNull('hash')
->where('hash', '!=', '')
->orderByDesc('id');
if ($onlyMissingFlagged) {
$query->where('has_missing_thumbnails', true);
}
return $query;
}
private function resolveTotalCandidates(mixed $baseQuery, ?int $artworkId, ?int $limit): int
{
$countQuery = clone $baseQuery;
if ($artworkId !== null) {
$countQuery->whereKey($artworkId);
}
$count = (int) $countQuery->count();
if ($limit !== null) {
return min($count, $limit);
}
return $count;
}
/**
* @return list<string>
*/
private function resolveConfiguredVariants(): array
{
return array_values(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
array_keys((array) config('uploads.derivatives', [])),
)));
}
/**
* @param list<string> $configuredVariants
* @return list<string>
*/
private function resolveSelectedVariants(array $configuredVariants): array
{
if ($configuredVariants === []) {
$this->error('No thumbnail variants are configured. Check uploads.derivatives.');
return [];
}
$requested = (array) $this->option('variant');
if ($requested === []) {
return $configuredVariants;
}
$normalizedRequested = array_values(array_unique(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
$requested,
))));
$invalid = array_values(array_diff($normalizedRequested, $configuredVariants));
if ($invalid !== []) {
$this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
$this->line('Configured variants: ' . implode(', ', $configuredVariants));
return [];
}
return $normalizedRequested;
}
/**
* @param list<string> $variants
* @return list<string>
*/
private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
{
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
if ($hash === '') {
return $variants;
}
$missing = [];
foreach ($variants as $variant) {
$objectPath = $storage->objectPathForVariant($variant, $hash, $hash . '.webp');
if (! $disk->exists($objectPath)) {
$missing[] = $variant;
}
}
return $missing;
}
private function resolveLocalSourcePath(Artwork $artwork, ArtworkOriginalFileLocator $locator): string
{
$hash = strtolower((string) ($artwork->hash ?? ''));
if (! $this->isValidHash($hash)) {
return '';
}
$preferred = $locator->resolveLocalPath($artwork);
if ($this->isUsableSourceFile($preferred)) {
return $preferred;
}
foreach ($this->candidateOriginalRoots() as $root) {
$candidatePath = $this->findNonZipSourceInRoot($root, $hash);
if ($candidatePath !== '') {
return $candidatePath;
}
}
return '';
}
/**
* @return list<string>
*/
private function candidateOriginalRoots(): array
{
$roots = [
trim((string) config('uploads.local_originals_root', '')),
trim((string) config('uploads.readonly_backup_originals_root', '')),
];
$normalizedRoots = [];
foreach ($roots as $root) {
if ($root === '') {
continue;
}
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
if ($normalizedRoot === '' || in_array($normalizedRoot, $normalizedRoots, true)) {
continue;
}
$normalizedRoots[] = $normalizedRoot;
}
return $normalizedRoots;
}
private function findNonZipSourceInRoot(string $root, string $hash): string
{
$directory = $root
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
. DIRECTORY_SEPARATOR . substr($hash, 2, 2);
if (! File::isDirectory($directory)) {
return '';
}
$matches = File::glob($directory . DIRECTORY_SEPARATOR . $hash . '.*');
if (! is_array($matches)) {
return '';
}
foreach ($matches as $path) {
if ($this->isUsableSourceFile($path)) {
return $path;
}
}
return '';
}
private function isUsableSourceFile(string $path): bool
{
if ($path === '' || ! File::isFile($path)) {
return false;
}
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
if ($extension === '' || ! in_array($extension, self::SOURCE_IMAGE_EXTENSIONS, true)) {
return false;
}
$mime = strtolower((string) (File::mimeType($path) ?? ''));
return str_starts_with($mime, 'image/');
}
private function isValidHash(string $hash): bool
{
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
}
/**
* @return resource|null
*/
private function openCsvHandle(string $csvPath)
{
if ($csvPath === '') {
return null;
}
File::ensureDirectoryExists(dirname($csvPath));
$handle = fopen($csvPath, 'wb');
if (! is_resource($handle)) {
throw new \RuntimeException('Unable to open CSV output path for writing: ' . $csvPath);
}
fputcsv($handle, ['artwork_id', 'status', 'variants', 'source_file', 'message']);
return $handle;
}
/**
* @param resource|null $csvHandle
* @param array<string, scalar|null> $row
*/
private function writeCsvRow($csvHandle, array $row): void
{
if (! is_resource($csvHandle)) {
return;
}
fputcsv($csvHandle, [
$row['artwork_id'] ?? '',
$row['status'] ?? '',
$row['variants'] ?? '',
$row['source_file'] ?? '',
$row['message'] ?? '',
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation\Traffic;
use App\Http\Controllers\Controller;
use App\Services\Traffic\OnlineVisitorRepository;
use Illuminate\Http\JsonResponse;
use Illuminate\View\View;
final class OnlineVisitorsController extends Controller
{
public function __construct(private readonly OnlineVisitorRepository $visitors)
{
}
public function index(): View
{
$summary = $this->visitors->summary();
$visitors = $this->visitors->all();
$activePages = $this->visitors->activePages();
return view('moderation.traffic.online', [
'summary' => $summary,
'visitors' => $visitors,
'activePages' => $activePages,
'generatedAt' => now()->toIso8601String(),
'dataUrl' => route('moderation.traffic.online.data'),
]);
}
public function data(): JsonResponse
{
return response()->json([
'summary' => $this->visitors->summary(),
'visitors' => $this->visitors->all(),
'active_pages' => $this->visitors->activePages(),
'generated_at' => now()->toIso8601String(),
]);
}
}

View File

@@ -0,0 +1,373 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class AcademyLessonMediaApiController extends Controller
{
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const MAX_FILE_SIZE_KB = 6144;
private const ASSET_CACHE_TTL_MINUTES = 15;
private ?ImageManager $manager = null;
public function __construct()
{
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function store(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$validated = $request->validate([
'slot' => ['nullable', 'string', 'in:cover,body'],
'image' => [
'required',
'file',
'image',
'max:' . self::MAX_FILE_SIZE_KB,
'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
],
]);
/** @var UploadedFile $file */
$file = $validated['image'];
$slot = $this->normalizeSlot($validated['slot'] ?? null);
try {
$stored = $this->storeMediaFile($file, $slot);
$this->forgetAssetCache();
return response()->json([
'success' => true,
'slot' => $slot,
'path' => $stored['path'],
'url' => $this->publicUrlForPath($stored['path']),
'width' => $stored['width'],
'height' => $stored['height'],
'mime_type' => 'image/webp',
'size_bytes' => $stored['size_bytes'],
]);
} catch (RuntimeException $e) {
return response()->json([
'error' => 'Validation failed',
'message' => $e->getMessage(),
], 422);
} catch (\Throwable $e) {
logger()->error('Academy lesson media upload failed', [
'user_id' => (int) ($request->user()?->id ?? 0),
'message' => $e->getMessage(),
]);
return response()->json([
'error' => 'Upload failed',
'message' => 'Could not upload lesson media right now.',
], 500);
}
}
public function destroy(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$validated = $request->validate([
'path' => ['required', 'string', 'max:2048'],
]);
$this->deleteMediaFile((string) $validated['path']);
$this->forgetAssetCache();
return response()->json([
'success' => true,
]);
}
public function assets(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$validated = $request->validate([
'limit' => ['nullable', 'integer', 'min:1', 'max:48'],
'page' => ['nullable', 'integer', 'min:1'],
'q' => ['nullable', 'string', 'max:100'],
]);
$limit = (int) ($validated['limit'] ?? 24);
$page = (int) ($validated['page'] ?? 1);
$query = Str::lower(trim((string) ($validated['q'] ?? '')));
$manifest = $this->academyAssetManifest();
if ($query !== '') {
$manifest = $manifest->filter(function (array $item) use ($query): bool {
return Str::contains($item['search_text'], $query);
})->values();
}
$total = $manifest->count();
$lastPage = max(1, (int) ceil(max($total, 1) / max($limit, 1)));
$page = min(max($page, 1), $lastPage);
$items = $manifest
->forPage($page, $limit)
->values()
->map(function (array $item): array {
return [
'path' => $item['path'],
'url' => $item['url'],
'name' => $item['name'],
'slot' => $item['slot'],
'modified_at' => $item['modified_at'] ? now()->setTimestamp($item['modified_at'])->toIso8601String() : null,
];
})
->all();
return response()->json([
'success' => true,
'items' => $items,
'pagination' => [
'page' => $page,
'per_page' => $limit,
'total' => $total,
'last_page' => $lastPage,
'has_more' => $page < $lastPage,
],
]);
}
/**
* @return array{path:string,width:int,height:int,size_bytes:int}
*/
private function storeMediaFile(UploadedFile $file, string $slot): array
{
$this->assertImageManager();
$this->assertStorageIsAllowed();
$constraints = $this->mediaConstraints($slot);
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || ! is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded image path.');
}
$raw = file_get_contents($uploadPath);
if ($raw === false || $raw === '') {
throw new RuntimeException('Unable to read uploaded image.');
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($raw));
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported image mime type.');
}
$size = @getimagesizefromstring($raw);
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded file is not a valid image.');
}
$width = (int) ($size[0] ?? 0);
$height = (int) ($size[1] ?? 0);
if ($width < $constraints['min_width'] || $height < $constraints['min_height']) {
throw new RuntimeException(sprintf(
'Image is too small. Minimum required size is %dx%d.',
$constraints['min_width'],
$constraints['min_height'],
));
}
$image = $this->manager->read($raw)->scaleDown(width: $constraints['max_width'], height: $constraints['max_height']);
$encoded = (string) $image->encode(new WebpEncoder(85));
$hash = hash('sha256', $encoded);
$path = $this->mediaPath($hash, $slot);
$disk = Storage::disk($this->mediaDiskName());
$written = $disk->put($path, $encoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
if ($written !== true) {
throw new RuntimeException('Unable to store image in object storage.');
}
return [
'path' => $path,
'width' => (int) $image->width(),
'height' => (int) $image->height(),
'size_bytes' => strlen($encoded),
];
}
private function authorizeStaff(Request $request): void
{
abort_unless((bool) $request->user()?->hasStaffAccess(), 403);
}
private function mediaDiskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function mediaPath(string $hash, string $slot): string
{
$folder = $slot === 'body' ? 'body' : 'covers';
return sprintf(
'academy/lessons/%s/%s/%s/%s.webp',
$folder,
substr($hash, 0, 2),
substr($hash, 2, 2),
$hash,
);
}
private function publicUrlForPath(string $path): string
{
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
private function academyAssetManifest(): Collection
{
return Cache::remember($this->academyAssetCacheKey(), now()->addMinutes(self::ASSET_CACHE_TTL_MINUTES), function (): Collection {
$disk = Storage::disk($this->mediaDiskName());
return collect($disk->allFiles('academy/lessons'))
->filter(fn (string $path): bool => Str::endsWith(Str::lower($path), ['.webp', '.jpg', '.jpeg', '.png']))
->map(function (string $path) use ($disk): array {
$modifiedAt = null;
try {
$modifiedAt = $disk->lastModified($path);
} catch (\Throwable) {
$modifiedAt = null;
}
$folder = Str::contains($path, '/body/') ? 'body' : (Str::contains($path, '/covers/') ? 'cover' : 'asset');
return [
'path' => $path,
'url' => $this->publicUrlForPath($path),
'name' => $this->humanAssetName($path),
'slot' => $folder,
'modified_at' => $modifiedAt ? (int) $modifiedAt : null,
'search_text' => Str::lower(implode(' ', [$path, $folder, $this->humanAssetName($path)])),
];
})
->sortByDesc(fn (array $item): int => (int) ($item['modified_at'] ?? 0))
->values();
});
}
private function academyAssetCacheKey(): string
{
return 'academy.lesson.assets.' . md5($this->mediaDiskName());
}
private function forgetAssetCache(): void
{
Cache::forget($this->academyAssetCacheKey());
}
private function humanAssetName(string $path): string
{
$filename = pathinfo($path, PATHINFO_FILENAME);
$clean = trim(str_replace(['-', '_'], ' ', $filename));
return $clean !== '' ? Str::headline($clean) : 'Academy image';
}
private function safeFileSize($disk, string $path): ?int
{
try {
$size = $disk->size($path);
return is_int($size) ? $size : null;
} catch (\Throwable) {
return null;
}
}
private function deleteMediaFile(string $path): void
{
$trimmed = ltrim(trim($path), '/');
if ($trimmed === '' || ! Str::startsWith($trimmed, ['academy/lessons/covers/', 'academy/lessons/body/'])) {
return;
}
Storage::disk($this->mediaDiskName())->delete($trimmed);
}
private function normalizeSlot(mixed $slot): string
{
return Str::lower(trim((string) $slot)) === 'body' ? 'body' : 'cover';
}
/**
* @return array{min_width:int,min_height:int,max_width:int,max_height:int}
*/
private function mediaConstraints(string $slot): array
{
if ($slot === 'body') {
return [
'min_width' => 64,
'min_height' => 64,
'max_width' => 2400,
'max_height' => 2400,
];
}
return [
'min_width' => 1200,
'min_height' => 630,
'max_width' => 2200,
'max_height' => 1400,
];
}
private function assertImageManager(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
$disk = Storage::disk($this->mediaDiskName());
if (! method_exists($disk, 'put')) {
throw new RuntimeException('Object storage is not configured for academy lesson uploads.');
}
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\Traffic\OnlineVisitorRepository;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class TrackOnlineVisitor
{
public function __construct(private readonly OnlineVisitorRepository $visitors)
{
}
public function handle(Request $request, Closure $next): Response
{
$shouldTrack = $this->shouldTrack($request);
$response = $next($request);
if ($shouldTrack) {
try {
$this->visitors->track($request);
} catch (\Throwable) {
// Presence tracking is best-effort only.
}
}
return $response;
}
private function shouldTrack(Request $request): bool
{
if (! in_array($request->getMethod(), ['GET', 'HEAD'], true)) {
return false;
}
if ($this->matchesAny($request, [
'build/*',
'storage/*',
'favicon.ico',
'livewire/*',
'_debugbar/*',
'telescope/*',
'horizon/*',
'moderation/*',
])) {
return false;
}
if ($request->path() === 'moderation/traffic/online/data') {
return false;
}
if ($this->matchesAny($request, [
'api/*',
'admin/*',
'dashboard*',
'studio*',
'settings*',
'messages*',
'creator*',
'login',
'register',
'forgot-password',
'reset-password/*',
'email/*',
'logout',
'up',
])) {
return false;
}
return ! $this->isStaticAssetPath($request->path());
}
/**
* @param array<int, string> $patterns
*/
private function matchesAny(Request $request, array $patterns): bool
{
foreach ($patterns as $pattern) {
if ($request->is($pattern)) {
return true;
}
}
return false;
}
private function isStaticAssetPath(string $path): bool
{
$normalizedPath = '/' . ltrim($path, '/');
if (in_array($normalizedPath, ['/robots.txt', '/sitemap.xml'], true) || str_starts_with($normalizedPath, '/sitemaps/')) {
return false;
}
return (bool) preg_match('/\.(?:css|js|mjs|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot|otf|mp4|webm|mp3|wav|pdf|zip)$/i', $normalizedPath);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class AcademyAiComparisonResult extends Model
{
use SoftDeletes;
protected $fillable = [
'lesson_block_id',
'provider',
'model_name',
'image_path',
'thumb_path',
'settings',
'strengths',
'weaknesses',
'best_for',
'score',
'sort_order',
'active',
];
protected $casts = [
'score' => 'integer',
'sort_order' => 'integer',
'active' => 'boolean',
];
public function block(): BelongsTo
{
return $this->belongsTo(AcademyLessonBlock::class, 'lesson_block_id');
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('sort_order')->orderBy('id');
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class AcademyLessonBlock extends Model
{
use SoftDeletes;
protected static function booted(): void
{
static::deleting(function (self $block): void {
$block->comparisonResults()->get()->each(function (AcademyAiComparisonResult $result) use ($block): void {
if ($block->isForceDeleting()) {
$result->forceDelete();
return;
}
$result->delete();
});
});
}
protected $fillable = [
'lesson_id',
'type',
'title',
'payload',
'sort_order',
'active',
];
protected $casts = [
'payload' => 'array',
'sort_order' => 'integer',
'active' => 'boolean',
];
public function lesson(): BelongsTo
{
return $this->belongsTo(AcademyLesson::class, 'lesson_id');
}
public function comparisonResults(): HasMany
{
return $this->hasMany(AcademyAiComparisonResult::class, 'lesson_block_id')
->orderBy('sort_order')
->orderBy('id');
}
public function activeComparisonResults(): HasMany
{
return $this->comparisonResults()->where('active', true);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('sort_order')->orderBy('id');
}
}

View File

@@ -1233,7 +1233,7 @@ final class HomepageService
->filter() ->filter()
->implode(', '); ->implode(', ');
$xsSources = collect(['xs', 'mobile_sm']) $xsSources = collect(['mobile_xs', 'mobile_sm'])
->map(function (string $variant) use ($variantUrls, $variants): ?string { ->map(function (string $variant) use ($variantUrls, $variants): ?string {
$url = $variantUrls[$variant] ?? null; $url = $variantUrls[$variant] ?? null;

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Services\News;
use App\Support\News\NewsCoverImage;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class NewsCoverImageService
{
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const MAX_FILE_SIZE_KB = 6144;
private const MAX_WIDTH = 2200;
private const MAX_HEIGHT = 1400;
private const MIN_WIDTH = 1200;
private const MIN_HEIGHT = 630;
private ?ImageManager $manager = null;
public function __construct()
{
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function maxFileSizeKb(): int
{
return self::MAX_FILE_SIZE_KB;
}
public function storeUploadedFile(UploadedFile $file): array
{
$this->assertImageManager();
$this->assertStorageIsAllowed();
$raw = $this->readUploadBytes($file);
$this->assertSupportedMimeType($raw);
$this->assertMinimumDimensions($raw);
$masterImage = $this->manager->read($raw)->scaleDown(width: self::MAX_WIDTH, height: self::MAX_HEIGHT);
$masterEncoded = (string) $masterImage->encode(new WebpEncoder(85));
$path = NewsCoverImage::path(hash('sha256', $masterEncoded));
$this->writeImage($path, $masterEncoded);
foreach (NewsCoverImage::VARIANTS as $variant => $config) {
$variantImage = $this->manager->read($raw)->scaleDown(width: (int) $config['width']);
$variantEncoded = (string) $variantImage->encode(new WebpEncoder((int) $config['quality']));
$this->writeImage(NewsCoverImage::variantPath($path, $variant), $variantEncoded);
}
return [
'path' => $path,
'url' => NewsCoverImage::url($path),
'width' => (int) $masterImage->width(),
'height' => (int) $masterImage->height(),
'size_bytes' => strlen($masterEncoded),
'mobile_url' => NewsCoverImage::variantUrl($path, 'mobile'),
'desktop_url' => NewsCoverImage::variantUrl($path, 'desktop'),
'srcset' => NewsCoverImage::srcset($path),
];
}
public function ensureVariants(string $path, bool $force = false): array
{
$trimmed = NewsCoverImage::normalizePath($path);
if (! NewsCoverImage::isManagedPath($trimmed)) {
return ['generated' => 0, 'skipped' => count(NewsCoverImage::VARIANTS)];
}
$this->assertImageManager();
$this->assertStorageIsAllowed();
$disk = Storage::disk($this->mediaDiskName());
if (! $disk->exists($trimmed)) {
throw new RuntimeException('Managed cover image is missing from object storage.');
}
$raw = $disk->get($trimmed);
if (! is_string($raw) || $raw === '') {
throw new RuntimeException('Unable to read managed cover image from object storage.');
}
$generated = 0;
$skipped = 0;
foreach (NewsCoverImage::VARIANTS as $variant => $config) {
$variantPath = NewsCoverImage::variantPath($trimmed, $variant);
if (! $force && $disk->exists($variantPath)) {
$skipped++;
continue;
}
$variantImage = $this->manager->read($raw)->scaleDown(width: (int) $config['width']);
$variantEncoded = (string) $variantImage->encode(new WebpEncoder((int) $config['quality']));
$this->writeImage($variantPath, $variantEncoded);
$generated++;
}
return ['generated' => $generated, 'skipped' => $skipped];
}
public function deleteManagedFiles(string $path): void
{
$paths = NewsCoverImage::managedPaths($path);
if ($paths === []) {
return;
}
Storage::disk($this->mediaDiskName())->delete($paths);
}
private function mediaDiskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function writeImage(string $path, string $encoded): void
{
$written = Storage::disk($this->mediaDiskName())->put($path, $encoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
if ($written !== true) {
throw new RuntimeException('Unable to store image in object storage.');
}
}
private function readUploadBytes(UploadedFile $file): string
{
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || ! is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded image path.');
}
$raw = file_get_contents($uploadPath);
if ($raw === false || $raw === '') {
throw new RuntimeException('Unable to read uploaded image.');
}
return $raw;
}
private function assertSupportedMimeType(string $raw): void
{
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($raw));
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported image mime type.');
}
}
private function assertMinimumDimensions(string $raw): void
{
$size = @getimagesizefromstring($raw);
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded file is not a valid image.');
}
$width = (int) ($size[0] ?? 0);
$height = (int) ($size[1] ?? 0);
if ($width < self::MIN_WIDTH || $height < self::MIN_HEIGHT) {
throw new RuntimeException(sprintf(
'Image is too small. Minimum required size is %dx%d.',
self::MIN_WIDTH,
self::MIN_HEIGHT,
));
}
}
private function assertImageManager(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
if (! app()->environment('production')) {
return;
}
$diskName = $this->mediaDiskName();
if (in_array($diskName, ['local', 'public'], true)) {
throw new RuntimeException('Production news media storage must use object storage, not local/public disks.');
}
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Services\Traffic;
use Illuminate\Http\Request;
final class BotClassifier
{
/**
* @return array{is_bot: bool, type: ?string, family: ?string}
*/
public function classify(Request $request): array
{
$userAgent = trim((string) $request->userAgent());
if ($userAgent === '') {
return $this->bot('suspicious_bot', 'Empty UA');
}
$normalized = strtolower($userAgent);
if ($family = $this->matchFamily($normalized, [
'curl' => ['curl'],
'wget' => ['wget'],
'python-requests' => ['python-requests'],
'libwww-perl' => ['libwww-perl'],
'Go-http-client' => ['go-http-client'],
'Java' => ['java/'],
'scrapy' => ['scrapy'],
'httpclient' => ['httpclient'],
'masscan' => ['masscan'],
'nikto' => ['nikto'],
'sqlmap' => ['sqlmap'],
])) {
return $this->bot('suspicious_bot', $family);
}
if ($family = $this->matchFamily($normalized, [
'Googlebot' => ['googlebot'],
'Bingbot' => ['bingbot'],
'DuckDuckBot' => ['duckduckbot'],
'YandexBot' => ['yandexbot'],
'Baiduspider' => ['baiduspider'],
'Applebot' => ['applebot'],
'Slurp' => ['slurp'],
])) {
return $this->bot('search_bot', $family);
}
if ($family = $this->matchFamily($normalized, [
'GPTBot' => ['gptbot'],
'ChatGPT-User' => ['chatgpt-user'],
'OAI-SearchBot' => ['oai-searchbot'],
'ClaudeBot' => ['claudebot'],
'PerplexityBot' => ['perplexitybot'],
'Bytespider' => ['bytespider'],
'CCBot' => ['ccbot'],
'Google-Extended' => ['google-extended'],
'anthropic-ai' => ['anthropic-ai'],
'cohere-ai' => ['cohere-ai'],
])) {
return $this->bot('ai_bot', $family);
}
if ($family = $this->matchFamily($normalized, [
'AhrefsBot' => ['ahrefsbot'],
'SemrushBot' => ['semrushbot'],
'MJ12bot' => ['mj12bot'],
'DotBot' => ['dotbot'],
'PetalBot' => ['petalbot'],
'DataForSeoBot' => ['dataforseobot'],
'BLEXBot' => ['blexbot'],
'MauiBot' => ['mauibot'],
'serpstatbot' => ['serpstatbot'],
])) {
return $this->bot('seo_bot', $family);
}
if ($family = $this->matchFamily($normalized, [
'facebookexternalhit' => ['facebookexternalhit'],
'Twitterbot' => ['twitterbot'],
'LinkedInBot' => ['linkedinbot'],
'Slackbot' => ['slackbot'],
'Discordbot' => ['discordbot'],
'TelegramBot' => ['telegrambot'],
'WhatsApp' => ['whatsapp'],
'Pinterestbot' => ['pinterestbot'],
])) {
return $this->bot('social_bot', $family);
}
if ($family = $this->matchFamily($normalized, [
'UptimeRobot' => ['uptimerobot'],
'Pingdom' => ['pingdom'],
'StatusCake' => ['statuscake'],
'Better Stack' => ['better stack', 'betterstack'],
'BetterUptime' => ['betteruptime'],
])) {
return $this->bot('monitoring_bot', $family);
}
if (strlen($userAgent) < 8) {
return $this->bot('suspicious_bot', 'Short UA');
}
if ($this->containsAny($normalized, ['bot', 'crawler', 'spider', 'crawl', 'preview'])) {
return $this->bot('unknown_bot', 'Unknown crawler');
}
return [
'is_bot' => false,
'type' => null,
'family' => null,
];
}
/**
* @param array<string, array<int, string>> $families
*/
private function matchFamily(string $normalizedUserAgent, array $families): ?string
{
foreach ($families as $family => $keywords) {
if ($this->containsAny($normalizedUserAgent, $keywords)) {
return $family;
}
}
return null;
}
/**
* @param array<int, string> $keywords
*/
private function containsAny(string $haystack, array $keywords): bool
{
foreach ($keywords as $keyword) {
if ($keyword !== '' && str_contains($haystack, $keyword)) {
return true;
}
}
return false;
}
/**
* @return array{is_bot: bool, type: string, family: string}
*/
private function bot(string $type, string $family): array
{
return [
'is_bot' => true,
'type' => $type,
'family' => $family,
];
}
}

View File

@@ -0,0 +1,361 @@
<?php
declare(strict_types=1);
namespace App\Services\Traffic;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
class OnlineVisitorRepository
{
public const INDEX_KEY = 'skinbase:presence:online:index';
public const KEY_PREFIX = 'skinbase:presence:online';
public const TTL_SECONDS = 300;
public function __construct(private readonly BotClassifier $classifier)
{
}
public function track(Request $request): void
{
try {
$classification = $this->classifier->classify($request);
$visitorKey = $this->resolveVisitorKey($request, $classification);
$existing = $this->readRecord($visitorKey);
$user = $request->user();
$now = now()->toIso8601String();
$record = [
'visitor_key' => $visitorKey,
'type' => $classification['is_bot']
? (string) $classification['type']
: ($user ? 'human_logged' : 'human_guest'),
'bot_family' => $classification['is_bot'] ? $classification['family'] : null,
'user_id' => $this->resolveUserId($user),
'user_name' => $this->resolveUserName($user),
'ip_masked' => $this->maskIp($this->resolveIp($request)),
'ip_hash' => hash('sha256', $this->resolveIp($request)),
'user_agent' => $this->truncate((string) $request->userAgent(), 512),
'browser' => $this->detectBrowser((string) $request->userAgent()),
'platform' => $this->detectPlatform((string) $request->userAgent()),
'current_url' => $this->currentUrl($request),
'route_name' => $request->route()?->getName(),
'referer' => $this->truncate((string) $request->headers->get('referer', ''), 512) ?: null,
'first_seen_at' => is_string($existing['first_seen_at'] ?? null)
? $existing['first_seen_at']
: $now,
'last_seen_at' => $now,
'hits' => (int) ($existing['hits'] ?? 0) + 1,
];
$this->storeRecord($visitorKey, $record, self::TTL_SECONDS);
$this->addIndexMember($visitorKey);
} catch (\Throwable $e) {
Log::warning('Online visitor tracking failed', [
'error' => $e->getMessage(),
'path' => $request->path(),
]);
}
}
/**
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
try {
$visitorKeys = array_values(array_unique(array_filter(array_map('strval', $this->readIndexMembers()))));
} catch (\Throwable $e) {
Log::warning('Online visitor index read failed', ['error' => $e->getMessage()]);
return [];
}
$records = [];
$expired = [];
foreach ($visitorKeys as $visitorKey) {
try {
$record = $this->readRecord($visitorKey);
} catch (\Throwable $e) {
Log::warning('Online visitor record read failed', [
'error' => $e->getMessage(),
'visitor_key' => $visitorKey,
]);
$record = null;
}
if ($record === null) {
$expired[] = $visitorKey;
continue;
}
$records[] = $record;
}
if ($expired !== []) {
try {
$this->removeIndexMembers($expired);
} catch (\Throwable $e) {
Log::warning('Online visitor index cleanup failed', ['error' => $e->getMessage()]);
}
}
usort($records, static function (array $left, array $right): int {
return strtotime((string) ($right['last_seen_at'] ?? '')) <=> strtotime((string) ($left['last_seen_at'] ?? ''));
});
return $records;
}
/**
* @return array{total:int,logged:int,guests:int,bots:int,search_bots:int,ai_bots:int,social_bots:int,seo_bots:int,suspicious_bots:int}
*/
public function summary(): array
{
$records = $this->all();
return [
'total' => count($records),
'logged' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'human_logged')),
'guests' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'human_guest')),
'bots' => count(array_filter($records, static fn (array $record): bool => str_ends_with((string) ($record['type'] ?? ''), '_bot'))),
'search_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'search_bot')),
'ai_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'ai_bot')),
'social_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'social_bot')),
'seo_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'seo_bot')),
'suspicious_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'suspicious_bot')),
];
}
/**
* @return array<int, array{url:string, visitors:int}>
*/
public function activePages(): array
{
$counts = [];
foreach ($this->all() as $record) {
$url = trim((string) ($record['current_url'] ?? ''));
if ($url === '') {
continue;
}
$counts[$url] = ($counts[$url] ?? 0) + 1;
}
arsort($counts);
$pages = [];
foreach ($counts as $url => $visitors) {
$pages[] = [
'url' => $url,
'visitors' => $visitors,
];
}
return $pages;
}
public function forget(string $visitorKey): void
{
try {
$this->deleteRecord($visitorKey);
$this->removeIndexMembers([$visitorKey]);
} catch (\Throwable $e) {
Log::warning('Online visitor forget failed', [
'error' => $e->getMessage(),
'visitor_key' => $visitorKey,
]);
}
}
/**
* @return array<int, string>
*/
protected function readIndexMembers(): array
{
return array_map('strval', Redis::smembers(self::INDEX_KEY));
}
/**
* @return array<string, mixed>|null
*/
protected function readRecord(string $visitorKey): ?array
{
$raw = Redis::get($this->recordKey($visitorKey));
if (! is_string($raw) || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
/**
* @param array<string, mixed> $record
*/
protected function storeRecord(string $visitorKey, array $record, int $ttlSeconds): void
{
Redis::setex(
$this->recordKey($visitorKey),
$ttlSeconds,
(string) json_encode($record, JSON_UNESCAPED_SLASHES)
);
}
protected function addIndexMember(string $visitorKey): void
{
Redis::sadd(self::INDEX_KEY, $visitorKey);
}
/**
* @param array<int, string> $visitorKeys
*/
protected function removeIndexMembers(array $visitorKeys): void
{
if ($visitorKeys === []) {
return;
}
Redis::srem(self::INDEX_KEY, ...$visitorKeys);
}
protected function deleteRecord(string $visitorKey): void
{
Redis::del($this->recordKey($visitorKey));
}
/**
* @param array{is_bot: bool, type: ?string, family: ?string} $classification
*/
private function resolveVisitorKey(Request $request, array $classification): string
{
$user = $request->user();
if ($user) {
return 'user:' . $user->getAuthIdentifier();
}
$ip = $this->resolveIp($request);
$userAgent = (string) $request->userAgent();
if ($classification['is_bot']) {
return 'bot:' . hash('sha256', $ip . '|' . $userAgent);
}
$sessionCookieName = (string) config('session.cookie', 'laravel_session');
$sessionCookie = (string) $request->cookies->get($sessionCookieName, '');
$guestSeed = $sessionCookie !== ''
? 'session:' . $sessionCookie
: 'fingerprint:' . $ip . '|' . $userAgent . '|' . (string) $request->header('Accept-Language', '');
return 'guest:' . hash('sha256', $guestSeed);
}
private function resolveIp(Request $request): string
{
$cloudflareIp = trim((string) $request->headers->get('CF-Connecting-IP', ''));
if ($cloudflareIp !== '' && filter_var($cloudflareIp, FILTER_VALIDATE_IP)) {
return $cloudflareIp;
}
return (string) ($request->ip() ?: '0.0.0.0');
}
private function maskIp(string $ip): string
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$parts = explode('.', $ip);
return sprintf('%s.%s.xxx.xxx', $parts[0] ?? '0', $parts[1] ?? '0');
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$parts = explode(':', $ip);
$parts = array_pad($parts, 4, '');
return sprintf('%s:%s:xxxx:xxxx', $parts[0] ?: '::', $parts[1] ?: '::');
}
return 'unknown';
}
private function detectBrowser(string $userAgent): string
{
$normalized = strtolower($userAgent);
return match (true) {
str_contains($normalized, 'edg/') => 'Edge',
str_contains($normalized, 'opr/') || str_contains($normalized, 'opera') => 'Opera',
str_contains($normalized, 'chrome') && ! str_contains($normalized, 'edg/') => 'Chrome',
str_contains($normalized, 'firefox') => 'Firefox',
str_contains($normalized, 'safari') && ! str_contains($normalized, 'chrome') => 'Safari',
default => 'Unknown',
};
}
private function detectPlatform(string $userAgent): string
{
$normalized = strtolower($userAgent);
return match (true) {
str_contains($normalized, 'windows') => 'Windows',
str_contains($normalized, 'iphone') || str_contains($normalized, 'ipad') || str_contains($normalized, 'ios') => 'iOS',
str_contains($normalized, 'android') => 'Android',
str_contains($normalized, 'mac os') || str_contains($normalized, 'macintosh') => 'macOS',
str_contains($normalized, 'linux') => 'Linux',
default => 'Unknown',
};
}
private function currentUrl(Request $request): string
{
$path = '/' . ltrim($request->path(), '/');
return $path === '//' ? '/' : $path;
}
private function recordKey(string $visitorKey): string
{
return self::KEY_PREFIX . ':' . $visitorKey;
}
private function truncate(string $value, int $limit): string
{
return Str::limit($value, $limit, '');
}
private function resolveUserId(?Authenticatable $user): ?int
{
if ($user === null) {
return null;
}
$identifier = $user->getAuthIdentifier();
return is_numeric($identifier) ? (int) $identifier : null;
}
private function resolveUserName(?Authenticatable $user): ?string
{
if ($user === null) {
return null;
}
$name = data_get($user, 'name')
?? data_get($user, 'username')
?? data_get($user, 'email');
return is_string($name) && $name !== '' ? $name : 'User';
}
}

View File

@@ -65,12 +65,12 @@ final class ArtworkFeaturedImagePath
$variantName = $this->normalizeVariant($variant); $variantName = $this->normalizeVariant($variant);
$orders = [ $orders = [
'xs' => ['xs', 'mobile_sm', 'mobile', 'tablet', 'desktop', 'desktop_xl'], 'mobile_xs' => ['mobile_xs', 'mobile_sm', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
'mobile_sm' => ['mobile_sm', 'mobile', 'tablet', 'desktop', 'desktop_xl'], 'mobile_sm' => ['mobile_sm', 'mobile_xs', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
'mobile' => ['mobile', 'mobile_sm', 'xs', 'tablet', 'desktop', 'desktop_xl'], 'mobile' => ['mobile', 'mobile_sm', 'mobile_xs', 'tablet', 'desktop', 'desktop_xl'],
'tablet' => ['tablet', 'desktop', 'desktop_xl', 'mobile', 'mobile_sm', 'xs'], 'tablet' => ['tablet', 'desktop', 'desktop_xl', 'mobile', 'mobile_sm', 'mobile_xs'],
'desktop' => ['desktop', 'desktop_xl', 'tablet', 'mobile', 'mobile_sm', 'xs'], 'desktop' => ['desktop', 'desktop_xl', 'tablet', 'mobile', 'mobile_sm', 'mobile_xs'],
'desktop_xl' => ['desktop_xl', 'desktop', 'tablet', 'mobile', 'mobile_sm', 'xs'], 'desktop_xl' => ['desktop_xl', 'desktop', 'tablet', 'mobile', 'mobile_sm', 'mobile_xs'],
]; ];
return $orders[$variantName] ?? [$this->defaultVariant()]; return $orders[$variantName] ?? [$this->defaultVariant()];

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Support\News;
final class NewsCoverImage
{
public const MANAGED_PREFIX = 'news/covers/';
public const VARIANTS = [
'mobile' => [
'width' => 400,
'quality' => 74,
'suffix' => 'mobile',
],
'desktop' => [
'width' => 768,
'quality' => 76,
'suffix' => 'desktop',
],
];
public static function isManagedPath(?string $path): bool
{
$trimmed = ltrim(trim((string) $path), '/');
return $trimmed !== '' && str_starts_with($trimmed, self::MANAGED_PREFIX);
}
public static function normalizePath(?string $path): string
{
return ltrim(trim((string) $path), '/');
}
public static function path(string $hash): string
{
return sprintf(
'news/covers/%s/%s/%s.webp',
substr($hash, 0, 2),
substr($hash, 2, 2),
$hash,
);
}
public static function variantPath(string $path, string $variant): string
{
$trimmed = self::normalizePath($path);
$config = self::VARIANTS[$variant] ?? null;
if ($trimmed === '' || $config === null) {
return $trimmed;
}
$directory = pathinfo($trimmed, PATHINFO_DIRNAME);
$filename = pathinfo($trimmed, PATHINFO_FILENAME);
$extension = pathinfo($trimmed, PATHINFO_EXTENSION) ?: 'webp';
return ($directory !== '.' ? $directory . '/' : '') . $filename . '-' . $config['suffix'] . '.' . $extension;
}
public static function managedPaths(string $path): array
{
$trimmed = self::normalizePath($path);
if (! self::isManagedPath($trimmed)) {
return [];
}
$paths = [$trimmed];
foreach (array_keys(self::VARIANTS) as $variant) {
$paths[] = self::variantPath($trimmed, $variant);
}
return array_values(array_unique($paths));
}
public static function url(?string $path): ?string
{
$trimmed = self::normalizePath($path);
if ($trimmed === '') {
return null;
}
if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) {
return $trimmed;
}
if (self::isManagedPath($trimmed)) {
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . $trimmed;
}
return asset('storage/' . $trimmed);
}
public static function variantUrl(?string $path, string $variant): ?string
{
$trimmed = self::normalizePath($path);
if (! self::isManagedPath($trimmed)) {
return null;
}
return self::url(self::variantPath($trimmed, $variant));
}
public static function srcset(?string $path): ?string
{
$trimmed = self::normalizePath($path);
if (! self::isManagedPath($trimmed)) {
return null;
}
$entries = [];
foreach (self::VARIANTS as $variant => $config) {
$url = self::variantUrl($trimmed, $variant);
if ($url === null) {
continue;
}
$entries[] = $url . ' ' . $config['width'] . 'w';
}
return $entries === [] ? null : implode(', ', $entries);
}
}

View File

@@ -0,0 +1,301 @@
import { r as reactExports, a as reactDomExports, R as React } from "./vendor-tiptap-DRFaxGEb.js";
import { S as ShareToast } from "../ssr.js";
import "util";
import "stream";
import "path";
import "http";
import "https";
import "url";
import "fs";
import "crypto";
import "http2";
import "assert";
import "tty";
import "os";
import "zlib";
import "events";
import "./vendor-tooltip-CIQaDNlG.js";
import "node:process";
import "node:path";
import "node:url";
import "./vendor-realtime-DYEIbD6w.js";
import "buffer";
import "child_process";
import "net";
import "tls";
import "./vendor-motion-CotXNotG.js";
import "process";
import "async_hooks";
const FeedShareArtworkModal = reactExports.lazy(() => import("../ssr.js").then((n) => n.a));
function facebookUrl(url) {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`;
}
function twitterUrl(url, title) {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`;
}
function pinterestUrl(url, imageUrl, title) {
return `https://pinterest.com/pin/create/button/?url=${encodeURIComponent(url)}&media=${encodeURIComponent(imageUrl)}&description=${encodeURIComponent(title)}`;
}
function emailUrl(url, title) {
return `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(url)}`;
}
function CopyIcon() {
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" }));
}
function CheckIcon() {
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 20 20", fill: "currentColor", className: "h-5 w-5 text-emerald-400" }, /* @__PURE__ */ React.createElement("path", { fillRule: "evenodd", d: "M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z", clipRule: "evenodd" }));
}
function FacebookIcon() {
return /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 24 24", fill: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { d: "M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12Z" }));
}
function XTwitterIcon() {
return /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 24 24", fill: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { d: "M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77Z" }));
}
function PinterestIcon() {
return /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 24 24", fill: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { d: "M12 2C6.477 2 2 6.477 2 12c0 4.236 2.636 7.855 6.356 9.312-.088-.791-.167-2.005.035-2.868.181-.78 1.172-4.97 1.172-4.97s-.299-.598-.299-1.482c0-1.388.806-2.425 1.808-2.425.853 0 1.265.64 1.265 1.408 0 .858-.546 2.14-.828 3.33-.236.995.5 1.807 1.482 1.807 1.778 0 3.144-1.874 3.144-4.58 0-2.393-1.72-4.068-4.177-4.068-2.845 0-4.515 2.135-4.515 4.34 0 .859.331 1.781.745 2.282a.3.3 0 0 1 .069.288l-.278 1.133c-.044.183-.145.222-.335.134-1.249-.581-2.03-2.407-2.03-3.874 0-3.154 2.292-6.052 6.608-6.052 3.469 0 6.165 2.472 6.165 5.776 0 3.447-2.173 6.22-5.19 6.22-1.013 0-1.965-.527-2.291-1.148l-.623 2.378c-.226.869-.835 1.958-1.244 2.621.937.29 1.931.446 2.962.446 5.523 0 10-4.477 10-10S17.523 2 12 2Z" }));
}
function EmailIcon() {
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" }));
}
function EmbedIcon() {
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" }));
}
function CloseIcon() {
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 2, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M6 18 18 6M6 6l12 12" }));
}
function openShareWindow(url) {
window.open(url, "_blank", "noopener,noreferrer,width=600,height=500");
}
function trackShare(artworkId, platform) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content");
fetch(`/api/artworks/${artworkId}/share`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": csrfToken || "" },
credentials: "same-origin",
body: JSON.stringify({ platform })
}).catch(() => {
});
}
function ArtworkShareModal({ open, onClose, artwork, shareUrl, isLoggedIn = false }) {
const backdropRef = reactExports.useRef(null);
const [linkCopied, setLinkCopied] = reactExports.useState(false);
const [embedCopied, setEmbedCopied] = reactExports.useState(false);
const [showEmbed, setShowEmbed] = reactExports.useState(false);
const [toastVisible, setToastVisible] = reactExports.useState(false);
const [toastMessage, setToastMessage] = reactExports.useState("");
const [profileShareOpen, setProfileShareOpen] = reactExports.useState(false);
const url = shareUrl || artwork?.canonical_url || (typeof window !== "undefined" ? window.location.href : "#");
const title = artwork?.title || "Artwork";
const imageUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.thumbs?.md?.url || "";
const thumbMdUrl = artwork?.thumbs?.md?.url || imageUrl;
const embedCode = `<a href="${url}">
<img src="${thumbMdUrl}" alt="${title.replace(/"/g, "&quot;")}" />
</a>`;
reactExports.useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}
}, [open]);
reactExports.useEffect(() => {
if (!open) return;
const handler = (e) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
reactExports.useEffect(() => {
if (open) {
setLinkCopied(false);
setEmbedCopied(false);
setShowEmbed(false);
}
}, [open]);
const showToast = reactExports.useCallback((msg) => {
setToastMessage(msg);
setToastVisible(true);
}, []);
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(url);
setLinkCopied(true);
showToast("Link copied!");
trackShare(artwork?.id, "copy");
setTimeout(() => setLinkCopied(false), 2500);
} catch {
}
};
const handleCopyEmbed = async () => {
try {
await navigator.clipboard.writeText(embedCode);
setEmbedCopied(true);
showToast("Embed code copied!");
trackShare(artwork?.id, "embed");
setTimeout(() => setEmbedCopied(false), 2500);
} catch {
}
};
const handlePlatformShare = (platform, shareLink) => {
openShareWindow(shareLink);
trackShare(artwork?.id, platform);
onClose();
};
if (!open) return null;
const SHARE_OPTIONS = [
{
label: linkCopied ? "Copied!" : "Copy Link",
icon: linkCopied ? /* @__PURE__ */ React.createElement(CheckIcon, null) : /* @__PURE__ */ React.createElement(CopyIcon, null),
onClick: handleCopyLink,
className: linkCopied ? "border-emerald-500/40 bg-emerald-500/15 text-emerald-400" : "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
},
{
label: "Facebook",
icon: /* @__PURE__ */ React.createElement(FacebookIcon, null),
onClick: () => handlePlatformShare("facebook", facebookUrl(url)),
className: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-[#1877F2]/40 hover:bg-[#1877F2]/15 hover:text-[#1877F2]"
},
{
label: "X (Twitter)",
icon: /* @__PURE__ */ React.createElement(XTwitterIcon, null),
onClick: () => handlePlatformShare("twitter", twitterUrl(url, title)),
className: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/30 hover:bg-white/[0.10] hover:text-white"
},
{
label: "Pinterest",
icon: /* @__PURE__ */ React.createElement(PinterestIcon, null),
onClick: () => handlePlatformShare("pinterest", pinterestUrl(url, imageUrl, title)),
className: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-[#E60023]/40 hover:bg-[#E60023]/15 hover:text-[#E60023]"
},
{
label: "Email",
icon: /* @__PURE__ */ React.createElement(EmailIcon, null),
onClick: () => {
window.location.href = emailUrl(url, title);
trackShare(artwork?.id, "email");
},
className: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
},
...isLoggedIn ? [{
label: "My Profile",
icon: /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-share-nodes h-5 w-5 text-[1.1rem]" }),
onClick: () => setProfileShareOpen(true),
className: "border-sky-500/30 bg-sky-500/10 text-sky-400 hover:border-sky-400/50 hover:bg-sky-500/20"
}] : []
];
return reactDomExports.createPortal(
/* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
"div",
{
ref: backdropRef,
onClick: (e) => {
if (e.target === backdropRef.current) onClose();
},
className: "fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4",
role: "dialog",
"aria-modal": "true",
"aria-label": "Share this artwork"
},
/* @__PURE__ */ React.createElement("div", { className: "w-full max-w-md rounded-2xl border border-nova-700/50 bg-nova-900/80 shadow-2xl backdrop-blur-xl" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between border-b border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement("h3", { className: "text-base font-semibold text-white" }, "Share this artwork"), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
onClick: onClose,
className: "rounded-lg p-1.5 text-white/40 transition hover:bg-white/[0.06] hover:text-white/70",
"aria-label": "Close share dialog"
},
/* @__PURE__ */ React.createElement(CloseIcon, null)
)), thumbMdUrl && /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 border-b border-white/[0.06] px-6 py-3" }, /* @__PURE__ */ React.createElement(
"img",
{
src: thumbMdUrl,
alt: title,
className: "h-14 w-14 rounded-lg object-cover",
loading: "lazy"
}
), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("p", { className: "truncate text-sm font-medium text-white" }, title), artwork?.user?.username && /* @__PURE__ */ React.createElement("p", { className: "truncate text-xs text-white/50" }, "by ", artwork.user.username))), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-3 gap-2.5 px-6 py-5 sm:grid-cols-5" }, SHARE_OPTIONS.map((opt) => /* @__PURE__ */ React.createElement(
"button",
{
key: opt.label,
type: "button",
onClick: opt.onClick,
className: [
"flex flex-col items-center gap-1.5 rounded-xl border px-2 py-3 text-xs font-medium transition-all duration-200",
opt.className
].join(" ")
},
opt.icon,
/* @__PURE__ */ React.createElement("span", { className: "truncate" }, opt.label)
))), /* @__PURE__ */ React.createElement("div", { className: "border-t border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
onClick: () => setShowEmbed(!showEmbed),
className: "flex items-center gap-2 text-sm font-medium text-white/60 transition hover:text-white/80"
},
/* @__PURE__ */ React.createElement(EmbedIcon, null),
showEmbed ? "Hide Embed Code" : "Embed Code",
/* @__PURE__ */ React.createElement(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
viewBox: "0 0 16 16",
fill: "currentColor",
className: `h-3.5 w-3.5 transition-transform duration-200 ${showEmbed ? "rotate-180" : ""}`
},
/* @__PURE__ */ React.createElement("path", { fillRule: "evenodd", d: "M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z", clipRule: "evenodd" })
)
), showEmbed && /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2" }, /* @__PURE__ */ React.createElement(
"textarea",
{
readOnly: true,
value: embedCode,
rows: 3,
className: "w-full resize-none rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-3 font-mono text-xs text-white/70 outline-none focus:border-white/[0.15]",
onClick: (e) => e.target.select()
}
), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
onClick: handleCopyEmbed,
className: [
"inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 text-xs font-medium transition-all duration-200",
embedCopied ? "border-emerald-500/40 bg-emerald-500/15 text-emerald-400" : "border-white/[0.08] bg-white/[0.04] text-white/60 hover:border-white/[0.15] hover:text-white/80"
].join(" ")
},
embedCopied ? /* @__PURE__ */ React.createElement(CheckIcon, null) : /* @__PURE__ */ React.createElement(CopyIcon, null),
embedCopied ? "Copied!" : "Copy Embed"
))))
), /* @__PURE__ */ React.createElement(
ShareToast,
{
message: toastMessage,
visible: toastVisible,
onHide: () => setToastVisible(false)
}
), profileShareOpen && /* @__PURE__ */ React.createElement(reactExports.Suspense, { fallback: null }, /* @__PURE__ */ React.createElement(
FeedShareArtworkModal,
{
isOpen: profileShareOpen,
onClose: () => setProfileShareOpen(false),
preselectedArtwork: artwork?.id ? {
id: artwork.id,
title: artwork.title,
thumb_url: artwork.thumbs?.md?.url ?? artwork.thumbs?.lg?.url ?? null,
user: artwork.user ?? null
} : null,
onShared: () => {
setProfileShareOpen(false);
showToast("Shared to your profile!");
}
}
))),
document.body
);
}
export {
ArtworkShareModal as default
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,10 +14,10 @@
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-es-import": [], "\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-es-import": [],
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-module": [], "\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-module": [],
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-es-import": [ "\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-es-import": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-module": [ "\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-module": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000D:/Sites/Skinbase26/node_modules/qs/lib/index.js?commonjs-es-import": [], "\u0000D:/Sites/Skinbase26/node_modules/qs/lib/index.js?commonjs-es-import": [],
"\u0000D:/Sites/Skinbase26/node_modules/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [], "\u0000D:/Sites/Skinbase26/node_modules/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [],
@@ -97,46 +97,46 @@
"/build/assets/vendor-tiptap-DRFaxGEb.js" "/build/assets/vendor-tiptap-DRFaxGEb.js"
], ],
"\u0000assert?commonjs-external": [ "\u0000assert?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000buffer?commonjs-external": [ "\u0000buffer?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000child_process?commonjs-external": [ "\u0000child_process?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000commonjsHelpers.js": [ "\u0000commonjsHelpers.js": [
"/build/assets/vendor-tiptap-DRFaxGEb.js" "/build/assets/vendor-tiptap-DRFaxGEb.js"
], ],
"\u0000crypto?commonjs-external": [ "\u0000crypto?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000events?commonjs-external": [ "\u0000events?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000fs?commonjs-external": [ "\u0000fs?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000http?commonjs-external": [ "\u0000http?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000https?commonjs-external": [ "\u0000https?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000net?commonjs-external": [ "\u0000net?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000stream?commonjs-external": [ "\u0000stream?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000tls?commonjs-external": [ "\u0000tls?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000url?commonjs-external": [ "\u0000url?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000util?commonjs-external": [ "\u0000util?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"node_modules/@emoji-mart/data/sets/15/native.json": [ "node_modules/@emoji-mart/data/sets/15/native.json": [
"/build/assets/emoji-data-4xGXbtDn.js" "/build/assets/emoji-data-4xGXbtDn.js"
@@ -1035,7 +1035,7 @@
"node_modules/inline-style-parser/cjs/index.js": [], "node_modules/inline-style-parser/cjs/index.js": [],
"node_modules/is-plain-obj/index.js": [], "node_modules/is-plain-obj/index.js": [],
"node_modules/laravel-echo/dist/echo.js": [ "node_modules/laravel-echo/dist/echo.js": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"node_modules/linkifyjs/dist/linkify.mjs": [ "node_modules/linkifyjs/dist/linkify.mjs": [
"/build/assets/vendor-tiptap-DRFaxGEb.js" "/build/assets/vendor-tiptap-DRFaxGEb.js"
@@ -1934,7 +1934,7 @@
], ],
"node_modules/proxy-from-env/index.js": [], "node_modules/proxy-from-env/index.js": [],
"node_modules/pusher-js/dist/node/pusher.js": [ "node_modules/pusher-js/dist/node/pusher.js": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"node_modules/qs/lib/formats.js": [], "node_modules/qs/lib/formats.js": [],
"node_modules/qs/lib/index.js": [], "node_modules/qs/lib/index.js": [],
@@ -2223,7 +2223,7 @@
"resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [], "resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [],
"resources/js/components/artwork/ArtworkShareButton.jsx": [], "resources/js/components/artwork/ArtworkShareButton.jsx": [],
"resources/js/components/artwork/ArtworkShareModal.jsx": [ "resources/js/components/artwork/ArtworkShareModal.jsx": [
"/build/assets/ArtworkShareModal-BPM8yel5.js" "/build/assets/ArtworkShareModal-BI8kkaqs.js"
], ],
"resources/js/components/artwork/ArtworkTags.jsx": [], "resources/js/components/artwork/ArtworkTags.jsx": [],
"resources/js/components/artwork/AuthorBioPopover.jsx": [], "resources/js/components/artwork/AuthorBioPopover.jsx": [],

View File

@@ -17,7 +17,7 @@ import { t as tippy } from "./assets/vendor-tooltip-CIQaDNlG.js";
import minproc from "node:process"; import minproc from "node:process";
import minpath from "node:path"; import minpath from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { P as Pusher, E as E$1 } from "./assets/vendor-realtime-Koiu-_pw.js"; import { P as Pusher, E as E$1 } from "./assets/vendor-realtime-DYEIbD6w.js";
import { u as useReducedMotion, m as motion, A as AnimatePresence } from "./assets/vendor-motion-CotXNotG.js"; import { u as useReducedMotion, m as motion, A as AnimatePresence } from "./assets/vendor-motion-CotXNotG.js";
import * as s from "process"; import * as s from "process";
import require$$2 from "async_hooks"; import require$$2 from "async_hooks";
@@ -32725,7 +32725,7 @@ function useWebShare({ onFallback } = {}) {
); );
return { canNativeShare, share }; return { canNativeShare, share };
} }
const ArtworkShareModal = reactExports.lazy(() => import("./assets/ArtworkShareModal-BPM8yel5.js")); const ArtworkShareModal = reactExports.lazy(() => import("./assets/ArtworkShareModal-BI8kkaqs.js"));
function ShareIcon() { function ShareIcon() {
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 1 1 0-2.684m0 2.684 6.632 3.316m-6.632-6 6.632-3.316m0 0a3 3 0 1 0 5.367-2.684 3 3 0 0 0-5.367 2.684Zm0 9.316a3 3 0 1 0 5.368 2.684 3 3 0 0 0-5.368-2.684Z" })); return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 1 1 0-2.684m0 2.684 6.632 3.316m-6.632-6 6.632-3.316m0 0a3 3 0 1 0 5.367-2.684 3 3 0 0 0-5.367 2.684Zm0 9.316a3 3 0 1 0 5.368 2.684 3 3 0 0 0-5.368-2.684Z" }));
} }

263
build-output.txt Normal file
View File

@@ -0,0 +1,263 @@
> build
> vite build && vite build --ssr
vite v7.3.1 building client environment for production...
transforming...
/fonts/nova-cards/anton-400.woff2 referenced in /fonts/nova-cards/anton-400.woff2 didn't resolve at build time, it will remain unchanged to be resolved at runtime
/fonts/nova-cards/caveat-400-700.woff2 referenced in /fonts/nova-cards/caveat-400-700.woff2 didn't resolve at build time, it will remain unchanged to be resolved at runtime
/fonts/nova-cards/cormorant-garamond-500-700.woff2 referenced in /fonts/nova-cards/cormorant-garamond-500-700.woff2 didn't resolve at build time, it will remain unchanged to be resolved at runtime
/fonts/nova-cards/inter-400-700.woff2 referenced in /fonts/nova-cards/inter-400-700.woff2 didn't resolve at build time, it will remain unchanged to be resolved at runtime
/fonts/nova-cards/libre-franklin-400-700.woff2 referenced in /fonts/nova-cards/libre-franklin-400-700.woff2 didn't resolve at build time, it will remain unchanged to be resolved at runtime
/fonts/nova-cards/playfair-display-600-700.woff2 referenced in /fonts/nova-cards/playfair-display-600-700.woff2 didn't resolve at build time, it will remain unchanged to be resolved at runtime
Ôťô 1524 modules transformed.
rendering chunks...
computing gzip size...
public/build/manifest.json 109.85 kB Ôöé gzip: 9.77 kB
public/build/assets/entry-pill-carousel-BvZ3q_Be.css  2.92 kB Ôöé gzip: 0.96 kB
public/build/assets/MasonryGallery-BzMmiEvv.css  3.07 kB Ôöé gzip: 0.95 kB
public/build/assets/nova-grid-CrqpHU2F.css  3.08 kB Ôöé gzip: 0.90 kB
public/build/assets/nova-CcxuoKdL.css  3.16 kB Ôöé gzip: 1.14 kB
public/build/assets/app-3AuZHcjn.css 383.17 kB Ôöé gzip: 51.28 kB
public/build/assets/loadEmojiMartData-C6Eyvkxq.js  0.17 kB Ôöé gzip: 0.17 kB
public/build/assets/RememberMeCheckbox-BNrl_LVi.js  0.38 kB Ôöé gzip: 0.29 kB
public/build/assets/AiBiography-C2BnMNCk.js  0.43 kB Ôöé gzip: 0.28 kB
public/build/assets/useWebShare-A8viBgBr.js  0.47 kB Ôöé gzip: 0.30 kB
public/build/assets/render-frame-BW-nMUIy.js  0.48 kB Ôöé gzip: 0.33 kB
public/build/assets/FeaturedArtworks-fC5muhUe.js  0.51 kB Ôöé gzip: 0.31 kB
public/build/assets/entry-search-CgdTdEwH.js  0.54 kB Ôöé gzip: 0.35 kB
public/build/assets/WorldStatusBadge-fQOpGaiG.js  0.64 kB Ôöé gzip: 0.39 kB
public/build/assets/WorldCampaignMeta-EspIVm9r.js  0.64 kB Ôöé gzip: 0.41 kB
public/build/assets/WorldAnalyticsSummaryCard-jyZWiApL.js  0.65 kB Ôöé gzip: 0.39 kB
public/build/assets/DocsStepList-W7DaSNG5.js  0.66 kB Ôöé gzip: 0.36 kB
public/build/assets/StudioContentIndex-DKBNpOi4.js  0.74 kB Ôöé gzip: 0.45 kB
public/build/assets/StudioArchived-F9dFF0ce.js  0.74 kB Ôöé gzip: 0.45 kB
public/build/assets/StudioDrafts-RtuSIjLK.js  0.76 kB Ôöé gzip: 0.46 kB
public/build/assets/scheduleCountdown-BSUyj4ow.js  0.77 kB Ôöé gzip: 0.41 kB
public/build/assets/QuickstartNextSteps-2vKlQsj3.js  0.78 kB Ôöé gzip: 0.43 kB
public/build/assets/GroupBadgePill-U2j00SrU.js  0.83 kB Ôöé gzip: 0.48 kB
public/build/assets/LevelBadge-DLvpFEMl.js  0.91 kB Ôöé gzip: 0.48 kB
public/build/assets/AchievementBadge-U4Rwvb-f.js  0.92 kB Ôöé gzip: 0.52 kB
public/build/assets/XPProgressBar-Cnq1ZQfY.js  0.95 kB Ôöé gzip: 0.56 kB
public/build/assets/DocsComparisonTable-CfIiOcg8.js  0.98 kB Ôöé gzip: 0.49 kB
public/build/assets/moderation-ChYG-EvS.js  1.08 kB Ôöé gzip: 0.57 kB
public/build/assets/preload-helper-I4rgV-VL.js  1.12 kB Ôöé gzip: 0.66 kB
public/build/assets/bootstrap-DDEtOhKr.js  1.18 kB Ôöé gzip: 0.75 kB
public/build/assets/StudioGroupArtworks-DyYRKiBc.js  1.21 kB Ôöé gzip: 0.65 kB
public/build/assets/StudioGroupCollections-DCuDlpQH.js  1.22 kB Ôöé gzip: 0.65 kB
public/build/assets/entry-masonry-gallery-JRJmA4N1.js  1.27 kB Ôöé gzip: 0.64 kB
public/build/assets/home-C9R7CAjY.js  1.31 kB Ôöé gzip: 0.60 kB
public/build/assets/DocsFaqAccordion-BNFbd9gv.js  1.36 kB Ôöé gzip: 0.68 kB
public/build/assets/isEventWithinNode-ChyJskcB.js  1.55 kB Ôöé gzip: 0.79 kB
public/build/assets/GroupStudioPromoCard-CXNI1Esg.js  1.56 kB Ôöé gzip: 0.67 kB
public/build/assets/Toggle-8-GT7jng.js  1.62 kB Ôöé gzip: 0.80 kB
public/build/assets/TextInput-Bd5LNQnv.js  1.63 kB Ôöé gzip: 0.81 kB
public/build/assets/ShareToast-CJ9HBKcq.js  1.67 kB Ôöé gzip: 0.96 kB
public/build/assets/MessageInboxBadge-BVwG2ye3.js  1.69 kB Ôöé gzip: 0.94 kB
public/build/assets/StudioStories-IjCHknbR.js  1.69 kB Ôöé gzip: 0.84 kB
public/build/assets/EmojiPickerButton-DLRacqck.js  1.74 kB Ôöé gzip: 0.92 kB
public/build/assets/StudioArtworks-BtdQyD0t.js  1.77 kB Ôöé gzip: 0.83 kB
public/build/assets/StudioCollections-CpaEHCm_.js  1.81 kB Ôöé gzip: 0.84 kB
public/build/assets/StudioGroupChallenges-iyH4CbWd.js  1.86 kB Ôöé gzip: 0.81 kB
public/build/assets/StudioGroupEvents-CXbbI08d.js  1.89 kB Ôöé gzip: 0.84 kB
public/build/assets/Dashboard-1q9woC6E.js  1.90 kB Ôöé gzip: 0.75 kB
public/build/assets/Checkbox-BbglLO_c.js  1.96 kB Ôöé gzip: 1.00 kB
public/build/assets/ArtworkViewer-BPSc4YT9.js  1.99 kB Ôöé gzip: 1.00 kB
public/build/assets/StudioGroupProjects-BOaAiKtB.js  1.99 kB Ôöé gzip: 0.85 kB
public/build/assets/Button-C97Uo6Gz.js  2.13 kB Ôöé gzip: 0.95 kB
public/build/assets/FollowButton-JpmxGoIm.js  2.15 kB Ôöé gzip: 1.20 kB
public/build/assets/Pagination-UfBEeszW.js  2.16 kB Ôöé gzip: 0.93 kB
public/build/assets/Modal-CecvWwau.js  2.16 kB Ôöé gzip: 1.05 kB
public/build/assets/StudioGroupActivity-D9qAKhZA.js  2.18 kB Ôöé gzip: 0.93 kB
public/build/assets/CrudIndex-DtO-9I7a.js  2.21 kB Ôöé gzip: 0.94 kB
public/build/assets/StudioSettings-DLRUREK7.js  2.29 kB Ôöé gzip: 0.93 kB
public/build/assets/Settings-2QKdwwLk.js  2.41 kB Ôöé gzip: 0.98 kB
public/build/assets/Submissions-CIpsHqDm.js  2.41 kB Ôöé gzip: 0.93 kB
public/build/assets/WorldChallengeArtworkCard-CIiMObvd.js  2.44 kB Ôöé gzip: 0.96 kB
public/build/assets/Pricing-zuJk_IAS.js  2.61 kB Ôöé gzip: 1.07 kB
public/build/assets/GroupEventShow-5BeC_oJO.js  2.62 kB Ôöé gzip: 0.99 kB
public/build/assets/StudioGroupPosts-SISaHUvR.js  2.68 kB Ôöé gzip: 0.94 kB
public/build/assets/NovaConfirmDialog-D8B-BIsp.js  2.69 kB Ôöé gzip: 1.16 kB
public/build/assets/SeoHead-WmOGKAfr.js  2.72 kB Ôöé gzip: 0.89 kB
public/build/assets/Radio-jTKYNmqU.js  2.84 kB Ôöé gzip: 1.21 kB
public/build/assets/UsernameQueue-C4goIl2k.js  3.11 kB Ôöé gzip: 1.26 kB
public/build/assets/UploadQueue-CuFFOgey.js  3.15 kB Ôöé gzip: 1.26 kB
public/build/assets/Stories-HvAkKwi7.js  3.16 kB Ôöé gzip: 1.20 kB
public/build/assets/worldAnalytics-TQj1HP4w.js  3.25 kB Ôöé gzip: 1.24 kB
public/build/assets/GroupPostShow-BUMNEwKL.js  3.30 kB Ôöé gzip: 1.43 kB
public/build/assets/StudioGroupReleases-DptXYqhO.js  3.34 kB Ôöé gzip: 1.16 kB
public/build/assets/DocsSidebarNav-B7VScg7e.js  3.34 kB Ôöé gzip: 1.35 kB
public/build/assets/Artworks-BUHa8eNU.js  3.34 kB Ôöé gzip: 1.25 kB
public/build/assets/StudioCardsIndex-DcFcct3c.js  3.44 kB Ôöé gzip: 1.35 kB
public/build/assets/TurnstileField-ChEIphj5.js  3.45 kB Ôöé gzip: 1.39 kB
public/build/assets/StudioGroupPostEditor-aJqfeGeQ.js  3.57 kB Ôöé gzip: 1.09 kB
public/build/assets/admin-DdhjEqVM.js  3.78 kB Ôöé gzip: 1.25 kB
public/build/assets/Dashboard-CHiT_s5l.js  3.83 kB Ôöé gzip: 1.38 kB
public/build/assets/ChallengeSubmit-BNYhzgAd.js  4.03 kB Ôöé gzip: 1.37 kB
public/build/assets/ProfileGallery-DH-7mEZJ.js  4.07 kB Ôöé gzip: 1.67 kB
public/build/assets/StudioCardAnalytics-5Rr2Pu7F.js  4.12 kB Ôöé gzip: 1.31 kB
public/build/assets/entry-topbar-C-3GLDEI.js  4.22 kB Ôöé gzip: 1.35 kB
public/build/assets/NotificationDropdown-JRO00JfU.js  4.31 kB Ôöé gzip: 1.86 kB
public/build/assets/ArtworkGallery-KwmYboBH.js  4.42 kB Ôöé gzip: 1.94 kB
public/build/assets/StudioGroupReviewQueue-BoKsXobR.js  4.61 kB Ôöé gzip: 1.44 kB
public/build/assets/CollectionSeriesShow-BR-HOcJ4.js  4.95 kB Ôöé gzip: 1.63 kB
public/build/assets/Index-8LFVJbwk.js  5.10 kB Ôöé gzip: 1.75 kB
public/build/assets/entry-similar-artworks-header--2UXKPo2.js  5.14 kB Ôöé gzip: 1.86 kB
public/build/assets/StudioArtworkAnalytics-CSAVbUzw.js  5.14 kB Ôöé gzip: 1.38 kB
public/build/assets/WorldMediaUploadField-BguH2LRX.js  5.21 kB Ôöé gzip: 2.09 kB
public/build/assets/List-DcFQYBgU.js  5.22 kB Ôöé gzip: 1.87 kB
public/build/assets/StorySocialPanel-D-CFFI6a.js  5.23 kB Ôöé gzip: 2.00 kB
public/build/assets/StudioGroupJoinRequests-DcE3ZmVx.js  5.25 kB Ôöé gzip: 1.68 kB
public/build/assets/StudioFollowers-DaJnOmxb.js  5.26 kB Ôöé gzip: 1.59 kB
public/build/assets/StudioGroupEventEditor-Bmh-gaS-.js  5.51 kB Ôöé gzip: 1.47 kB
public/build/assets/AdminLayout-D1UJ9iPA.js  5.55 kB Ôöé gzip: 1.86 kB
public/build/assets/StudioSearch-BUSDc5cV.js  5.55 kB Ôöé gzip: 1.55 kB
public/build/assets/ReactionBar-qDsubGzH.js  5.63 kB Ôöé gzip: 2.29 kB
public/build/assets/StudioGroupRecruitment-DEOD6C1l.js  5.82 kB Ôöé gzip: 1.59 kB
public/build/assets/entry-pill-carousel-BME2m_Aj.js  5.89 kB Ôöé gzip: 2.18 kB
public/build/assets/StudioGroupAssets-Blip239L.js  5.93 kB Ôöé gzip: 1.80 kB
public/build/assets/StudioGroupsIndex-Dm7SeHp5.js  5.94 kB Ôöé gzip: 1.89 kB
public/build/assets/app-zotai0D6.js  6.02 kB Ôöé gzip: 2.74 kB
public/build/assets/GroupProjectShow-B0Kcp5Vy.js  6.16 kB Ôöé gzip: 1.65 kB
public/build/assets/StudioFeatured-egKjyiNl.js  6.49 kB Ôöé gzip: 2.21 kB
public/build/assets/GroupChallengeShow-3Zibsz1N.js  6.52 kB Ôöé gzip: 2.06 kB
public/build/assets/NovaCardsAssetPackAdmin-C98PgPda.js  6.62 kB Ôöé gzip: 2.23 kB
public/build/assets/StudioActivity-D2wdHReB.js  6.89 kB Ôöé gzip: 2.26 kB
public/build/assets/StudioGroupReputation-Yrd3Gy1r.js  6.89 kB Ôöé gzip: 1.61 kB
public/build/assets/Index-DhXpkfgT.js  6.92 kB Ôöé gzip: 2.30 kB
public/build/assets/ShareArtworkModal-DyLrV0qb.js  7.26 kB Ôöé gzip: 2.32 kB
public/build/assets/GroupReleaseShow-CXfO8SVQ.js  7.36 kB Ôöé gzip: 1.80 kB
public/build/assets/StudioNewsTaxonomies-D-nZtVgj.js  7.50 kB Ôöé gzip: 1.68 kB
public/build/assets/Index-DlVPlduo.js  7.54 kB Ôöé gzip: 1.95 kB
public/build/assets/StudioNewsIndex-1aqr51M8.js  7.57 kB Ôöé gzip: 2.34 kB
public/build/assets/StudioGroupInvitations-BbHOzczf.js  7.69 kB Ôöé gzip: 1.99 kB
public/build/assets/NovaCardsChallengeAdmin-BxRH4HHD.js  7.70 kB Ôöé gzip: 2.41 kB
public/build/assets/WorldFamilyCard-g3Q3lPUa.js  8.13 kB Ôöé gzip: 2.24 kB
public/build/assets/AuthAudit-DQm7x0js.js  8.20 kB Ôöé gzip: 2.42 kB
public/build/assets/StudioWorldsIndex-Caggblv3.js  8.28 kB Ôöé gzip: 2.54 kB
public/build/assets/StudioInbox-C2IjKLJu.js  8.40 kB Ôöé gzip: 2.26 kB
public/build/assets/CollectionHistory-DR_tBTQJ.js  8.46 kB Ôöé gzip: 2.71 kB
public/build/assets/NovaSelect-CvnMafSD.js  8.52 kB Ôöé gzip: 3.12 kB
public/build/assets/HomepageAnnouncement-BeRTPuZV.js  8.89 kB Ôöé gzip: 2.72 kB
public/build/assets/LatestCommentsPage-Crx5rnQW.js  8.91 kB Ôöé gzip: 3.38 kB
public/build/assets/NovaCardCanvasPreview-NNIpZmna.js  8.92 kB Ôöé gzip: 3.44 kB
public/build/assets/leaderboard-Be43dPek.js  9.03 kB Ôöé gzip: 3.08 kB
public/build/assets/StudioChallenges-pvF_FNOG.js  9.04 kB Ôöé gzip: 2.28 kB
public/build/assets/NovaCardsTemplateAdmin-e8kzGbiX.js  9.11 kB Ôöé gzip: 2.68 kB
public/build/assets/CollectionAnalytics-Bma-i9aj.js  9.17 kB Ôöé gzip: 2.44 kB
public/build/assets/collections-CavW6YDp.js  9.31 kB Ôöé gzip: 2.47 kB
public/build/assets/NovaCardsCollectionAdmin-BmB2xE9t.js  9.31 kB Ôöé gzip: 2.81 kB
public/build/assets/StudioGroupChallengeEditor-Cj0MdxOR.js  9.37 kB Ôöé gzip: 2.31 kB
public/build/assets/StudioGroupMembers-BtJB0RKE.js  9.45 kB Ôöé gzip: 2.52 kB
public/build/assets/StudioGroupProjectEditor-Be1olaBp.js  9.55 kB Ôöé gzip: 2.18 kB
public/build/assets/MasonryGallery-BoIeuFbA.js  9.66 kB Ôöé gzip: 3.92 kB
public/build/assets/DateTimePicker-D0x39reT.js  9.69 kB Ôöé gzip: 3.46 kB
public/build/assets/StudioScheduled-6n-aDD2C.js  9.87 kB Ôöé gzip: 2.73 kB
public/build/assets/StudioGroupCreate-BK7iVrNo.js  10.15 kB Ôöé gzip: 2.66 kB
public/build/assets/CollectionCard-CW0X2UXQ.js  10.41 kB Ôöé gzip: 3.02 kB
public/build/assets/StudioAssets-BDVKjFbv.js  10.61 kB Ôöé gzip: 2.77 kB
public/build/assets/StudioGroupSettings-YDZVDATA.js  10.75 kB Ôöé gzip: 2.60 kB
public/build/assets/WorldIndex-DWX1_dqc.js  10.77 kB Ôöé gzip: 3.38 kB
public/build/assets/ArtworkShareModal-BpGzIqZc.js  11.03 kB Ôöé gzip: 4.01 kB
public/build/assets/studio-CAAJcae7.js  11.18 kB Ôöé gzip: 2.50 kB
public/build/assets/StudioGroupReleaseEditor-C_tT-2ZK.js  11.26 kB Ôöé gzip: 2.50 kB
public/build/assets/StudioPreferences-CbCJHFZ3.js  11.40 kB Ôöé gzip: 3.28 kB
public/build/assets/CommentList-BTSXIXRd.js  11.61 kB Ôöé gzip: 3.66 kB
public/build/assets/StudioComments-CVDWXVkx.js  11.93 kB Ôöé gzip: 3.20 kB
public/build/assets/emojiFlood-two4w6S_.js  12.28 kB Ôöé gzip: 4.24 kB
public/build/assets/StudioGrowth-CcN89sK5.js  12.50 kB Ôöé gzip: 2.94 kB
public/build/assets/ArtworkMaturityQueue-Cko0vs1j.js  12.74 kB Ôöé gzip: 3.75 kB
public/build/assets/DailyActivity-6d6LKgBU.js  12.79 kB Ôöé gzip: 3.01 kB
public/build/assets/StudioAnalytics-DpxQkg4p.js  13.67 kB Ôöé gzip: 3.17 kB
public/build/assets/GroupIndex-BlsqLGwZ.js  14.08 kB Ôöé gzip: 3.31 kB
public/build/assets/StudioProfile-D9PN75-j.js  14.57 kB Ôöé gzip: 3.73 kB
public/build/assets/TroubleshootingHelpPage-DOrpJMWy.js  15.26 kB Ôöé gzip: 4.95 kB
public/build/assets/AccountHelpPage-BkSuCyM_.js  15.69 kB Ôöé gzip: 5.20 kB
public/build/assets/AiBiographyAdmin-BgFN05Oz.js  15.77 kB Ôöé gzip: 3.94 kB
public/build/assets/CategoriesPage-CKl6PUyv.js  16.17 kB Ôöé gzip: 4.96 kB
public/build/assets/CommunityActivityPage-BbqEHVYK.js  16.42 kB Ôöé gzip: 4.35 kB
public/build/assets/ProfileHero-C8WW9XaZ.js  16.45 kB Ôöé gzip: 5.07 kB
public/build/assets/NewsComments-CIOibLSk.js  16.48 kB Ôöé gzip: 4.92 kB
public/build/assets/StudioLayout-EsdK63Tt.js  16.96 kB Ôöé gzip: 5.11 kB
public/build/assets/Show-B8I3H0f8.js  18.02 kB Ôöé gzip: 4.85 kB
public/build/assets/feed-6wi1IrNj.js  18.20 kB Ôöé gzip: 4.06 kB
public/build/assets/GroupQuickstartPage-CKspxV3l.js  19.22 kB Ôöé gzip: 6.14 kB
public/build/assets/CrudForm-BP4DeOMb.js  19.48 kB Ôöé gzip: 4.75 kB
public/build/assets/CollectionFeaturedIndex-CscBO6s2.js  20.20 kB Ôöé gzip: 4.62 kB
public/build/assets/SavedCollections-B7XGsh66.js  20.51 kB Ôöé gzip: 5.32 kB
public/build/assets/StudioCalendar-C-ZqPXqN.js  20.71 kB Ôöé gzip: 4.42 kB
public/build/assets/AuthHelpPage-k6ZAVRrn.js  20.74 kB Ôöé gzip: 6.41 kB
public/build/assets/PostCardSkeleton-Bridi1pW.js  21.87 kB Ôöé gzip: 5.80 kB
public/build/assets/ProfileHelpPage-RmI9Ixdu.js  22.19 kB Ôöé gzip: 6.71 kB
public/build/assets/UploadHelpPage-DK3IBVv-.js  22.43 kB Ôöé gzip: 6.93 kB
public/build/assets/CardsHelpPage-BHnECSwD.js  23.53 kB Ôöé gzip: 7.36 kB
public/build/assets/SearchBar-BvVB5g6u.js  23.78 kB Ôöé gzip: 5.06 kB
public/build/assets/StudioDashboard-NV62m-_W.js  24.17 kB Ôöé gzip: 4.11 kB
public/build/assets/StudioGroupDashboard-BfKzil5G.js  24.33 kB Ôöé gzip: 4.53 kB
public/build/assets/FeaturedArtworksAdmin-D_vXOvK4.js  24.41 kB Ôöé gzip: 6.14 kB
public/build/assets/CollectionDashboard-B3CkegmC.js  24.56 kB Ôöé gzip: 5.79 kB
public/build/assets/ArtworkCard-BqO7t18e.js  25.13 kB Ôöé gzip: 7.61 kB
public/build/assets/StudioHelpPage-CxFbER9L.js  25.18 kB Ôöé gzip: 7.53 kB
public/build/assets/nova-Cb8Jt7G8.js  25.74 kB Ôöé gzip: 7.51 kB
public/build/assets/WorldSubmissionSelector-B12ePKNZ.js  26.16 kB Ôöé gzip: 8.27 kB
public/build/assets/StudioUploadQueue-CFbmx7Km.js  26.74 kB Ôöé gzip: 6.77 kB
public/build/assets/GroupFaqPage-1rX8UFW8.js  28.01 kB Ôöé gzip: 8.74 kB
public/build/assets/WorldsHelpPage-LQTfe6S_.js  28.20 kB Ôöé gzip: 8.64 kB
public/build/assets/NovaCardsAdminIndex-B89AJiP4.js  28.99 kB Ôöé gzip: 5.79 kB
public/build/assets/Form-CzwRrQpH.js  29.07 kB Ôöé gzip: 8.16 kB
public/build/assets/LessonEditor-CKN0FVlX.js  29.54 kB Ôöé gzip: 7.80 kB
public/build/assets/CollectionStaffSurfaces-BAt_mFhq.js  34.33 kB Ôöé gzip: 6.80 kB
public/build/assets/StudioContentBrowser-DIPUTR8u.js  34.41 kB Ôöé gzip: 8.11 kB
public/build/assets/vendor-tooltip-CnBRltuW.js  35.79 kB Ôöé gzip: 12.69 kB
public/build/assets/index-qFyJwsfU.js  37.13 kB Ôöé gzip: 14.86 kB
public/build/assets/HelpCenterPage-CoxjWWvH.js  41.36 kB Ôöé gzip: 10.04 kB
public/build/assets/forum-qDB55hjW.js  42.15 kB Ôöé gzip: 9.80 kB
public/build/assets/settings-CZhBkfUt.js  42.40 kB Ôöé gzip: 11.24 kB
public/build/assets/GroupHelpPage-DJeoap3E.js  43.15 kB Ôöé gzip: 12.12 kB
public/build/assets/module.esm-CCyjftVT.js  45.88 kB Ôöé gzip: 16.51 kB
public/build/assets/WorldShow-DHXM3cKb.js  49.44 kB Ôöé gzip: 8.30 kB
public/build/assets/RichTextEditor-Bt71q8op.js  51.22 kB Ôöé gzip: 13.36 kB
public/build/assets/CollectionShow-DygrFMKU.js  51.58 kB Ôöé gzip: 11.73 kB
public/build/assets/CollectionStaffProgramming-DVM7JuaD.js  52.90 kB Ôöé gzip: 9.53 kB
public/build/assets/StoryEditor-BXqMeM3o.js  54.39 kB Ôöé gzip: 14.38 kB
public/build/assets/index-NFidTLXk.js  61.24 kB Ôöé gzip: 13.86 kB
public/build/assets/Index-QYLK_IzZ.js  64.98 kB Ôöé gzip: 17.69 kB
public/build/assets/GroupShow-DOtdW99f.js  64.99 kB Ôöé gzip: 10.97 kB
public/build/assets/StudioCardEditor-BXK8VJVJ.js  67.52 kB Ôöé gzip: 14.39 kB
public/build/assets/StudioNewsEditor-Dae0SpEl.js  68.33 kB Ôöé gzip: 17.36 kB
public/build/assets/vendor-realtime-CC6hBp0A.js  73.99 kB Ôöé gzip: 21.34 kB
public/build/assets/emoji-ui-RjUF93sV.js  77.27 kB Ôöé gzip: 27.77 kB
public/build/assets/index.esm-t39M3oTI.js  80.99 kB Ôöé gzip: 27.66 kB
public/build/assets/StudioArtworkEdit-CZkt8TiR.js  90.78 kB Ôöé gzip: 20.90 kB
public/build/assets/artwork-BQWlZoE5.js 108.57 kB Ôöé gzip: 27.87 kB
public/build/assets/index-Cr7xflb-.js 117.88 kB Ôöé gzip: 36.29 kB
public/build/assets/CollectionManage-Dvhkb44m.js 125.86 kB Ôöé gzip: 23.17 kB
public/build/assets/vendor-motion-D2pGNhd4.js 134.48 kB Ôöé gzip: 44.48 kB
public/build/assets/ProfileShow-DekSt66N.js 148.94 kB Ôöé gzip: 33.67 kB
public/build/assets/StudioWorldEditor-BBwIOsk4.js 153.30 kB Ôöé gzip: 28.57 kB
public/build/assets/upload-v6eTVHa6.js 154.16 kB Ôöé gzip: 40.85 kB
public/build/assets/client-ZHR_6vo2.js 180.87 kB Ôöé gzip: 56.91 kB
public/build/assets/vendor-syntax-C7R9Wg3P.js 313.97 kB Ôöé gzip: 97.35 kB
public/build/assets/emoji-data-48B9X9Wg.js 432.75 kB Ôöé gzip: 82.80 kB
public/build/assets/vendor-tiptap-C7JLf1wU.js 492.51 kB Ôöé gzip: 155.17 kB
Ôťô built in 14.22s
vite v7.3.1 building ssr environment for production...
transforming...
Ôťô 1324 modules transformed.
rendering chunks...
bootstrap/ssr/ssr-manifest.json  111.54 kB
bootstrap/ssr/assets/ArtworkShareModal-BPM8yel5.js  16.42 kB
bootstrap/ssr/assets/vendor-tooltip-CIQaDNlG.js  93.73 kB
bootstrap/ssr/assets/emoji-ui-C_DZUNyP.js  133.00 kB
bootstrap/ssr/assets/vendor-motion-CotXNotG.js  282.99 kB
bootstrap/ssr/assets/vendor-realtime-Koiu-_pw.js  378.07 kB
bootstrap/ssr/assets/emoji-data-4xGXbtDn.js  433.09 kB
bootstrap/ssr/assets/vendor-tiptap-DRFaxGEb.js 1,149.63 kB
bootstrap/ssr/ssr.js 7,768.76 kB
Ôťô built in 9.50s

View File

@@ -62,7 +62,7 @@ return [
], ],
'featured_variants' => [ 'featured_variants' => [
'xs' => ['width' => 400, 'height' => 512, 'quality' => 76, 'media' => '(max-width: 479px)', 'sizes' => '100vw'], 'mobile_xs' => ['width' => 400, 'height' => 400, 'quality' => 76, 'media' => '(max-width: 479px)', 'sizes' => '100vw'],
'mobile_sm' => ['width' => 640, 'height' => 640, 'quality' => 78, 'media' => '(max-width: 639px)', 'sizes' => '100vw'], 'mobile_sm' => ['width' => 640, 'height' => 640, 'quality' => 78, 'media' => '(max-width: 639px)', 'sizes' => '100vw'],
'mobile' => ['width' => 900, 'height' => 900, 'quality' => 80, 'media' => '(max-width: 767px)', 'sizes' => '100vw'], 'mobile' => ['width' => 900, 'height' => 900, 'quality' => 80, 'media' => '(max-width: 767px)', 'sizes' => '100vw'],
'tablet' => ['width' => 1280, 'height' => 900, 'quality' => 82, 'media' => '(max-width: 1279px)', 'sizes' => '100vw'], 'tablet' => ['width' => 1280, 'height' => 900, 'quality' => 82, 'media' => '(max-width: 1279px)', 'sizes' => '100vw'],

View File

@@ -0,0 +1,30 @@
<?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('academy_lesson_blocks', function (Blueprint $table): void {
$table->id();
$table->foreignId('lesson_id')->constrained('academy_lessons')->cascadeOnDelete();
$table->string('type')->index();
$table->string('title')->nullable();
$table->json('payload')->nullable();
$table->unsignedInteger('sort_order')->default(0)->index();
$table->boolean('active')->default(true)->index();
$table->timestamps();
$table->softDeletes();
$table->index(['lesson_id', 'sort_order']);
});
}
public function down(): void
{
Schema::dropIfExists('academy_lesson_blocks');
}
};

View File

@@ -0,0 +1,36 @@
<?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('academy_ai_comparison_results', function (Blueprint $table): void {
$table->id();
$table->foreignId('lesson_block_id')->constrained('academy_lesson_blocks')->cascadeOnDelete();
$table->string('provider')->nullable();
$table->string('model_name')->nullable();
$table->string('image_path');
$table->string('thumb_path')->nullable();
$table->text('settings')->nullable();
$table->text('strengths')->nullable();
$table->text('weaknesses')->nullable();
$table->text('best_for')->nullable();
$table->unsignedTinyInteger('score')->nullable();
$table->unsignedInteger('sort_order')->default(0)->index();
$table->boolean('active')->default(true)->index();
$table->timestamps();
$table->softDeletes();
$table->index(['lesson_block_id', 'sort_order'], 'academy_ai_compare_block_sort_idx');
});
}
public function down(): void
{
Schema::dropIfExists('academy_ai_comparison_results');
}
};

View File

@@ -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('artwork_comments', function (Blueprint $table): void {
$table->index(['created_at', 'id'], 'idx_artwork_comments_created_at');
});
}
public function down(): void
{
Schema::table('artwork_comments', function (Blueprint $table): void {
$table->dropIndex('idx_artwork_comments_created_at');
});
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/gfx/sb_logo_web.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,48 @@
(function () {
function initializeRegistrationTurnstile() {
var form = document.querySelector('[data-bot-form]');
var submitButton = document.querySelector('[data-turnstile-submit]');
var status = document.querySelector('[data-turnstile-status]');
var responseField = document.querySelector('input[name="cf-turnstile-response"]');
if (!form || !submitButton || !status || !responseField) {
return;
}
var setReadyState = function () {
var hasToken = responseField.value.trim() !== '';
submitButton.disabled = !hasToken;
status.textContent = hasToken
? 'Security verification ready.'
: 'Complete the security check before continuing.';
};
setReadyState();
var syncTokenState = window.setInterval(function () {
if (!document.body.contains(form)) {
window.clearInterval(syncTokenState);
return;
}
setReadyState();
}, 250);
form.addEventListener('submit', function (event) {
if (responseField.value.trim() !== '') {
return;
}
event.preventDefault();
setReadyState();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeRegistrationTurnstile, { once: true });
return;
}
initializeRegistrationTurnstile();
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
import React, { useCallback } from 'react'
import { Node, mergeAttributes as mergeNodeAttributes } from '@tiptap/core'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
function readImageAttrs(element) {
const imageElements = Array.from(element.querySelectorAll?.('img') || [])
const subtitleElement = element.querySelector?.('figcaption')
return {
leftSrc: imageElements[0]?.getAttribute('src') || '',
leftAlt: imageElements[0]?.getAttribute('alt') || '',
rightSrc: imageElements[1]?.getAttribute('src') || '',
rightAlt: imageElements[1]?.getAttribute('alt') || '',
subtitle: subtitleElement?.textContent?.trim() || '',
}
}
function RichCompareNodeView({ editor, node, selected, updateAttributes, deleteNode, getPos }) {
const selectNode = useCallback(() => {
if (!editor || typeof getPos !== 'function') {
return
}
editor.chain().focus().setNodeSelection(getPos()).run()
}, [editor, getPos])
return (
<NodeViewWrapper
as="figure"
className={["rich-compare-node", selected ? 'is-selected' : ''].filter(Boolean).join(' ')}
data-rich-compare="true"
onMouseDown={(event) => {
if (event.target instanceof HTMLElement && event.target.closest('input, textarea, button, select, label')) {
return
}
selectNode()
}}
>
<div className="rich-compare-node__grid">
<div className="rich-compare-node__tile">
<span className="rich-compare-node__badge">Left</span>
<img
src={node.attrs.leftSrc}
alt={node.attrs.leftAlt || ''}
className="rich-compare-node__img"
loading="lazy"
decoding="async"
/>
</div>
<div className="rich-compare-node__tile">
<span className="rich-compare-node__badge">Right</span>
<img
src={node.attrs.rightSrc}
alt={node.attrs.rightAlt || ''}
className="rich-compare-node__img"
loading="lazy"
decoding="async"
/>
</div>
</div>
{!selected && node.attrs.subtitle ? (
<figcaption className="rich-compare-node__subtitle">{node.attrs.subtitle}</figcaption>
) : null}
{selected ? (
<div className="rich-compare-node__editor" contentEditable={false}>
<div className="grid gap-3 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Left alt text</span>
<input
value={node.attrs.leftAlt || ''}
onChange={(event) => updateAttributes({ leftAlt: event.target.value })}
placeholder="Describe the left image"
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
/>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Right alt text</span>
<input
value={node.attrs.rightAlt || ''}
onChange={(event) => updateAttributes({ rightAlt: event.target.value })}
placeholder="Describe the right image"
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
/>
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Subtitle</span>
<input
value={node.attrs.subtitle || ''}
onChange={(event) => updateAttributes({ subtitle: event.target.value })}
placeholder="Visible caption below the comparison"
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
/>
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={selectNode}
className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
>
Keep selected
</button>
<button
type="button"
onClick={deleteNode}
className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15"
>
Remove comparison
</button>
</div>
</div>
) : null}
</NodeViewWrapper>
)
}
const RichCompare = Node.create({
name: 'imageCompare',
group: 'block',
atom: true,
addAttributes() {
return {
leftSrc: { default: '' },
leftAlt: { default: '' },
rightSrc: { default: '' },
rightAlt: { default: '' },
subtitle: { default: '' },
}
},
parseHTML() {
return [
{
tag: 'figure[data-rich-compare]',
getAttrs: (element) => readImageAttrs(element),
},
]
},
renderHTML({ node, HTMLAttributes }) {
const {
leftSrc: _leftSrc,
leftAlt: _leftAlt,
rightSrc: _rightSrc,
rightAlt: _rightAlt,
subtitle: _subtitle,
...figureHTMLAttributes
} = HTMLAttributes
const leftImageAttributes = {
src: node.attrs.leftSrc,
alt: node.attrs.leftAlt || '',
loading: 'lazy',
decoding: 'async',
class: 'rich-compare-node__img',
}
const rightImageAttributes = {
src: node.attrs.rightSrc,
alt: node.attrs.rightAlt || '',
loading: 'lazy',
decoding: 'async',
class: 'rich-compare-node__img',
}
return [
'figure',
mergeNodeAttributes(this.options.HTMLAttributes, figureHTMLAttributes, {
'data-rich-compare': 'true',
}),
['div', { class: 'rich-compare-node__grid' },
['div', { class: 'rich-compare-node__tile' }, ['img', leftImageAttributes]],
['div', { class: 'rich-compare-node__tile' }, ['img', rightImageAttributes]],
],
...(node.attrs.subtitle ? [['figcaption', { class: 'rich-compare-node__subtitle' }, node.attrs.subtitle]] : []),
]
},
addNodeView() {
return ReactNodeViewRenderer(RichCompareNodeView)
},
})
export default RichCompare

View File

@@ -0,0 +1,317 @@
import React, { useCallback, useEffect, useRef } from 'react'
import { mergeAttributes } from '@tiptap/core'
import Image from '@tiptap/extension-image'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
function parsePixelValue(rawValue) {
const normalized = String(rawValue || '').trim()
if (!normalized) {
return null
}
const parsed = Number.parseFloat(normalized.replace(/px$/i, ''))
return Number.isFinite(parsed) ? Math.round(parsed) : null
}
function readImageAttrs(element) {
const imageElement = element.tagName?.toLowerCase() === 'img'
? element
: element.querySelector?.('img')
const captionElement = element.querySelector?.('figcaption')
return {
src: imageElement?.getAttribute('src') || '',
alt: imageElement?.getAttribute('alt') || '',
title: imageElement?.getAttribute('title') || '',
caption: captionElement?.textContent?.trim() || '',
width: parsePixelValue(
element.getAttribute?.('data-width')
|| element.getAttribute?.('width')
|| imageElement?.getAttribute('width')
|| element.style?.width
|| '',
),
}
}
function RichImageNodeView({ editor, node, selected, updateAttributes, deleteNode, getPos }) {
const imageRef = useRef(null)
const cleanupResizeRef = useRef(null)
useEffect(() => () => {
if (typeof cleanupResizeRef.current === 'function') {
cleanupResizeRef.current()
}
}, [])
const selectNode = useCallback(() => {
if (!editor || typeof getPos !== 'function') {
return
}
editor.chain().focus().setNodeSelection(getPos()).run()
}, [editor, getPos])
const startResize = useCallback((event) => {
if (!imageRef.current || event.button !== 0) {
return
}
event.preventDefault()
event.stopPropagation()
selectNode()
const imageElement = imageRef.current
const parentWidth = imageElement.parentElement?.getBoundingClientRect().width || imageElement.getBoundingClientRect().width || 0
const startX = event.clientX
const startWidth = node.attrs.width || Math.round(imageElement.getBoundingClientRect().width) || 0
const minWidth = 180
const maxWidth = Math.max(minWidth, Math.round(parentWidth || 1280))
const handleMove = (moveEvent) => {
const nextWidth = clamp(Math.round(startWidth + (moveEvent.clientX - startX)), minWidth, maxWidth)
updateAttributes({ width: nextWidth })
}
const handleUp = () => {
window.removeEventListener('pointermove', handleMove)
window.removeEventListener('pointerup', handleUp)
cleanupResizeRef.current = null
}
cleanupResizeRef.current = handleUp
window.addEventListener('pointermove', handleMove)
window.addEventListener('pointerup', handleUp)
}, [node.attrs.width, selectNode, updateAttributes])
const width = Number.isFinite(Number(node.attrs.width)) && Number(node.attrs.width) > 0
? Number(node.attrs.width)
: null
return (
<NodeViewWrapper
as="figure"
className={[
'rich-image-node',
selected ? 'is-selected' : '',
].filter(Boolean).join(' ')}
data-rich-image="true"
onMouseDown={(event) => {
if (event.target instanceof HTMLElement && event.target.closest('input, textarea, button, select, label')) {
return
}
selectNode()
}}
>
<div className="rich-image-node__frame">
<img
ref={imageRef}
src={node.attrs.src}
alt={node.attrs.alt || ''}
title={node.attrs.title || ''}
className="rich-image-node__img"
style={width ? { width: `${width}px` } : undefined}
/>
{selected ? (
<button
type="button"
data-drag-handle
className="rich-image-node__drag-handle"
title="Drag to move image"
onMouseDown={selectNode}
>
<i className="fa-solid fa-grip-lines" />
</button>
) : null}
{selected ? (
<button
type="button"
className="rich-image-node__resize-handle"
title="Resize image"
onPointerDown={startResize}
>
<i className="fa-solid fa-up-right-and-down-left-from-center" />
</button>
) : null}
</div>
{!selected && node.attrs.caption ? (
<figcaption className="rich-image-node__caption">{node.attrs.caption}</figcaption>
) : null}
{selected ? (
<div className="rich-image-node__editor" contentEditable={false}>
<div className="grid gap-3 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Alt text</span>
<input
value={node.attrs.alt || ''}
onChange={(event) => updateAttributes({ alt: event.target.value })}
placeholder="Describe the image for screen readers"
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
/>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Caption</span>
<input
value={node.attrs.caption || ''}
onChange={(event) => updateAttributes({ caption: event.target.value })}
placeholder="Visible caption below the image"
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
/>
</label>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_auto] md:items-end">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Width</span>
<input
type="number"
min="180"
max="2400"
value={width || ''}
onChange={(event) => {
const nextValue = Number.parseInt(event.target.value, 10)
updateAttributes({ width: Number.isFinite(nextValue) ? nextValue : null })
}}
placeholder="Auto"
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
/>
</label>
<button
type="button"
onClick={() => updateAttributes({ width: null })}
className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
>
Fit
</button>
<button
type="button"
onClick={deleteNode}
className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15"
>
Remove
</button>
</div>
</div>
) : null}
</NodeViewWrapper>
)
}
const RichImage = Image.extend({
addOptions() {
return {
...this.parent?.(),
HTMLAttributes: {
class: 'rich-image-node',
},
}
},
addAttributes() {
return {
...this.parent?.(),
alt: {
default: '',
},
title: {
default: '',
},
caption: {
default: '',
},
width: {
default: null,
parseHTML: (element) => parsePixelValue(
element.getAttribute?.('data-width')
|| element.getAttribute?.('width')
|| element.style?.width
|| '',
),
renderHTML: (attributes) => {
const width = Number(attributes.width)
if (!Number.isFinite(width) || width <= 0) {
return {}
}
return {
'data-width': String(Math.round(width)),
style: `width:${Math.round(width)}px;max-width:100%;`,
}
},
},
}
},
parseHTML() {
return [
{
tag: 'figure[data-rich-image]',
getAttrs: (element) => readImageAttrs(element),
},
{
tag: 'img[src]',
getAttrs: (element) => readImageAttrs(element),
},
]
},
renderHTML({ node, HTMLAttributes }) {
const {
src: _src,
alt: _alt,
title: _title,
caption: _caption,
width: _width,
'data-width': _dataWidth,
...figureHTMLAttributes
} = HTMLAttributes
const figureAttributes = mergeAttributes(this.options.HTMLAttributes, figureHTMLAttributes, {
'data-rich-image': 'true',
})
const imageAttributes = {
src: node.attrs.src,
alt: node.attrs.alt || '',
title: node.attrs.title || '',
loading: 'lazy',
decoding: 'async',
}
if (Number.isFinite(Number(node.attrs.width)) && Number(node.attrs.width) > 0) {
const width = Math.round(Number(node.attrs.width))
imageAttributes.style = `width:${width}px;max-width:100%;`
imageAttributes['data-width'] = String(width)
}
const children = [
['img', imageAttributes],
]
if (node.attrs.caption) {
children.push(['figcaption', { class: 'rich-image-node__caption' }, node.attrs.caption])
}
return ['figure', figureAttributes, ...children]
},
addNodeView() {
return ReactNodeViewRenderer(RichImageNodeView)
},
})
export default RichImage

View File

@@ -0,0 +1,259 @@
import React, { useCallback } from 'react'
import { BubbleMenu } from '@tiptap/react/menus'
function TableButton({ onClick, active = false, disabled = false, title, children }) {
return (
<button
type="button"
onMouseDown={(event) => {
event.preventDefault()
}}
onClick={onClick}
disabled={disabled}
title={title}
className={[
'inline-flex h-8 items-center justify-center rounded-lg px-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
active
? 'bg-sky-600/25 text-sky-300'
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
disabled && 'pointer-events-none opacity-30',
].filter(Boolean).join(' ')}
>
{children}
</button>
)
}
export function TableInsertDialog({
open,
rows,
cols,
withHeaderRow,
withHeaderColumn,
onRowsChange,
onColsChange,
onHeaderRowChange,
onHeaderColumnChange,
onClose,
onInsert,
}) {
if (!open) return null
return (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose?.()
}
}}
role="presentation"
>
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
<div className="border-b border-white/[0.06] px-6 py-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Table</div>
<h3 className="mt-2 text-lg font-semibold text-white">Insert table</h3>
<p className="mt-2 text-sm leading-6 text-white/65">Create a table and edit rows and columns directly in the editor.</p>
</div>
<div className="grid gap-4 px-6 py-5 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Rows</span>
<input
type="number"
min="1"
max="12"
value={rows}
onChange={(event) => onRowsChange?.(Number.parseInt(event.target.value, 10) || 1)}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
/>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Columns</span>
<input
type="number"
min="1"
max="12"
value={cols}
onChange={(event) => onColsChange?.(Number.parseInt(event.target.value, 10) || 1)}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
/>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200 md:col-span-2">
<input
type="checkbox"
checked={withHeaderRow}
onChange={(event) => onHeaderRowChange?.(event.target.checked)}
className="mt-1"
/>
<span>
<span className="block font-semibold text-white">Header row</span>
<span className="mt-1 block text-xs leading-5 text-slate-400">Use a header row for column labels.</span>
</span>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200 md:col-span-2">
<input
type="checkbox"
checked={withHeaderColumn}
onChange={(event) => onHeaderColumnChange?.(event.target.checked)}
className="mt-1"
/>
<span>
<span className="block font-semibold text-white">Header column</span>
<span className="mt-1 block text-xs leading-5 text-slate-400">Use a header column for row labels.</span>
</span>
</label>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
<button type="button" onClick={onClose} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
Cancel
</button>
<button type="button" onClick={onInsert} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
Insert table
</button>
</div>
</div>
</div>
)
}
export default function RichTableControls({ editor }) {
const isTableActive = Boolean(editor?.isActive('table'))
const canRun = useCallback((commandName) => {
if (!editor) return false
try {
const chain = editor.can().chain().focus()
const next = typeof chain[commandName] === 'function' ? chain[commandName]() : null
return Boolean(next?.run?.())
} catch {
return false
}
}, [editor])
const runCommand = useCallback((commandName) => {
if (!editor) return
const chain = editor.chain().focus()
if (typeof chain[commandName] !== 'function') return
chain[commandName]().run()
}, [editor])
const deleteTable = useCallback(() => {
if (!editor) return
editor.chain().focus().deleteTable().run()
}, [editor])
const getActiveTable = useCallback(() => {
if (!editor) return null
const { state } = editor
const { $from } = state.selection
for (let depth = $from.depth; depth >= 0; depth -= 1) {
const node = $from.node(depth)
if (node?.type?.name !== 'table') {
continue
}
return {
node,
depth,
pos: $from.before(depth),
}
}
return null
}, [editor])
const moveTable = useCallback((direction) => {
if (!editor) return
const tableInfo = getActiveTable()
if (!tableInfo) return
const { state, view } = editor
const { doc } = state
const tableNode = tableInfo.node
const tablePos = tableInfo.pos
const tableSize = tableNode.nodeSize
let childPos = 1
let previous = null
let current = null
let next = null
for (let index = 0; index < doc.childCount; index += 1) {
const child = doc.child(index)
if (childPos === tablePos) {
current = { node: child, pos: childPos }
next = index + 1 < doc.childCount
? { node: doc.child(index + 1), pos: childPos + child.nodeSize }
: null
break
}
previous = { node: child, pos: childPos }
childPos += child.nodeSize
}
if (!current) return
const tr = state.tr.delete(tablePos, tablePos + tableSize)
let insertPos = tablePos
if (direction === 'up') {
if (!previous) return
insertPos = previous.pos
} else if (direction === 'down') {
if (!next) return
insertPos = next.pos + next.node.nodeSize - tableSize
} else {
return
}
tr.insert(insertPos, tableNode.type.create(tableNode.attrs, tableNode.content, tableNode.marks))
view.dispatch(tr)
editor.chain().focus().setNodeSelection(insertPos).run()
}, [editor, getActiveTable])
if (!editor) return null
return (
<BubbleMenu
editor={editor}
shouldShow={({ editor: bubbleEditor }) => Boolean(bubbleEditor?.isActive('table'))}
tippyOptions={{
placement: 'top-start',
offset: [0, 12],
duration: 100,
}}
className="rich-table-toolbar"
>
<div className="flex flex-wrap items-center gap-2 rounded-2xl border border-sky-300/25 bg-[linear-gradient(180deg,rgba(12,18,29,0.98),rgba(6,10,16,0.98))] px-3 py-2 text-xs text-slate-400 shadow-[0_18px_50px_rgba(0,0,0,0.35)]">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 font-semibold uppercase tracking-[0.16em] text-slate-300">Table tools</span>
<TableButton onClick={() => runCommand('addRowBefore')} disabled={!canRun('addRowBefore')} title="Add row before">Row +</TableButton>
<TableButton onClick={() => runCommand('addRowAfter')} disabled={!canRun('addRowAfter')} title="Add row after">Row +</TableButton>
<TableButton onClick={() => runCommand('deleteRow')} disabled={!canRun('deleteRow')} title="Delete row">Del row</TableButton>
<TableButton onClick={() => runCommand('addColumnBefore')} disabled={!canRun('addColumnBefore')} title="Add column before">Col +</TableButton>
<TableButton onClick={() => runCommand('addColumnAfter')} disabled={!canRun('addColumnAfter')} title="Add column after">Col +</TableButton>
<TableButton onClick={() => runCommand('deleteColumn')} disabled={!canRun('deleteColumn')} title="Delete column">Del col</TableButton>
<TableButton onClick={() => runCommand('mergeCells')} disabled={!canRun('mergeCells')} title="Merge selected cells">Merge</TableButton>
<TableButton onClick={() => runCommand('splitCell')} disabled={!canRun('splitCell')} title="Split selected cell">Split</TableButton>
<TableButton onClick={() => runCommand('toggleHeaderRow')} disabled={!canRun('toggleHeaderRow')} active={isTableActive} title="Toggle header row">Header row</TableButton>
<TableButton onClick={() => runCommand('toggleHeaderColumn')} disabled={!canRun('toggleHeaderColumn')} active={isTableActive} title="Toggle header column">Header col</TableButton>
<TableButton onClick={() => moveTable('up')} disabled={!getActiveTable()} title="Move table up">Move up</TableButton>
<TableButton onClick={() => moveTable('down')} disabled={!getActiveTable()} title="Move table down">Move down</TableButton>
<TableButton onClick={deleteTable} title="Delete table">Delete table</TableButton>
</div>
</BubbleMenu>
)
}

View File

@@ -0,0 +1,290 @@
@extends('layouts.nova')
@php
$initialPayload = [
'summary' => $summary,
'visitors' => $visitors,
'active_pages' => $activePages,
'generated_at' => $generatedAt,
];
@endphp
@push('head')
<style>
body.page-moderation main { padding-top: 4rem; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.body.classList.add('page-moderation')
})
</script>
@endpush
@section('content')
<section class="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8 text-zinc-100">
<div class="flex flex-col gap-4 rounded-3xl border border-white/10 bg-slate-950/70 p-6 shadow-2xl shadow-slate-950/40">
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<div class="text-xs font-semibold uppercase tracking-[0.24em] text-cyan-300/80">Moderation Traffic</div>
<h1 class="mt-2 text-3xl font-semibold text-white">Online Visitors</h1>
<p class="mt-2 max-w-3xl text-sm text-zinc-300">Live view of logged users, guests, crawlers, AI bots, and suspicious traffic.</p>
</div>
<div class="flex flex-wrap items-center gap-3 text-sm text-zinc-400">
<a href="{{ url('/moderation') }}" class="inline-flex items-center rounded-full border border-white/10 px-4 py-2 text-zinc-200 transition hover:border-cyan-300/40 hover:text-cyan-200">Back to moderation</a>
<span id="online-generated-at" class="rounded-full border border-white/10 bg-white/5 px-4 py-2">Updated {{ $generatedAt }}</span>
</div>
</div>
<div id="online-summary" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-7"></div>
</div>
<div class="grid gap-6 xl:grid-cols-[1.7fr_1fr]">
<div class="rounded-3xl border border-white/10 bg-slate-950/70 p-6 shadow-2xl shadow-slate-950/30">
<div class="flex flex-col gap-4 border-b border-white/10 pb-5 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-lg font-semibold text-white">Visitors</h2>
<p class="mt-1 text-sm text-zinc-400">Filter the live table without leaving the page.</p>
</div>
<div id="visitor-filters" class="flex flex-wrap gap-2"></div>
</div>
<div class="mt-5 overflow-x-auto">
<table class="min-w-full divide-y divide-white/10 text-sm">
<thead class="text-left text-xs uppercase tracking-[0.18em] text-zinc-500">
<tr>
<th class="px-3 py-3 font-medium">Type</th>
<th class="px-3 py-3 font-medium">User</th>
<th class="px-3 py-3 font-medium">IP</th>
<th class="px-3 py-3 font-medium">Bot / Browser</th>
<th class="px-3 py-3 font-medium">Current URL</th>
<th class="px-3 py-3 font-medium">Referer</th>
<th class="px-3 py-3 font-medium">First Seen</th>
<th class="px-3 py-3 font-medium">Last Seen</th>
<th class="px-3 py-3 font-medium">Hits</th>
</tr>
</thead>
<tbody id="online-visitors-table" class="divide-y divide-white/5 text-zinc-200"></tbody>
</table>
</div>
</div>
<aside class="rounded-3xl border border-white/10 bg-slate-950/70 p-6 shadow-2xl shadow-slate-950/30">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-white">Active Pages</h2>
<p class="mt-1 text-sm text-zinc-400">Current URLs with live visitor counts.</p>
</div>
</div>
<div id="online-active-pages" class="mt-5 space-y-3"></div>
</aside>
</div>
</section>
<script>
(() => {
const dataUrl = @json($dataUrl);
const initialState = @json($initialPayload);
const summaryContainer = document.getElementById('online-summary');
const filtersContainer = document.getElementById('visitor-filters');
const tableBody = document.getElementById('online-visitors-table');
const activePagesContainer = document.getElementById('online-active-pages');
const generatedAtContainer = document.getElementById('online-generated-at');
const dateFormatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
});
const summaryCards = [
{ key: 'total', label: 'Online now' },
{ key: 'logged', label: 'Logged users' },
{ key: 'guests', label: 'Guests' },
{ key: 'bots', label: 'Bots' },
{ key: 'search_bots', label: 'Search bots' },
{ key: 'ai_bots', label: 'AI bots' },
{ key: 'suspicious_bots', label: 'Suspicious' },
];
const filterDefinitions = [
{ key: 'all', label: 'All', matches: () => true },
{ key: 'logged', label: 'Logged', matches: (visitor) => visitor.type === 'human_logged' },
{ key: 'guests', label: 'Guests', matches: (visitor) => visitor.type === 'human_guest' },
{ key: 'bots', label: 'Bots', matches: (visitor) => String(visitor.type || '').endsWith('_bot') },
{ key: 'search_bot', label: 'Search bots', matches: (visitor) => visitor.type === 'search_bot' },
{ key: 'ai_bot', label: 'AI bots', matches: (visitor) => visitor.type === 'ai_bot' },
{ key: 'social_bot', label: 'Social bots', matches: (visitor) => visitor.type === 'social_bot' },
{ key: 'seo_bot', label: 'SEO bots', matches: (visitor) => visitor.type === 'seo_bot' },
{ key: 'suspicious_bot', label: 'Suspicious', matches: (visitor) => visitor.type === 'suspicious_bot' },
];
const typeLabels = {
human_logged: 'Logged',
human_guest: 'Guest',
search_bot: 'Search Bot',
ai_bot: 'AI Bot',
social_bot: 'Social Bot',
seo_bot: 'SEO Bot',
monitoring_bot: 'Monitoring Bot',
suspicious_bot: 'Suspicious',
unknown_bot: 'Unknown Bot',
};
const typeClasses = {
human_logged: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200',
human_guest: 'border-sky-400/30 bg-sky-400/10 text-sky-200',
search_bot: 'border-indigo-400/30 bg-indigo-400/10 text-indigo-200',
ai_bot: 'border-fuchsia-400/30 bg-fuchsia-400/10 text-fuchsia-200',
social_bot: 'border-amber-400/30 bg-amber-400/10 text-amber-200',
seo_bot: 'border-orange-400/30 bg-orange-400/10 text-orange-200',
monitoring_bot: 'border-teal-400/30 bg-teal-400/10 text-teal-200',
suspicious_bot: 'border-rose-400/30 bg-rose-400/10 text-rose-200',
unknown_bot: 'border-zinc-400/30 bg-zinc-400/10 text-zinc-200',
};
let activeFilter = 'all';
let state = initialState;
function formatDate(value) {
if (!value) {
return '—';
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : dateFormatter.format(parsed);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function renderSummary() {
summaryContainer.innerHTML = summaryCards.map((card) => `
<article class="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div class="text-xs uppercase tracking-[0.18em] text-zinc-500">${card.label}</div>
<div class="mt-3 text-3xl font-semibold text-white">${state.summary?.[card.key] ?? 0}</div>
</article>
`).join('');
}
function renderFilters() {
filtersContainer.innerHTML = filterDefinitions.map((filter) => {
const isActive = filter.key === activeFilter;
return `<button type="button" data-filter="${filter.key}" class="rounded-full border px-3 py-1.5 text-sm transition ${isActive ? 'border-cyan-300/50 bg-cyan-300/10 text-cyan-100' : 'border-white/10 bg-white/[0.03] text-zinc-300 hover:border-white/20 hover:text-white'}">${filter.label}</button>`;
}).join('');
filtersContainer.querySelectorAll('[data-filter]').forEach((button) => {
button.addEventListener('click', () => {
activeFilter = button.getAttribute('data-filter') || 'all';
renderFilters();
renderVisitors();
});
});
}
function filteredVisitors() {
const currentFilter = filterDefinitions.find((filter) => filter.key === activeFilter) || filterDefinitions[0];
return (state.visitors || []).filter(currentFilter.matches);
}
function renderVisitors() {
const visitors = filteredVisitors();
if (visitors.length === 0) {
tableBody.innerHTML = '<tr><td colspan="9" class="px-3 py-8 text-center text-zinc-500">No visitors match the current filter.</td></tr>';
return;
}
tableBody.innerHTML = visitors.map((visitor) => {
const type = String(visitor.type || 'unknown_bot');
const badgeClass = typeClasses[type] || typeClasses.unknown_bot;
const badgeLabel = typeLabels[type] || 'Unknown';
const agentLabel = visitor.bot_family || visitor.browser || 'Unknown';
const userLabel = visitor.user_name || (type === 'human_guest' ? 'Guest visitor' : 'Anonymous');
const currentUrl = visitor.current_url || '/';
const referer = visitor.referer || '—';
return `
<tr class="align-top">
<td class="px-3 py-4"><span class="inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold ${badgeClass}">${badgeLabel}</span></td>
<td class="px-3 py-4">
<div class="font-medium text-white">${escapeHtml(userLabel)}</div>
<div class="mt-1 text-xs text-zinc-500">${visitor.user_id ? `User #${escapeHtml(visitor.user_id)}` : 'No account'}</div>
</td>
<td class="px-3 py-4 font-mono text-xs text-zinc-300">${escapeHtml(visitor.ip_masked || 'unknown')}</td>
<td class="px-3 py-4">
<div class="font-medium text-white">${escapeHtml(agentLabel)}</div>
<div class="mt-1 text-xs text-zinc-500">${escapeHtml(visitor.browser || 'Unknown')} · ${escapeHtml(visitor.platform || 'Unknown')}</div>
</td>
<td class="px-3 py-4">
<a href="${escapeHtml(currentUrl)}" class="break-all text-cyan-200 hover:text-cyan-100 hover:underline">${escapeHtml(currentUrl)}</a>
<div class="mt-1 text-xs text-zinc-500">${escapeHtml(visitor.route_name || 'No route name')}</div>
</td>
<td class="px-3 py-4 break-all text-zinc-400">${escapeHtml(referer)}</td>
<td class="px-3 py-4 text-zinc-300">${escapeHtml(formatDate(visitor.first_seen_at))}</td>
<td class="px-3 py-4 text-zinc-300">${escapeHtml(formatDate(visitor.last_seen_at))}</td>
<td class="px-3 py-4 text-white">${escapeHtml(visitor.hits || 0)}</td>
</tr>
`;
}).join('');
}
function renderActivePages() {
const pages = state.active_pages || [];
if (pages.length === 0) {
activePagesContainer.innerHTML = '<div class="rounded-2xl border border-dashed border-white/10 px-4 py-6 text-sm text-zinc-500">No active public pages recorded.</div>';
return;
}
activePagesContainer.innerHTML = pages.map((page) => `
<div class="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div class="flex items-start justify-between gap-3">
<a href="${escapeHtml(page.url)}" class="break-all text-sm font-medium text-cyan-200 hover:text-cyan-100 hover:underline">${escapeHtml(page.url)}</a>
<span class="rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-1 text-xs text-zinc-300">${escapeHtml(page.visitors)} online</span>
</div>
</div>
`).join('');
}
function renderGeneratedAt() {
generatedAtContainer.textContent = `Updated ${formatDate(state.generated_at)}`;
}
async function loadOnlineVisitors() {
try {
const response = await fetch(dataUrl, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
});
if (!response.ok) {
return;
}
state = await response.json();
renderAll();
} catch (_error) {
// Keep the last rendered snapshot if polling fails.
}
}
function renderAll() {
renderSummary();
renderFilters();
renderVisitors();
renderActivePages();
renderGeneratedAt();
}
renderAll();
setInterval(loadOnlineVisitors, 10000);
})();
</script>
@endsection

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('renders the latest comments page', function (): void {
$author = User::factory()->create();
$artwork = Artwork::factory()->for($author)->create();
ArtworkComment::factory()->for($artwork)->for($author)->create([
'content' => 'Latest comments page regression comment',
'raw_content' => 'Latest comments page regression comment',
'rendered_content' => '<p>Latest comments page regression comment</p>',
'created_at' => now()->subMinutes(5),
]);
$this->get(route('legacy.latest_comments'))
->assertOk()
->assertSee('Latest Comments');
});
it('returns latest comments api data', function (): void {
$author = User::factory()->create();
$artwork = Artwork::factory()->for($author)->create([
'title' => 'Latest Comments Artwork',
'slug' => 'latest-comments-artwork',
]);
$comment = ArtworkComment::factory()->for($artwork)->for($author)->create([
'content' => 'Latest comments api regression comment',
'raw_content' => 'Latest comments api regression comment',
'rendered_content' => '<p>Latest comments api regression comment</p>',
'created_at' => now()->subMinutes(10),
]);
$this->getJson(route('api.comments.latest'))
->assertOk()
->assertJsonPath('data.0.comment_id', $comment->id)
->assertJsonPath('data.0.commenter.id', $author->id)
->assertJsonPath('data.0.artwork.id', $artwork->id)
->assertJsonPath('meta.total', 1);
});

View File

@@ -341,14 +341,14 @@ test('homepage renders featured hero picture and preload from dedicated featured
$desktopUrl = $paths->url($artwork, 'desktop'); $desktopUrl = $paths->url($artwork, 'desktop');
$desktopXlUrl = $paths->url($artwork, 'desktop_xl'); $desktopXlUrl = $paths->url($artwork, 'desktop_xl');
$xsUrl = $paths->url($artwork, 'xs'); $mobileXsUrl = $paths->url($artwork, 'mobile_xs');
$mobileUrl = $paths->url($artwork, 'mobile'); $mobileUrl = $paths->url($artwork, 'mobile');
$this->get(route('index')) $this->get(route('index'))
->assertOk() ->assertOk()
->assertSee($desktopUrl, false) ->assertSee($desktopUrl, false)
->assertSee($desktopXlUrl, false) ->assertSee($desktopXlUrl, false)
->assertSee($xsUrl, false) ->assertSee($mobileXsUrl, false)
->assertSee($mobileUrl, false) ->assertSee($mobileUrl, false)
->assertSee('rel="preload"', false) ->assertSee('rel="preload"', false)
->assertSee('type="image/webp"', false) ->assertSee('type="image/webp"', false)

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Moderation;
use App\Models\User;
use App\Services\Traffic\OnlineVisitorRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
final class OnlineVisitorsModerationTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_moderation_online_page_requires_staff_access(): void
{
$user = User::factory()->create(['role' => 'user']);
$this->get('/moderation/traffic/online')
->assertRedirect(route('login'));
$this->actingAs($user)
->get('/moderation/traffic/online')
->assertRedirect(route('index'));
$this->actingAs($user)
->getJson('/moderation/traffic/online/data')
->assertForbidden();
}
public function test_staff_can_open_online_page_and_json_endpoint(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$repository = Mockery::mock(OnlineVisitorRepository::class);
$repository->shouldReceive('summary')->twice()->andReturn([
'total' => 3,
'logged' => 1,
'guests' => 1,
'bots' => 1,
'search_bots' => 1,
'ai_bots' => 0,
'social_bots' => 0,
'seo_bots' => 0,
'suspicious_bots' => 0,
]);
$repository->shouldReceive('all')->twice()->andReturn([
[
'visitor_key' => 'user:1',
'type' => 'human_logged',
'user_name' => 'Gregor',
'ip_masked' => '188.230.xxx.xxx',
'browser' => 'Chrome',
'platform' => 'Windows',
'current_url' => '/wallpapers',
'route_name' => 'wallpapers.index',
'referer' => null,
'first_seen_at' => now()->subMinute()->toIso8601String(),
'last_seen_at' => now()->toIso8601String(),
'hits' => 2,
],
]);
$repository->shouldReceive('activePages')->twice()->andReturn([
['url' => '/wallpapers', 'visitors' => 3],
]);
$repository->shouldReceive('track')->andReturnNull();
$this->app->instance(OnlineVisitorRepository::class, $repository);
$this->actingAs($admin)
->get('/moderation/traffic/online')
->assertOk()
->assertSee('Online Visitors')
->assertSee('Live view of logged users, guests, crawlers, AI bots, and suspicious traffic.');
$this->actingAs($admin)
->getJson('/moderation/traffic/online/data')
->assertOk()
->assertJsonStructure([
'summary' => ['total', 'logged', 'guests', 'bots', 'search_bots', 'ai_bots', 'social_bots', 'seo_bots', 'suspicious_bots'],
'visitors',
'active_pages',
'generated_at',
])
->assertJsonPath('summary.total', 3)
->assertJsonPath('active_pages.0.url', '/wallpapers');
}
public function test_redis_failure_does_not_break_public_request(): void
{
$repository = Mockery::mock(OnlineVisitorRepository::class);
$repository->shouldReceive('track')->andThrow(new \RuntimeException('Redis unavailable'));
$this->app->instance(OnlineVisitorRepository::class, $repository);
$this->get('/robots.txt')
->assertOk();
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Traffic;
use App\Models\User;
use App\Services\Traffic\BotClassifier;
use App\Services\Traffic\OnlineVisitorRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
final class OnlineVisitorTrackingTest extends TestCase
{
use RefreshDatabase;
public function test_googlebot_is_classified_as_search_bot(): void
{
$classifier = app(BotClassifier::class);
$request = Request::create('/wallpapers', 'GET', server: ['HTTP_USER_AGENT' => 'Googlebot/2.1']);
$result = $classifier->classify($request);
self::assertTrue($result['is_bot']);
self::assertSame('search_bot', $result['type']);
self::assertSame('Googlebot', $result['family']);
}
public function test_gptbot_is_classified_as_ai_bot(): void
{
$classifier = app(BotClassifier::class);
$request = Request::create('/art/1/test', 'GET', server: ['HTTP_USER_AGENT' => 'GPTBot']);
$result = $classifier->classify($request);
self::assertTrue($result['is_bot']);
self::assertSame('ai_bot', $result['type']);
self::assertSame('GPTBot', $result['family']);
}
public function test_suspicious_user_agent_is_classified_as_suspicious_bot(): void
{
$classifier = app(BotClassifier::class);
$request = Request::create('/rate.php', 'GET', server: ['HTTP_USER_AGENT' => 'python-requests/2.31']);
$result = $classifier->classify($request);
self::assertTrue($result['is_bot']);
self::assertSame('suspicious_bot', $result['type']);
self::assertSame('python-requests', $result['family']);
}
public function test_logged_in_user_is_tracked_with_ttl_and_hit_counter(): void
{
$user = User::factory()->create(['role' => 'admin', 'name' => 'Gregor']);
$repository = new InMemoryOnlineVisitorRepository(app(BotClassifier::class));
$request = Request::create('/wallpapers', 'GET', server: [
'HTTP_USER_AGENT' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0',
'HTTP_CF_CONNECTING_IP' => '188.230.12.14',
]);
$request->setUserResolver(static fn (): User => $user);
$repository->track($request);
$repository->track($request);
$records = $repository->all();
self::assertCount(1, $records);
self::assertSame('human_logged', $records[0]['type']);
self::assertSame(2, $records[0]['hits']);
self::assertSame('188.230.xxx.xxx', $records[0]['ip_masked']);
self::assertSame(OnlineVisitorRepository::TTL_SECONDS, $repository->lastStoredTtl);
}
public function test_guest_tracking_cleans_expired_records_from_index(): void
{
$repository = new InMemoryOnlineVisitorRepository(app(BotClassifier::class));
$request = Request::create('/news/test', 'GET', server: [
'HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) Safari/605.1.15',
'REMOTE_ADDR' => '192.168.10.22',
]);
$request->cookies->set((string) config('session.cookie'), 'guest-session-cookie');
$repository->track($request);
$repository->seedIndexOnly('guest:expired');
$records = $repository->all();
$summary = $repository->summary();
$pages = $repository->activePages();
self::assertCount(1, $records);
self::assertSame('human_guest', $records[0]['type']);
self::assertSame(1, $summary['guests']);
self::assertSame('/news/test', $pages[0]['url']);
self::assertSame(['guest:expired'], $repository->removedFromIndex);
}
}
final class InMemoryOnlineVisitorRepository extends OnlineVisitorRepository
{
/** @var array<string, array<string, mixed>> */
private array $records = [];
/** @var array<int, string> */
private array $index = [];
/** @var array<int, string> */
public array $removedFromIndex = [];
public ?int $lastStoredTtl = null;
/**
* @return array<int, string>
*/
protected function readIndexMembers(): array
{
return $this->index;
}
/**
* @return array<string, mixed>|null
*/
protected function readRecord(string $visitorKey): ?array
{
return $this->records[$visitorKey] ?? null;
}
/**
* @param array<string, mixed> $record
*/
protected function storeRecord(string $visitorKey, array $record, int $ttlSeconds): void
{
$this->records[$visitorKey] = $record;
$this->lastStoredTtl = $ttlSeconds;
}
protected function addIndexMember(string $visitorKey): void
{
if (! in_array($visitorKey, $this->index, true)) {
$this->index[] = $visitorKey;
}
}
/**
* @param array<int, string> $visitorKeys
*/
protected function removeIndexMembers(array $visitorKeys): void
{
foreach ($visitorKeys as $visitorKey) {
$this->removedFromIndex[] = $visitorKey;
$this->index = array_values(array_filter($this->index, static fn (string $indexedKey): bool => $indexedKey !== $visitorKey));
}
}
protected function deleteRecord(string $visitorKey): void
{
unset($this->records[$visitorKey]);
}
public function seedIndexOnly(string $visitorKey): void
{
$this->index[] = $visitorKey;
}
}