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
@@ -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',
]);
}
}
@@ -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'] ?? '',
]);
}
}
@@ -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(),
]);
}
}
@@ -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.');
}
}
}
+103
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);
}
}
+51
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');
}
}
+73
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');
}
}
+1 -1
View File
@@ -1233,7 +1233,7 @@ final class HomepageService
->filter()
->implode(', ');
$xsSources = collect(['xs', 'mobile_sm'])
$xsSources = collect(['mobile_xs', 'mobile_sm'])
->map(function (string $variant) use ($variantUrls, $variants): ?string {
$url = $variantUrls[$variant] ?? null;
+217
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.');
}
}
}
+158
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,
];
}
}
@@ -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';
}
}
+6 -6
View File
@@ -65,12 +65,12 @@ final class ArtworkFeaturedImagePath
$variantName = $this->normalizeVariant($variant);
$orders = [
'xs' => ['xs', 'mobile_sm', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
'mobile_sm' => ['mobile_sm', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
'mobile' => ['mobile', 'mobile_sm', 'xs', 'tablet', 'desktop', 'desktop_xl'],
'tablet' => ['tablet', 'desktop', 'desktop_xl', 'mobile', 'mobile_sm', 'xs'],
'desktop' => ['desktop', 'desktop_xl', 'tablet', 'mobile', 'mobile_sm', 'xs'],
'desktop_xl' => ['desktop_xl', 'desktop', 'tablet', 'mobile', 'mobile_sm', 'xs'],
'mobile_xs' => ['mobile_xs', 'mobile_sm', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
'mobile_sm' => ['mobile_sm', 'mobile_xs', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
'mobile' => ['mobile', 'mobile_sm', 'mobile_xs', 'tablet', 'desktop', 'desktop_xl'],
'tablet' => ['tablet', 'desktop', 'desktop_xl', 'mobile', 'mobile_sm', 'mobile_xs'],
'desktop' => ['desktop', 'desktop_xl', 'tablet', 'mobile', 'mobile_sm', 'mobile_xs'],
'desktop_xl' => ['desktop_xl', 'desktop', 'tablet', 'mobile', 'mobile_sm', 'mobile_xs'],
];
return $orders[$variantName] ?? [$this->defaultVariant()];
+131
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);
}
}
@@ -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
+18 -18
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-module": [],
"\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": [
"/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/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [],
@@ -97,46 +97,46 @@
"/build/assets/vendor-tiptap-DRFaxGEb.js"
],
"\u0000assert?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js"
"/build/assets/vendor-realtime-DYEIbD6w.js"
],
"\u0000buffer?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js"
"/build/assets/vendor-realtime-DYEIbD6w.js"
],
"\u0000child_process?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js"
"/build/assets/vendor-realtime-DYEIbD6w.js"
],
"\u0000commonjsHelpers.js": [
"/build/assets/vendor-tiptap-DRFaxGEb.js"
],
"\u0000crypto?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js"
"/build/assets/vendor-realtime-DYEIbD6w.js"
],
"\u0000events?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js"
"/build/assets/vendor-realtime-DYEIbD6w.js"
],
"\u0000fs?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js"
"/build/assets/vendor-realtime-DYEIbD6w.js"
],
"\u0000http?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js"
"/build/assets/vendor-realtime-DYEIbD6w.js"
],
"\u0000https?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js"
"/build/assets/vendor-realtime-DYEIbD6w.js"
],
"\u0000net?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js"
"/build/assets/vendor-realtime-DYEIbD6w.js"
],
"\u0000stream?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js"
"/build/assets/vendor-realtime-DYEIbD6w.js"
],
"\u0000tls?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js"
"/build/assets/vendor-realtime-DYEIbD6w.js"
],
"\u0000url?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js"
"/build/assets/vendor-realtime-DYEIbD6w.js"
],
"\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": [
"/build/assets/emoji-data-4xGXbtDn.js"
@@ -1035,7 +1035,7 @@
"node_modules/inline-style-parser/cjs/index.js": [],
"node_modules/is-plain-obj/index.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": [
"/build/assets/vendor-tiptap-DRFaxGEb.js"
@@ -1934,7 +1934,7 @@
],
"node_modules/proxy-from-env/index.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/index.js": [],
@@ -2223,7 +2223,7 @@
"resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [],
"resources/js/components/artwork/ArtworkShareButton.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/AuthorBioPopover.jsx": [],
+2 -2
View File
@@ -17,7 +17,7 @@ import { t as tippy } from "./assets/vendor-tooltip-CIQaDNlG.js";
import minproc from "node:process";
import minpath from "node:path";
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 * as s from "process";
import require$$2 from "async_hooks";
@@ -32725,7 +32725,7 @@ function useWebShare({ onFallback } = {}) {
);
return { canNativeShare, share };
}
const ArtworkShareModal = reactExports.lazy(() => import("./assets/ArtworkShareModal-BPM8yel5.js"));
const ArtworkShareModal = reactExports.lazy(() => import("./assets/ArtworkShareModal-BI8kkaqs.js"));
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" }));
}
+263
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
+1 -1
View File
@@ -62,7 +62,7 @@ return [
],
'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' => ['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'],
@@ -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');
}
};
@@ -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');
}
};
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

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