Featured artworks thumbnails
This commit is contained in:
99
app/Console/Commands/GenerateNewsCoverThumbnailsCommand.php
Normal file
99
app/Console/Commands/GenerateNewsCoverThumbnailsCommand.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||||
|
use App\Services\News\NewsCoverImageService;
|
||||||
|
use App\Support\News\NewsCoverImage;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use cPad\Plugins\News\Models\NewsArticle;
|
||||||
|
|
||||||
|
final class GenerateNewsCoverThumbnailsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'news:generate-cover-thumbnails {--id=* : Restrict to one or more news article IDs} {--force : Regenerate variants even when they already exist}';
|
||||||
|
|
||||||
|
protected $description = 'Generate missing responsive cover thumbnails for managed news cover images';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly NewsCoverImageService $covers,
|
||||||
|
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$ids = collect((array) $this->option('id'))
|
||||||
|
->map(static fn (mixed $id): int => (int) $id)
|
||||||
|
->filter(static fn (int $id): bool => $id > 0)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$force = (bool) $this->option('force');
|
||||||
|
|
||||||
|
$query = NewsArticle::query()
|
||||||
|
->select(['id', 'title', 'cover_image'])
|
||||||
|
->whereNotNull('cover_image')
|
||||||
|
->where('cover_image', '!=', '');
|
||||||
|
|
||||||
|
if ($ids !== []) {
|
||||||
|
$query->whereIn('id', $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
$generated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$purged = 0;
|
||||||
|
|
||||||
|
$query->orderBy('id')->chunkById(100, function ($articles) use (&$generated, &$skipped, &$failed, &$purged, $force): void {
|
||||||
|
foreach ($articles as $article) {
|
||||||
|
$path = trim((string) $article->cover_image);
|
||||||
|
|
||||||
|
if (! NewsCoverImage::isManagedPath($path)) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->covers->ensureVariants($path, $force);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$this->warn(sprintf('Article %d failed: %s', (int) $article->id, $e->getMessage()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($result['generated'] ?? 0) > 0) {
|
||||||
|
$generated++;
|
||||||
|
|
||||||
|
if ($force && $this->purgeVariantCache($path, (int) $article->id)) {
|
||||||
|
$purged++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info(sprintf('News cover thumbnail generation complete: generated=%d skipped=%d failed=%d purged=%d', $generated, $skipped, $failed, $purged));
|
||||||
|
|
||||||
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function purgeVariantCache(string $path, int $articleId): bool
|
||||||
|
{
|
||||||
|
$variantPaths = array_values(array_map(
|
||||||
|
static fn (string $variant): string => NewsCoverImage::variantPath($path, $variant),
|
||||||
|
array_keys(NewsCoverImage::VARIANTS),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $this->cdnPurge->purgeArtworkObjectPaths($variantPaths, [
|
||||||
|
'article_id' => $articleId,
|
||||||
|
'reason' => 'news_cover_thumbnails_regenerated',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
502
app/Console/Commands/RepairArtworkThumbnailsCommand.php
Normal file
502
app/Console/Commands/RepairArtworkThumbnailsCommand.php
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Repositories\Uploads\ArtworkFileRepository;
|
||||||
|
use App\Services\ArtworkOriginalFileLocator;
|
||||||
|
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||||
|
use App\Services\Uploads\UploadDerivativesService;
|
||||||
|
use App\Services\Uploads\UploadStorageService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class RepairArtworkThumbnailsCommand extends Command
|
||||||
|
{
|
||||||
|
private const SOURCE_IMAGE_EXTENSIONS = [
|
||||||
|
'avif',
|
||||||
|
'bmp',
|
||||||
|
'gif',
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'png',
|
||||||
|
'tif',
|
||||||
|
'tiff',
|
||||||
|
'webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $signature = 'artworks:repair-missing-thumbnails
|
||||||
|
{--id= : Repair only this artwork ID}
|
||||||
|
{--limit= : Stop after processing this many artworks}
|
||||||
|
{--chunk=200 : Number of artworks to scan per batch}
|
||||||
|
{--variant=* : Specific thumbnail variants to repair (defaults to all configured derivatives)}
|
||||||
|
{--only-missing-flagged : Scan only artworks already marked with has_missing_thumbnails=1}
|
||||||
|
{--csv= : Optional path to write a CSV report}
|
||||||
|
{--force : Regenerate the selected variants even when they already exist}
|
||||||
|
{--dry-run : Report repairs without writing files}';
|
||||||
|
|
||||||
|
protected $description = 'Scan artworks from newest to oldest, detect missing CDN thumbnails, and rebuild only the missing derivatives from local source files.';
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
UploadStorageService $storage,
|
||||||
|
UploadDerivativesService $derivatives,
|
||||||
|
ArtworkFileRepository $artworkFiles,
|
||||||
|
ArtworkOriginalFileLocator $locator,
|
||||||
|
ArtworkCdnPurgeService $cdnPurge,
|
||||||
|
): int {
|
||||||
|
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
|
||||||
|
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||||
|
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
|
||||||
|
$onlyMissingFlagged = (bool) $this->option('only-missing-flagged');
|
||||||
|
$csvPath = trim((string) $this->option('csv'));
|
||||||
|
$force = (bool) $this->option('force');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
$allVariants = $this->resolveConfiguredVariants();
|
||||||
|
$selectedVariants = $this->resolveSelectedVariants($allVariants);
|
||||||
|
if ($selectedVariants === []) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditColumnsAvailable = Schema::hasColumns('artworks', [
|
||||||
|
'has_missing_thumbnails',
|
||||||
|
'missing_thumbnail_variants_json',
|
||||||
|
'thumbnails_checked_at',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($onlyMissingFlagged && ! $auditColumnsAvailable) {
|
||||||
|
$this->error('The --only-missing-flagged option requires thumbnail audit columns on the artworks table.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diskName = $storage->objectDiskName();
|
||||||
|
$disk = Storage::disk($diskName);
|
||||||
|
$csvHandle = $this->openCsvHandle($csvPath);
|
||||||
|
|
||||||
|
$baseQuery = $this->baseQuery($onlyMissingFlagged);
|
||||||
|
$totalCandidates = $this->resolveTotalCandidates($baseQuery, $artworkId, $limit);
|
||||||
|
$progressBar = $totalCandidates > 0 ? $this->output->createProgressBar($totalCandidates) : null;
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Starting thumbnail repair. order=id_desc include_trashed=yes disk=%s variants=%s chunk=%d limit=%s flagged_only=%s force=%s dry_run=%s csv=%s',
|
||||||
|
$diskName,
|
||||||
|
implode(',', $selectedVariants),
|
||||||
|
$chunkSize,
|
||||||
|
$limit !== null ? (string) $limit : 'all',
|
||||||
|
$onlyMissingFlagged ? 'yes' : 'no',
|
||||||
|
$force ? 'yes' : 'no',
|
||||||
|
$dryRun ? 'yes' : 'no',
|
||||||
|
$csvPath !== '' ? $csvPath : 'off',
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($progressBar !== null) {
|
||||||
|
$progressBar->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$healthy = 0;
|
||||||
|
$planned = 0;
|
||||||
|
$repaired = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$lastSeenId = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
$artworks = $this->nextChunk($baseQuery, $artworkId, $chunkSize, $lastSeenId);
|
||||||
|
if ($artworks->isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($artworks as $artwork) {
|
||||||
|
if ($limit !== null && $processed >= $limit) {
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$targetVariants = $force
|
||||||
|
? $selectedVariants
|
||||||
|
: $this->resolveMissingVariants($artwork, $selectedVariants, $storage, $disk);
|
||||||
|
|
||||||
|
if ($targetVariants === []) {
|
||||||
|
$healthy++;
|
||||||
|
$processed++;
|
||||||
|
$this->writeCsvRow($csvHandle, [
|
||||||
|
'artwork_id' => (int) $artwork->id,
|
||||||
|
'status' => 'healthy',
|
||||||
|
'variants' => '',
|
||||||
|
'source_file' => '',
|
||||||
|
'message' => '',
|
||||||
|
]);
|
||||||
|
$progressBar?->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourcePath = $this->resolveLocalSourcePath($artwork, $locator);
|
||||||
|
if ($sourcePath === '') {
|
||||||
|
throw new \RuntimeException('No local original source file was found in the configured artwork roots.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$planned++;
|
||||||
|
$this->line(sprintf(
|
||||||
|
'Artwork %d would repair thumbnails: %s',
|
||||||
|
(int) $artwork->id,
|
||||||
|
implode(',', $targetVariants),
|
||||||
|
));
|
||||||
|
$this->line(' source_file: ' . $sourcePath);
|
||||||
|
$this->writeCsvRow($csvHandle, [
|
||||||
|
'artwork_id' => (int) $artwork->id,
|
||||||
|
'status' => 'planned',
|
||||||
|
'variants' => implode(',', $targetVariants),
|
||||||
|
'source_file' => $sourcePath,
|
||||||
|
'message' => '',
|
||||||
|
]);
|
||||||
|
$processed++;
|
||||||
|
$progressBar?->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assets = $derivatives->generateSelectedPublicDerivatives($sourcePath, (string) $artwork->hash, $targetVariants);
|
||||||
|
if ($assets === []) {
|
||||||
|
throw new \RuntimeException('No thumbnail assets were generated for the requested variants.');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($artwork, $assets, $artworkFiles, $storage, $disk, $allVariants, $auditColumnsAvailable): void {
|
||||||
|
foreach ($assets as $variant => $asset) {
|
||||||
|
$artworkFiles->upsert((int) $artwork->id, (string) $variant, $asset['path'], $asset['mime'], $asset['size']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$update = [
|
||||||
|
'thumb_ext' => 'webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($auditColumnsAvailable) {
|
||||||
|
$remainingMissing = $this->resolveMissingVariants($artwork, $allVariants, $storage, $disk);
|
||||||
|
$update['has_missing_thumbnails'] = $remainingMissing !== [];
|
||||||
|
$update['missing_thumbnail_variants_json'] = $remainingMissing === []
|
||||||
|
? null
|
||||||
|
: json_encode(array_values($remainingMissing), JSON_UNESCAPED_SLASHES);
|
||||||
|
$update['thumbnails_checked_at'] = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
Artwork::query()->withTrashed()->whereKey($artwork->id)->update($update);
|
||||||
|
});
|
||||||
|
|
||||||
|
$cdnPurge->purgeArtworkObjectPaths(array_map(
|
||||||
|
static fn (array $asset): string => (string) $asset['path'],
|
||||||
|
array_values($assets),
|
||||||
|
), [
|
||||||
|
'artwork_id' => (int) $artwork->id,
|
||||||
|
'reason' => 'thumbnail_repair',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$repaired++;
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Artwork %d repaired thumbnails: %s',
|
||||||
|
(int) $artwork->id,
|
||||||
|
implode(',', array_keys($assets)),
|
||||||
|
));
|
||||||
|
$this->writeCsvRow($csvHandle, [
|
||||||
|
'artwork_id' => (int) $artwork->id,
|
||||||
|
'status' => 'repaired',
|
||||||
|
'variants' => implode(',', array_keys($assets)),
|
||||||
|
'source_file' => $sourcePath,
|
||||||
|
'message' => '',
|
||||||
|
]);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$failed++;
|
||||||
|
$this->warn(sprintf('Artwork %d repair failed: %s', (int) $artwork->id, $exception->getMessage()));
|
||||||
|
$this->writeCsvRow($csvHandle, [
|
||||||
|
'artwork_id' => (int) $artwork->id,
|
||||||
|
'status' => 'failed',
|
||||||
|
'variants' => isset($targetVariants) && is_array($targetVariants) ? implode(',', $targetVariants) : '',
|
||||||
|
'source_file' => isset($sourcePath) ? (string) $sourcePath : '',
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed++;
|
||||||
|
$progressBar?->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastSeenId = (int) $artworks->last()->id;
|
||||||
|
} while (true);
|
||||||
|
} finally {
|
||||||
|
if ($progressBar !== null) {
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_resource($csvHandle)) {
|
||||||
|
fclose($csvHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Thumbnail repair complete. processed=%d healthy=%d planned=%d repaired=%d failed=%d',
|
||||||
|
$processed,
|
||||||
|
$healthy,
|
||||||
|
$planned,
|
||||||
|
$repaired,
|
||||||
|
$failed,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Artwork>
|
||||||
|
*/
|
||||||
|
private function nextChunk(mixed $baseQuery, ?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
|
||||||
|
{
|
||||||
|
$query = clone $baseQuery;
|
||||||
|
|
||||||
|
if ($artworkId !== null) {
|
||||||
|
$query->whereKey($artworkId);
|
||||||
|
} elseif ($lastSeenId !== null) {
|
||||||
|
$query->where('id', '<', $lastSeenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->limit($chunkSize)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function baseQuery(bool $onlyMissingFlagged): mixed
|
||||||
|
{
|
||||||
|
$query = Artwork::query()
|
||||||
|
->withTrashed()
|
||||||
|
->select(['id', 'slug', 'hash', 'file_path', 'file_ext', 'thumb_ext'])
|
||||||
|
->whereNotNull('hash')
|
||||||
|
->where('hash', '!=', '')
|
||||||
|
->orderByDesc('id');
|
||||||
|
|
||||||
|
if ($onlyMissingFlagged) {
|
||||||
|
$query->where('has_missing_thumbnails', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTotalCandidates(mixed $baseQuery, ?int $artworkId, ?int $limit): int
|
||||||
|
{
|
||||||
|
$countQuery = clone $baseQuery;
|
||||||
|
|
||||||
|
if ($artworkId !== null) {
|
||||||
|
$countQuery->whereKey($artworkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = (int) $countQuery->count();
|
||||||
|
if ($limit !== null) {
|
||||||
|
return min($count, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function resolveConfiguredVariants(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
static fn ($variant): string => strtolower(trim((string) $variant)),
|
||||||
|
array_keys((array) config('uploads.derivatives', [])),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $configuredVariants
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function resolveSelectedVariants(array $configuredVariants): array
|
||||||
|
{
|
||||||
|
if ($configuredVariants === []) {
|
||||||
|
$this->error('No thumbnail variants are configured. Check uploads.derivatives.');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$requested = (array) $this->option('variant');
|
||||||
|
if ($requested === []) {
|
||||||
|
return $configuredVariants;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedRequested = array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn ($variant): string => strtolower(trim((string) $variant)),
|
||||||
|
$requested,
|
||||||
|
))));
|
||||||
|
|
||||||
|
$invalid = array_values(array_diff($normalizedRequested, $configuredVariants));
|
||||||
|
if ($invalid !== []) {
|
||||||
|
$this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
|
||||||
|
$this->line('Configured variants: ' . implode(', ', $configuredVariants));
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalizedRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $variants
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
|
||||||
|
{
|
||||||
|
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
|
||||||
|
if ($hash === '') {
|
||||||
|
return $variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
$missing = [];
|
||||||
|
foreach ($variants as $variant) {
|
||||||
|
$objectPath = $storage->objectPathForVariant($variant, $hash, $hash . '.webp');
|
||||||
|
if (! $disk->exists($objectPath)) {
|
||||||
|
$missing[] = $variant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLocalSourcePath(Artwork $artwork, ArtworkOriginalFileLocator $locator): string
|
||||||
|
{
|
||||||
|
$hash = strtolower((string) ($artwork->hash ?? ''));
|
||||||
|
if (! $this->isValidHash($hash)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$preferred = $locator->resolveLocalPath($artwork);
|
||||||
|
if ($this->isUsableSourceFile($preferred)) {
|
||||||
|
return $preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->candidateOriginalRoots() as $root) {
|
||||||
|
$candidatePath = $this->findNonZipSourceInRoot($root, $hash);
|
||||||
|
if ($candidatePath !== '') {
|
||||||
|
return $candidatePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function candidateOriginalRoots(): array
|
||||||
|
{
|
||||||
|
$roots = [
|
||||||
|
trim((string) config('uploads.local_originals_root', '')),
|
||||||
|
trim((string) config('uploads.readonly_backup_originals_root', '')),
|
||||||
|
];
|
||||||
|
|
||||||
|
$normalizedRoots = [];
|
||||||
|
|
||||||
|
foreach ($roots as $root) {
|
||||||
|
if ($root === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
|
||||||
|
if ($normalizedRoot === '' || in_array($normalizedRoot, $normalizedRoots, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedRoots[] = $normalizedRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalizedRoots;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findNonZipSourceInRoot(string $root, string $hash): string
|
||||||
|
{
|
||||||
|
$directory = $root
|
||||||
|
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
|
||||||
|
. DIRECTORY_SEPARATOR . substr($hash, 2, 2);
|
||||||
|
|
||||||
|
if (! File::isDirectory($directory)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$matches = File::glob($directory . DIRECTORY_SEPARATOR . $hash . '.*');
|
||||||
|
if (! is_array($matches)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($matches as $path) {
|
||||||
|
if ($this->isUsableSourceFile($path)) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isUsableSourceFile(string $path): bool
|
||||||
|
{
|
||||||
|
if ($path === '' || ! File::isFile($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
if ($extension === '' || ! in_array($extension, self::SOURCE_IMAGE_EXTENSIONS, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = strtolower((string) (File::mimeType($path) ?? ''));
|
||||||
|
|
||||||
|
return str_starts_with($mime, 'image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isValidHash(string $hash): bool
|
||||||
|
{
|
||||||
|
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return resource|null
|
||||||
|
*/
|
||||||
|
private function openCsvHandle(string $csvPath)
|
||||||
|
{
|
||||||
|
if ($csvPath === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
File::ensureDirectoryExists(dirname($csvPath));
|
||||||
|
$handle = fopen($csvPath, 'wb');
|
||||||
|
if (! is_resource($handle)) {
|
||||||
|
throw new \RuntimeException('Unable to open CSV output path for writing: ' . $csvPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
fputcsv($handle, ['artwork_id', 'status', 'variants', 'source_file', 'message']);
|
||||||
|
|
||||||
|
return $handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource|null $csvHandle
|
||||||
|
* @param array<string, scalar|null> $row
|
||||||
|
*/
|
||||||
|
private function writeCsvRow($csvHandle, array $row): void
|
||||||
|
{
|
||||||
|
if (! is_resource($csvHandle)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fputcsv($csvHandle, [
|
||||||
|
$row['artwork_id'] ?? '',
|
||||||
|
$row['status'] ?? '',
|
||||||
|
$row['variants'] ?? '',
|
||||||
|
$row['source_file'] ?? '',
|
||||||
|
$row['message'] ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
app/Http/Middleware/TrackOnlineVisitor.php
Normal file
103
app/Http/Middleware/TrackOnlineVisitor.php
Normal 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
app/Models/AcademyAiComparisonResult.php
Normal file
51
app/Models/AcademyAiComparisonResult.php
Normal 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
app/Models/AcademyLessonBlock.php
Normal file
73
app/Models/AcademyLessonBlock.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1233,7 +1233,7 @@ final class HomepageService
|
|||||||
->filter()
|
->filter()
|
||||||
->implode(', ');
|
->implode(', ');
|
||||||
|
|
||||||
$xsSources = collect(['xs', 'mobile_sm'])
|
$xsSources = collect(['mobile_xs', 'mobile_sm'])
|
||||||
->map(function (string $variant) use ($variantUrls, $variants): ?string {
|
->map(function (string $variant) use ($variantUrls, $variants): ?string {
|
||||||
$url = $variantUrls[$variant] ?? null;
|
$url = $variantUrls[$variant] ?? null;
|
||||||
|
|
||||||
|
|||||||
217
app/Services/News/NewsCoverImageService.php
Normal file
217
app/Services/News/NewsCoverImageService.php
Normal 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
app/Services/Traffic/BotClassifier.php
Normal file
158
app/Services/Traffic/BotClassifier.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
361
app/Services/Traffic/OnlineVisitorRepository.php
Normal file
361
app/Services/Traffic/OnlineVisitorRepository.php
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Traffic;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class OnlineVisitorRepository
|
||||||
|
{
|
||||||
|
public const INDEX_KEY = 'skinbase:presence:online:index';
|
||||||
|
public const KEY_PREFIX = 'skinbase:presence:online';
|
||||||
|
public const TTL_SECONDS = 300;
|
||||||
|
|
||||||
|
public function __construct(private readonly BotClassifier $classifier)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function track(Request $request): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$classification = $this->classifier->classify($request);
|
||||||
|
$visitorKey = $this->resolveVisitorKey($request, $classification);
|
||||||
|
$existing = $this->readRecord($visitorKey);
|
||||||
|
$user = $request->user();
|
||||||
|
$now = now()->toIso8601String();
|
||||||
|
|
||||||
|
$record = [
|
||||||
|
'visitor_key' => $visitorKey,
|
||||||
|
'type' => $classification['is_bot']
|
||||||
|
? (string) $classification['type']
|
||||||
|
: ($user ? 'human_logged' : 'human_guest'),
|
||||||
|
'bot_family' => $classification['is_bot'] ? $classification['family'] : null,
|
||||||
|
'user_id' => $this->resolveUserId($user),
|
||||||
|
'user_name' => $this->resolveUserName($user),
|
||||||
|
'ip_masked' => $this->maskIp($this->resolveIp($request)),
|
||||||
|
'ip_hash' => hash('sha256', $this->resolveIp($request)),
|
||||||
|
'user_agent' => $this->truncate((string) $request->userAgent(), 512),
|
||||||
|
'browser' => $this->detectBrowser((string) $request->userAgent()),
|
||||||
|
'platform' => $this->detectPlatform((string) $request->userAgent()),
|
||||||
|
'current_url' => $this->currentUrl($request),
|
||||||
|
'route_name' => $request->route()?->getName(),
|
||||||
|
'referer' => $this->truncate((string) $request->headers->get('referer', ''), 512) ?: null,
|
||||||
|
'first_seen_at' => is_string($existing['first_seen_at'] ?? null)
|
||||||
|
? $existing['first_seen_at']
|
||||||
|
: $now,
|
||||||
|
'last_seen_at' => $now,
|
||||||
|
'hits' => (int) ($existing['hits'] ?? 0) + 1,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->storeRecord($visitorKey, $record, self::TTL_SECONDS);
|
||||||
|
$this->addIndexMember($visitorKey);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Online visitor tracking failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'path' => $request->path(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$visitorKeys = array_values(array_unique(array_filter(array_map('strval', $this->readIndexMembers()))));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Online visitor index read failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$records = [];
|
||||||
|
$expired = [];
|
||||||
|
|
||||||
|
foreach ($visitorKeys as $visitorKey) {
|
||||||
|
try {
|
||||||
|
$record = $this->readRecord($visitorKey);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Online visitor record read failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'visitor_key' => $visitorKey,
|
||||||
|
]);
|
||||||
|
$record = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record === null) {
|
||||||
|
$expired[] = $visitorKey;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$records[] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($expired !== []) {
|
||||||
|
try {
|
||||||
|
$this->removeIndexMembers($expired);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Online visitor index cleanup failed', ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($records, static function (array $left, array $right): int {
|
||||||
|
return strtotime((string) ($right['last_seen_at'] ?? '')) <=> strtotime((string) ($left['last_seen_at'] ?? ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{total:int,logged:int,guests:int,bots:int,search_bots:int,ai_bots:int,social_bots:int,seo_bots:int,suspicious_bots:int}
|
||||||
|
*/
|
||||||
|
public function summary(): array
|
||||||
|
{
|
||||||
|
$records = $this->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => count($records),
|
||||||
|
'logged' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'human_logged')),
|
||||||
|
'guests' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'human_guest')),
|
||||||
|
'bots' => count(array_filter($records, static fn (array $record): bool => str_ends_with((string) ($record['type'] ?? ''), '_bot'))),
|
||||||
|
'search_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'search_bot')),
|
||||||
|
'ai_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'ai_bot')),
|
||||||
|
'social_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'social_bot')),
|
||||||
|
'seo_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'seo_bot')),
|
||||||
|
'suspicious_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'suspicious_bot')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{url:string, visitors:int}>
|
||||||
|
*/
|
||||||
|
public function activePages(): array
|
||||||
|
{
|
||||||
|
$counts = [];
|
||||||
|
|
||||||
|
foreach ($this->all() as $record) {
|
||||||
|
$url = trim((string) ($record['current_url'] ?? ''));
|
||||||
|
|
||||||
|
if ($url === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts[$url] = ($counts[$url] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
arsort($counts);
|
||||||
|
|
||||||
|
$pages = [];
|
||||||
|
|
||||||
|
foreach ($counts as $url => $visitors) {
|
||||||
|
$pages[] = [
|
||||||
|
'url' => $url,
|
||||||
|
'visitors' => $visitors,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forget(string $visitorKey): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->deleteRecord($visitorKey);
|
||||||
|
$this->removeIndexMembers([$visitorKey]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Online visitor forget failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'visitor_key' => $visitorKey,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
protected function readIndexMembers(): array
|
||||||
|
{
|
||||||
|
return array_map('strval', Redis::smembers(self::INDEX_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
protected function readRecord(string $visitorKey): ?array
|
||||||
|
{
|
||||||
|
$raw = Redis::get($this->recordKey($visitorKey));
|
||||||
|
|
||||||
|
if (! is_string($raw) || $raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $record
|
||||||
|
*/
|
||||||
|
protected function storeRecord(string $visitorKey, array $record, int $ttlSeconds): void
|
||||||
|
{
|
||||||
|
Redis::setex(
|
||||||
|
$this->recordKey($visitorKey),
|
||||||
|
$ttlSeconds,
|
||||||
|
(string) json_encode($record, JSON_UNESCAPED_SLASHES)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function addIndexMember(string $visitorKey): void
|
||||||
|
{
|
||||||
|
Redis::sadd(self::INDEX_KEY, $visitorKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $visitorKeys
|
||||||
|
*/
|
||||||
|
protected function removeIndexMembers(array $visitorKeys): void
|
||||||
|
{
|
||||||
|
if ($visitorKeys === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Redis::srem(self::INDEX_KEY, ...$visitorKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function deleteRecord(string $visitorKey): void
|
||||||
|
{
|
||||||
|
Redis::del($this->recordKey($visitorKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{is_bot: bool, type: ?string, family: ?string} $classification
|
||||||
|
*/
|
||||||
|
private function resolveVisitorKey(Request $request, array $classification): string
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
return 'user:' . $user->getAuthIdentifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
$ip = $this->resolveIp($request);
|
||||||
|
$userAgent = (string) $request->userAgent();
|
||||||
|
|
||||||
|
if ($classification['is_bot']) {
|
||||||
|
return 'bot:' . hash('sha256', $ip . '|' . $userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionCookieName = (string) config('session.cookie', 'laravel_session');
|
||||||
|
$sessionCookie = (string) $request->cookies->get($sessionCookieName, '');
|
||||||
|
$guestSeed = $sessionCookie !== ''
|
||||||
|
? 'session:' . $sessionCookie
|
||||||
|
: 'fingerprint:' . $ip . '|' . $userAgent . '|' . (string) $request->header('Accept-Language', '');
|
||||||
|
|
||||||
|
return 'guest:' . hash('sha256', $guestSeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveIp(Request $request): string
|
||||||
|
{
|
||||||
|
$cloudflareIp = trim((string) $request->headers->get('CF-Connecting-IP', ''));
|
||||||
|
|
||||||
|
if ($cloudflareIp !== '' && filter_var($cloudflareIp, FILTER_VALIDATE_IP)) {
|
||||||
|
return $cloudflareIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) ($request->ip() ?: '0.0.0.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maskIp(string $ip): string
|
||||||
|
{
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
|
$parts = explode('.', $ip);
|
||||||
|
|
||||||
|
return sprintf('%s.%s.xxx.xxx', $parts[0] ?? '0', $parts[1] ?? '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||||
|
$parts = explode(':', $ip);
|
||||||
|
$parts = array_pad($parts, 4, '');
|
||||||
|
|
||||||
|
return sprintf('%s:%s:xxxx:xxxx', $parts[0] ?: '::', $parts[1] ?: '::');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectBrowser(string $userAgent): string
|
||||||
|
{
|
||||||
|
$normalized = strtolower($userAgent);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
str_contains($normalized, 'edg/') => 'Edge',
|
||||||
|
str_contains($normalized, 'opr/') || str_contains($normalized, 'opera') => 'Opera',
|
||||||
|
str_contains($normalized, 'chrome') && ! str_contains($normalized, 'edg/') => 'Chrome',
|
||||||
|
str_contains($normalized, 'firefox') => 'Firefox',
|
||||||
|
str_contains($normalized, 'safari') && ! str_contains($normalized, 'chrome') => 'Safari',
|
||||||
|
default => 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectPlatform(string $userAgent): string
|
||||||
|
{
|
||||||
|
$normalized = strtolower($userAgent);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
str_contains($normalized, 'windows') => 'Windows',
|
||||||
|
str_contains($normalized, 'iphone') || str_contains($normalized, 'ipad') || str_contains($normalized, 'ios') => 'iOS',
|
||||||
|
str_contains($normalized, 'android') => 'Android',
|
||||||
|
str_contains($normalized, 'mac os') || str_contains($normalized, 'macintosh') => 'macOS',
|
||||||
|
str_contains($normalized, 'linux') => 'Linux',
|
||||||
|
default => 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentUrl(Request $request): string
|
||||||
|
{
|
||||||
|
$path = '/' . ltrim($request->path(), '/');
|
||||||
|
|
||||||
|
return $path === '//' ? '/' : $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordKey(string $visitorKey): string
|
||||||
|
{
|
||||||
|
return self::KEY_PREFIX . ':' . $visitorKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function truncate(string $value, int $limit): string
|
||||||
|
{
|
||||||
|
return Str::limit($value, $limit, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveUserId(?Authenticatable $user): ?int
|
||||||
|
{
|
||||||
|
if ($user === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$identifier = $user->getAuthIdentifier();
|
||||||
|
|
||||||
|
return is_numeric($identifier) ? (int) $identifier : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveUserName(?Authenticatable $user): ?string
|
||||||
|
{
|
||||||
|
if ($user === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = data_get($user, 'name')
|
||||||
|
?? data_get($user, 'username')
|
||||||
|
?? data_get($user, 'email');
|
||||||
|
|
||||||
|
return is_string($name) && $name !== '' ? $name : 'User';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,12 +65,12 @@ final class ArtworkFeaturedImagePath
|
|||||||
$variantName = $this->normalizeVariant($variant);
|
$variantName = $this->normalizeVariant($variant);
|
||||||
|
|
||||||
$orders = [
|
$orders = [
|
||||||
'xs' => ['xs', 'mobile_sm', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
|
'mobile_xs' => ['mobile_xs', 'mobile_sm', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
|
||||||
'mobile_sm' => ['mobile_sm', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
|
'mobile_sm' => ['mobile_sm', 'mobile_xs', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
|
||||||
'mobile' => ['mobile', 'mobile_sm', 'xs', 'tablet', 'desktop', 'desktop_xl'],
|
'mobile' => ['mobile', 'mobile_sm', 'mobile_xs', 'tablet', 'desktop', 'desktop_xl'],
|
||||||
'tablet' => ['tablet', 'desktop', 'desktop_xl', 'mobile', 'mobile_sm', 'xs'],
|
'tablet' => ['tablet', 'desktop', 'desktop_xl', 'mobile', 'mobile_sm', 'mobile_xs'],
|
||||||
'desktop' => ['desktop', 'desktop_xl', 'tablet', 'mobile', 'mobile_sm', 'xs'],
|
'desktop' => ['desktop', 'desktop_xl', 'tablet', 'mobile', 'mobile_sm', 'mobile_xs'],
|
||||||
'desktop_xl' => ['desktop_xl', 'desktop', 'tablet', 'mobile', 'mobile_sm', 'xs'],
|
'desktop_xl' => ['desktop_xl', 'desktop', 'tablet', 'mobile', 'mobile_sm', 'mobile_xs'],
|
||||||
];
|
];
|
||||||
|
|
||||||
return $orders[$variantName] ?? [$this->defaultVariant()];
|
return $orders[$variantName] ?? [$this->defaultVariant()];
|
||||||
|
|||||||
131
app/Support/News/NewsCoverImage.php
Normal file
131
app/Support/News/NewsCoverImage.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
301
bootstrap/ssr/assets/ArtworkShareModal-BI8kkaqs.js
Normal file
301
bootstrap/ssr/assets/ArtworkShareModal-BI8kkaqs.js
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { r as reactExports, a as reactDomExports, R as React } from "./vendor-tiptap-DRFaxGEb.js";
|
||||||
|
import { S as ShareToast } from "../ssr.js";
|
||||||
|
import "util";
|
||||||
|
import "stream";
|
||||||
|
import "path";
|
||||||
|
import "http";
|
||||||
|
import "https";
|
||||||
|
import "url";
|
||||||
|
import "fs";
|
||||||
|
import "crypto";
|
||||||
|
import "http2";
|
||||||
|
import "assert";
|
||||||
|
import "tty";
|
||||||
|
import "os";
|
||||||
|
import "zlib";
|
||||||
|
import "events";
|
||||||
|
import "./vendor-tooltip-CIQaDNlG.js";
|
||||||
|
import "node:process";
|
||||||
|
import "node:path";
|
||||||
|
import "node:url";
|
||||||
|
import "./vendor-realtime-DYEIbD6w.js";
|
||||||
|
import "buffer";
|
||||||
|
import "child_process";
|
||||||
|
import "net";
|
||||||
|
import "tls";
|
||||||
|
import "./vendor-motion-CotXNotG.js";
|
||||||
|
import "process";
|
||||||
|
import "async_hooks";
|
||||||
|
const FeedShareArtworkModal = reactExports.lazy(() => import("../ssr.js").then((n) => n.a));
|
||||||
|
function facebookUrl(url) {
|
||||||
|
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
|
function twitterUrl(url, title) {
|
||||||
|
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`;
|
||||||
|
}
|
||||||
|
function pinterestUrl(url, imageUrl, title) {
|
||||||
|
return `https://pinterest.com/pin/create/button/?url=${encodeURIComponent(url)}&media=${encodeURIComponent(imageUrl)}&description=${encodeURIComponent(title)}`;
|
||||||
|
}
|
||||||
|
function emailUrl(url, title) {
|
||||||
|
return `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
|
function CopyIcon() {
|
||||||
|
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" }));
|
||||||
|
}
|
||||||
|
function CheckIcon() {
|
||||||
|
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 20 20", fill: "currentColor", className: "h-5 w-5 text-emerald-400" }, /* @__PURE__ */ React.createElement("path", { fillRule: "evenodd", d: "M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z", clipRule: "evenodd" }));
|
||||||
|
}
|
||||||
|
function FacebookIcon() {
|
||||||
|
return /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 24 24", fill: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { d: "M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12Z" }));
|
||||||
|
}
|
||||||
|
function XTwitterIcon() {
|
||||||
|
return /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 24 24", fill: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { d: "M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77Z" }));
|
||||||
|
}
|
||||||
|
function PinterestIcon() {
|
||||||
|
return /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 24 24", fill: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { d: "M12 2C6.477 2 2 6.477 2 12c0 4.236 2.636 7.855 6.356 9.312-.088-.791-.167-2.005.035-2.868.181-.78 1.172-4.97 1.172-4.97s-.299-.598-.299-1.482c0-1.388.806-2.425 1.808-2.425.853 0 1.265.64 1.265 1.408 0 .858-.546 2.14-.828 3.33-.236.995.5 1.807 1.482 1.807 1.778 0 3.144-1.874 3.144-4.58 0-2.393-1.72-4.068-4.177-4.068-2.845 0-4.515 2.135-4.515 4.34 0 .859.331 1.781.745 2.282a.3.3 0 0 1 .069.288l-.278 1.133c-.044.183-.145.222-.335.134-1.249-.581-2.03-2.407-2.03-3.874 0-3.154 2.292-6.052 6.608-6.052 3.469 0 6.165 2.472 6.165 5.776 0 3.447-2.173 6.22-5.19 6.22-1.013 0-1.965-.527-2.291-1.148l-.623 2.378c-.226.869-.835 1.958-1.244 2.621.937.29 1.931.446 2.962.446 5.523 0 10-4.477 10-10S17.523 2 12 2Z" }));
|
||||||
|
}
|
||||||
|
function EmailIcon() {
|
||||||
|
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" }));
|
||||||
|
}
|
||||||
|
function EmbedIcon() {
|
||||||
|
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" }));
|
||||||
|
}
|
||||||
|
function CloseIcon() {
|
||||||
|
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 2, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M6 18 18 6M6 6l12 12" }));
|
||||||
|
}
|
||||||
|
function openShareWindow(url) {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer,width=600,height=500");
|
||||||
|
}
|
||||||
|
function trackShare(artworkId, platform) {
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content");
|
||||||
|
fetch(`/api/artworks/${artworkId}/share`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": csrfToken || "" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify({ platform })
|
||||||
|
}).catch(() => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function ArtworkShareModal({ open, onClose, artwork, shareUrl, isLoggedIn = false }) {
|
||||||
|
const backdropRef = reactExports.useRef(null);
|
||||||
|
const [linkCopied, setLinkCopied] = reactExports.useState(false);
|
||||||
|
const [embedCopied, setEmbedCopied] = reactExports.useState(false);
|
||||||
|
const [showEmbed, setShowEmbed] = reactExports.useState(false);
|
||||||
|
const [toastVisible, setToastVisible] = reactExports.useState(false);
|
||||||
|
const [toastMessage, setToastMessage] = reactExports.useState("");
|
||||||
|
const [profileShareOpen, setProfileShareOpen] = reactExports.useState(false);
|
||||||
|
const url = shareUrl || artwork?.canonical_url || (typeof window !== "undefined" ? window.location.href : "#");
|
||||||
|
const title = artwork?.title || "Artwork";
|
||||||
|
const imageUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.thumbs?.md?.url || "";
|
||||||
|
const thumbMdUrl = artwork?.thumbs?.md?.url || imageUrl;
|
||||||
|
const embedCode = `<a href="${url}">
|
||||||
|
<img src="${thumbMdUrl}" alt="${title.replace(/"/g, """)}" />
|
||||||
|
</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
|
||||||
|
};
|
||||||
8068
bootstrap/ssr/assets/vendor-motion-CotXNotG.js
Normal file
8068
bootstrap/ssr/assets/vendor-motion-CotXNotG.js
Normal file
File diff suppressed because it is too large
Load Diff
9704
bootstrap/ssr/assets/vendor-realtime-DYEIbD6w.js
Normal file
9704
bootstrap/ssr/assets/vendor-realtime-DYEIbD6w.js
Normal file
File diff suppressed because it is too large
Load Diff
32876
bootstrap/ssr/assets/vendor-tiptap-DRFaxGEb.js
Normal file
32876
bootstrap/ssr/assets/vendor-tiptap-DRFaxGEb.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,10 @@
|
|||||||
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-es-import": [],
|
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-es-import": [],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-module": [],
|
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-module": [],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-es-import": [
|
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-es-import": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-module": [
|
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-module": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/qs/lib/index.js?commonjs-es-import": [],
|
"\u0000D:/Sites/Skinbase26/node_modules/qs/lib/index.js?commonjs-es-import": [],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [],
|
"\u0000D:/Sites/Skinbase26/node_modules/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [],
|
||||||
@@ -97,46 +97,46 @@
|
|||||||
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
||||||
],
|
],
|
||||||
"\u0000assert?commonjs-external": [
|
"\u0000assert?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000buffer?commonjs-external": [
|
"\u0000buffer?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000child_process?commonjs-external": [
|
"\u0000child_process?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000commonjsHelpers.js": [
|
"\u0000commonjsHelpers.js": [
|
||||||
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
||||||
],
|
],
|
||||||
"\u0000crypto?commonjs-external": [
|
"\u0000crypto?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000events?commonjs-external": [
|
"\u0000events?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000fs?commonjs-external": [
|
"\u0000fs?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000http?commonjs-external": [
|
"\u0000http?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000https?commonjs-external": [
|
"\u0000https?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000net?commonjs-external": [
|
"\u0000net?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000stream?commonjs-external": [
|
"\u0000stream?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000tls?commonjs-external": [
|
"\u0000tls?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000url?commonjs-external": [
|
"\u0000url?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"\u0000util?commonjs-external": [
|
"\u0000util?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"node_modules/@emoji-mart/data/sets/15/native.json": [
|
"node_modules/@emoji-mart/data/sets/15/native.json": [
|
||||||
"/build/assets/emoji-data-4xGXbtDn.js"
|
"/build/assets/emoji-data-4xGXbtDn.js"
|
||||||
@@ -1035,7 +1035,7 @@
|
|||||||
"node_modules/inline-style-parser/cjs/index.js": [],
|
"node_modules/inline-style-parser/cjs/index.js": [],
|
||||||
"node_modules/is-plain-obj/index.js": [],
|
"node_modules/is-plain-obj/index.js": [],
|
||||||
"node_modules/laravel-echo/dist/echo.js": [
|
"node_modules/laravel-echo/dist/echo.js": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"node_modules/linkifyjs/dist/linkify.mjs": [
|
"node_modules/linkifyjs/dist/linkify.mjs": [
|
||||||
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
||||||
@@ -1934,7 +1934,7 @@
|
|||||||
],
|
],
|
||||||
"node_modules/proxy-from-env/index.js": [],
|
"node_modules/proxy-from-env/index.js": [],
|
||||||
"node_modules/pusher-js/dist/node/pusher.js": [
|
"node_modules/pusher-js/dist/node/pusher.js": [
|
||||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||||
],
|
],
|
||||||
"node_modules/qs/lib/formats.js": [],
|
"node_modules/qs/lib/formats.js": [],
|
||||||
"node_modules/qs/lib/index.js": [],
|
"node_modules/qs/lib/index.js": [],
|
||||||
@@ -2223,7 +2223,7 @@
|
|||||||
"resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [],
|
"resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [],
|
||||||
"resources/js/components/artwork/ArtworkShareButton.jsx": [],
|
"resources/js/components/artwork/ArtworkShareButton.jsx": [],
|
||||||
"resources/js/components/artwork/ArtworkShareModal.jsx": [
|
"resources/js/components/artwork/ArtworkShareModal.jsx": [
|
||||||
"/build/assets/ArtworkShareModal-BPM8yel5.js"
|
"/build/assets/ArtworkShareModal-BI8kkaqs.js"
|
||||||
],
|
],
|
||||||
"resources/js/components/artwork/ArtworkTags.jsx": [],
|
"resources/js/components/artwork/ArtworkTags.jsx": [],
|
||||||
"resources/js/components/artwork/AuthorBioPopover.jsx": [],
|
"resources/js/components/artwork/AuthorBioPopover.jsx": [],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { t as tippy } from "./assets/vendor-tooltip-CIQaDNlG.js";
|
|||||||
import minproc from "node:process";
|
import minproc from "node:process";
|
||||||
import minpath from "node:path";
|
import minpath from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { P as Pusher, E as E$1 } from "./assets/vendor-realtime-Koiu-_pw.js";
|
import { P as Pusher, E as E$1 } from "./assets/vendor-realtime-DYEIbD6w.js";
|
||||||
import { u as useReducedMotion, m as motion, A as AnimatePresence } from "./assets/vendor-motion-CotXNotG.js";
|
import { u as useReducedMotion, m as motion, A as AnimatePresence } from "./assets/vendor-motion-CotXNotG.js";
|
||||||
import * as s from "process";
|
import * as s from "process";
|
||||||
import require$$2 from "async_hooks";
|
import require$$2 from "async_hooks";
|
||||||
@@ -32725,7 +32725,7 @@ function useWebShare({ onFallback } = {}) {
|
|||||||
);
|
);
|
||||||
return { canNativeShare, share };
|
return { canNativeShare, share };
|
||||||
}
|
}
|
||||||
const ArtworkShareModal = reactExports.lazy(() => import("./assets/ArtworkShareModal-BPM8yel5.js"));
|
const ArtworkShareModal = reactExports.lazy(() => import("./assets/ArtworkShareModal-BI8kkaqs.js"));
|
||||||
function ShareIcon() {
|
function ShareIcon() {
|
||||||
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 1 1 0-2.684m0 2.684 6.632 3.316m-6.632-6 6.632-3.316m0 0a3 3 0 1 0 5.367-2.684 3 3 0 0 0-5.367 2.684Zm0 9.316a3 3 0 1 0 5.368 2.684 3 3 0 0 0-5.368-2.684Z" }));
|
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 1 1 0-2.684m0 2.684 6.632 3.316m-6.632-6 6.632-3.316m0 0a3 3 0 1 0 5.367-2.684 3 3 0 0 0-5.367 2.684Zm0 9.316a3 3 0 1 0 5.368 2.684 3 3 0 0 0-5.368-2.684Z" }));
|
||||||
}
|
}
|
||||||
|
|||||||
263
build-output.txt
Normal file
263
build-output.txt
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
|
||||||
|
> build
|
||||||
|
> vite build && vite build --ssr
|
||||||
|
|
||||||
|
[36mvite v7.3.1 [32mbuilding client environment for production...[36m[39m
|
||||||
|
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
|
||||||
|
[32mÔťô[39m 1524 modules transformed.
|
||||||
|
rendering chunks...
|
||||||
|
computing gzip size...
|
||||||
|
[2mpublic/build/[22m[32mmanifest.json [39m[1m[2m109.85 kB[22m[1m[22m[2m Ôöé gzip: 9.77 kB[22m
|
||||||
|
[2mpublic/build/[22m[35massets/entry-pill-carousel-BvZ3q_Be.css [39m[1m[2m 2.92 kB[22m[1m[22m[2m Ôöé gzip: 0.96 kB[22m
|
||||||
|
[2mpublic/build/[22m[35massets/MasonryGallery-BzMmiEvv.css [39m[1m[2m 3.07 kB[22m[1m[22m[2m Ôöé gzip: 0.95 kB[22m
|
||||||
|
[2mpublic/build/[22m[35massets/nova-grid-CrqpHU2F.css [39m[1m[2m 3.08 kB[22m[1m[22m[2m Ôöé gzip: 0.90 kB[22m
|
||||||
|
[2mpublic/build/[22m[35massets/nova-CcxuoKdL.css [39m[1m[2m 3.16 kB[22m[1m[22m[2m Ôöé gzip: 1.14 kB[22m
|
||||||
|
[2mpublic/build/[22m[35massets/app-3AuZHcjn.css [39m[1m[2m383.17 kB[22m[1m[22m[2m Ôöé gzip: 51.28 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/loadEmojiMartData-C6Eyvkxq.js [39m[1m[2m 0.17 kB[22m[1m[22m[2m Ôöé gzip: 0.17 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/RememberMeCheckbox-BNrl_LVi.js [39m[1m[2m 0.38 kB[22m[1m[22m[2m Ôöé gzip: 0.29 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/AiBiography-C2BnMNCk.js [39m[1m[2m 0.43 kB[22m[1m[22m[2m Ôöé gzip: 0.28 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/useWebShare-A8viBgBr.js [39m[1m[2m 0.47 kB[22m[1m[22m[2m Ôöé gzip: 0.30 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/render-frame-BW-nMUIy.js [39m[1m[2m 0.48 kB[22m[1m[22m[2m Ôöé gzip: 0.33 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/FeaturedArtworks-fC5muhUe.js [39m[1m[2m 0.51 kB[22m[1m[22m[2m Ôöé gzip: 0.31 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/entry-search-CgdTdEwH.js [39m[1m[2m 0.54 kB[22m[1m[22m[2m Ôöé gzip: 0.35 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/WorldStatusBadge-fQOpGaiG.js [39m[1m[2m 0.64 kB[22m[1m[22m[2m Ôöé gzip: 0.39 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/WorldCampaignMeta-EspIVm9r.js [39m[1m[2m 0.64 kB[22m[1m[22m[2m Ôöé gzip: 0.41 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/WorldAnalyticsSummaryCard-jyZWiApL.js [39m[1m[2m 0.65 kB[22m[1m[22m[2m Ôöé gzip: 0.39 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/DocsStepList-W7DaSNG5.js [39m[1m[2m 0.66 kB[22m[1m[22m[2m Ôöé gzip: 0.36 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioContentIndex-DKBNpOi4.js [39m[1m[2m 0.74 kB[22m[1m[22m[2m Ôöé gzip: 0.45 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioArchived-F9dFF0ce.js [39m[1m[2m 0.74 kB[22m[1m[22m[2m Ôöé gzip: 0.45 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioDrafts-RtuSIjLK.js [39m[1m[2m 0.76 kB[22m[1m[22m[2m Ôöé gzip: 0.46 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/scheduleCountdown-BSUyj4ow.js [39m[1m[2m 0.77 kB[22m[1m[22m[2m Ôöé gzip: 0.41 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/QuickstartNextSteps-2vKlQsj3.js [39m[1m[2m 0.78 kB[22m[1m[22m[2m Ôöé gzip: 0.43 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/GroupBadgePill-U2j00SrU.js [39m[1m[2m 0.83 kB[22m[1m[22m[2m Ôöé gzip: 0.48 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/LevelBadge-DLvpFEMl.js [39m[1m[2m 0.91 kB[22m[1m[22m[2m Ôöé gzip: 0.48 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/AchievementBadge-U4Rwvb-f.js [39m[1m[2m 0.92 kB[22m[1m[22m[2m Ôöé gzip: 0.52 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/XPProgressBar-Cnq1ZQfY.js [39m[1m[2m 0.95 kB[22m[1m[22m[2m Ôöé gzip: 0.56 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/DocsComparisonTable-CfIiOcg8.js [39m[1m[2m 0.98 kB[22m[1m[22m[2m Ôöé gzip: 0.49 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/moderation-ChYG-EvS.js [39m[1m[2m 1.08 kB[22m[1m[22m[2m Ôöé gzip: 0.57 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/preload-helper-I4rgV-VL.js [39m[1m[2m 1.12 kB[22m[1m[22m[2m Ôöé gzip: 0.66 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/bootstrap-DDEtOhKr.js [39m[1m[2m 1.18 kB[22m[1m[22m[2m Ôöé gzip: 0.75 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupArtworks-DyYRKiBc.js [39m[1m[2m 1.21 kB[22m[1m[22m[2m Ôöé gzip: 0.65 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupCollections-DCuDlpQH.js [39m[1m[2m 1.22 kB[22m[1m[22m[2m Ôöé gzip: 0.65 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/entry-masonry-gallery-JRJmA4N1.js [39m[1m[2m 1.27 kB[22m[1m[22m[2m Ôöé gzip: 0.64 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/home-C9R7CAjY.js [39m[1m[2m 1.31 kB[22m[1m[22m[2m Ôöé gzip: 0.60 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/DocsFaqAccordion-BNFbd9gv.js [39m[1m[2m 1.36 kB[22m[1m[22m[2m Ôöé gzip: 0.68 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/isEventWithinNode-ChyJskcB.js [39m[1m[2m 1.55 kB[22m[1m[22m[2m Ôöé gzip: 0.79 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/GroupStudioPromoCard-CXNI1Esg.js [39m[1m[2m 1.56 kB[22m[1m[22m[2m Ôöé gzip: 0.67 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Toggle-8-GT7jng.js [39m[1m[2m 1.62 kB[22m[1m[22m[2m Ôöé gzip: 0.80 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/TextInput-Bd5LNQnv.js [39m[1m[2m 1.63 kB[22m[1m[22m[2m Ôöé gzip: 0.81 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ShareToast-CJ9HBKcq.js [39m[1m[2m 1.67 kB[22m[1m[22m[2m Ôöé gzip: 0.96 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/MessageInboxBadge-BVwG2ye3.js [39m[1m[2m 1.69 kB[22m[1m[22m[2m Ôöé gzip: 0.94 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioStories-IjCHknbR.js [39m[1m[2m 1.69 kB[22m[1m[22m[2m Ôöé gzip: 0.84 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/EmojiPickerButton-DLRacqck.js [39m[1m[2m 1.74 kB[22m[1m[22m[2m Ôöé gzip: 0.92 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioArtworks-BtdQyD0t.js [39m[1m[2m 1.77 kB[22m[1m[22m[2m Ôöé gzip: 0.83 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioCollections-CpaEHCm_.js [39m[1m[2m 1.81 kB[22m[1m[22m[2m Ôöé gzip: 0.84 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupChallenges-iyH4CbWd.js [39m[1m[2m 1.86 kB[22m[1m[22m[2m Ôöé gzip: 0.81 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupEvents-CXbbI08d.js [39m[1m[2m 1.89 kB[22m[1m[22m[2m Ôöé gzip: 0.84 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Dashboard-1q9woC6E.js [39m[1m[2m 1.90 kB[22m[1m[22m[2m Ôöé gzip: 0.75 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Checkbox-BbglLO_c.js [39m[1m[2m 1.96 kB[22m[1m[22m[2m Ôöé gzip: 1.00 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ArtworkViewer-BPSc4YT9.js [39m[1m[2m 1.99 kB[22m[1m[22m[2m Ôöé gzip: 1.00 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupProjects-BOaAiKtB.js [39m[1m[2m 1.99 kB[22m[1m[22m[2m Ôöé gzip: 0.85 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Button-C97Uo6Gz.js [39m[1m[2m 2.13 kB[22m[1m[22m[2m Ôöé gzip: 0.95 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/FollowButton-JpmxGoIm.js [39m[1m[2m 2.15 kB[22m[1m[22m[2m Ôöé gzip: 1.20 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Pagination-UfBEeszW.js [39m[1m[2m 2.16 kB[22m[1m[22m[2m Ôöé gzip: 0.93 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Modal-CecvWwau.js [39m[1m[2m 2.16 kB[22m[1m[22m[2m Ôöé gzip: 1.05 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupActivity-D9qAKhZA.js [39m[1m[2m 2.18 kB[22m[1m[22m[2m Ôöé gzip: 0.93 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CrudIndex-DtO-9I7a.js [39m[1m[2m 2.21 kB[22m[1m[22m[2m Ôöé gzip: 0.94 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioSettings-DLRUREK7.js [39m[1m[2m 2.29 kB[22m[1m[22m[2m Ôöé gzip: 0.93 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Settings-2QKdwwLk.js [39m[1m[2m 2.41 kB[22m[1m[22m[2m Ôöé gzip: 0.98 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Submissions-CIpsHqDm.js [39m[1m[2m 2.41 kB[22m[1m[22m[2m Ôöé gzip: 0.93 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/WorldChallengeArtworkCard-CIiMObvd.js [39m[1m[2m 2.44 kB[22m[1m[22m[2m Ôöé gzip: 0.96 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Pricing-zuJk_IAS.js [39m[1m[2m 2.61 kB[22m[1m[22m[2m Ôöé gzip: 1.07 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/GroupEventShow-5BeC_oJO.js [39m[1m[2m 2.62 kB[22m[1m[22m[2m Ôöé gzip: 0.99 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupPosts-SISaHUvR.js [39m[1m[2m 2.68 kB[22m[1m[22m[2m Ôöé gzip: 0.94 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/NovaConfirmDialog-D8B-BIsp.js [39m[1m[2m 2.69 kB[22m[1m[22m[2m Ôöé gzip: 1.16 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/SeoHead-WmOGKAfr.js [39m[1m[2m 2.72 kB[22m[1m[22m[2m Ôöé gzip: 0.89 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Radio-jTKYNmqU.js [39m[1m[2m 2.84 kB[22m[1m[22m[2m Ôöé gzip: 1.21 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/UsernameQueue-C4goIl2k.js [39m[1m[2m 3.11 kB[22m[1m[22m[2m Ôöé gzip: 1.26 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/UploadQueue-CuFFOgey.js [39m[1m[2m 3.15 kB[22m[1m[22m[2m Ôöé gzip: 1.26 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Stories-HvAkKwi7.js [39m[1m[2m 3.16 kB[22m[1m[22m[2m Ôöé gzip: 1.20 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/worldAnalytics-TQj1HP4w.js [39m[1m[2m 3.25 kB[22m[1m[22m[2m Ôöé gzip: 1.24 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/GroupPostShow-BUMNEwKL.js [39m[1m[2m 3.30 kB[22m[1m[22m[2m Ôöé gzip: 1.43 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupReleases-DptXYqhO.js [39m[1m[2m 3.34 kB[22m[1m[22m[2m Ôöé gzip: 1.16 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/DocsSidebarNav-B7VScg7e.js [39m[1m[2m 3.34 kB[22m[1m[22m[2m Ôöé gzip: 1.35 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Artworks-BUHa8eNU.js [39m[1m[2m 3.34 kB[22m[1m[22m[2m Ôöé gzip: 1.25 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioCardsIndex-DcFcct3c.js [39m[1m[2m 3.44 kB[22m[1m[22m[2m Ôöé gzip: 1.35 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/TurnstileField-ChEIphj5.js [39m[1m[2m 3.45 kB[22m[1m[22m[2m Ôöé gzip: 1.39 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupPostEditor-aJqfeGeQ.js [39m[1m[2m 3.57 kB[22m[1m[22m[2m Ôöé gzip: 1.09 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/admin-DdhjEqVM.js [39m[1m[2m 3.78 kB[22m[1m[22m[2m Ôöé gzip: 1.25 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Dashboard-CHiT_s5l.js [39m[1m[2m 3.83 kB[22m[1m[22m[2m Ôöé gzip: 1.38 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ChallengeSubmit-BNYhzgAd.js [39m[1m[2m 4.03 kB[22m[1m[22m[2m Ôöé gzip: 1.37 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ProfileGallery-DH-7mEZJ.js [39m[1m[2m 4.07 kB[22m[1m[22m[2m Ôöé gzip: 1.67 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioCardAnalytics-5Rr2Pu7F.js [39m[1m[2m 4.12 kB[22m[1m[22m[2m Ôöé gzip: 1.31 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/entry-topbar-C-3GLDEI.js [39m[1m[2m 4.22 kB[22m[1m[22m[2m Ôöé gzip: 1.35 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/NotificationDropdown-JRO00JfU.js [39m[1m[2m 4.31 kB[22m[1m[22m[2m Ôöé gzip: 1.86 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ArtworkGallery-KwmYboBH.js [39m[1m[2m 4.42 kB[22m[1m[22m[2m Ôöé gzip: 1.94 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupReviewQueue-BoKsXobR.js [39m[1m[2m 4.61 kB[22m[1m[22m[2m Ôöé gzip: 1.44 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CollectionSeriesShow-BR-HOcJ4.js [39m[1m[2m 4.95 kB[22m[1m[22m[2m Ôöé gzip: 1.63 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Index-8LFVJbwk.js [39m[1m[2m 5.10 kB[22m[1m[22m[2m Ôöé gzip: 1.75 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/entry-similar-artworks-header--2UXKPo2.js [39m[1m[2m 5.14 kB[22m[1m[22m[2m Ôöé gzip: 1.86 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioArtworkAnalytics-CSAVbUzw.js [39m[1m[2m 5.14 kB[22m[1m[22m[2m Ôöé gzip: 1.38 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/WorldMediaUploadField-BguH2LRX.js [39m[1m[2m 5.21 kB[22m[1m[22m[2m Ôöé gzip: 2.09 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/List-DcFQYBgU.js [39m[1m[2m 5.22 kB[22m[1m[22m[2m Ôöé gzip: 1.87 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StorySocialPanel-D-CFFI6a.js [39m[1m[2m 5.23 kB[22m[1m[22m[2m Ôöé gzip: 2.00 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupJoinRequests-DcE3ZmVx.js [39m[1m[2m 5.25 kB[22m[1m[22m[2m Ôöé gzip: 1.68 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioFollowers-DaJnOmxb.js [39m[1m[2m 5.26 kB[22m[1m[22m[2m Ôöé gzip: 1.59 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupEventEditor-Bmh-gaS-.js [39m[1m[2m 5.51 kB[22m[1m[22m[2m Ôöé gzip: 1.47 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/AdminLayout-D1UJ9iPA.js [39m[1m[2m 5.55 kB[22m[1m[22m[2m Ôöé gzip: 1.86 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioSearch-BUSDc5cV.js [39m[1m[2m 5.55 kB[22m[1m[22m[2m Ôöé gzip: 1.55 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ReactionBar-qDsubGzH.js [39m[1m[2m 5.63 kB[22m[1m[22m[2m Ôöé gzip: 2.29 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupRecruitment-DEOD6C1l.js [39m[1m[2m 5.82 kB[22m[1m[22m[2m Ôöé gzip: 1.59 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/entry-pill-carousel-BME2m_Aj.js [39m[1m[2m 5.89 kB[22m[1m[22m[2m Ôöé gzip: 2.18 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupAssets-Blip239L.js [39m[1m[2m 5.93 kB[22m[1m[22m[2m Ôöé gzip: 1.80 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupsIndex-Dm7SeHp5.js [39m[1m[2m 5.94 kB[22m[1m[22m[2m Ôöé gzip: 1.89 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/app-zotai0D6.js [39m[1m[2m 6.02 kB[22m[1m[22m[2m Ôöé gzip: 2.74 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/GroupProjectShow-B0Kcp5Vy.js [39m[1m[2m 6.16 kB[22m[1m[22m[2m Ôöé gzip: 1.65 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioFeatured-egKjyiNl.js [39m[1m[2m 6.49 kB[22m[1m[22m[2m Ôöé gzip: 2.21 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/GroupChallengeShow-3Zibsz1N.js [39m[1m[2m 6.52 kB[22m[1m[22m[2m Ôöé gzip: 2.06 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/NovaCardsAssetPackAdmin-C98PgPda.js [39m[1m[2m 6.62 kB[22m[1m[22m[2m Ôöé gzip: 2.23 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioActivity-D2wdHReB.js [39m[1m[2m 6.89 kB[22m[1m[22m[2m Ôöé gzip: 2.26 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupReputation-Yrd3Gy1r.js [39m[1m[2m 6.89 kB[22m[1m[22m[2m Ôöé gzip: 1.61 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Index-DhXpkfgT.js [39m[1m[2m 6.92 kB[22m[1m[22m[2m Ôöé gzip: 2.30 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ShareArtworkModal-DyLrV0qb.js [39m[1m[2m 7.26 kB[22m[1m[22m[2m Ôöé gzip: 2.32 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/GroupReleaseShow-CXfO8SVQ.js [39m[1m[2m 7.36 kB[22m[1m[22m[2m Ôöé gzip: 1.80 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioNewsTaxonomies-D-nZtVgj.js [39m[1m[2m 7.50 kB[22m[1m[22m[2m Ôöé gzip: 1.68 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Index-DlVPlduo.js [39m[1m[2m 7.54 kB[22m[1m[22m[2m Ôöé gzip: 1.95 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioNewsIndex-1aqr51M8.js [39m[1m[2m 7.57 kB[22m[1m[22m[2m Ôöé gzip: 2.34 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupInvitations-BbHOzczf.js [39m[1m[2m 7.69 kB[22m[1m[22m[2m Ôöé gzip: 1.99 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/NovaCardsChallengeAdmin-BxRH4HHD.js [39m[1m[2m 7.70 kB[22m[1m[22m[2m Ôöé gzip: 2.41 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/WorldFamilyCard-g3Q3lPUa.js [39m[1m[2m 8.13 kB[22m[1m[22m[2m Ôöé gzip: 2.24 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/AuthAudit-DQm7x0js.js [39m[1m[2m 8.20 kB[22m[1m[22m[2m Ôöé gzip: 2.42 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioWorldsIndex-Caggblv3.js [39m[1m[2m 8.28 kB[22m[1m[22m[2m Ôöé gzip: 2.54 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioInbox-C2IjKLJu.js [39m[1m[2m 8.40 kB[22m[1m[22m[2m Ôöé gzip: 2.26 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CollectionHistory-DR_tBTQJ.js [39m[1m[2m 8.46 kB[22m[1m[22m[2m Ôöé gzip: 2.71 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/NovaSelect-CvnMafSD.js [39m[1m[2m 8.52 kB[22m[1m[22m[2m Ôöé gzip: 3.12 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/HomepageAnnouncement-BeRTPuZV.js [39m[1m[2m 8.89 kB[22m[1m[22m[2m Ôöé gzip: 2.72 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/LatestCommentsPage-Crx5rnQW.js [39m[1m[2m 8.91 kB[22m[1m[22m[2m Ôöé gzip: 3.38 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/NovaCardCanvasPreview-NNIpZmna.js [39m[1m[2m 8.92 kB[22m[1m[22m[2m Ôöé gzip: 3.44 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/leaderboard-Be43dPek.js [39m[1m[2m 9.03 kB[22m[1m[22m[2m Ôöé gzip: 3.08 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioChallenges-pvF_FNOG.js [39m[1m[2m 9.04 kB[22m[1m[22m[2m Ôöé gzip: 2.28 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/NovaCardsTemplateAdmin-e8kzGbiX.js [39m[1m[2m 9.11 kB[22m[1m[22m[2m Ôöé gzip: 2.68 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CollectionAnalytics-Bma-i9aj.js [39m[1m[2m 9.17 kB[22m[1m[22m[2m Ôöé gzip: 2.44 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/collections-CavW6YDp.js [39m[1m[2m 9.31 kB[22m[1m[22m[2m Ôöé gzip: 2.47 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/NovaCardsCollectionAdmin-BmB2xE9t.js [39m[1m[2m 9.31 kB[22m[1m[22m[2m Ôöé gzip: 2.81 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupChallengeEditor-Cj0MdxOR.js [39m[1m[2m 9.37 kB[22m[1m[22m[2m Ôöé gzip: 2.31 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupMembers-BtJB0RKE.js [39m[1m[2m 9.45 kB[22m[1m[22m[2m Ôöé gzip: 2.52 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupProjectEditor-Be1olaBp.js [39m[1m[2m 9.55 kB[22m[1m[22m[2m Ôöé gzip: 2.18 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/MasonryGallery-BoIeuFbA.js [39m[1m[2m 9.66 kB[22m[1m[22m[2m Ôöé gzip: 3.92 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/DateTimePicker-D0x39reT.js [39m[1m[2m 9.69 kB[22m[1m[22m[2m Ôöé gzip: 3.46 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioScheduled-6n-aDD2C.js [39m[1m[2m 9.87 kB[22m[1m[22m[2m Ôöé gzip: 2.73 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupCreate-BK7iVrNo.js [39m[1m[2m 10.15 kB[22m[1m[22m[2m Ôöé gzip: 2.66 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CollectionCard-CW0X2UXQ.js [39m[1m[2m 10.41 kB[22m[1m[22m[2m Ôöé gzip: 3.02 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioAssets-BDVKjFbv.js [39m[1m[2m 10.61 kB[22m[1m[22m[2m Ôöé gzip: 2.77 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupSettings-YDZVDATA.js [39m[1m[2m 10.75 kB[22m[1m[22m[2m Ôöé gzip: 2.60 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/WorldIndex-DWX1_dqc.js [39m[1m[2m 10.77 kB[22m[1m[22m[2m Ôöé gzip: 3.38 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ArtworkShareModal-BpGzIqZc.js [39m[1m[2m 11.03 kB[22m[1m[22m[2m Ôöé gzip: 4.01 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/studio-CAAJcae7.js [39m[1m[2m 11.18 kB[22m[1m[22m[2m Ôöé gzip: 2.50 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupReleaseEditor-C_tT-2ZK.js [39m[1m[2m 11.26 kB[22m[1m[22m[2m Ôöé gzip: 2.50 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioPreferences-CbCJHFZ3.js [39m[1m[2m 11.40 kB[22m[1m[22m[2m Ôöé gzip: 3.28 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CommentList-BTSXIXRd.js [39m[1m[2m 11.61 kB[22m[1m[22m[2m Ôöé gzip: 3.66 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioComments-CVDWXVkx.js [39m[1m[2m 11.93 kB[22m[1m[22m[2m Ôöé gzip: 3.20 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/emojiFlood-two4w6S_.js [39m[1m[2m 12.28 kB[22m[1m[22m[2m Ôöé gzip: 4.24 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGrowth-CcN89sK5.js [39m[1m[2m 12.50 kB[22m[1m[22m[2m Ôöé gzip: 2.94 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ArtworkMaturityQueue-Cko0vs1j.js [39m[1m[2m 12.74 kB[22m[1m[22m[2m Ôöé gzip: 3.75 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/DailyActivity-6d6LKgBU.js [39m[1m[2m 12.79 kB[22m[1m[22m[2m Ôöé gzip: 3.01 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioAnalytics-DpxQkg4p.js [39m[1m[2m 13.67 kB[22m[1m[22m[2m Ôöé gzip: 3.17 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/GroupIndex-BlsqLGwZ.js [39m[1m[2m 14.08 kB[22m[1m[22m[2m Ôöé gzip: 3.31 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioProfile-D9PN75-j.js [39m[1m[2m 14.57 kB[22m[1m[22m[2m Ôöé gzip: 3.73 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/TroubleshootingHelpPage-DOrpJMWy.js [39m[1m[2m 15.26 kB[22m[1m[22m[2m Ôöé gzip: 4.95 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/AccountHelpPage-BkSuCyM_.js [39m[1m[2m 15.69 kB[22m[1m[22m[2m Ôöé gzip: 5.20 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/AiBiographyAdmin-BgFN05Oz.js [39m[1m[2m 15.77 kB[22m[1m[22m[2m Ôöé gzip: 3.94 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CategoriesPage-CKl6PUyv.js [39m[1m[2m 16.17 kB[22m[1m[22m[2m Ôöé gzip: 4.96 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CommunityActivityPage-BbqEHVYK.js [39m[1m[2m 16.42 kB[22m[1m[22m[2m Ôöé gzip: 4.35 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ProfileHero-C8WW9XaZ.js [39m[1m[2m 16.45 kB[22m[1m[22m[2m Ôöé gzip: 5.07 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/NewsComments-CIOibLSk.js [39m[1m[2m 16.48 kB[22m[1m[22m[2m Ôöé gzip: 4.92 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioLayout-EsdK63Tt.js [39m[1m[2m 16.96 kB[22m[1m[22m[2m Ôöé gzip: 5.11 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Show-B8I3H0f8.js [39m[1m[2m 18.02 kB[22m[1m[22m[2m Ôöé gzip: 4.85 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/feed-6wi1IrNj.js [39m[1m[2m 18.20 kB[22m[1m[22m[2m Ôöé gzip: 4.06 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/GroupQuickstartPage-CKspxV3l.js [39m[1m[2m 19.22 kB[22m[1m[22m[2m Ôöé gzip: 6.14 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CrudForm-BP4DeOMb.js [39m[1m[2m 19.48 kB[22m[1m[22m[2m Ôöé gzip: 4.75 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CollectionFeaturedIndex-CscBO6s2.js [39m[1m[2m 20.20 kB[22m[1m[22m[2m Ôöé gzip: 4.62 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/SavedCollections-B7XGsh66.js [39m[1m[2m 20.51 kB[22m[1m[22m[2m Ôöé gzip: 5.32 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioCalendar-C-ZqPXqN.js [39m[1m[2m 20.71 kB[22m[1m[22m[2m Ôöé gzip: 4.42 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/AuthHelpPage-k6ZAVRrn.js [39m[1m[2m 20.74 kB[22m[1m[22m[2m Ôöé gzip: 6.41 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/PostCardSkeleton-Bridi1pW.js [39m[1m[2m 21.87 kB[22m[1m[22m[2m Ôöé gzip: 5.80 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ProfileHelpPage-RmI9Ixdu.js [39m[1m[2m 22.19 kB[22m[1m[22m[2m Ôöé gzip: 6.71 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/UploadHelpPage-DK3IBVv-.js [39m[1m[2m 22.43 kB[22m[1m[22m[2m Ôöé gzip: 6.93 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CardsHelpPage-BHnECSwD.js [39m[1m[2m 23.53 kB[22m[1m[22m[2m Ôöé gzip: 7.36 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/SearchBar-BvVB5g6u.js [39m[1m[2m 23.78 kB[22m[1m[22m[2m Ôöé gzip: 5.06 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioDashboard-NV62m-_W.js [39m[1m[2m 24.17 kB[22m[1m[22m[2m Ôöé gzip: 4.11 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioGroupDashboard-BfKzil5G.js [39m[1m[2m 24.33 kB[22m[1m[22m[2m Ôöé gzip: 4.53 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/FeaturedArtworksAdmin-D_vXOvK4.js [39m[1m[2m 24.41 kB[22m[1m[22m[2m Ôöé gzip: 6.14 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CollectionDashboard-B3CkegmC.js [39m[1m[2m 24.56 kB[22m[1m[22m[2m Ôöé gzip: 5.79 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ArtworkCard-BqO7t18e.js [39m[1m[2m 25.13 kB[22m[1m[22m[2m Ôöé gzip: 7.61 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioHelpPage-CxFbER9L.js [39m[1m[2m 25.18 kB[22m[1m[22m[2m Ôöé gzip: 7.53 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/nova-Cb8Jt7G8.js [39m[1m[2m 25.74 kB[22m[1m[22m[2m Ôöé gzip: 7.51 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/WorldSubmissionSelector-B12ePKNZ.js [39m[1m[2m 26.16 kB[22m[1m[22m[2m Ôöé gzip: 8.27 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioUploadQueue-CFbmx7Km.js [39m[1m[2m 26.74 kB[22m[1m[22m[2m Ôöé gzip: 6.77 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/GroupFaqPage-1rX8UFW8.js [39m[1m[2m 28.01 kB[22m[1m[22m[2m Ôöé gzip: 8.74 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/WorldsHelpPage-LQTfe6S_.js [39m[1m[2m 28.20 kB[22m[1m[22m[2m Ôöé gzip: 8.64 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/NovaCardsAdminIndex-B89AJiP4.js [39m[1m[2m 28.99 kB[22m[1m[22m[2m Ôöé gzip: 5.79 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Form-CzwRrQpH.js [39m[1m[2m 29.07 kB[22m[1m[22m[2m Ôöé gzip: 8.16 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/LessonEditor-CKN0FVlX.js [39m[1m[2m 29.54 kB[22m[1m[22m[2m Ôöé gzip: 7.80 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CollectionStaffSurfaces-BAt_mFhq.js [39m[1m[2m 34.33 kB[22m[1m[22m[2m Ôöé gzip: 6.80 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioContentBrowser-DIPUTR8u.js [39m[1m[2m 34.41 kB[22m[1m[22m[2m Ôöé gzip: 8.11 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/vendor-tooltip-CnBRltuW.js [39m[1m[2m 35.79 kB[22m[1m[22m[2m Ôöé gzip: 12.69 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/index-qFyJwsfU.js [39m[1m[2m 37.13 kB[22m[1m[22m[2m Ôöé gzip: 14.86 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/HelpCenterPage-CoxjWWvH.js [39m[1m[2m 41.36 kB[22m[1m[22m[2m Ôöé gzip: 10.04 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/forum-qDB55hjW.js [39m[1m[2m 42.15 kB[22m[1m[22m[2m Ôöé gzip: 9.80 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/settings-CZhBkfUt.js [39m[1m[2m 42.40 kB[22m[1m[22m[2m Ôöé gzip: 11.24 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/GroupHelpPage-DJeoap3E.js [39m[1m[2m 43.15 kB[22m[1m[22m[2m Ôöé gzip: 12.12 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/module.esm-CCyjftVT.js [39m[1m[2m 45.88 kB[22m[1m[22m[2m Ôöé gzip: 16.51 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/WorldShow-DHXM3cKb.js [39m[1m[2m 49.44 kB[22m[1m[22m[2m Ôöé gzip: 8.30 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/RichTextEditor-Bt71q8op.js [39m[1m[2m 51.22 kB[22m[1m[22m[2m Ôöé gzip: 13.36 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CollectionShow-DygrFMKU.js [39m[1m[2m 51.58 kB[22m[1m[22m[2m Ôöé gzip: 11.73 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CollectionStaffProgramming-DVM7JuaD.js [39m[1m[2m 52.90 kB[22m[1m[22m[2m Ôöé gzip: 9.53 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StoryEditor-BXqMeM3o.js [39m[1m[2m 54.39 kB[22m[1m[22m[2m Ôöé gzip: 14.38 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/index-NFidTLXk.js [39m[1m[2m 61.24 kB[22m[1m[22m[2m Ôöé gzip: 13.86 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/Index-QYLK_IzZ.js [39m[1m[2m 64.98 kB[22m[1m[22m[2m Ôöé gzip: 17.69 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/GroupShow-DOtdW99f.js [39m[1m[2m 64.99 kB[22m[1m[22m[2m Ôöé gzip: 10.97 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioCardEditor-BXK8VJVJ.js [39m[1m[2m 67.52 kB[22m[1m[22m[2m Ôöé gzip: 14.39 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioNewsEditor-Dae0SpEl.js [39m[1m[2m 68.33 kB[22m[1m[22m[2m Ôöé gzip: 17.36 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/vendor-realtime-CC6hBp0A.js [39m[1m[2m 73.99 kB[22m[1m[22m[2m Ôöé gzip: 21.34 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/emoji-ui-RjUF93sV.js [39m[1m[2m 77.27 kB[22m[1m[22m[2m Ôöé gzip: 27.77 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/index.esm-t39M3oTI.js [39m[1m[2m 80.99 kB[22m[1m[22m[2m Ôöé gzip: 27.66 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioArtworkEdit-CZkt8TiR.js [39m[1m[2m 90.78 kB[22m[1m[22m[2m Ôöé gzip: 20.90 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/artwork-BQWlZoE5.js [39m[1m[2m108.57 kB[22m[1m[22m[2m Ôöé gzip: 27.87 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/index-Cr7xflb-.js [39m[1m[2m117.88 kB[22m[1m[22m[2m Ôöé gzip: 36.29 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/CollectionManage-Dvhkb44m.js [39m[1m[2m125.86 kB[22m[1m[22m[2m Ôöé gzip: 23.17 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/vendor-motion-D2pGNhd4.js [39m[1m[2m134.48 kB[22m[1m[22m[2m Ôöé gzip: 44.48 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/ProfileShow-DekSt66N.js [39m[1m[2m148.94 kB[22m[1m[22m[2m Ôöé gzip: 33.67 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/StudioWorldEditor-BBwIOsk4.js [39m[1m[2m153.30 kB[22m[1m[22m[2m Ôöé gzip: 28.57 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/upload-v6eTVHa6.js [39m[1m[2m154.16 kB[22m[1m[22m[2m Ôöé gzip: 40.85 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/client-ZHR_6vo2.js [39m[1m[2m180.87 kB[22m[1m[22m[2m Ôöé gzip: 56.91 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/vendor-syntax-C7R9Wg3P.js [39m[1m[2m313.97 kB[22m[1m[22m[2m Ôöé gzip: 97.35 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/emoji-data-48B9X9Wg.js [39m[1m[2m432.75 kB[22m[1m[22m[2m Ôöé gzip: 82.80 kB[22m
|
||||||
|
[2mpublic/build/[22m[36massets/vendor-tiptap-C7JLf1wU.js [39m[1m[2m492.51 kB[22m[1m[22m[2m Ôöé gzip: 155.17 kB[22m
|
||||||
|
[32mÔťô built in 14.22s[39m
|
||||||
|
[36mvite v7.3.1 [32mbuilding ssr environment for production...[36m[39m
|
||||||
|
transforming...
|
||||||
|
[32mÔťô[39m 1324 modules transformed.
|
||||||
|
rendering chunks...
|
||||||
|
[2mbootstrap/ssr/[22m[32mssr-manifest.json [39m[1m[2m 111.54 kB[22m[1m[22m
|
||||||
|
[2mbootstrap/ssr/[22m[36massets/ArtworkShareModal-BPM8yel5.js [39m[1m[2m 16.42 kB[22m[1m[22m
|
||||||
|
[2mbootstrap/ssr/[22m[36massets/vendor-tooltip-CIQaDNlG.js [39m[1m[2m 93.73 kB[22m[1m[22m
|
||||||
|
[2mbootstrap/ssr/[22m[36massets/emoji-ui-C_DZUNyP.js [39m[1m[2m 133.00 kB[22m[1m[22m
|
||||||
|
[2mbootstrap/ssr/[22m[36massets/vendor-motion-CotXNotG.js [39m[1m[2m 282.99 kB[22m[1m[22m
|
||||||
|
[2mbootstrap/ssr/[22m[36massets/vendor-realtime-Koiu-_pw.js [39m[1m[2m 378.07 kB[22m[1m[22m
|
||||||
|
[2mbootstrap/ssr/[22m[36massets/emoji-data-4xGXbtDn.js [39m[1m[2m 433.09 kB[22m[1m[22m
|
||||||
|
[2mbootstrap/ssr/[22m[36massets/vendor-tiptap-DRFaxGEb.js [39m[1m[33m1,149.63 kB[39m[22m
|
||||||
|
[2mbootstrap/ssr/[22m[36mssr.js [39m[1m[33m7,768.76 kB[39m[22m
|
||||||
|
[32mÔťô built in 9.50s[39m
|
||||||
@@ -62,7 +62,7 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
'featured_variants' => [
|
'featured_variants' => [
|
||||||
'xs' => ['width' => 400, 'height' => 512, 'quality' => 76, 'media' => '(max-width: 479px)', 'sizes' => '100vw'],
|
'mobile_xs' => ['width' => 400, 'height' => 400, 'quality' => 76, 'media' => '(max-width: 479px)', 'sizes' => '100vw'],
|
||||||
'mobile_sm' => ['width' => 640, 'height' => 640, 'quality' => 78, 'media' => '(max-width: 639px)', 'sizes' => '100vw'],
|
'mobile_sm' => ['width' => 640, 'height' => 640, 'quality' => 78, 'media' => '(max-width: 639px)', 'sizes' => '100vw'],
|
||||||
'mobile' => ['width' => 900, 'height' => 900, 'quality' => 80, 'media' => '(max-width: 767px)', 'sizes' => '100vw'],
|
'mobile' => ['width' => 900, 'height' => 900, 'quality' => 80, 'media' => '(max-width: 767px)', 'sizes' => '100vw'],
|
||||||
'tablet' => ['width' => 1280, 'height' => 900, 'quality' => 82, 'media' => '(max-width: 1279px)', 'sizes' => '100vw'],
|
'tablet' => ['width' => 1280, 'height' => 900, 'quality' => 82, 'media' => '(max-width: 1279px)', 'sizes' => '100vw'],
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
BIN
public/gfx/sb_logo_full.webp
Normal file
BIN
public/gfx/sb_logo_full.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/gfx/sb_logo_web.webp
Normal file
BIN
public/gfx/sb_logo_web.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
48
public/js/register-turnstile.js
Normal file
48
public/js/register-turnstile.js
Normal 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();
|
||||||
|
})();
|
||||||
1127
resources/js/Pages/Admin/Academy/LessonEditor.jsx
Normal file
1127
resources/js/Pages/Admin/Academy/LessonEditor.jsx
Normal file
File diff suppressed because it is too large
Load Diff
193
resources/js/components/forum/RichCompareNode.jsx
Normal file
193
resources/js/components/forum/RichCompareNode.jsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { Node, mergeAttributes as mergeNodeAttributes } from '@tiptap/core'
|
||||||
|
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
|
|
||||||
|
function readImageAttrs(element) {
|
||||||
|
const imageElements = Array.from(element.querySelectorAll?.('img') || [])
|
||||||
|
const subtitleElement = element.querySelector?.('figcaption')
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftSrc: imageElements[0]?.getAttribute('src') || '',
|
||||||
|
leftAlt: imageElements[0]?.getAttribute('alt') || '',
|
||||||
|
rightSrc: imageElements[1]?.getAttribute('src') || '',
|
||||||
|
rightAlt: imageElements[1]?.getAttribute('alt') || '',
|
||||||
|
subtitle: subtitleElement?.textContent?.trim() || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RichCompareNodeView({ editor, node, selected, updateAttributes, deleteNode, getPos }) {
|
||||||
|
const selectNode = useCallback(() => {
|
||||||
|
if (!editor || typeof getPos !== 'function') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.chain().focus().setNodeSelection(getPos()).run()
|
||||||
|
}, [editor, getPos])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper
|
||||||
|
as="figure"
|
||||||
|
className={["rich-compare-node", selected ? 'is-selected' : ''].filter(Boolean).join(' ')}
|
||||||
|
data-rich-compare="true"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target instanceof HTMLElement && event.target.closest('input, textarea, button, select, label')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectNode()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="rich-compare-node__grid">
|
||||||
|
<div className="rich-compare-node__tile">
|
||||||
|
<span className="rich-compare-node__badge">Left</span>
|
||||||
|
<img
|
||||||
|
src={node.attrs.leftSrc}
|
||||||
|
alt={node.attrs.leftAlt || ''}
|
||||||
|
className="rich-compare-node__img"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rich-compare-node__tile">
|
||||||
|
<span className="rich-compare-node__badge">Right</span>
|
||||||
|
<img
|
||||||
|
src={node.attrs.rightSrc}
|
||||||
|
alt={node.attrs.rightAlt || ''}
|
||||||
|
className="rich-compare-node__img"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selected && node.attrs.subtitle ? (
|
||||||
|
<figcaption className="rich-compare-node__subtitle">{node.attrs.subtitle}</figcaption>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<div className="rich-compare-node__editor" contentEditable={false}>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Left alt text</span>
|
||||||
|
<input
|
||||||
|
value={node.attrs.leftAlt || ''}
|
||||||
|
onChange={(event) => updateAttributes({ leftAlt: event.target.value })}
|
||||||
|
placeholder="Describe the left image"
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Right alt text</span>
|
||||||
|
<input
|
||||||
|
value={node.attrs.rightAlt || ''}
|
||||||
|
onChange={(event) => updateAttributes({ rightAlt: event.target.value })}
|
||||||
|
placeholder="Describe the right image"
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Subtitle</span>
|
||||||
|
<input
|
||||||
|
value={node.attrs.subtitle || ''}
|
||||||
|
onChange={(event) => updateAttributes({ subtitle: event.target.value })}
|
||||||
|
placeholder="Visible caption below the comparison"
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={selectNode}
|
||||||
|
className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
|
||||||
|
>
|
||||||
|
Keep selected
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={deleteNode}
|
||||||
|
className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15"
|
||||||
|
>
|
||||||
|
Remove comparison
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RichCompare = Node.create({
|
||||||
|
name: 'imageCompare',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
leftSrc: { default: '' },
|
||||||
|
leftAlt: { default: '' },
|
||||||
|
rightSrc: { default: '' },
|
||||||
|
rightAlt: { default: '' },
|
||||||
|
subtitle: { default: '' },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'figure[data-rich-compare]',
|
||||||
|
getAttrs: (element) => readImageAttrs(element),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
const {
|
||||||
|
leftSrc: _leftSrc,
|
||||||
|
leftAlt: _leftAlt,
|
||||||
|
rightSrc: _rightSrc,
|
||||||
|
rightAlt: _rightAlt,
|
||||||
|
subtitle: _subtitle,
|
||||||
|
...figureHTMLAttributes
|
||||||
|
} = HTMLAttributes
|
||||||
|
|
||||||
|
const leftImageAttributes = {
|
||||||
|
src: node.attrs.leftSrc,
|
||||||
|
alt: node.attrs.leftAlt || '',
|
||||||
|
loading: 'lazy',
|
||||||
|
decoding: 'async',
|
||||||
|
class: 'rich-compare-node__img',
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightImageAttributes = {
|
||||||
|
src: node.attrs.rightSrc,
|
||||||
|
alt: node.attrs.rightAlt || '',
|
||||||
|
loading: 'lazy',
|
||||||
|
decoding: 'async',
|
||||||
|
class: 'rich-compare-node__img',
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'figure',
|
||||||
|
mergeNodeAttributes(this.options.HTMLAttributes, figureHTMLAttributes, {
|
||||||
|
'data-rich-compare': 'true',
|
||||||
|
}),
|
||||||
|
['div', { class: 'rich-compare-node__grid' },
|
||||||
|
['div', { class: 'rich-compare-node__tile' }, ['img', leftImageAttributes]],
|
||||||
|
['div', { class: 'rich-compare-node__tile' }, ['img', rightImageAttributes]],
|
||||||
|
],
|
||||||
|
...(node.attrs.subtitle ? [['figcaption', { class: 'rich-compare-node__subtitle' }, node.attrs.subtitle]] : []),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(RichCompareNodeView)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default RichCompare
|
||||||
317
resources/js/components/forum/RichImageNode.jsx
Normal file
317
resources/js/components/forum/RichImageNode.jsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { mergeAttributes } from '@tiptap/core'
|
||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePixelValue(rawValue) {
|
||||||
|
const normalized = String(rawValue || '').trim()
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseFloat(normalized.replace(/px$/i, ''))
|
||||||
|
return Number.isFinite(parsed) ? Math.round(parsed) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImageAttrs(element) {
|
||||||
|
const imageElement = element.tagName?.toLowerCase() === 'img'
|
||||||
|
? element
|
||||||
|
: element.querySelector?.('img')
|
||||||
|
|
||||||
|
const captionElement = element.querySelector?.('figcaption')
|
||||||
|
|
||||||
|
return {
|
||||||
|
src: imageElement?.getAttribute('src') || '',
|
||||||
|
alt: imageElement?.getAttribute('alt') || '',
|
||||||
|
title: imageElement?.getAttribute('title') || '',
|
||||||
|
caption: captionElement?.textContent?.trim() || '',
|
||||||
|
width: parsePixelValue(
|
||||||
|
element.getAttribute?.('data-width')
|
||||||
|
|| element.getAttribute?.('width')
|
||||||
|
|| imageElement?.getAttribute('width')
|
||||||
|
|| element.style?.width
|
||||||
|
|| '',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RichImageNodeView({ editor, node, selected, updateAttributes, deleteNode, getPos }) {
|
||||||
|
const imageRef = useRef(null)
|
||||||
|
const cleanupResizeRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (typeof cleanupResizeRef.current === 'function') {
|
||||||
|
cleanupResizeRef.current()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectNode = useCallback(() => {
|
||||||
|
if (!editor || typeof getPos !== 'function') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.chain().focus().setNodeSelection(getPos()).run()
|
||||||
|
}, [editor, getPos])
|
||||||
|
|
||||||
|
const startResize = useCallback((event) => {
|
||||||
|
if (!imageRef.current || event.button !== 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
selectNode()
|
||||||
|
|
||||||
|
const imageElement = imageRef.current
|
||||||
|
const parentWidth = imageElement.parentElement?.getBoundingClientRect().width || imageElement.getBoundingClientRect().width || 0
|
||||||
|
const startX = event.clientX
|
||||||
|
const startWidth = node.attrs.width || Math.round(imageElement.getBoundingClientRect().width) || 0
|
||||||
|
const minWidth = 180
|
||||||
|
const maxWidth = Math.max(minWidth, Math.round(parentWidth || 1280))
|
||||||
|
|
||||||
|
const handleMove = (moveEvent) => {
|
||||||
|
const nextWidth = clamp(Math.round(startWidth + (moveEvent.clientX - startX)), minWidth, maxWidth)
|
||||||
|
updateAttributes({ width: nextWidth })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUp = () => {
|
||||||
|
window.removeEventListener('pointermove', handleMove)
|
||||||
|
window.removeEventListener('pointerup', handleUp)
|
||||||
|
cleanupResizeRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupResizeRef.current = handleUp
|
||||||
|
window.addEventListener('pointermove', handleMove)
|
||||||
|
window.addEventListener('pointerup', handleUp)
|
||||||
|
}, [node.attrs.width, selectNode, updateAttributes])
|
||||||
|
|
||||||
|
const width = Number.isFinite(Number(node.attrs.width)) && Number(node.attrs.width) > 0
|
||||||
|
? Number(node.attrs.width)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper
|
||||||
|
as="figure"
|
||||||
|
className={[
|
||||||
|
'rich-image-node',
|
||||||
|
selected ? 'is-selected' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
data-rich-image="true"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target instanceof HTMLElement && event.target.closest('input, textarea, button, select, label')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectNode()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="rich-image-node__frame">
|
||||||
|
<img
|
||||||
|
ref={imageRef}
|
||||||
|
src={node.attrs.src}
|
||||||
|
alt={node.attrs.alt || ''}
|
||||||
|
title={node.attrs.title || ''}
|
||||||
|
className="rich-image-node__img"
|
||||||
|
style={width ? { width: `${width}px` } : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-drag-handle
|
||||||
|
className="rich-image-node__drag-handle"
|
||||||
|
title="Drag to move image"
|
||||||
|
onMouseDown={selectNode}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-grip-lines" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rich-image-node__resize-handle"
|
||||||
|
title="Resize image"
|
||||||
|
onPointerDown={startResize}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-up-right-and-down-left-from-center" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selected && node.attrs.caption ? (
|
||||||
|
<figcaption className="rich-image-node__caption">{node.attrs.caption}</figcaption>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<div className="rich-image-node__editor" contentEditable={false}>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Alt text</span>
|
||||||
|
<input
|
||||||
|
value={node.attrs.alt || ''}
|
||||||
|
onChange={(event) => updateAttributes({ alt: event.target.value })}
|
||||||
|
placeholder="Describe the image for screen readers"
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Caption</span>
|
||||||
|
<input
|
||||||
|
value={node.attrs.caption || ''}
|
||||||
|
onChange={(event) => updateAttributes({ caption: event.target.value })}
|
||||||
|
placeholder="Visible caption below the image"
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_auto] md:items-end">
|
||||||
|
<label className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Width</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="180"
|
||||||
|
max="2400"
|
||||||
|
value={width || ''}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = Number.parseInt(event.target.value, 10)
|
||||||
|
updateAttributes({ width: Number.isFinite(nextValue) ? nextValue : null })
|
||||||
|
}}
|
||||||
|
placeholder="Auto"
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateAttributes({ width: null })}
|
||||||
|
className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
|
||||||
|
>
|
||||||
|
Fit
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={deleteNode}
|
||||||
|
className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RichImage = Image.extend({
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'rich-image-node',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
alt: {
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
caption: {
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => parsePixelValue(
|
||||||
|
element.getAttribute?.('data-width')
|
||||||
|
|| element.getAttribute?.('width')
|
||||||
|
|| element.style?.width
|
||||||
|
|| '',
|
||||||
|
),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
const width = Number(attributes.width)
|
||||||
|
|
||||||
|
if (!Number.isFinite(width) || width <= 0) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'data-width': String(Math.round(width)),
|
||||||
|
style: `width:${Math.round(width)}px;max-width:100%;`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'figure[data-rich-image]',
|
||||||
|
getAttrs: (element) => readImageAttrs(element),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'img[src]',
|
||||||
|
getAttrs: (element) => readImageAttrs(element),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
const {
|
||||||
|
src: _src,
|
||||||
|
alt: _alt,
|
||||||
|
title: _title,
|
||||||
|
caption: _caption,
|
||||||
|
width: _width,
|
||||||
|
'data-width': _dataWidth,
|
||||||
|
...figureHTMLAttributes
|
||||||
|
} = HTMLAttributes
|
||||||
|
|
||||||
|
const figureAttributes = mergeAttributes(this.options.HTMLAttributes, figureHTMLAttributes, {
|
||||||
|
'data-rich-image': 'true',
|
||||||
|
})
|
||||||
|
const imageAttributes = {
|
||||||
|
src: node.attrs.src,
|
||||||
|
alt: node.attrs.alt || '',
|
||||||
|
title: node.attrs.title || '',
|
||||||
|
loading: 'lazy',
|
||||||
|
decoding: 'async',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(Number(node.attrs.width)) && Number(node.attrs.width) > 0) {
|
||||||
|
const width = Math.round(Number(node.attrs.width))
|
||||||
|
imageAttributes.style = `width:${width}px;max-width:100%;`
|
||||||
|
imageAttributes['data-width'] = String(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = [
|
||||||
|
['img', imageAttributes],
|
||||||
|
]
|
||||||
|
|
||||||
|
if (node.attrs.caption) {
|
||||||
|
children.push(['figcaption', { class: 'rich-image-node__caption' }, node.attrs.caption])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['figure', figureAttributes, ...children]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(RichImageNodeView)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default RichImage
|
||||||
259
resources/js/components/forum/RichTableControls.jsx
Normal file
259
resources/js/components/forum/RichTableControls.jsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { BubbleMenu } from '@tiptap/react/menus'
|
||||||
|
|
||||||
|
function TableButton({ onClick, active = false, disabled = false, title, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
|
className={[
|
||||||
|
'inline-flex h-8 items-center justify-center rounded-lg px-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] transition-colors',
|
||||||
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
|
||||||
|
active
|
||||||
|
? 'bg-sky-600/25 text-sky-300'
|
||||||
|
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
|
||||||
|
disabled && 'pointer-events-none opacity-30',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableInsertDialog({
|
||||||
|
open,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
withHeaderRow,
|
||||||
|
withHeaderColumn,
|
||||||
|
onRowsChange,
|
||||||
|
onColsChange,
|
||||||
|
onHeaderRowChange,
|
||||||
|
onHeaderColumnChange,
|
||||||
|
onClose,
|
||||||
|
onInsert,
|
||||||
|
}) {
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||||
|
<div className="border-b border-white/[0.06] px-6 py-5">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Table</div>
|
||||||
|
<h3 className="mt-2 text-lg font-semibold text-white">Insert table</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-white/65">Create a table and edit rows and columns directly in the editor.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 px-6 py-5 md:grid-cols-2">
|
||||||
|
<label className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Rows</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="12"
|
||||||
|
value={rows}
|
||||||
|
onChange={(event) => onRowsChange?.(Number.parseInt(event.target.value, 10) || 1)}
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-2 text-sm text-slate-300">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Columns</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="12"
|
||||||
|
value={cols}
|
||||||
|
onChange={(event) => onColsChange?.(Number.parseInt(event.target.value, 10) || 1)}
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200 md:col-span-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={withHeaderRow}
|
||||||
|
onChange={(event) => onHeaderRowChange?.(event.target.checked)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block font-semibold text-white">Header row</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-slate-400">Use a header row for column labels.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200 md:col-span-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={withHeaderColumn}
|
||||||
|
onChange={(event) => onHeaderColumnChange?.(event.target.checked)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block font-semibold text-white">Header column</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-slate-400">Use a header column for row labels.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||||||
|
<button type="button" onClick={onClose} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onInsert} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
|
||||||
|
Insert table
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RichTableControls({ editor }) {
|
||||||
|
const isTableActive = Boolean(editor?.isActive('table'))
|
||||||
|
|
||||||
|
const canRun = useCallback((commandName) => {
|
||||||
|
if (!editor) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chain = editor.can().chain().focus()
|
||||||
|
const next = typeof chain[commandName] === 'function' ? chain[commandName]() : null
|
||||||
|
return Boolean(next?.run?.())
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const runCommand = useCallback((commandName) => {
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
const chain = editor.chain().focus()
|
||||||
|
if (typeof chain[commandName] !== 'function') return
|
||||||
|
|
||||||
|
chain[commandName]().run()
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const deleteTable = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
editor.chain().focus().deleteTable().run()
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const getActiveTable = useCallback(() => {
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
const { state } = editor
|
||||||
|
const { $from } = state.selection
|
||||||
|
|
||||||
|
for (let depth = $from.depth; depth >= 0; depth -= 1) {
|
||||||
|
const node = $from.node(depth)
|
||||||
|
if (node?.type?.name !== 'table') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
pos: $from.before(depth),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const moveTable = useCallback((direction) => {
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
const tableInfo = getActiveTable()
|
||||||
|
if (!tableInfo) return
|
||||||
|
|
||||||
|
const { state, view } = editor
|
||||||
|
const { doc } = state
|
||||||
|
const tableNode = tableInfo.node
|
||||||
|
const tablePos = tableInfo.pos
|
||||||
|
const tableSize = tableNode.nodeSize
|
||||||
|
|
||||||
|
let childPos = 1
|
||||||
|
let previous = null
|
||||||
|
let current = null
|
||||||
|
let next = null
|
||||||
|
|
||||||
|
for (let index = 0; index < doc.childCount; index += 1) {
|
||||||
|
const child = doc.child(index)
|
||||||
|
if (childPos === tablePos) {
|
||||||
|
current = { node: child, pos: childPos }
|
||||||
|
next = index + 1 < doc.childCount
|
||||||
|
? { node: doc.child(index + 1), pos: childPos + child.nodeSize }
|
||||||
|
: null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
previous = { node: child, pos: childPos }
|
||||||
|
childPos += child.nodeSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current) return
|
||||||
|
|
||||||
|
const tr = state.tr.delete(tablePos, tablePos + tableSize)
|
||||||
|
let insertPos = tablePos
|
||||||
|
|
||||||
|
if (direction === 'up') {
|
||||||
|
if (!previous) return
|
||||||
|
insertPos = previous.pos
|
||||||
|
} else if (direction === 'down') {
|
||||||
|
if (!next) return
|
||||||
|
insertPos = next.pos + next.node.nodeSize - tableSize
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.insert(insertPos, tableNode.type.create(tableNode.attrs, tableNode.content, tableNode.marks))
|
||||||
|
view.dispatch(tr)
|
||||||
|
editor.chain().focus().setNodeSelection(insertPos).run()
|
||||||
|
}, [editor, getActiveTable])
|
||||||
|
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
shouldShow={({ editor: bubbleEditor }) => Boolean(bubbleEditor?.isActive('table'))}
|
||||||
|
tippyOptions={{
|
||||||
|
placement: 'top-start',
|
||||||
|
offset: [0, 12],
|
||||||
|
duration: 100,
|
||||||
|
}}
|
||||||
|
className="rich-table-toolbar"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-2xl border border-sky-300/25 bg-[linear-gradient(180deg,rgba(12,18,29,0.98),rgba(6,10,16,0.98))] px-3 py-2 text-xs text-slate-400 shadow-[0_18px_50px_rgba(0,0,0,0.35)]">
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 font-semibold uppercase tracking-[0.16em] text-slate-300">Table tools</span>
|
||||||
|
<TableButton onClick={() => runCommand('addRowBefore')} disabled={!canRun('addRowBefore')} title="Add row before">Row +</TableButton>
|
||||||
|
<TableButton onClick={() => runCommand('addRowAfter')} disabled={!canRun('addRowAfter')} title="Add row after">Row +</TableButton>
|
||||||
|
<TableButton onClick={() => runCommand('deleteRow')} disabled={!canRun('deleteRow')} title="Delete row">Del row</TableButton>
|
||||||
|
<TableButton onClick={() => runCommand('addColumnBefore')} disabled={!canRun('addColumnBefore')} title="Add column before">Col +</TableButton>
|
||||||
|
<TableButton onClick={() => runCommand('addColumnAfter')} disabled={!canRun('addColumnAfter')} title="Add column after">Col +</TableButton>
|
||||||
|
<TableButton onClick={() => runCommand('deleteColumn')} disabled={!canRun('deleteColumn')} title="Delete column">Del col</TableButton>
|
||||||
|
<TableButton onClick={() => runCommand('mergeCells')} disabled={!canRun('mergeCells')} title="Merge selected cells">Merge</TableButton>
|
||||||
|
<TableButton onClick={() => runCommand('splitCell')} disabled={!canRun('splitCell')} title="Split selected cell">Split</TableButton>
|
||||||
|
<TableButton onClick={() => runCommand('toggleHeaderRow')} disabled={!canRun('toggleHeaderRow')} active={isTableActive} title="Toggle header row">Header row</TableButton>
|
||||||
|
<TableButton onClick={() => runCommand('toggleHeaderColumn')} disabled={!canRun('toggleHeaderColumn')} active={isTableActive} title="Toggle header column">Header col</TableButton>
|
||||||
|
<TableButton onClick={() => moveTable('up')} disabled={!getActiveTable()} title="Move table up">Move up</TableButton>
|
||||||
|
<TableButton onClick={() => moveTable('down')} disabled={!getActiveTable()} title="Move table down">Move down</TableButton>
|
||||||
|
<TableButton onClick={deleteTable} title="Delete table">Delete table</TableButton>
|
||||||
|
</div>
|
||||||
|
</BubbleMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
290
resources/views/moderation/traffic/online.blade.php
Normal file
290
resources/views/moderation/traffic/online.blade.php
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
@extends('layouts.nova')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$initialPayload = [
|
||||||
|
'summary' => $summary,
|
||||||
|
'visitors' => $visitors,
|
||||||
|
'active_pages' => $activePages,
|
||||||
|
'generated_at' => $generatedAt,
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
body.page-moderation main { padding-top: 4rem; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.body.classList.add('page-moderation')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<section class="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8 text-zinc-100">
|
||||||
|
<div class="flex flex-col gap-4 rounded-3xl border border-white/10 bg-slate-950/70 p-6 shadow-2xl shadow-slate-950/40">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.24em] text-cyan-300/80">Moderation Traffic</div>
|
||||||
|
<h1 class="mt-2 text-3xl font-semibold text-white">Online Visitors</h1>
|
||||||
|
<p class="mt-2 max-w-3xl text-sm text-zinc-300">Live view of logged users, guests, crawlers, AI bots, and suspicious traffic.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-sm text-zinc-400">
|
||||||
|
<a href="{{ url('/moderation') }}" class="inline-flex items-center rounded-full border border-white/10 px-4 py-2 text-zinc-200 transition hover:border-cyan-300/40 hover:text-cyan-200">Back to moderation</a>
|
||||||
|
<span id="online-generated-at" class="rounded-full border border-white/10 bg-white/5 px-4 py-2">Updated {{ $generatedAt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="online-summary" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-7"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[1.7fr_1fr]">
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-slate-950/70 p-6 shadow-2xl shadow-slate-950/30">
|
||||||
|
<div class="flex flex-col gap-4 border-b border-white/10 pb-5 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Visitors</h2>
|
||||||
|
<p class="mt-1 text-sm text-zinc-400">Filter the live table without leaving the page.</p>
|
||||||
|
</div>
|
||||||
|
<div id="visitor-filters" class="flex flex-wrap gap-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-white/10 text-sm">
|
||||||
|
<thead class="text-left text-xs uppercase tracking-[0.18em] text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-3 font-medium">Type</th>
|
||||||
|
<th class="px-3 py-3 font-medium">User</th>
|
||||||
|
<th class="px-3 py-3 font-medium">IP</th>
|
||||||
|
<th class="px-3 py-3 font-medium">Bot / Browser</th>
|
||||||
|
<th class="px-3 py-3 font-medium">Current URL</th>
|
||||||
|
<th class="px-3 py-3 font-medium">Referer</th>
|
||||||
|
<th class="px-3 py-3 font-medium">First Seen</th>
|
||||||
|
<th class="px-3 py-3 font-medium">Last Seen</th>
|
||||||
|
<th class="px-3 py-3 font-medium">Hits</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="online-visitors-table" class="divide-y divide-white/5 text-zinc-200"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="rounded-3xl border border-white/10 bg-slate-950/70 p-6 shadow-2xl shadow-slate-950/30">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Active Pages</h2>
|
||||||
|
<p class="mt-1 text-sm text-zinc-400">Current URLs with live visitor counts.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="online-active-pages" class="mt-5 space-y-3"></div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const dataUrl = @json($dataUrl);
|
||||||
|
const initialState = @json($initialPayload);
|
||||||
|
const summaryContainer = document.getElementById('online-summary');
|
||||||
|
const filtersContainer = document.getElementById('visitor-filters');
|
||||||
|
const tableBody = document.getElementById('online-visitors-table');
|
||||||
|
const activePagesContainer = document.getElementById('online-active-pages');
|
||||||
|
const generatedAtContainer = document.getElementById('online-generated-at');
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryCards = [
|
||||||
|
{ key: 'total', label: 'Online now' },
|
||||||
|
{ key: 'logged', label: 'Logged users' },
|
||||||
|
{ key: 'guests', label: 'Guests' },
|
||||||
|
{ key: 'bots', label: 'Bots' },
|
||||||
|
{ key: 'search_bots', label: 'Search bots' },
|
||||||
|
{ key: 'ai_bots', label: 'AI bots' },
|
||||||
|
{ key: 'suspicious_bots', label: 'Suspicious' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterDefinitions = [
|
||||||
|
{ key: 'all', label: 'All', matches: () => true },
|
||||||
|
{ key: 'logged', label: 'Logged', matches: (visitor) => visitor.type === 'human_logged' },
|
||||||
|
{ key: 'guests', label: 'Guests', matches: (visitor) => visitor.type === 'human_guest' },
|
||||||
|
{ key: 'bots', label: 'Bots', matches: (visitor) => String(visitor.type || '').endsWith('_bot') },
|
||||||
|
{ key: 'search_bot', label: 'Search bots', matches: (visitor) => visitor.type === 'search_bot' },
|
||||||
|
{ key: 'ai_bot', label: 'AI bots', matches: (visitor) => visitor.type === 'ai_bot' },
|
||||||
|
{ key: 'social_bot', label: 'Social bots', matches: (visitor) => visitor.type === 'social_bot' },
|
||||||
|
{ key: 'seo_bot', label: 'SEO bots', matches: (visitor) => visitor.type === 'seo_bot' },
|
||||||
|
{ key: 'suspicious_bot', label: 'Suspicious', matches: (visitor) => visitor.type === 'suspicious_bot' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const typeLabels = {
|
||||||
|
human_logged: 'Logged',
|
||||||
|
human_guest: 'Guest',
|
||||||
|
search_bot: 'Search Bot',
|
||||||
|
ai_bot: 'AI Bot',
|
||||||
|
social_bot: 'Social Bot',
|
||||||
|
seo_bot: 'SEO Bot',
|
||||||
|
monitoring_bot: 'Monitoring Bot',
|
||||||
|
suspicious_bot: 'Suspicious',
|
||||||
|
unknown_bot: 'Unknown Bot',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeClasses = {
|
||||||
|
human_logged: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200',
|
||||||
|
human_guest: 'border-sky-400/30 bg-sky-400/10 text-sky-200',
|
||||||
|
search_bot: 'border-indigo-400/30 bg-indigo-400/10 text-indigo-200',
|
||||||
|
ai_bot: 'border-fuchsia-400/30 bg-fuchsia-400/10 text-fuchsia-200',
|
||||||
|
social_bot: 'border-amber-400/30 bg-amber-400/10 text-amber-200',
|
||||||
|
seo_bot: 'border-orange-400/30 bg-orange-400/10 text-orange-200',
|
||||||
|
monitoring_bot: 'border-teal-400/30 bg-teal-400/10 text-teal-200',
|
||||||
|
suspicious_bot: 'border-rose-400/30 bg-rose-400/10 text-rose-200',
|
||||||
|
unknown_bot: 'border-zinc-400/30 bg-zinc-400/10 text-zinc-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
let activeFilter = 'all';
|
||||||
|
let state = initialState;
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? value : dateFormatter.format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
48
tests/Feature/Community/LatestCommentsTest.php
Normal file
48
tests/Feature/Community/LatestCommentsTest.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\ArtworkComment;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('renders the latest comments page', function (): void {
|
||||||
|
$author = User::factory()->create();
|
||||||
|
$artwork = Artwork::factory()->for($author)->create();
|
||||||
|
|
||||||
|
ArtworkComment::factory()->for($artwork)->for($author)->create([
|
||||||
|
'content' => 'Latest comments page regression comment',
|
||||||
|
'raw_content' => 'Latest comments page regression comment',
|
||||||
|
'rendered_content' => '<p>Latest comments page regression comment</p>',
|
||||||
|
'created_at' => now()->subMinutes(5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('legacy.latest_comments'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Latest Comments');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns latest comments api data', function (): void {
|
||||||
|
$author = User::factory()->create();
|
||||||
|
$artwork = Artwork::factory()->for($author)->create([
|
||||||
|
'title' => 'Latest Comments Artwork',
|
||||||
|
'slug' => 'latest-comments-artwork',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$comment = ArtworkComment::factory()->for($artwork)->for($author)->create([
|
||||||
|
'content' => 'Latest comments api regression comment',
|
||||||
|
'raw_content' => 'Latest comments api regression comment',
|
||||||
|
'rendered_content' => '<p>Latest comments api regression comment</p>',
|
||||||
|
'created_at' => now()->subMinutes(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson(route('api.comments.latest'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.0.comment_id', $comment->id)
|
||||||
|
->assertJsonPath('data.0.commenter.id', $author->id)
|
||||||
|
->assertJsonPath('data.0.artwork.id', $artwork->id)
|
||||||
|
->assertJsonPath('meta.total', 1);
|
||||||
|
});
|
||||||
@@ -341,14 +341,14 @@ test('homepage renders featured hero picture and preload from dedicated featured
|
|||||||
|
|
||||||
$desktopUrl = $paths->url($artwork, 'desktop');
|
$desktopUrl = $paths->url($artwork, 'desktop');
|
||||||
$desktopXlUrl = $paths->url($artwork, 'desktop_xl');
|
$desktopXlUrl = $paths->url($artwork, 'desktop_xl');
|
||||||
$xsUrl = $paths->url($artwork, 'xs');
|
$mobileXsUrl = $paths->url($artwork, 'mobile_xs');
|
||||||
$mobileUrl = $paths->url($artwork, 'mobile');
|
$mobileUrl = $paths->url($artwork, 'mobile');
|
||||||
|
|
||||||
$this->get(route('index'))
|
$this->get(route('index'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee($desktopUrl, false)
|
->assertSee($desktopUrl, false)
|
||||||
->assertSee($desktopXlUrl, false)
|
->assertSee($desktopXlUrl, false)
|
||||||
->assertSee($xsUrl, false)
|
->assertSee($mobileXsUrl, false)
|
||||||
->assertSee($mobileUrl, false)
|
->assertSee($mobileUrl, false)
|
||||||
->assertSee('rel="preload"', false)
|
->assertSee('rel="preload"', false)
|
||||||
->assertSee('type="image/webp"', false)
|
->assertSee('type="image/webp"', false)
|
||||||
|
|||||||
105
tests/Feature/Moderation/OnlineVisitorsModerationTest.php
Normal file
105
tests/Feature/Moderation/OnlineVisitorsModerationTest.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Moderation;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Traffic\OnlineVisitorRepository;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class OnlineVisitorsModerationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Mockery::close();
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_moderation_online_page_requires_staff_access(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['role' => 'user']);
|
||||||
|
|
||||||
|
$this->get('/moderation/traffic/online')
|
||||||
|
->assertRedirect(route('login'));
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/moderation/traffic/online')
|
||||||
|
->assertRedirect(route('index'));
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->getJson('/moderation/traffic/online/data')
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_staff_can_open_online_page_and_json_endpoint(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$repository = Mockery::mock(OnlineVisitorRepository::class);
|
||||||
|
$repository->shouldReceive('summary')->twice()->andReturn([
|
||||||
|
'total' => 3,
|
||||||
|
'logged' => 1,
|
||||||
|
'guests' => 1,
|
||||||
|
'bots' => 1,
|
||||||
|
'search_bots' => 1,
|
||||||
|
'ai_bots' => 0,
|
||||||
|
'social_bots' => 0,
|
||||||
|
'seo_bots' => 0,
|
||||||
|
'suspicious_bots' => 0,
|
||||||
|
]);
|
||||||
|
$repository->shouldReceive('all')->twice()->andReturn([
|
||||||
|
[
|
||||||
|
'visitor_key' => 'user:1',
|
||||||
|
'type' => 'human_logged',
|
||||||
|
'user_name' => 'Gregor',
|
||||||
|
'ip_masked' => '188.230.xxx.xxx',
|
||||||
|
'browser' => 'Chrome',
|
||||||
|
'platform' => 'Windows',
|
||||||
|
'current_url' => '/wallpapers',
|
||||||
|
'route_name' => 'wallpapers.index',
|
||||||
|
'referer' => null,
|
||||||
|
'first_seen_at' => now()->subMinute()->toIso8601String(),
|
||||||
|
'last_seen_at' => now()->toIso8601String(),
|
||||||
|
'hits' => 2,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$repository->shouldReceive('activePages')->twice()->andReturn([
|
||||||
|
['url' => '/wallpapers', 'visitors' => 3],
|
||||||
|
]);
|
||||||
|
$repository->shouldReceive('track')->andReturnNull();
|
||||||
|
$this->app->instance(OnlineVisitorRepository::class, $repository);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get('/moderation/traffic/online')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Online Visitors')
|
||||||
|
->assertSee('Live view of logged users, guests, crawlers, AI bots, and suspicious traffic.');
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->getJson('/moderation/traffic/online/data')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'summary' => ['total', 'logged', 'guests', 'bots', 'search_bots', 'ai_bots', 'social_bots', 'seo_bots', 'suspicious_bots'],
|
||||||
|
'visitors',
|
||||||
|
'active_pages',
|
||||||
|
'generated_at',
|
||||||
|
])
|
||||||
|
->assertJsonPath('summary.total', 3)
|
||||||
|
->assertJsonPath('active_pages.0.url', '/wallpapers');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_redis_failure_does_not_break_public_request(): void
|
||||||
|
{
|
||||||
|
$repository = Mockery::mock(OnlineVisitorRepository::class);
|
||||||
|
$repository->shouldReceive('track')->andThrow(new \RuntimeException('Redis unavailable'));
|
||||||
|
$this->app->instance(OnlineVisitorRepository::class, $repository);
|
||||||
|
|
||||||
|
$this->get('/robots.txt')
|
||||||
|
->assertOk();
|
||||||
|
}
|
||||||
|
}
|
||||||
167
tests/Feature/Traffic/OnlineVisitorTrackingTest.php
Normal file
167
tests/Feature/Traffic/OnlineVisitorTrackingTest.php
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Traffic;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Traffic\BotClassifier;
|
||||||
|
use App\Services\Traffic\OnlineVisitorRepository;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class OnlineVisitorTrackingTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_googlebot_is_classified_as_search_bot(): void
|
||||||
|
{
|
||||||
|
$classifier = app(BotClassifier::class);
|
||||||
|
$request = Request::create('/wallpapers', 'GET', server: ['HTTP_USER_AGENT' => 'Googlebot/2.1']);
|
||||||
|
|
||||||
|
$result = $classifier->classify($request);
|
||||||
|
|
||||||
|
self::assertTrue($result['is_bot']);
|
||||||
|
self::assertSame('search_bot', $result['type']);
|
||||||
|
self::assertSame('Googlebot', $result['family']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_gptbot_is_classified_as_ai_bot(): void
|
||||||
|
{
|
||||||
|
$classifier = app(BotClassifier::class);
|
||||||
|
$request = Request::create('/art/1/test', 'GET', server: ['HTTP_USER_AGENT' => 'GPTBot']);
|
||||||
|
|
||||||
|
$result = $classifier->classify($request);
|
||||||
|
|
||||||
|
self::assertTrue($result['is_bot']);
|
||||||
|
self::assertSame('ai_bot', $result['type']);
|
||||||
|
self::assertSame('GPTBot', $result['family']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_suspicious_user_agent_is_classified_as_suspicious_bot(): void
|
||||||
|
{
|
||||||
|
$classifier = app(BotClassifier::class);
|
||||||
|
$request = Request::create('/rate.php', 'GET', server: ['HTTP_USER_AGENT' => 'python-requests/2.31']);
|
||||||
|
|
||||||
|
$result = $classifier->classify($request);
|
||||||
|
|
||||||
|
self::assertTrue($result['is_bot']);
|
||||||
|
self::assertSame('suspicious_bot', $result['type']);
|
||||||
|
self::assertSame('python-requests', $result['family']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_logged_in_user_is_tracked_with_ttl_and_hit_counter(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['role' => 'admin', 'name' => 'Gregor']);
|
||||||
|
$repository = new InMemoryOnlineVisitorRepository(app(BotClassifier::class));
|
||||||
|
|
||||||
|
$request = Request::create('/wallpapers', 'GET', server: [
|
||||||
|
'HTTP_USER_AGENT' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0',
|
||||||
|
'HTTP_CF_CONNECTING_IP' => '188.230.12.14',
|
||||||
|
]);
|
||||||
|
$request->setUserResolver(static fn (): User => $user);
|
||||||
|
|
||||||
|
$repository->track($request);
|
||||||
|
$repository->track($request);
|
||||||
|
|
||||||
|
$records = $repository->all();
|
||||||
|
|
||||||
|
self::assertCount(1, $records);
|
||||||
|
self::assertSame('human_logged', $records[0]['type']);
|
||||||
|
self::assertSame(2, $records[0]['hits']);
|
||||||
|
self::assertSame('188.230.xxx.xxx', $records[0]['ip_masked']);
|
||||||
|
self::assertSame(OnlineVisitorRepository::TTL_SECONDS, $repository->lastStoredTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_guest_tracking_cleans_expired_records_from_index(): void
|
||||||
|
{
|
||||||
|
$repository = new InMemoryOnlineVisitorRepository(app(BotClassifier::class));
|
||||||
|
|
||||||
|
$request = Request::create('/news/test', 'GET', server: [
|
||||||
|
'HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) Safari/605.1.15',
|
||||||
|
'REMOTE_ADDR' => '192.168.10.22',
|
||||||
|
]);
|
||||||
|
$request->cookies->set((string) config('session.cookie'), 'guest-session-cookie');
|
||||||
|
|
||||||
|
$repository->track($request);
|
||||||
|
$repository->seedIndexOnly('guest:expired');
|
||||||
|
|
||||||
|
$records = $repository->all();
|
||||||
|
$summary = $repository->summary();
|
||||||
|
$pages = $repository->activePages();
|
||||||
|
|
||||||
|
self::assertCount(1, $records);
|
||||||
|
self::assertSame('human_guest', $records[0]['type']);
|
||||||
|
self::assertSame(1, $summary['guests']);
|
||||||
|
self::assertSame('/news/test', $pages[0]['url']);
|
||||||
|
self::assertSame(['guest:expired'], $repository->removedFromIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class InMemoryOnlineVisitorRepository extends OnlineVisitorRepository
|
||||||
|
{
|
||||||
|
/** @var array<string, array<string, mixed>> */
|
||||||
|
private array $records = [];
|
||||||
|
|
||||||
|
/** @var array<int, string> */
|
||||||
|
private array $index = [];
|
||||||
|
|
||||||
|
/** @var array<int, string> */
|
||||||
|
public array $removedFromIndex = [];
|
||||||
|
|
||||||
|
public ?int $lastStoredTtl = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
protected function readIndexMembers(): array
|
||||||
|
{
|
||||||
|
return $this->index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
protected function readRecord(string $visitorKey): ?array
|
||||||
|
{
|
||||||
|
return $this->records[$visitorKey] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $record
|
||||||
|
*/
|
||||||
|
protected function storeRecord(string $visitorKey, array $record, int $ttlSeconds): void
|
||||||
|
{
|
||||||
|
$this->records[$visitorKey] = $record;
|
||||||
|
$this->lastStoredTtl = $ttlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function addIndexMember(string $visitorKey): void
|
||||||
|
{
|
||||||
|
if (! in_array($visitorKey, $this->index, true)) {
|
||||||
|
$this->index[] = $visitorKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $visitorKeys
|
||||||
|
*/
|
||||||
|
protected function removeIndexMembers(array $visitorKeys): void
|
||||||
|
{
|
||||||
|
foreach ($visitorKeys as $visitorKey) {
|
||||||
|
$this->removedFromIndex[] = $visitorKey;
|
||||||
|
$this->index = array_values(array_filter($this->index, static fn (string $indexedKey): bool => $indexedKey !== $visitorKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function deleteRecord(string $visitorKey): void
|
||||||
|
{
|
||||||
|
unset($this->records[$visitorKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function seedIndexOnly(string $visitorKey): void
|
||||||
|
{
|
||||||
|
$this->index[] = $visitorKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user