Auth: convert auth views and verification email to Nova layout
This commit is contained in:
@@ -2,124 +2,308 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\AvatarService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use App\Models\User;
|
||||
use App\Models\UserProfile;
|
||||
use Intervention\Image\ImageManagerStatic as Image;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AvatarsMigrate extends Command
|
||||
{
|
||||
protected $signature = 'avatars:migrate {--force : Reprocess avatars even when avatar_hash is already present} {--limit=0 : Limit number of users processed}';
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'avatars:migrate
|
||||
{--dry-run : Do not write files or update database}
|
||||
{--force : Overwrite existing migrated avatars}
|
||||
{--remove-legacy : Remove legacy files after successful migration}
|
||||
{--path=public/files/usericons : Legacy path to scan}
|
||||
';
|
||||
|
||||
protected $description = 'Migrate legacy avatars into CDN-ready WebP variants and avatar_hash metadata';
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Migrate legacy avatars from public/files/usericons to storage/app/public/avatars and generate sizes (WebP)';
|
||||
|
||||
public function __construct(private readonly AvatarService $service)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
/**
|
||||
* Allowed MIME types for source images.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $allowed = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
/**
|
||||
* Target sizes to generate.
|
||||
*
|
||||
* @var int[]
|
||||
*/
|
||||
protected $sizes = [32, 64, 128, 256, 512];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$force = (bool) $this->option('force');
|
||||
$limit = max(0, (int) $this->option('limit'));
|
||||
$dry = $this->option('dry-run');
|
||||
$force = $this->option('force');
|
||||
$removeLegacy = $this->option('remove-legacy');
|
||||
$legacyPath = base_path($this->option('path'));
|
||||
|
||||
$this->info('Starting avatar migration...');
|
||||
$this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : ''));
|
||||
|
||||
$rows = DB::table('user_profiles as p')
|
||||
->leftJoin('users as u', 'u.id', '=', 'p.user_id')
|
||||
->select([
|
||||
'p.user_id',
|
||||
'p.avatar_hash',
|
||||
'p.avatar_legacy',
|
||||
'u.icon as user_icon',
|
||||
])
|
||||
->when(!$force, fn ($query) => $query->whereNull('p.avatar_hash'))
|
||||
->where(function ($query) {
|
||||
$query->whereNotNull('p.avatar_legacy')
|
||||
->orWhereNotNull('u.icon');
|
||||
})
|
||||
->orderBy('p.user_id')
|
||||
->when($limit > 0, fn ($query) => $query->limit($limit))
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
$this->info('No avatars require migration.');
|
||||
|
||||
return self::SUCCESS;
|
||||
// Detect processing backend: Intervention preferred, GD fallback
|
||||
$useIntervention = class_exists('Intervention\\Image\\ImageManagerStatic');
|
||||
if ($useIntervention) {
|
||||
Image::configure(['driver' => extension_loaded('imagick') ? 'imagick' : 'gd']);
|
||||
}
|
||||
|
||||
$migrated = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$bar = null;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$userId = (int) $row->user_id;
|
||||
$legacyName = $this->normalizeLegacyName($row->avatar_legacy ?: $row->user_icon);
|
||||
User::with('profile')->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) {
|
||||
foreach ($users as $user) {
|
||||
/** @var UserProfile|null $profile */
|
||||
$profile = $user->profile;
|
||||
|
||||
if ($legacyName === null) {
|
||||
$skipped++;
|
||||
if (!$profile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $this->locateLegacyAvatarPath($userId, $legacyName);
|
||||
if ($path === null) {
|
||||
$failed++;
|
||||
$this->warn("User {$userId}: legacy avatar not found ({$legacyName})");
|
||||
// Skip if already migrated unless --force
|
||||
if (!$force && !empty($profile->avatar_hash)) {
|
||||
$this->line("[skip] user={$user->id} already migrated");
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = $this->findLegacyFile($profile, $user->id, $legacyPath);
|
||||
|
||||
if (!$source) {
|
||||
$this->line("[noop] user={$user->id} no legacy file found");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$hash = $this->service->storeFromLegacyFile($userId, $path);
|
||||
if (!$hash) {
|
||||
$failed++;
|
||||
$this->warn("User {$userId}: unable to process legacy avatar ({$legacyName})");
|
||||
$this->line("[proc] user={$user->id} file={$source}");
|
||||
|
||||
if ($useIntervention) {
|
||||
$img = Image::make($source);
|
||||
$mime = $img->mime();
|
||||
} else {
|
||||
$info = @getimagesize($source);
|
||||
$mime = $info['mime'] ?? null;
|
||||
}
|
||||
|
||||
if (!in_array($mime, $this->allowed, true)) {
|
||||
$this->line("[reject] user={$user->id} unsupported mime={$mime}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$migrated++;
|
||||
$this->line("User {$userId}: migrated ({$hash})");
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn("User {$userId}: migration failed ({$e->getMessage()})");
|
||||
}
|
||||
// Re-encode full original to webp (strip metadata)
|
||||
if ($useIntervention) {
|
||||
$originalBlob = (string) $img->encode('webp', 82);
|
||||
} else {
|
||||
$originalBlob = $this->gdEncodeWebp($source, 82);
|
||||
}
|
||||
|
||||
$this->info("Avatar migration complete. Migrated={$migrated}, Skipped={$skipped}, Failed={$failed}");
|
||||
// Hybrid hash: deterministic user-id fingerprint + short content fingerprint
|
||||
// idPart = sha1(zero-padded user id), contentPart = first 12 chars of sha1(original webp blob)
|
||||
$idPart = sha1(sprintf('%08d', $user->id));
|
||||
$contentPart = substr(sha1($originalBlob), 0, 12);
|
||||
$hash = sprintf('%s_%s', $idPart, $contentPart);
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
if ($dry) {
|
||||
$this->line("[dry] user={$user->id} would write avatars for hash={$hash}");
|
||||
} else {
|
||||
// Use hash-based directory structure: avatars/ab/cd/{hash}/
|
||||
$hashPrefix1 = substr($hash, 0, 2);
|
||||
$hashPrefix2 = substr($hash, 2, 2);
|
||||
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
|
||||
Storage::disk('public')->makeDirectory($dir);
|
||||
|
||||
// Save original.webp
|
||||
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
|
||||
|
||||
// Generate sizes
|
||||
foreach ($this->sizes as $size) {
|
||||
if ($useIntervention) {
|
||||
$thumb = Image::make($source)->fit($size, $size, function ($constraint) {
|
||||
$constraint->upsize();
|
||||
});
|
||||
|
||||
$thumbBlob = (string) $thumb->encode('webp', 82);
|
||||
} else {
|
||||
$thumbBlob = $this->gdCreateThumbnailWebp($source, $size, 82);
|
||||
}
|
||||
Storage::disk('public')->put("{$dir}/{$size}.webp", $thumbBlob);
|
||||
}
|
||||
|
||||
private function normalizeLegacyName(?string $value): ?string
|
||||
// Update DB
|
||||
$profile->avatar_hash = $hash;
|
||||
$profile->avatar_mime = 'image/webp';
|
||||
$profile->avatar_updated_at = Carbon::now();
|
||||
$profile->save();
|
||||
|
||||
$this->line("[ok] user={$user->id} migrated hash={$hash}");
|
||||
|
||||
if ($removeLegacy && !empty($profile->avatar_legacy)) {
|
||||
$legacyFile = base_path("public/files/usericons/{$profile->avatar_legacy}");
|
||||
if (file_exists($legacyFile)) {
|
||||
@unlink($legacyFile);
|
||||
$this->line("[rm] removed legacy file {$legacyFile}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error("[error] user={$user->id} {$e->getMessage()}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->info('Avatar migration complete');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find a legacy avatar file for a user/profile.
|
||||
*
|
||||
* @param UserProfile $profile
|
||||
* @param int $userId
|
||||
* @param string $legacyBase
|
||||
* @return string|null
|
||||
*/
|
||||
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase): ?string
|
||||
{
|
||||
if (!$value) {
|
||||
// 1) If profile->avatar_legacy looks like a filename, try it
|
||||
if (!empty($profile->avatar_legacy)) {
|
||||
$p = $legacyBase . DIRECTORY_SEPARATOR . $profile->avatar_legacy;
|
||||
if (file_exists($p)) {
|
||||
return $p;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Try files named by user id with common extensions
|
||||
$exts = ['png','jpg','jpeg','webp','gif'];
|
||||
foreach ($exts as $ext) {
|
||||
$p = $legacyBase . DIRECTORY_SEPARATOR . "{$userId}.{$ext}";
|
||||
if (file_exists($p)) {
|
||||
return $p;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Try any file under legacy dir that contains the user id in name
|
||||
if (is_dir($legacyBase)) {
|
||||
$files = glob($legacyBase . DIRECTORY_SEPARATOR . "*{$userId}*.*");
|
||||
if (!empty($files)) {
|
||||
return $files[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return basename(urldecode($trimmed));
|
||||
}
|
||||
|
||||
private function locateLegacyAvatarPath(int $userId, string $legacyName): ?string
|
||||
/**
|
||||
* GD-based encode to WebP binary blob.
|
||||
*
|
||||
* @param string $path
|
||||
* @param int $quality
|
||||
* @return string
|
||||
*/
|
||||
protected function gdEncodeWebp(string $path, int $quality = 82): string
|
||||
{
|
||||
$candidates = [
|
||||
public_path('avatar/' . $legacyName),
|
||||
public_path('avatar/' . $userId . '/' . $legacyName),
|
||||
public_path('user-picture/' . $legacyName),
|
||||
storage_path('app/public/avatar/' . $legacyName),
|
||||
storage_path('app/public/avatar/' . $userId . '/' . $legacyName),
|
||||
storage_path('app/public/user-picture/' . $legacyName),
|
||||
base_path('oldSite/www/files/usericons/' . $legacyName),
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_string($candidate) && is_file($candidate) && is_readable($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
if (!function_exists('imagewebp')) {
|
||||
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
|
||||
}
|
||||
|
||||
return null;
|
||||
$src = $this->gdCreateResource($path);
|
||||
if (!$src) {
|
||||
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
imagewebp($src, null, $quality);
|
||||
$data = ob_get_clean();
|
||||
imagedestroy($src);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a center-cropped square thumbnail and return WebP binary.
|
||||
*
|
||||
* @param string $path
|
||||
* @param int $size
|
||||
* @param int $quality
|
||||
* @return string
|
||||
*/
|
||||
protected function gdCreateThumbnailWebp(string $path, int $size, int $quality = 82): string
|
||||
{
|
||||
if (!function_exists('imagewebp')) {
|
||||
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
|
||||
}
|
||||
|
||||
$src = $this->gdCreateResource($path);
|
||||
if (!$src) {
|
||||
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
|
||||
}
|
||||
|
||||
$w = imagesx($src);
|
||||
$h = imagesy($src);
|
||||
$min = min($w, $h);
|
||||
$srcX = (int) floor(($w - $min) / 2);
|
||||
$srcY = (int) floor(($h - $min) / 2);
|
||||
|
||||
$dst = imagecreatetruecolor($size, $size);
|
||||
// preserve transparency
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
|
||||
imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $size, $size, $min, $min);
|
||||
|
||||
ob_start();
|
||||
imagewebp($dst, null, $quality);
|
||||
$data = ob_get_clean();
|
||||
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create GD image resource from file path.
|
||||
*
|
||||
* @param string $path
|
||||
* @return resource|false
|
||||
*/
|
||||
protected function gdCreateResource(string $path)
|
||||
{
|
||||
$info = @getimagesize($path);
|
||||
if (!$info) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mime = $info['mime'] ?? '';
|
||||
|
||||
switch ($mime) {
|
||||
case 'image/jpeg':
|
||||
return imagecreatefromjpeg($path);
|
||||
case 'image/png':
|
||||
return imagecreatefrompng($path);
|
||||
case 'image/webp':
|
||||
if (function_exists('imagecreatefromwebp')) {
|
||||
return imagecreatefromwebp($path);
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
96
app/Console/Commands/EnforceUsernamePolicy.php
Normal file
96
app/Console/Commands/EnforceUsernamePolicy.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class EnforceUsernamePolicy extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:enforce-usernames {--dry-run : Report only, no writes}';
|
||||
|
||||
protected $description = 'Normalize and enforce username policy on existing users, with collision resolution and redirect logging.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$logPath = storage_path('logs/username_migration.log');
|
||||
@file_put_contents($logPath, '['.now()."] enforce-usernames dry_run=".($dryRun ? '1' : '0')."\n", FILE_APPEND);
|
||||
|
||||
$used = User::query()->whereNotNull('username')->pluck('id', 'username')->mapWithKeys(fn ($id, $username) => [strtolower((string) $username) => (int) $id])->all();
|
||||
|
||||
$updated = 0;
|
||||
|
||||
User::query()->orderBy('id')->chunkById(500, function ($users) use (&$used, &$updated, $dryRun, $logPath): void {
|
||||
foreach ($users as $user) {
|
||||
$current = strtolower(trim((string) ($user->username ?? '')));
|
||||
$base = UsernamePolicy::sanitizeLegacy($current !== '' ? $current : ('user'.$user->id));
|
||||
|
||||
if (UsernamePolicy::isReserved($base) || UsernamePolicy::similarReserved($base) !== null) {
|
||||
$base = 'user'.$user->id;
|
||||
}
|
||||
|
||||
$candidate = substr($base, 0, UsernamePolicy::max());
|
||||
$suffix = 1;
|
||||
while ((isset($used[$candidate]) && (int) $used[$candidate] !== (int) $user->id) || UsernamePolicy::isReserved($candidate) || UsernamePolicy::similarReserved($candidate) !== null) {
|
||||
$suffixStr = (string) $suffix;
|
||||
$prefixLen = max(1, UsernamePolicy::max() - strlen($suffixStr));
|
||||
$candidate = substr($base, 0, $prefixLen) . $suffixStr;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
$needsUpdate = $candidate !== $current;
|
||||
if (! $needsUpdate) {
|
||||
$used[$candidate] = (int) $user->id;
|
||||
continue;
|
||||
}
|
||||
|
||||
@file_put_contents($logPath, sprintf("[%s] user_id=%d old=%s new=%s\n", now()->toDateTimeString(), (int) $user->id, $current, $candidate), FILE_APPEND);
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::transaction(function () use ($user, $current, $candidate): void {
|
||||
if ($current !== '' && Schema::hasTable('username_history')) {
|
||||
DB::table('username_history')->insert([
|
||||
'user_id' => (int) $user->id,
|
||||
'old_username' => $current,
|
||||
'changed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($current !== '' && Schema::hasTable('username_redirects')) {
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => $current],
|
||||
[
|
||||
'new_username' => $candidate,
|
||||
'user_id' => (int) $user->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
DB::table('users')->where('id', (int) $user->id)->update([
|
||||
'username' => $candidate,
|
||||
'username_changed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
$used[$candidate] = (int) $user->id;
|
||||
$updated++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Username policy enforcement complete. Updated: {$updated}" . ($dryRun ? ' (dry run)' : ''));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\ForumCategory;
|
||||
use App\Models\User;
|
||||
use App\Models\ForumThread;
|
||||
use App\Models\ForumPost;
|
||||
use Exception;
|
||||
@@ -13,12 +15,19 @@ use App\Services\BbcodeConverter;
|
||||
|
||||
class ForumMigrateOld extends Command
|
||||
{
|
||||
protected $signature = 'forum:migrate-old {--dry-run} {--only=} {--limit=} {--chunk=500} {--report}';
|
||||
protected $signature = 'forum:migrate-old {--dry-run} {--only=} {--limit=} {--chunk=500} {--report} {--repair-orphans}';
|
||||
|
||||
protected $description = 'Migrate legacy forum data from legacy DB into new forum tables';
|
||||
|
||||
protected string $logPath;
|
||||
|
||||
protected ?int $limit = null;
|
||||
|
||||
protected ?int $deletedUserId = null;
|
||||
|
||||
/** @var array<int,int> */
|
||||
protected array $missingUserIds = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
@@ -33,6 +42,17 @@ class ForumMigrateOld extends Command
|
||||
$dry = $this->option('dry-run');
|
||||
$only = $this->option('only');
|
||||
$chunk = (int)$this->option('chunk');
|
||||
$this->limit = $this->option('limit') !== null ? max(0, (int) $this->option('limit')) : null;
|
||||
|
||||
$only = $only === 'attachments' ? 'gallery' : $only;
|
||||
if ($only && !in_array($only, ['categories', 'threads', 'posts', 'gallery', 'repair-orphans'], true)) {
|
||||
$this->error('Invalid --only value. Allowed: categories, threads, posts, gallery (or attachments), repair-orphans.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($chunk < 1) {
|
||||
$chunk = 500;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$only || $only === 'categories') {
|
||||
@@ -51,6 +71,10 @@ class ForumMigrateOld extends Command
|
||||
$this->migrateGallery($dry, $chunk);
|
||||
}
|
||||
|
||||
if ($this->option('repair-orphans') || $only === 'repair-orphans') {
|
||||
$this->repairOrphanPosts($dry);
|
||||
}
|
||||
|
||||
if ($this->option('report')) {
|
||||
$this->generateReport();
|
||||
}
|
||||
@@ -74,8 +98,13 @@ class ForumMigrateOld extends Command
|
||||
->select('root_id')
|
||||
->distinct()
|
||||
->where('root_id', '>', 0)
|
||||
->orderBy('root_id')
|
||||
->pluck('root_id');
|
||||
|
||||
if ($this->limit !== null && $this->limit > 0) {
|
||||
$roots = $roots->take($this->limit);
|
||||
}
|
||||
|
||||
$this->info('Found ' . $roots->count() . ' legacy root ids');
|
||||
|
||||
foreach ($roots as $rootId) {
|
||||
@@ -90,10 +119,12 @@ class ForumMigrateOld extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($rootId, $name, $slug) {
|
||||
ForumCategory::updateOrCreate(
|
||||
['id' => $rootId],
|
||||
['name' => $name, 'slug' => $slug]
|
||||
);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
$this->info('Categories migrated');
|
||||
@@ -107,15 +138,26 @@ class ForumMigrateOld extends Command
|
||||
$query = $legacy->table('forum_topics')->orderBy('topic_id');
|
||||
|
||||
$total = $query->count();
|
||||
if ($this->limit !== null && $this->limit > 0) {
|
||||
$total = min($total, $this->limit);
|
||||
}
|
||||
$this->info("Total threads to process: {$total}");
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
$processed = 0;
|
||||
$limit = $this->limit;
|
||||
|
||||
// chunk by legacy primary key `topic_id`
|
||||
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
|
||||
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
|
||||
foreach ($rows as $r) {
|
||||
if ($limit !== null && $limit > 0 && $processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
$processed++;
|
||||
|
||||
$data = [
|
||||
'id' => $r->topic_id,
|
||||
@@ -137,7 +179,9 @@ class ForumMigrateOld extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($data) {
|
||||
ForumThread::updateOrCreate(['id' => $data['id']], $data);
|
||||
}, 3);
|
||||
}
|
||||
}, 'topic_id');
|
||||
|
||||
@@ -153,15 +197,26 @@ class ForumMigrateOld extends Command
|
||||
|
||||
$query = $legacy->table('forum_posts')->orderBy('post_id');
|
||||
$total = $query->count();
|
||||
if ($this->limit !== null && $this->limit > 0) {
|
||||
$total = min($total, $this->limit);
|
||||
}
|
||||
$this->info("Total posts to process: {$total}");
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
$processed = 0;
|
||||
$limit = $this->limit;
|
||||
|
||||
// legacy forum_posts uses `post_id` as primary key
|
||||
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
|
||||
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
|
||||
foreach ($rows as $r) {
|
||||
if ($limit !== null && $limit > 0 && $processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
$processed++;
|
||||
|
||||
$data = [
|
||||
'id' => $r->post_id,
|
||||
@@ -177,7 +232,9 @@ class ForumMigrateOld extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($data) {
|
||||
ForumPost::updateOrCreate(['id' => $data['id']], $data);
|
||||
}, 3);
|
||||
}
|
||||
}, 'post_id');
|
||||
|
||||
@@ -243,15 +300,50 @@ class ForumMigrateOld extends Command
|
||||
protected function resolveUserId($userId)
|
||||
{
|
||||
if (empty($userId)) {
|
||||
return 1;
|
||||
return $this->resolveDeletedUserId();
|
||||
}
|
||||
|
||||
// check users table in default connection
|
||||
if (\DB::table('users')->where('id', $userId)->exists()) {
|
||||
if (DB::table('users')->where('id', $userId)->exists()) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
return 1; // fallback system user
|
||||
$uid = (int) $userId;
|
||||
if ($uid > 0 && !in_array($uid, $this->missingUserIds, true)) {
|
||||
$this->missingUserIds[] = $uid;
|
||||
}
|
||||
|
||||
return $this->resolveDeletedUserId();
|
||||
}
|
||||
|
||||
protected function resolveDeletedUserId(): int
|
||||
{
|
||||
if ($this->deletedUserId !== null) {
|
||||
return $this->deletedUserId;
|
||||
}
|
||||
|
||||
$userOne = User::query()->find(1);
|
||||
if ($userOne) {
|
||||
$this->deletedUserId = 1;
|
||||
return $this->deletedUserId;
|
||||
}
|
||||
|
||||
$fallback = User::query()->orderBy('id')->first();
|
||||
if ($fallback) {
|
||||
$this->deletedUserId = (int) $fallback->id;
|
||||
return $this->deletedUserId;
|
||||
}
|
||||
|
||||
$created = User::query()->create([
|
||||
'name' => 'Deleted User',
|
||||
'email' => 'deleted-user+forum@skinbase.local',
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'role' => 'user',
|
||||
]);
|
||||
|
||||
$this->deletedUserId = (int) $created->id;
|
||||
|
||||
return $this->deletedUserId;
|
||||
}
|
||||
|
||||
protected function convertLegacyMessage($msg)
|
||||
@@ -260,6 +352,114 @@ class ForumMigrateOld extends Command
|
||||
return $converter->convert($msg);
|
||||
}
|
||||
|
||||
protected function repairOrphanPosts(bool $dry): void
|
||||
{
|
||||
$this->info('Repairing orphan posts');
|
||||
|
||||
$orphansQuery = ForumPost::query()->whereDoesntHave('thread')->orderBy('id');
|
||||
$orphanCount = (clone $orphansQuery)->count();
|
||||
|
||||
if ($orphanCount === 0) {
|
||||
$this->info('No orphan posts found.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->warn("Found {$orphanCount} orphan posts.");
|
||||
|
||||
$repairThread = $this->resolveOrCreateOrphanRepairThread($dry);
|
||||
|
||||
if ($repairThread === null) {
|
||||
$this->warn('Unable to resolve/create repair thread in dry-run mode. Reporting only.');
|
||||
(clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post): void {
|
||||
$this->line("- orphan post id={$post->id} thread_id={$post->thread_id} user_id={$post->user_id}");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
$this->line("Repair target thread: {$repairThread->id} ({$repairThread->slug})");
|
||||
|
||||
if ($dry) {
|
||||
$this->info("[dry] Would reassign {$orphanCount} orphan posts to thread {$repairThread->id}");
|
||||
(clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post) use ($repairThread): void {
|
||||
$this->log("[dry] orphan post {$post->id}: {$post->thread_id} -> {$repairThread->id}");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
|
||||
(clone $orphansQuery)->chunkById(500, function ($posts) use ($repairThread, &$updated): void {
|
||||
DB::transaction(function () use ($posts, $repairThread, &$updated): void {
|
||||
/** @var ForumPost $post */
|
||||
foreach ($posts as $post) {
|
||||
$post->thread_id = $repairThread->id;
|
||||
$post->is_edited = true;
|
||||
$post->edited_at = $post->edited_at ?: now();
|
||||
$post->save();
|
||||
$updated++;
|
||||
}
|
||||
}, 3);
|
||||
}, 'id');
|
||||
|
||||
$latestPostAt = ForumPost::query()
|
||||
->where('thread_id', $repairThread->id)
|
||||
->max('created_at');
|
||||
|
||||
if ($latestPostAt) {
|
||||
$repairThread->last_post_at = $latestPostAt;
|
||||
$repairThread->save();
|
||||
}
|
||||
|
||||
$this->info("Repaired orphan posts: {$updated}");
|
||||
$this->log("Repaired orphan posts: {$updated} -> thread {$repairThread->id}");
|
||||
}
|
||||
|
||||
protected function resolveOrCreateOrphanRepairThread(bool $dry): ?ForumThread
|
||||
{
|
||||
$slug = 'migration-orphaned-posts';
|
||||
|
||||
$existing = ForumThread::query()->where('slug', $slug)->first();
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$category = ForumCategory::query()->ordered()->first();
|
||||
|
||||
if (!$category && !$dry) {
|
||||
$category = ForumCategory::query()->create([
|
||||
'name' => 'Migration Repairs',
|
||||
'slug' => 'migration-repairs',
|
||||
'parent_id' => null,
|
||||
'position' => 9999,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($dry) {
|
||||
return new ForumThread([
|
||||
'id' => 0,
|
||||
'slug' => $slug,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return ForumThread::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'user_id' => $this->resolveDeletedUserId(),
|
||||
'title' => 'Migration: Orphaned Posts Recovery',
|
||||
'slug' => $slug,
|
||||
'content' => 'Automatic recovery thread for legacy posts whose source thread no longer exists after migration.',
|
||||
'views' => 0,
|
||||
'is_locked' => false,
|
||||
'is_pinned' => false,
|
||||
'visibility' => 'staff',
|
||||
'last_post_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function generateReport()
|
||||
{
|
||||
$this->info('Generating migration report');
|
||||
@@ -275,12 +475,32 @@ class ForumMigrateOld extends Command
|
||||
'categories' => ForumCategory::count(),
|
||||
'threads' => ForumThread::count(),
|
||||
'posts' => ForumPost::count(),
|
||||
'attachments' => \DB::table('forum_attachments')->count(),
|
||||
'attachments' => DB::table('forum_attachments')->count(),
|
||||
];
|
||||
|
||||
$orphans = ForumPost::query()
|
||||
->whereDoesntHave('thread')
|
||||
->count();
|
||||
|
||||
$legacyThreadsWithLastUpdate = $legacy->table('forum_topics')->whereNotNull('last_update')->count();
|
||||
$newThreadsWithLastPost = ForumThread::query()->whereNotNull('last_post_at')->count();
|
||||
$legacyPostsWithPostDate = $legacy->table('forum_posts')->whereNotNull('post_date')->count();
|
||||
$newPostsWithCreatedAt = ForumPost::query()->whereNotNull('created_at')->count();
|
||||
|
||||
$report = [
|
||||
'missing_users_count' => count($this->missingUserIds),
|
||||
'missing_users' => $this->missingUserIds,
|
||||
'orphan_posts' => $orphans,
|
||||
'timestamp_mismatches' => [
|
||||
'threads_last_post_gap' => max(0, $legacyThreadsWithLastUpdate - $newThreadsWithLastPost),
|
||||
'posts_created_at_gap' => max(0, $legacyPostsWithPostDate - $newPostsWithCreatedAt),
|
||||
],
|
||||
];
|
||||
|
||||
$this->info('Legacy counts: ' . json_encode($legacyCounts));
|
||||
$this->info('New counts: ' . json_encode($newCounts));
|
||||
$this->log('Report: legacy=' . json_encode($legacyCounts) . ' new=' . json_encode($newCounts));
|
||||
$this->info('Report: ' . json_encode($report));
|
||||
$this->log('Report: legacy=' . json_encode($legacyCounts) . ' new=' . json_encode($newCounts) . ' extra=' . json_encode($report));
|
||||
}
|
||||
|
||||
protected function log(string $msg)
|
||||
@@ -301,14 +521,25 @@ class ForumMigrateOld extends Command
|
||||
|
||||
$query = $legacy->table('forum_topics_gallery')->orderBy('id');
|
||||
$total = $query->count();
|
||||
if ($this->limit !== null && $this->limit > 0) {
|
||||
$total = min($total, $this->limit);
|
||||
}
|
||||
$this->info("Total gallery items to process: {$total}");
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
|
||||
$processed = 0;
|
||||
$limit = $this->limit;
|
||||
|
||||
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
|
||||
foreach ($rows as $r) {
|
||||
if ($limit !== null && $limit > 0 && $processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
$processed++;
|
||||
|
||||
// expected legacy fields: id, name, category (topic id), folder, datum, description
|
||||
$topicId = $r->category ?? ($r->topic_id ?? null);
|
||||
@@ -368,16 +599,21 @@ class ForumMigrateOld extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
\App\Models\ForumAttachment::create([
|
||||
DB::transaction(function () use ($postId, $relativePath, $fileSize, $mimeType, $width, $height) {
|
||||
\App\Models\ForumAttachment::query()->updateOrCreate(
|
||||
[
|
||||
'post_id' => $postId,
|
||||
'file_path' => $relativePath,
|
||||
],
|
||||
[
|
||||
'file_size' => $fileSize ?? 0,
|
||||
'mime_type' => $mimeType,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
]
|
||||
);
|
||||
}, 3);
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ImportLegacyUsers extends Command
|
||||
@@ -15,9 +17,13 @@ class ImportLegacyUsers extends Command
|
||||
|
||||
protected array $usedUsernames = [];
|
||||
protected array $usedEmails = [];
|
||||
protected string $migrationLogPath;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->migrationLogPath = storage_path('logs/username_migration.log');
|
||||
@file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
|
||||
|
||||
$this->usedUsernames = User::pluck('username', 'username')->filter()->all();
|
||||
$this->usedEmails = User::pluck('email', 'email')->filter()->all();
|
||||
|
||||
@@ -56,9 +62,19 @@ class ImportLegacyUsers extends Command
|
||||
protected function importRow($row, $statRow = null): void
|
||||
{
|
||||
$legacyId = (int) $row->user_id;
|
||||
$baseUsername = $this->sanitizeUsername($row->uname ?: ('user'.$legacyId));
|
||||
$rawLegacyUsername = (string) ($row->uname ?: ('user'.$legacyId));
|
||||
$baseUsername = $this->sanitizeUsername($rawLegacyUsername);
|
||||
$username = $this->uniqueUsername($baseUsername);
|
||||
|
||||
$normalizedLegacy = UsernamePolicy::normalize($rawLegacyUsername);
|
||||
if ($normalizedLegacy !== $username) {
|
||||
@file_put_contents(
|
||||
$this->migrationLogPath,
|
||||
sprintf("[%s] user_id=%d old=%s new=%s\n", now()->toDateTimeString(), $legacyId, $normalizedLegacy, $username),
|
||||
FILE_APPEND
|
||||
);
|
||||
}
|
||||
|
||||
$email = $this->prepareEmail($row->email ?? null, $username);
|
||||
|
||||
$legacyPassword = $row->password2 ?: $row->password ?: null;
|
||||
@@ -88,6 +104,7 @@ class ImportLegacyUsers extends Command
|
||||
DB::table('users')->insert([
|
||||
'id' => $legacyId,
|
||||
'username' => $username,
|
||||
'username_changed_at' => now(),
|
||||
'name' => $row->real_name ?: $username,
|
||||
'email' => $email,
|
||||
'password' => $passwordHash,
|
||||
@@ -126,6 +143,21 @@ class ImportLegacyUsers extends Command
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if (Schema::hasTable('username_redirects')) {
|
||||
$old = UsernamePolicy::normalize((string) ($row->uname ?? ''));
|
||||
if ($old !== '' && $old !== $username) {
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => $old],
|
||||
[
|
||||
'new_username' => $username,
|
||||
'user_id' => $legacyId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,19 +175,12 @@ class ImportLegacyUsers extends Command
|
||||
|
||||
protected function sanitizeUsername(string $username): string
|
||||
{
|
||||
$username = strtolower(trim($username));
|
||||
$username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user';
|
||||
return trim($username, '.-') ?: 'user';
|
||||
return UsernamePolicy::sanitizeLegacy($username);
|
||||
}
|
||||
|
||||
protected function uniqueUsername(string $base): string
|
||||
{
|
||||
$name = $base;
|
||||
$i = 1;
|
||||
while (isset($this->usedUsernames[$name]) || DB::table('users')->where('username', $name)->exists()) {
|
||||
$name = $base . '-' . $i;
|
||||
$i++;
|
||||
}
|
||||
$name = UsernamePolicy::uniqueCandidate($base);
|
||||
$this->usedUsernames[$name] = $name;
|
||||
return $name;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected $commands = [
|
||||
ImportLegacyUsers::class,
|
||||
\App\Console\Commands\EnforceUsernamePolicy::class,
|
||||
ImportCategories::class,
|
||||
MigrateFeaturedWorks::class,
|
||||
\App\Console\Commands\AvatarsMigrate::class,
|
||||
|
||||
149
app/Http/Controllers/Api/Admin/UsernameApprovalController.php
Normal file
149
app/Http/Controllers/Api/Admin/UsernameApprovalController.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class UsernameApprovalController extends Controller
|
||||
{
|
||||
public function pending(): JsonResponse
|
||||
{
|
||||
$rows = DB::table('username_approval_requests')
|
||||
->where('status', 'pending')
|
||||
->orderBy('created_at')
|
||||
->get([
|
||||
'id',
|
||||
'user_id',
|
||||
'requested_username',
|
||||
'context',
|
||||
'similar_to',
|
||||
'payload',
|
||||
'created_at',
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $rows], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function approve(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$row = DB::table('username_approval_requests')->where('id', $id)->first();
|
||||
if (! $row) {
|
||||
return response()->json(['message' => 'Request not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ((string) $row->status !== 'pending') {
|
||||
return response()->json(['message' => 'Request is not pending.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
DB::table('username_approval_requests')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'status' => 'approved',
|
||||
'reviewed_by' => (int) $request->user()->id,
|
||||
'reviewed_at' => now(),
|
||||
'review_note' => (string) $request->input('note', ''),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ((string) $row->context === 'profile_update' && ! empty($row->user_id)) {
|
||||
$this->applyProfileRename((int) $row->user_id, (string) $row->requested_username);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'id' => $id,
|
||||
'status' => 'approved',
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function reject(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$affected = DB::table('username_approval_requests')
|
||||
->where('id', $id)
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'rejected',
|
||||
'reviewed_by' => (int) $request->user()->id,
|
||||
'reviewed_at' => now(),
|
||||
'review_note' => (string) $request->input('note', ''),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($affected === 0) {
|
||||
return response()->json(['message' => 'Request not found or not pending.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'id' => $id,
|
||||
'status' => 'rejected',
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
private function applyProfileRename(int $userId, string $requestedUsername): void
|
||||
{
|
||||
$user = User::query()->find($userId);
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$requested = UsernamePolicy::normalize($requestedUsername);
|
||||
if ($requested === '') {
|
||||
throw new \RuntimeException('Requested username is invalid.');
|
||||
}
|
||||
|
||||
$exists = User::query()
|
||||
->whereRaw('LOWER(username) = ?', [$requested])
|
||||
->where('id', '!=', $userId)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw new \RuntimeException('Requested username is already taken.');
|
||||
}
|
||||
|
||||
$old = UsernamePolicy::normalize((string) ($user->username ?? ''));
|
||||
if ($old === $requested) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user->username = $requested;
|
||||
$user->username_changed_at = now();
|
||||
$user->save();
|
||||
|
||||
if ($old !== '') {
|
||||
DB::table('username_history')->insert([
|
||||
'user_id' => $userId,
|
||||
'old_username' => $old,
|
||||
'changed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => $old],
|
||||
[
|
||||
'new_username' => $requested,
|
||||
'user_id' => $userId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -535,6 +535,7 @@ final class UploadController extends Controller
|
||||
'upload_id' => (string) $upload->id,
|
||||
'status' => (string) $upload->status,
|
||||
'published_at' => optional($upload->published_at)->toISOString(),
|
||||
'final_path' => (string) ($upload->final_path ?? ''),
|
||||
], Response::HTTP_OK);
|
||||
} catch (UploadOwnershipException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);
|
||||
|
||||
44
app/Http/Controllers/Api/UsernameAvailabilityController.php
Normal file
44
app/Http/Controllers/Api/UsernameAvailabilityController.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UsernameRequest;
|
||||
use App\Models\User;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class UsernameAvailabilityController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$candidate = UsernamePolicy::normalize((string) $request->query('username', ''));
|
||||
|
||||
$validator = validator(
|
||||
['username' => $candidate],
|
||||
['username' => UsernameRequest::formatRules()]
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'available' => false,
|
||||
'normalized' => $candidate,
|
||||
'errors' => $validator->errors()->toArray(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$ignoreUserId = $request->user()?->id;
|
||||
$exists = User::query()
|
||||
->whereRaw('LOWER(username) = ?', [$candidate])
|
||||
->when($ignoreUserId !== null, fn ($q) => $q->where('id', '!=', (int) $ignoreUserId))
|
||||
->exists();
|
||||
|
||||
return response()->json([
|
||||
'available' => ! $exists,
|
||||
'normalized' => $candidate,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,46 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\RegistrationVerificationMail;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use App\Services\Security\RecaptchaVerifier;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RecaptchaVerifier $recaptchaVerifier
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(): View
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.register');
|
||||
return view('auth.register', [
|
||||
'prefillEmail' => (string) $request->query('email', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function notice(Request $request): View
|
||||
{
|
||||
$email = (string) session('registration_email', '');
|
||||
$remaining = $email === '' ? 0 : $this->resendRemainingSeconds($email);
|
||||
|
||||
return view('auth.register-notice', [
|
||||
'email' => $email,
|
||||
'resendSeconds' => $remaining,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,22 +52,127 @@ class RegisteredUserController extends Controller
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
'website' => ['nullable', 'max:0'],
|
||||
]);
|
||||
|
||||
if ($this->recaptchaVerifier->isEnabled()) {
|
||||
$request->validate([
|
||||
'g-recaptcha-response' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$verified = $this->recaptchaVerifier->verify(
|
||||
(string) $request->input('g-recaptcha-response', ''),
|
||||
$request->ip()
|
||||
);
|
||||
|
||||
if (! $verified) {
|
||||
return back()
|
||||
->withInput($request->except('website'))
|
||||
->withErrors(['captcha' => 'reCAPTCHA verification failed. Please try again.']);
|
||||
}
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
'username' => null,
|
||||
'name' => Str::before((string) $validated['email'], '@'),
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'is_active' => false,
|
||||
'onboarding_step' => 'email',
|
||||
'username_changed_at' => now(),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
$token = Str::random(64);
|
||||
DB::table('user_verification_tokens')->insert([
|
||||
'user_id' => $user->id,
|
||||
'token' => $token,
|
||||
'expires_at' => now()->addDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
Mail::to($user->email)->queue(new RegistrationVerificationMail($token));
|
||||
|
||||
return redirect(route('dashboard', absolute: false));
|
||||
$cooldown = $this->resendCooldownSeconds();
|
||||
$this->setResendCooldown((string) $validated['email'], $cooldown);
|
||||
|
||||
return redirect(route('register.notice', absolute: false))
|
||||
->with('status', 'Verification email sent. Please check your inbox.')
|
||||
->with('registration_email', (string) $validated['email']);
|
||||
}
|
||||
|
||||
public function resendVerification(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
|
||||
]);
|
||||
|
||||
$email = (string) $validated['email'];
|
||||
$remaining = $this->resendRemainingSeconds($email);
|
||||
if ($remaining > 0) {
|
||||
return back()
|
||||
->with('registration_email', $email)
|
||||
->withErrors(['email' => "Please wait {$remaining} seconds before resending."]);
|
||||
}
|
||||
|
||||
$user = User::query()
|
||||
->where('email', $email)
|
||||
->whereNull('email_verified_at')
|
||||
->where('onboarding_step', 'email')
|
||||
->first();
|
||||
|
||||
if (! $user) {
|
||||
return back()
|
||||
->with('registration_email', $email)
|
||||
->withErrors(['email' => 'No pending verification found for this email.']);
|
||||
}
|
||||
|
||||
DB::table('user_verification_tokens')->where('user_id', $user->id)->delete();
|
||||
|
||||
$token = Str::random(64);
|
||||
DB::table('user_verification_tokens')->insert([
|
||||
'user_id' => $user->id,
|
||||
'token' => $token,
|
||||
'expires_at' => now()->addDay(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Mail::to($user->email)->queue(new RegistrationVerificationMail($token));
|
||||
|
||||
$cooldown = $this->resendCooldownSeconds();
|
||||
$this->setResendCooldown($email, $cooldown);
|
||||
|
||||
return redirect(route('register.notice', absolute: false))
|
||||
->with('registration_email', $email)
|
||||
->with('status', 'Verification email resent. Please check your inbox.');
|
||||
}
|
||||
|
||||
private function resendCooldownSeconds(): int
|
||||
{
|
||||
return max(5, (int) config('antispam.register.resend_cooldown_seconds', 60));
|
||||
}
|
||||
|
||||
private function resendCooldownCacheKey(string $email): string
|
||||
{
|
||||
return 'register:resend:cooldown:' . sha1(strtolower(trim($email)));
|
||||
}
|
||||
|
||||
private function setResendCooldown(string $email, int $seconds): void
|
||||
{
|
||||
$until = CarbonImmutable::now()->addSeconds($seconds)->timestamp;
|
||||
Cache::put($this->resendCooldownCacheKey($email), $until, $seconds + 5);
|
||||
}
|
||||
|
||||
private function resendRemainingSeconds(string $email): int
|
||||
{
|
||||
$until = (int) Cache::get($this->resendCooldownCacheKey($email), 0);
|
||||
if ($until <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(0, $until - time());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegistrationVerificationController extends Controller
|
||||
{
|
||||
public function __invoke(string $token): RedirectResponse
|
||||
{
|
||||
$record = DB::table('user_verification_tokens')
|
||||
->where('token', $token)
|
||||
->first();
|
||||
|
||||
if (! $record) {
|
||||
return redirect(route('login', absolute: false))
|
||||
->withErrors(['email' => 'Verification link is invalid.']);
|
||||
}
|
||||
|
||||
if (now()->greaterThan($record->expires_at)) {
|
||||
DB::table('user_verification_tokens')->where('id', $record->id)->delete();
|
||||
|
||||
return redirect(route('login', absolute: false))
|
||||
->withErrors(['email' => 'Verification link has expired.']);
|
||||
}
|
||||
|
||||
$user = User::query()->find((int) $record->user_id);
|
||||
if (! $user) {
|
||||
DB::table('user_verification_tokens')->where('id', $record->id)->delete();
|
||||
|
||||
return redirect(route('login', absolute: false))
|
||||
->withErrors(['email' => 'Verification link is invalid.']);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'email_verified_at' => $user->email_verified_at ?? now(),
|
||||
'onboarding_step' => 'verified',
|
||||
'is_active' => true,
|
||||
])->save();
|
||||
|
||||
DB::table('user_verification_tokens')
|
||||
->where('id', $record->id)
|
||||
->delete();
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect('/setup/password')->with('status', 'Email verified. Continue with password setup.');
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Auth/SetupPasswordController.php
Normal file
45
app/Http/Controllers/Auth/SetupPasswordController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SetupPasswordController extends Controller
|
||||
{
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.setup-password', [
|
||||
'email' => (string) ($request->user()?->email ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'password' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:10',
|
||||
'regex:/\d/',
|
||||
'regex:/[^\w\s]/',
|
||||
'confirmed',
|
||||
],
|
||||
], [
|
||||
'password.min' => 'Your password must be at least 10 characters.',
|
||||
'password.regex' => 'Your password must include at least one number and one symbol.',
|
||||
'password.confirmed' => 'Password confirmation does not match.',
|
||||
]);
|
||||
|
||||
$request->user()->forceFill([
|
||||
'password' => Hash::make((string) $validated['password']),
|
||||
'onboarding_step' => 'password',
|
||||
'needs_password_reset' => false,
|
||||
])->save();
|
||||
|
||||
return redirect('/setup/username')->with('status', 'Password saved. Choose your public username to finish setup.');
|
||||
}
|
||||
}
|
||||
94
app/Http/Controllers/Auth/SetupUsernameController.php
Normal file
94
app/Http/Controllers/Auth/SetupUsernameController.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UsernameRequest;
|
||||
use App\Services\UsernameApprovalService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SetupUsernameController extends Controller
|
||||
{
|
||||
public function __construct(private readonly UsernameApprovalService $usernameApprovalService)
|
||||
{
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.setup-username', [
|
||||
'username' => (string) ($request->user()?->username ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize((string) $request->input('username', ''));
|
||||
$request->merge(['username' => $normalized]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'username' => UsernameRequest::rulesFor((int) $request->user()->id),
|
||||
], [
|
||||
'username.required' => 'Please choose a username to continue.',
|
||||
'username.unique' => 'This username is already taken.',
|
||||
'username.regex' => 'Use only letters, numbers, underscores, or hyphens.',
|
||||
'username.min' => 'Username must be at least 3 characters.',
|
||||
'username.max' => 'Username must be at most 20 characters.',
|
||||
]);
|
||||
|
||||
$candidate = (string) $validated['username'];
|
||||
$user = $request->user();
|
||||
|
||||
$similar = UsernamePolicy::similarReserved($candidate);
|
||||
if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($candidate, (int) $user->id)) {
|
||||
$this->usernameApprovalService->submit($user, $candidate, 'onboarding_username', [
|
||||
'current_username' => (string) ($user->username ?? ''),
|
||||
]);
|
||||
|
||||
return back()
|
||||
->withInput()
|
||||
->with('status', 'Your request has been submitted for manual username review.')
|
||||
->withErrors([
|
||||
'username' => 'This username is too similar to a reserved name and requires manual approval.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $candidate): void {
|
||||
$oldUsername = (string) ($user->username ?? '');
|
||||
|
||||
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_history')) {
|
||||
DB::table('username_history')->insert([
|
||||
'user_id' => (int) $user->id,
|
||||
'old_username' => strtolower($oldUsername),
|
||||
'changed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_redirects')) {
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => strtolower($oldUsername)],
|
||||
[
|
||||
'new_username' => strtolower($candidate),
|
||||
'user_id' => (int) $user->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'username' => strtolower($candidate),
|
||||
'onboarding_step' => 'complete',
|
||||
'username_changed_at' => now(),
|
||||
])->save();
|
||||
});
|
||||
|
||||
return redirect('/@' . strtolower($candidate));
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Community;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\LegacyService;
|
||||
|
||||
class ForumController extends Controller
|
||||
{
|
||||
protected LegacyService $legacy;
|
||||
|
||||
public function __construct(LegacyService $legacy)
|
||||
{
|
||||
$this->legacy = $legacy;
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$data = $this->legacy->forumIndex();
|
||||
|
||||
if (empty($data['topics']) || count($data['topics']) === 0) {
|
||||
try {
|
||||
$categories = \App\Models\ForumCategory::query()
|
||||
->withCount(['threads as num_subtopics'])
|
||||
->orderBy('position')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$topics = $categories->map(function ($category) {
|
||||
$threadIds = \App\Models\ForumThread::where('category_id', $category->id)->pluck('id');
|
||||
|
||||
return (object) [
|
||||
'topic_id' => $category->id,
|
||||
'topic' => $category->name,
|
||||
'discuss' => null,
|
||||
'last_update' => \App\Models\ForumThread::where('category_id', $category->id)->max('last_post_at'),
|
||||
'num_posts' => $threadIds->isEmpty() ? 0 : \App\Models\ForumPost::whereIn('thread_id', $threadIds)->count(),
|
||||
'num_subtopics' => (int) ($category->num_subtopics ?? 0),
|
||||
];
|
||||
});
|
||||
|
||||
$data['topics'] = $topics;
|
||||
} catch (\Throwable $e) {
|
||||
// keep legacy response
|
||||
}
|
||||
}
|
||||
|
||||
return view('community.forum.index', $data);
|
||||
}
|
||||
|
||||
public function topic(Request $request, $topic_id, $slug = null)
|
||||
{
|
||||
// Redirect to canonical slug when possible
|
||||
try {
|
||||
$thread = \App\Models\ForumThread::find((int) $topic_id);
|
||||
if ($thread && !empty($thread->slug)) {
|
||||
$correct = $thread->slug;
|
||||
if ($slug !== $correct) {
|
||||
$qs = $request->getQueryString();
|
||||
$url = route('legacy.forum.topic', ['topic_id' => $topic_id, 'slug' => $correct]);
|
||||
if ($qs) $url .= '?' . $qs;
|
||||
return redirect($url, 301);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
|
||||
$data = $this->legacy->forumTopic((int) $topic_id, (int) $request->query('page', 1));
|
||||
|
||||
if (! $data) {
|
||||
// fallback to new forum tables if migration already ran
|
||||
try {
|
||||
$thread = \App\Models\ForumThread::with(['posts.user'])->find((int) $topic_id);
|
||||
if ($thread) {
|
||||
$posts = \App\Models\ForumPost::where('thread_id', $thread->id)->orderBy('created_at')->get();
|
||||
$data = [
|
||||
'type' => 'posts',
|
||||
'thread' => $thread,
|
||||
'posts' => $posts,
|
||||
'page_title' => $thread->title ?? 'Forum',
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore and fall through to placeholder
|
||||
}
|
||||
}
|
||||
|
||||
if (! $data) {
|
||||
return view('shared.placeholder');
|
||||
}
|
||||
|
||||
if (isset($data['type']) && $data['type'] === 'subtopics') {
|
||||
return view('community.forum.topic', $data);
|
||||
}
|
||||
|
||||
return view('community.forum.posts', $data);
|
||||
}
|
||||
}
|
||||
348
app/Http/Controllers/Forum/ForumController.php
Normal file
348
app/Http/Controllers/Forum/ForumController.php
Normal file
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Forum;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ForumCategory;
|
||||
use App\Models\ForumPost;
|
||||
use App\Models\ForumPostReport;
|
||||
use App\Models\ForumThread;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ForumController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$categories = Cache::remember('forum:index:categories:v1', now()->addMinutes(5), function () {
|
||||
return ForumCategory::query()
|
||||
->select(['id', 'name', 'slug', 'parent_id', 'position'])
|
||||
->roots()
|
||||
->ordered()
|
||||
->withForumStats()
|
||||
->get()
|
||||
->map(function (ForumCategory $category) {
|
||||
return [
|
||||
'id' => $category->id,
|
||||
'name' => $category->name,
|
||||
'slug' => $category->slug,
|
||||
'thread_count' => (int) ($category->thread_count ?? 0),
|
||||
'post_count' => (int) ($category->post_count ?? 0),
|
||||
'last_activity_at' => $category->lastThread?->last_post_at ?? $category->lastThread?->updated_at,
|
||||
'preview_image' => $category->preview_image,
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
$data = [
|
||||
'categories' => $categories,
|
||||
'page_title' => 'Forum',
|
||||
'page_meta_description' => 'Skinbase forum discussions.',
|
||||
'page_meta_keywords' => 'forum, discussions, topics, skinbase',
|
||||
];
|
||||
|
||||
return view('forum.index', $data);
|
||||
}
|
||||
|
||||
public function showCategory(Request $request, ForumCategory $category)
|
||||
{
|
||||
$subtopics = ForumThread::query()
|
||||
->where('category_id', $category->id)
|
||||
->withCount('posts')
|
||||
->with('user:id,name')
|
||||
->orderByDesc('is_pinned')
|
||||
->orderByDesc('last_post_at')
|
||||
->orderByDesc('id')
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
|
||||
$subtopics->getCollection()->transform(function (ForumThread $item) {
|
||||
return (object) [
|
||||
'topic_id' => $item->id,
|
||||
'topic' => $item->title,
|
||||
'discuss' => $item->content,
|
||||
'post_date' => $item->created_at,
|
||||
'last_update' => $item->last_post_at ?? $item->created_at,
|
||||
'uname' => $item->user?->name,
|
||||
'num_posts' => (int) ($item->posts_count ?? 0),
|
||||
];
|
||||
});
|
||||
|
||||
$topic = (object) [
|
||||
'topic_id' => $category->id,
|
||||
'topic' => $category->name,
|
||||
'discuss' => null,
|
||||
];
|
||||
|
||||
return view('forum.community.topic', [
|
||||
'type' => 'subtopics',
|
||||
'topic' => $topic,
|
||||
'subtopics' => $subtopics,
|
||||
'category' => $category,
|
||||
'page_title' => $category->name,
|
||||
'page_meta_description' => 'Forum section: ' . $category->name,
|
||||
'page_meta_keywords' => 'forum, section, skinbase',
|
||||
]);
|
||||
}
|
||||
|
||||
public function showThread(Request $request, ForumThread $thread, ?string $slug = null)
|
||||
{
|
||||
if (! empty($thread->slug) && $slug !== $thread->slug) {
|
||||
return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug], 301);
|
||||
}
|
||||
|
||||
$thread->loadMissing([
|
||||
'category:id,name,slug',
|
||||
'user:id,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
]);
|
||||
|
||||
$threadMeta = Cache::remember(
|
||||
'forum:thread:meta:v1:' . $thread->id . ':' . ($thread->updated_at?->timestamp ?? 0),
|
||||
now()->addMinutes(5),
|
||||
fn () => [
|
||||
'category' => $thread->category,
|
||||
'author' => $thread->user,
|
||||
]
|
||||
);
|
||||
|
||||
$sort = strtolower((string) $request->query('sort', 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$opPost = ForumPost::query()
|
||||
->where('thread_id', $thread->id)
|
||||
->with([
|
||||
'user:id,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'attachments:id,post_id,file_path,file_size,mime_type,width,height',
|
||||
])
|
||||
->orderBy('created_at', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->first();
|
||||
|
||||
$posts = ForumPost::query()
|
||||
->where('thread_id', $thread->id)
|
||||
->when($opPost, fn ($query) => $query->where('id', '!=', $opPost->id))
|
||||
->with([
|
||||
'user:id,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'attachments:id,post_id,file_path,file_size,mime_type,width,height',
|
||||
])
|
||||
->orderBy('created_at', $sort)
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
|
||||
$replyCount = max((int) ForumPost::query()->where('thread_id', $thread->id)->count() - 1, 0);
|
||||
|
||||
$attachments = collect($opPost?->attachments ?? [])
|
||||
->merge($posts->getCollection()->flatMap(fn (ForumPost $post) => $post->attachments ?? []))
|
||||
->values();
|
||||
|
||||
$quotedPost = null;
|
||||
$quotePostId = (int) $request->query('quote', 0);
|
||||
|
||||
if ($quotePostId > 0) {
|
||||
$quotedPost = ForumPost::query()
|
||||
->where('thread_id', $thread->id)
|
||||
->with('user:id,name')
|
||||
->find($quotePostId);
|
||||
}
|
||||
|
||||
$replyPrefill = old('content');
|
||||
|
||||
if ($replyPrefill === null && $quotedPost) {
|
||||
$quotedAuthor = (string) ($quotedPost->user?->name ?? 'Anonymous');
|
||||
$quoteText = trim(strip_tags((string) $quotedPost->content));
|
||||
$quoteText = preg_replace('/\s+/', ' ', $quoteText) ?? $quoteText;
|
||||
$quoteSnippet = Str::limit($quoteText, 300);
|
||||
|
||||
$replyPrefill = '[quote=' . $quotedAuthor . ']'
|
||||
. $quoteSnippet
|
||||
. '[/quote]'
|
||||
. "\n\n";
|
||||
}
|
||||
|
||||
return view('forum.thread.show', [
|
||||
'thread' => $thread,
|
||||
'category' => $threadMeta['category'] ?? $thread->category,
|
||||
'author' => $threadMeta['author'] ?? $thread->user,
|
||||
'opPost' => $opPost,
|
||||
'posts' => $posts,
|
||||
'attachments' => $attachments,
|
||||
'reply_count' => $replyCount,
|
||||
'quoted_post' => $quotedPost,
|
||||
'reply_prefill' => $replyPrefill,
|
||||
'sort' => $sort,
|
||||
'page_title' => $thread->title,
|
||||
'page_meta_description' => 'Forum thread: ' . $thread->title,
|
||||
'page_meta_keywords' => 'forum, thread, skinbase',
|
||||
]);
|
||||
}
|
||||
|
||||
public function createThreadForm(ForumCategory $category)
|
||||
{
|
||||
return view('forum.community.new-thread', [
|
||||
'category' => $category,
|
||||
'page_title' => 'New thread',
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeThread(Request $request, ForumCategory $category)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string', 'min:2'],
|
||||
]);
|
||||
|
||||
$baseSlug = Str::slug((string) $validated['title']);
|
||||
$slug = $baseSlug ?: ('thread-' . time());
|
||||
$counter = 2;
|
||||
while (ForumThread::where('slug', $slug)->exists()) {
|
||||
$slug = ($baseSlug ?: 'thread') . '-' . $counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
$thread = ForumThread::create([
|
||||
'category_id' => $category->id,
|
||||
'user_id' => (int) $user->id,
|
||||
'title' => $validated['title'],
|
||||
'slug' => $slug,
|
||||
'content' => $validated['content'],
|
||||
'views' => 0,
|
||||
'is_locked' => false,
|
||||
'is_pinned' => false,
|
||||
'visibility' => 'public',
|
||||
'last_post_at' => now(),
|
||||
]);
|
||||
|
||||
ForumPost::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => (int) $user->id,
|
||||
'content' => $validated['content'],
|
||||
'is_edited' => false,
|
||||
'edited_at' => null,
|
||||
]);
|
||||
|
||||
return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]);
|
||||
}
|
||||
|
||||
public function reply(Request $request, ForumThread $thread)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
abort_if($thread->is_locked, 423, 'Thread is locked.');
|
||||
|
||||
$validated = $request->validate([
|
||||
'content' => ['required', 'string', 'min:2'],
|
||||
]);
|
||||
|
||||
ForumPost::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => (int) $user->id,
|
||||
'content' => $validated['content'],
|
||||
'is_edited' => false,
|
||||
'edited_at' => null,
|
||||
]);
|
||||
|
||||
$thread->last_post_at = now();
|
||||
$thread->save();
|
||||
|
||||
return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]);
|
||||
}
|
||||
|
||||
public function editPostForm(ForumPost $post)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
abort_unless(((int) $post->user_id === (int) $user->id) || Gate::allows('moderate-forum'), 403);
|
||||
|
||||
return view('forum.community.edit-post', [
|
||||
'post' => $post,
|
||||
'thread' => $post->thread,
|
||||
'page_title' => 'Edit post',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatePost(Request $request, ForumPost $post)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
abort_unless(((int) $post->user_id === (int) $user->id) || Gate::allows('moderate-forum'), 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'content' => ['required', 'string', 'min:2'],
|
||||
]);
|
||||
|
||||
$post->content = $validated['content'];
|
||||
$post->is_edited = true;
|
||||
$post->edited_at = now();
|
||||
$post->save();
|
||||
|
||||
return redirect()->route('forum.thread.show', ['thread' => $post->thread_id, 'slug' => $post->thread?->slug]);
|
||||
}
|
||||
|
||||
public function reportPost(Request $request, ForumPost $post)
|
||||
{
|
||||
$user = Auth::user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
abort_if((int) $post->user_id === (int) $user->id, 422, 'You cannot report your own post.');
|
||||
|
||||
$validated = $request->validate([
|
||||
'reason' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
ForumPostReport::query()->updateOrCreate(
|
||||
[
|
||||
'post_id' => (int) $post->id,
|
||||
'reporter_user_id' => (int) $user->id,
|
||||
],
|
||||
[
|
||||
'thread_id' => (int) $post->thread_id,
|
||||
'reason' => $validated['reason'] ?? null,
|
||||
'status' => 'open',
|
||||
'source_url' => (string) $request->headers->get('referer', ''),
|
||||
'reported_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
return back()->with('status', 'Post reported. Thank you for helping moderate the forum.');
|
||||
}
|
||||
|
||||
public function lockThread(ForumThread $thread)
|
||||
{
|
||||
$thread->is_locked = true;
|
||||
$thread->save();
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function unlockThread(ForumThread $thread)
|
||||
{
|
||||
$thread->is_locked = false;
|
||||
$thread->save();
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function pinThread(ForumThread $thread)
|
||||
{
|
||||
$thread->is_pinned = true;
|
||||
$thread->save();
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function unpinThread(ForumThread $thread)
|
||||
{
|
||||
$thread->is_pinned = false;
|
||||
$thread->save();
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\LegacyService;
|
||||
|
||||
class ForumController extends Controller
|
||||
{
|
||||
protected LegacyService $legacy;
|
||||
|
||||
public function __construct(LegacyService $legacy)
|
||||
{
|
||||
$this->legacy = $legacy;
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$data = $this->legacy->forumIndex();
|
||||
return view('legacy.forum.index', $data);
|
||||
}
|
||||
|
||||
public function topic(Request $request, $topic_id)
|
||||
{
|
||||
$data = $this->legacy->forumTopic((int) $topic_id, (int) $request->query('page', 1));
|
||||
|
||||
if (! $data) {
|
||||
return view('legacy.placeholder');
|
||||
}
|
||||
|
||||
if (isset($data['type']) && $data['type'] === 'subtopics') {
|
||||
return view('legacy.forum.topic', $data);
|
||||
}
|
||||
|
||||
return view('legacy.forum.posts', $data);
|
||||
}
|
||||
}
|
||||
@@ -255,133 +255,6 @@ class LegacyController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function forumIndex()
|
||||
{
|
||||
$page_title = 'Forum';
|
||||
$page_meta_description = 'Skinbase forum threads.';
|
||||
$page_meta_keywords = 'forum, discussions, topics, skinbase';
|
||||
|
||||
try {
|
||||
$topics = DB::table('forum_topics as t')
|
||||
->select(
|
||||
't.topic_id',
|
||||
't.topic',
|
||||
't.discuss',
|
||||
't.last_update',
|
||||
't.privilege',
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id IN (SELECT topic_id FROM forum_topics st WHERE st.root_id = t.topic_id)) AS num_posts'),
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_topics st WHERE st.root_id = t.topic_id) AS num_subtopics')
|
||||
)
|
||||
->where('t.root_id', 0)
|
||||
->where('t.privilege', '<', 4)
|
||||
->orderByDesc('t.last_update')
|
||||
->limit(100)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$topics = collect();
|
||||
}
|
||||
|
||||
return view('legacy.forum.index', compact(
|
||||
'topics',
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords'
|
||||
));
|
||||
}
|
||||
|
||||
public function forumTopic(Request $request, int $topic_id)
|
||||
{
|
||||
try {
|
||||
$topic = DB::table('forum_topics')->where('topic_id', $topic_id)->first();
|
||||
} catch (\Throwable $e) {
|
||||
$topic = null;
|
||||
}
|
||||
|
||||
if (!$topic) {
|
||||
return redirect('/forum');
|
||||
}
|
||||
|
||||
$page_title = $topic->topic;
|
||||
$page_meta_description = Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160);
|
||||
$page_meta_keywords = 'forum, topic, skinbase';
|
||||
|
||||
// Fetch subtopics; if none exist, fall back to posts (matches legacy behavior where some topics hold posts directly)
|
||||
try {
|
||||
$subtopics = DB::table('forum_topics as t')
|
||||
->leftJoin('users as u', 't.user_id', '=', 'u.user_id')
|
||||
->select(
|
||||
't.topic_id',
|
||||
't.topic',
|
||||
't.discuss',
|
||||
't.post_date',
|
||||
't.last_update',
|
||||
'u.uname',
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id = t.topic_id) AS num_posts')
|
||||
)
|
||||
->where('t.root_id', $topic->topic_id)
|
||||
->orderByDesc('t.last_update')
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$subtopics = new LengthAwarePaginator([], 0, 50, 1, [
|
||||
'path' => $request->url(),
|
||||
'query' => $request->query(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($subtopics->total() > 0) {
|
||||
return view('legacy.forum.topic', compact(
|
||||
'topic',
|
||||
'subtopics',
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords'
|
||||
));
|
||||
}
|
||||
|
||||
$sort = strtolower($request->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
// First try topic_id; if empty, retry using legacy tid column
|
||||
$posts = new LengthAwarePaginator([], 0, 50, 1, [
|
||||
'path' => $request->url(),
|
||||
'query' => $request->query(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$posts = DB::table('forum_posts as p')
|
||||
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
|
||||
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
|
||||
->where('p.topic_id', $topic->topic_id)
|
||||
->orderBy('p.post_date', $sort)
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
// will retry with tid
|
||||
}
|
||||
|
||||
if ($posts->total() === 0) {
|
||||
try {
|
||||
$posts = DB::table('forum_posts as p')
|
||||
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
|
||||
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
|
||||
->where('p.tid', $topic->topic_id)
|
||||
->orderBy('p.post_date', $sort)
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
// keep empty paginator
|
||||
}
|
||||
}
|
||||
|
||||
return view('legacy.forum.posts', compact(
|
||||
'topic',
|
||||
'posts',
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch featured artworks with graceful fallbacks.
|
||||
*/
|
||||
@@ -437,19 +310,16 @@ class LegacyController extends Controller
|
||||
private function forumNews(): array
|
||||
{
|
||||
try {
|
||||
return DB::table('forum_topics as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
|
||||
->select(
|
||||
't1.topic_id',
|
||||
't1.topic',
|
||||
't1.views',
|
||||
't1.post_date',
|
||||
't1.preview',
|
||||
't2.uname'
|
||||
)
|
||||
->where('t1.root_id', 2876)
|
||||
->where('t1.privilege', '<', 4)
|
||||
->orderByDesc('t1.post_date')
|
||||
return DB::table('forum_threads as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
|
||||
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
|
||||
->selectRaw('t1.id as topic_id, t1.title as topic, t1.views, t1.created_at as post_date, t1.content as preview, COALESCE(t2.name, ?) as uname', ['Unknown'])
|
||||
->whereNull('t1.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->where('t1.category_id', 2876)
|
||||
->orWhereIn('c.slug', ['news', 'forum-news']);
|
||||
})
|
||||
->orderByDesc('t1.created_at')
|
||||
->limit(8)
|
||||
->get()
|
||||
->toArray();
|
||||
@@ -487,17 +357,25 @@ class LegacyController extends Controller
|
||||
private function latestForumActivity(): array
|
||||
{
|
||||
try {
|
||||
return DB::table('forum_topics as t1')
|
||||
->select(
|
||||
't1.topic_id',
|
||||
't1.topic',
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
|
||||
)
|
||||
->where('t1.root_id', '<>', 0)
|
||||
->where('t1.root_id', '<>', 2876)
|
||||
->where('t1.privilege', '<', 4)
|
||||
->orderByDesc('t1.last_update')
|
||||
->orderByDesc('t1.post_date')
|
||||
return DB::table('forum_threads as t1')
|
||||
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
|
||||
->leftJoin('forum_posts as p', function ($join) {
|
||||
$join->on('p.thread_id', '=', 't1.id')
|
||||
->whereNull('p.deleted_at');
|
||||
})
|
||||
->selectRaw('t1.id as topic_id, t1.title as topic, COUNT(p.id) AS numPosts')
|
||||
->whereNull('t1.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->where('t1.category_id', '<>', 2876)
|
||||
->orWhereNull('t1.category_id');
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNull('c.slug')
|
||||
->orWhereNotIn('c.slug', ['news', 'forum-news']);
|
||||
})
|
||||
->groupBy('t1.id', 't1.title')
|
||||
->orderByDesc('t1.last_post_at')
|
||||
->orderByDesc('t1.created_at')
|
||||
->limit(10)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
@@ -4,9 +4,15 @@ namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\UsernameApprovalService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -14,6 +20,49 @@ use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworkService,
|
||||
private readonly UsernameApprovalService $usernameApprovalService,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function showByUsername(Request $request, string $username)
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
|
||||
if (! $user) {
|
||||
$redirect = DB::table('username_redirects')
|
||||
->whereRaw('LOWER(old_username) = ?', [$normalized])
|
||||
->value('new_username');
|
||||
|
||||
if ($redirect) {
|
||||
return redirect()->route('profile.show', ['username' => strtolower((string) $redirect)], 301);
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($username !== strtolower((string) $user->username)) {
|
||||
return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301);
|
||||
}
|
||||
|
||||
return $this->renderUserProfile($request, $user);
|
||||
}
|
||||
|
||||
public function legacyById(Request $request, int $id, ?string $username = null)
|
||||
{
|
||||
$user = User::query()->findOrFail($id);
|
||||
|
||||
return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301);
|
||||
}
|
||||
|
||||
public function legacyByUsername(Request $request, string $username)
|
||||
{
|
||||
return redirect()->route('profile.show', ['username' => UsernamePolicy::normalize($username)], 301);
|
||||
}
|
||||
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
@@ -33,6 +82,56 @@ class ProfileController extends Controller
|
||||
$user->name = $validated['name'];
|
||||
}
|
||||
|
||||
if (array_key_exists('username', $validated)) {
|
||||
$incomingUsername = UsernamePolicy::normalize((string) $validated['username']);
|
||||
$currentUsername = UsernamePolicy::normalize((string) ($user->username ?? ''));
|
||||
|
||||
if ($incomingUsername !== '' && $incomingUsername !== $currentUsername) {
|
||||
$similar = UsernamePolicy::similarReserved($incomingUsername);
|
||||
if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($incomingUsername, (int) $user->id)) {
|
||||
$this->usernameApprovalService->submit($user, $incomingUsername, 'profile_update', [
|
||||
'current_username' => $currentUsername,
|
||||
]);
|
||||
|
||||
return Redirect::back()->withErrors([
|
||||
'username' => 'This username is too similar to a reserved name and requires manual approval.',
|
||||
]);
|
||||
}
|
||||
|
||||
$cooldownDays = (int) config('usernames.rename_cooldown_days', 90);
|
||||
$isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false;
|
||||
|
||||
if (! $isAdmin && $user->username_changed_at !== null && $user->username_changed_at->gt(now()->subDays($cooldownDays))) {
|
||||
return Redirect::back()->withErrors([
|
||||
'username' => "Username can only be changed once every {$cooldownDays} days.",
|
||||
]);
|
||||
}
|
||||
|
||||
$user->username = $incomingUsername;
|
||||
$user->username_changed_at = now();
|
||||
|
||||
DB::table('username_history')->insert([
|
||||
'user_id' => (int) $user->id,
|
||||
'old_username' => $currentUsername,
|
||||
'changed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($currentUsername !== '') {
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => $currentUsername],
|
||||
[
|
||||
'new_username' => $incomingUsername,
|
||||
'user_id' => (int) $user->id,
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($validated['email']) && empty($user->email)) {
|
||||
$user->email = $validated['email'];
|
||||
$user->email_verified_at = null;
|
||||
@@ -154,4 +253,41 @@ class ProfileController extends Controller
|
||||
|
||||
return Redirect::to('/user')->with('status', 'password-updated');
|
||||
}
|
||||
|
||||
private function renderUserProfile(Request $request, User $user)
|
||||
{
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$perPage = 24;
|
||||
|
||||
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage)
|
||||
->through(function (Artwork $art) {
|
||||
$present = \App\Services\ThumbnailPresenter::present($art, 'md');
|
||||
|
||||
return (object) [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'picture' => $art->file_name,
|
||||
'datum' => $art->published_at,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
];
|
||||
});
|
||||
|
||||
$legacyUser = (object) [
|
||||
'user_id' => $user->id,
|
||||
'uname' => $user->username ?? $user->name,
|
||||
'name' => $user->name,
|
||||
'real_name' => $user->name,
|
||||
'icon' => DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash'),
|
||||
'about_me' => $user->bio ?? null,
|
||||
];
|
||||
|
||||
return response()->view('legacy.profile', [
|
||||
'user' => $legacyUser,
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Profile: ' . ($legacyUser->uname ?? ''),
|
||||
'page_canonical' => url('/@' . strtolower((string) ($user->username ?? ''))),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class UserController extends Controller
|
||||
$profile = null;
|
||||
}
|
||||
|
||||
return view('user.user', [
|
||||
return view('legacy.user', [
|
||||
'profile' => $profile,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -29,26 +30,32 @@ class HomeController extends Controller
|
||||
$featured = $featuredResult->getCollection()->first();
|
||||
} elseif (is_array($featuredResult)) {
|
||||
$featured = $featuredResult[0] ?? null;
|
||||
} elseif ($featuredResult instanceof Collection) {
|
||||
$featured = $featuredResult->first();
|
||||
} else {
|
||||
$featured = method_exists($featuredResult, 'first') ? $featuredResult->first() : $featuredResult;
|
||||
$featured = $featuredResult;
|
||||
}
|
||||
|
||||
$memberFeatured = $featured;
|
||||
|
||||
$latestUploads = $this->artworks->getLatestArtworks(20);
|
||||
|
||||
// Forum news (root forum section id 2876)
|
||||
// Forum news (prefer migrated legacy news category id 2876, fallback to slug)
|
||||
try {
|
||||
$forumNews = DB::table('forum_topics as t1')
|
||||
->leftJoin('users as u', 't1.user_id', '=', 'u.user_id')
|
||||
->select('t1.topic_id', 't1.topic', 'u.uname', 't1.post_date', 't1.preview')
|
||||
->where('t1.root_id', 2876)
|
||||
->where('t1.privilege', '<', 4)
|
||||
->orderBy('t1.post_date', 'desc')
|
||||
$forumNews = DB::table('forum_threads as t1')
|
||||
->leftJoin('users as u', 't1.user_id', '=', 'u.id')
|
||||
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
|
||||
->selectRaw('t1.id as topic_id, t1.title as topic, COALESCE(u.name, ?) as uname, t1.created_at as post_date, t1.content as preview', ['Unknown'])
|
||||
->whereNull('t1.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->where('t1.category_id', 2876)
|
||||
->orWhereIn('c.slug', ['news', 'forum-news']);
|
||||
})
|
||||
->orderByDesc('t1.created_at')
|
||||
->limit(8)
|
||||
->get();
|
||||
} catch (QueryException $e) {
|
||||
Log::warning('Forum topics table missing or DB error when loading forum news', ['exception' => $e->getMessage()]);
|
||||
Log::warning('Forum threads table missing or DB error when loading forum news', ['exception' => $e->getMessage()]);
|
||||
$forumNews = collect();
|
||||
}
|
||||
|
||||
@@ -66,19 +73,31 @@ class HomeController extends Controller
|
||||
$ourNews = collect();
|
||||
}
|
||||
|
||||
// Latest forum activity (exclude rootless and news root)
|
||||
// Latest forum activity (exclude forum news category)
|
||||
try {
|
||||
$latestForumActivity = DB::table('forum_topics as t1')
|
||||
->selectRaw('t1.topic_id, t1.topic, (SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
|
||||
->where('t1.root_id', '<>', 0)
|
||||
->where('t1.root_id', '<>', 2876)
|
||||
->where('t1.privilege', '<', 4)
|
||||
->orderBy('t1.last_update', 'desc')
|
||||
->orderBy('t1.post_date', 'desc')
|
||||
$latestForumActivity = DB::table('forum_threads as t1')
|
||||
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
|
||||
->leftJoin('forum_posts as p', function ($join) {
|
||||
$join->on('p.thread_id', '=', 't1.id')
|
||||
->whereNull('p.deleted_at');
|
||||
})
|
||||
->selectRaw('t1.id as topic_id, t1.title as topic, COUNT(p.id) as numPosts')
|
||||
->whereNull('t1.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->where('t1.category_id', '<>', 2876)
|
||||
->orWhereNull('t1.category_id');
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNull('c.slug')
|
||||
->orWhereNotIn('c.slug', ['news', 'forum-news']);
|
||||
})
|
||||
->groupBy('t1.id', 't1.title')
|
||||
->orderByDesc('t1.last_post_at')
|
||||
->orderByDesc('t1.created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
} catch (QueryException $e) {
|
||||
Log::warning('Forum topics table missing or DB error when loading latest forum activity', ['exception' => $e->getMessage()]);
|
||||
Log::warning('Forum threads table missing or DB error when loading latest forum activity', ['exception' => $e->getMessage()]);
|
||||
$latestForumActivity = collect();
|
||||
}
|
||||
|
||||
|
||||
36
app/Http/Middleware/EnsureOnboardingComplete.php
Normal file
36
app/Http/Middleware/EnsureOnboardingComplete.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureOnboardingComplete
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$step = strtolower((string) ($user->onboarding_step ?? ''));
|
||||
if ($step === 'complete') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$target = match ($step) {
|
||||
'email' => '/login',
|
||||
'verified' => '/setup/password',
|
||||
'password', 'username' => '/setup/username',
|
||||
default => '/setup/password',
|
||||
};
|
||||
|
||||
if ($request->is(ltrim($target, '/'))) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return redirect($target);
|
||||
}
|
||||
}
|
||||
28
app/Http/Middleware/NormalizeUsername.php
Normal file
28
app/Http/Middleware/NormalizeUsername.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\UsernamePolicy;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NormalizeUsername
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$payload = $request->all();
|
||||
|
||||
if (array_key_exists('username', $payload)) {
|
||||
$payload['username'] = UsernamePolicy::normalize((string) $payload['username']);
|
||||
}
|
||||
|
||||
if (array_key_exists('old_username', $payload)) {
|
||||
$payload['old_username'] = UsernamePolicy::normalize((string) $payload['old_username']);
|
||||
}
|
||||
|
||||
$request->merge($payload);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -16,7 +17,7 @@ class ProfileUpdateRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => ['sometimes', 'string', 'max:255'],
|
||||
'username' => ['sometimes', ...UsernameRequest::rulesFor((int) $this->user()->id)],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -42,4 +43,13 @@ class ProfileUpdateRequest extends FormRequest
|
||||
'photo' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('username')) {
|
||||
$this->merge([
|
||||
'username' => UsernamePolicy::normalize((string) $this->input('username')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
73
app/Http/Requests/UsernameRequest.php
Normal file
73
app/Http/Requests/UsernameRequest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UsernameRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('username')) {
|
||||
$this->merge([
|
||||
'username' => UsernamePolicy::normalize((string) $this->input('username')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => self::rulesFor($this->resolveIgnoreUserId()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public static function rulesFor(?int $ignoreUserId = null): array
|
||||
{
|
||||
return [
|
||||
...self::formatRules(),
|
||||
Rule::unique(User::class, 'username')->ignore($ignoreUserId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public static function formatRules(): array
|
||||
{
|
||||
return [
|
||||
'required',
|
||||
'string',
|
||||
'min:' . UsernamePolicy::min(),
|
||||
'max:' . UsernamePolicy::max(),
|
||||
'regex:' . UsernamePolicy::regex(),
|
||||
Rule::notIn(UsernamePolicy::reserved()),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveIgnoreUserId(): ?int
|
||||
{
|
||||
$user = $this->user();
|
||||
if ($user) {
|
||||
return (int) $user->id;
|
||||
}
|
||||
|
||||
$routeUserId = $this->route('id') ?? $this->route('user');
|
||||
if (is_numeric($routeUserId)) {
|
||||
return (int) $routeUserId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
62
app/Mail/RegistrationVerificationMail.php
Normal file
62
app/Mail/RegistrationVerificationMail.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RegistrationVerificationMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
public array $backoff = [60, 300, 900];
|
||||
|
||||
public function __construct(public readonly string $token)
|
||||
{
|
||||
$this->onQueue('mail');
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Verify your Skinbase email',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
$appUrl = rtrim((string) config('app.url', 'http://localhost'), '/');
|
||||
|
||||
return new Content(
|
||||
view: 'emails.registration-verification',
|
||||
with: [
|
||||
'verificationUrl' => url('/verify/'.$this->token),
|
||||
'expiresInHours' => 24,
|
||||
'supportUrl' => $appUrl . '/support',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::warning('registration verification mail job failed', [
|
||||
'token_prefix' => substr($this->token, 0, 12),
|
||||
'message' => $exception->getMessage(),
|
||||
'class' => get_class($exception),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class ForumCategory extends Model
|
||||
{
|
||||
@@ -14,13 +19,77 @@ class ForumCategory extends Model
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
public function parent()
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ForumCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function threads()
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(ForumCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function threads(): HasMany
|
||||
{
|
||||
return $this->hasMany(ForumThread::class, 'category_id');
|
||||
}
|
||||
|
||||
public function postsThroughThreads(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(
|
||||
ForumPost::class,
|
||||
ForumThread::class,
|
||||
'category_id',
|
||||
'thread_id',
|
||||
'id',
|
||||
'id'
|
||||
);
|
||||
}
|
||||
|
||||
public function lastThread(): HasOne
|
||||
{
|
||||
return $this->hasOne(ForumThread::class, 'category_id')->latestOfMany('last_post_at');
|
||||
}
|
||||
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('position')->orderBy('id');
|
||||
}
|
||||
|
||||
public function scopeRoots(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('parent_id');
|
||||
}
|
||||
|
||||
public function scopeWithForumStats(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->withCount(['threads as thread_count'])
|
||||
->withCount(['postsThroughThreads as post_count'])
|
||||
->with(['lastThread' => function ($relationQuery) {
|
||||
$relationQuery->select([
|
||||
'forum_threads.id',
|
||||
'forum_threads.category_id',
|
||||
'forum_threads.last_post_at',
|
||||
'forum_threads.updated_at',
|
||||
]);
|
||||
}]);
|
||||
}
|
||||
|
||||
public function getPreviewImageAttribute(): string
|
||||
{
|
||||
$slug = (string) ($this->slug ?? '');
|
||||
$map = (array) config('forum.preview_images.map', []);
|
||||
$default = (string) config('forum.preview_images.default', '/images/forum/default.jpg');
|
||||
|
||||
if ($slug !== '' && !empty($map[$slug])) {
|
||||
return (string) $map[$slug];
|
||||
}
|
||||
|
||||
if ($slug !== '') {
|
||||
return '/images/forum/defaults/' . $slug . '.jpg';
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
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 ForumPost extends Model
|
||||
@@ -18,16 +21,42 @@ class ForumPost extends Model
|
||||
public $incrementing = true;
|
||||
|
||||
protected $casts = [
|
||||
'is_edited' => 'boolean',
|
||||
'edited_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function thread()
|
||||
public function thread(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ForumThread::class, 'thread_id');
|
||||
}
|
||||
|
||||
public function attachments()
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ForumAttachment::class, 'post_id');
|
||||
}
|
||||
|
||||
public function scopeInThread(Builder $query, int $threadId): Builder
|
||||
{
|
||||
return $query->where('thread_id', $threadId);
|
||||
}
|
||||
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopePinned(Builder $query): Builder
|
||||
{
|
||||
return $query->whereHas('thread', fn (Builder $threadQuery) => $threadQuery->where('is_pinned', true));
|
||||
}
|
||||
|
||||
public function scopeRecent(Builder $query): Builder
|
||||
{
|
||||
return $query->orderByDesc('created_at')->orderByDesc('id');
|
||||
}
|
||||
}
|
||||
|
||||
40
app/Models/ForumPostReport.php
Normal file
40
app/Models/ForumPostReport.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ForumPostReport extends Model
|
||||
{
|
||||
protected $table = 'forum_post_reports';
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'thread_id',
|
||||
'reporter_user_id',
|
||||
'reason',
|
||||
'status',
|
||||
'source_url',
|
||||
'reported_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'reported_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ForumPost::class, 'post_id');
|
||||
}
|
||||
|
||||
public function thread(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ForumThread::class, 'thread_id');
|
||||
}
|
||||
|
||||
public function reporter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reporter_user_id');
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
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 ForumThread extends Model
|
||||
@@ -18,16 +21,43 @@ class ForumThread extends Model
|
||||
public $incrementing = true;
|
||||
|
||||
protected $casts = [
|
||||
'is_locked' => 'boolean',
|
||||
'is_pinned' => 'boolean',
|
||||
'last_post_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function category()
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ForumCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function posts()
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(ForumPost::class, 'thread_id');
|
||||
}
|
||||
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return $query->where('visibility', 'public');
|
||||
}
|
||||
|
||||
public function scopePinned(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_pinned', true);
|
||||
}
|
||||
|
||||
public function scopeRecent(Builder $query): Builder
|
||||
{
|
||||
return $query->orderByDesc('last_post_at')->orderByDesc('id');
|
||||
}
|
||||
|
||||
public function scopeInCategory(Builder $query, int $categoryId): Builder
|
||||
{
|
||||
return $query->where('category_id', $categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,13 @@ class User extends Authenticatable
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'username',
|
||||
'username_changed_at',
|
||||
'onboarding_step',
|
||||
'name',
|
||||
'email',
|
||||
'is_active',
|
||||
'needs_password_reset',
|
||||
'password',
|
||||
'role',
|
||||
];
|
||||
@@ -46,6 +51,7 @@ class User extends Authenticatable
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'username_changed_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
|
||||
@@ -11,6 +11,9 @@ use App\Services\Upload\UploadDraftService;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -30,7 +33,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureAuthRateLimiters();
|
||||
$this->configureUploadRateLimiters();
|
||||
$this->configureMailFailureLogging();
|
||||
|
||||
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
|
||||
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
|
||||
@@ -84,6 +89,37 @@ class AppServiceProvider extends ServiceProvider
|
||||
});
|
||||
}
|
||||
|
||||
private function configureAuthRateLimiters(): void
|
||||
{
|
||||
RateLimiter::for('register', function (Request $request): array {
|
||||
$emailKey = strtolower((string) $request->input('email', 'unknown'));
|
||||
$ipLimit = (int) config('antispam.register.ip_per_minute', 20);
|
||||
$emailLimit = (int) config('antispam.register.email_per_minute', 6);
|
||||
|
||||
return [
|
||||
Limit::perMinute($ipLimit)->by('register:ip:' . $request->ip()),
|
||||
Limit::perMinute($emailLimit)->by('register:email:' . $emailKey),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function configureMailFailureLogging(): void
|
||||
{
|
||||
Event::listen(JobFailed::class, function (JobFailed $event): void {
|
||||
if (! str_contains(strtolower($event->job->resolveName()), 'sendqueuedmailable')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::warning('mail delivery failed', [
|
||||
'transport' => config('mail.default'),
|
||||
'job_name' => $event->job->resolveName(),
|
||||
'queue' => $event->job->getQueue(),
|
||||
'connection' => $event->connectionName,
|
||||
'exception' => $event->exception->getMessage(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private function configureUploadRateLimiters(): void
|
||||
{
|
||||
RateLimiter::for('uploads-init', function (Request $request): array {
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Services;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\ArtworkFeature;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Contracts\Pagination\CursorPaginator;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
@@ -220,14 +219,48 @@ class ArtworkService
|
||||
}
|
||||
}
|
||||
|
||||
$categoryIds = $this->categoryAndDescendantIds($current);
|
||||
|
||||
$query = $this->browseQuery($sort)
|
||||
->whereHas('categories', function ($q) use ($current) {
|
||||
$q->where('categories.id', $current->id);
|
||||
->whereHas('categories', function ($q) use ($categoryIds) {
|
||||
$q->whereIn('categories.id', $categoryIds);
|
||||
});
|
||||
|
||||
return $query->cursorPaginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect category id plus all descendant category ids.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function categoryAndDescendantIds(Category $category): array
|
||||
{
|
||||
$allIds = [(int) $category->id];
|
||||
$frontier = [(int) $category->id];
|
||||
|
||||
while (! empty($frontier)) {
|
||||
$children = Category::whereIn('parent_id', $frontier)
|
||||
->pluck('id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
if (empty($children)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$newIds = array_values(array_diff($children, $allIds));
|
||||
if (empty($newIds)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$allIds = array_values(array_unique(array_merge($allIds, $newIds)));
|
||||
$frontier = $newIds;
|
||||
}
|
||||
|
||||
return $allIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured artworks ordered by featured_at DESC, optionally filtered by type.
|
||||
* Uses artwork_features table and applies public/approved/published filters.
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -120,19 +119,16 @@ class LegacyService
|
||||
public function forumNews(): array
|
||||
{
|
||||
try {
|
||||
return DB::table('forum_topics as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
|
||||
->select(
|
||||
't1.topic_id',
|
||||
't1.topic',
|
||||
't1.views',
|
||||
't1.post_date',
|
||||
't1.preview',
|
||||
't2.uname'
|
||||
)
|
||||
->where('t1.root_id', 2876)
|
||||
->where('t1.privilege', '<', 4)
|
||||
->orderByDesc('t1.post_date')
|
||||
return DB::table('forum_threads as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
|
||||
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
|
||||
->selectRaw('t1.id as topic_id, t1.title as topic, t1.views, t1.created_at as post_date, t1.content as preview, COALESCE(t2.name, ?) as uname', ['Unknown'])
|
||||
->whereNull('t1.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->where('t1.category_id', 2876)
|
||||
->orWhereIn('c.slug', ['news', 'forum-news']);
|
||||
})
|
||||
->orderByDesc('t1.created_at')
|
||||
->limit(8)
|
||||
->get()
|
||||
->toArray();
|
||||
@@ -170,17 +166,25 @@ class LegacyService
|
||||
public function latestForumActivity(): array
|
||||
{
|
||||
try {
|
||||
return DB::table('forum_topics as t1')
|
||||
->select(
|
||||
't1.topic_id',
|
||||
't1.topic',
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
|
||||
)
|
||||
->where('t1.root_id', '<>', 0)
|
||||
->where('t1.root_id', '<>', 2876)
|
||||
->where('t1.privilege', '<', 4)
|
||||
->orderByDesc('t1.last_update')
|
||||
->orderByDesc('t1.post_date')
|
||||
return DB::table('forum_threads as t1')
|
||||
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
|
||||
->leftJoin('forum_posts as p', function ($join) {
|
||||
$join->on('p.thread_id', '=', 't1.id')
|
||||
->whereNull('p.deleted_at');
|
||||
})
|
||||
->selectRaw('t1.id as topic_id, t1.title as topic, COUNT(p.id) AS numPosts')
|
||||
->whereNull('t1.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->where('t1.category_id', '<>', 2876)
|
||||
->orWhereNull('t1.category_id');
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNull('c.slug')
|
||||
->orWhereNotIn('c.slug', ['news', 'forum-news']);
|
||||
})
|
||||
->groupBy('t1.id', 't1.title')
|
||||
->orderByDesc('t1.last_post_at')
|
||||
->orderByDesc('t1.created_at')
|
||||
->limit(10)
|
||||
->get()
|
||||
->toArray();
|
||||
@@ -266,7 +270,7 @@ class LegacyService
|
||||
$row->encoded = $encoded;
|
||||
// Prefer new files.skinbase.org when possible
|
||||
try {
|
||||
$art = \App\Models\Artwork::find($row->id);
|
||||
$art = Artwork::find($row->id);
|
||||
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
|
||||
$row->thumb_url = $present['url'];
|
||||
$row->thumb_srcset = $present['srcset'];
|
||||
@@ -402,126 +406,6 @@ class LegacyService
|
||||
];
|
||||
}
|
||||
|
||||
public function forumIndex()
|
||||
{
|
||||
try {
|
||||
$topics = DB::table('forum_topics as t')
|
||||
->select(
|
||||
't.topic_id',
|
||||
't.topic',
|
||||
't.discuss',
|
||||
't.last_update',
|
||||
't.privilege',
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id IN (SELECT topic_id FROM forum_topics st WHERE st.root_id = t.topic_id)) AS num_posts'),
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_topics st WHERE st.root_id = t.topic_id) AS num_subtopics')
|
||||
)
|
||||
->where('t.root_id', 0)
|
||||
->where('t.privilege', '<', 4)
|
||||
->orderByDesc('t.last_update')
|
||||
->limit(100)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$topics = collect();
|
||||
}
|
||||
|
||||
return [
|
||||
'topics' => $topics,
|
||||
'page_title' => 'Forum',
|
||||
'page_meta_description' => 'Skinbase forum threads.',
|
||||
'page_meta_keywords' => 'forum, discussions, topics, skinbase',
|
||||
];
|
||||
}
|
||||
|
||||
public function forumTopic(int $topic_id, int $page = 1)
|
||||
{
|
||||
try {
|
||||
$topic = DB::table('forum_topics')->where('topic_id', $topic_id)->first();
|
||||
} catch (\Throwable $e) {
|
||||
$topic = null;
|
||||
}
|
||||
|
||||
if (! $topic) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$subtopics = DB::table('forum_topics as t')
|
||||
->leftJoin('users as u', 't.user_id', '=', 'u.user_id')
|
||||
->select(
|
||||
't.topic_id',
|
||||
't.topic',
|
||||
't.discuss',
|
||||
't.post_date',
|
||||
't.last_update',
|
||||
'u.uname',
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id = t.topic_id) AS num_posts')
|
||||
)
|
||||
->where('t.root_id', $topic->topic_id)
|
||||
->orderByDesc('t.last_update')
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$subtopics = null;
|
||||
}
|
||||
|
||||
if ($subtopics && $subtopics->total() > 0) {
|
||||
return [
|
||||
'type' => 'subtopics',
|
||||
'topic' => $topic,
|
||||
'subtopics' => $subtopics,
|
||||
'page_title' => $topic->topic,
|
||||
'page_meta_description' => \Illuminate\Support\Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160),
|
||||
'page_meta_keywords' => 'forum, topic, skinbase',
|
||||
];
|
||||
}
|
||||
|
||||
$sort = strtolower(request()->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
try {
|
||||
$posts = DB::table('forum_posts as p')
|
||||
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
|
||||
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
|
||||
->where('p.topic_id', $topic->topic_id)
|
||||
->orderBy('p.post_date', $sort)
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$posts = null;
|
||||
}
|
||||
|
||||
if (! $posts || $posts->total() === 0) {
|
||||
try {
|
||||
$posts = DB::table('forum_posts as p')
|
||||
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
|
||||
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
|
||||
->where('p.tid', $topic->topic_id)
|
||||
->orderBy('p.post_date', $sort)
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$posts = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure $posts is always a LengthAwarePaginator so views can iterate and render pagination safely
|
||||
if (! $posts) {
|
||||
$currentPage = max(1, (int) request()->query('page', $page));
|
||||
$items = collect();
|
||||
$posts = new LengthAwarePaginator($items, 0, 50, $currentPage, [
|
||||
'path' => Paginator::resolveCurrentPath(),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'posts',
|
||||
'topic' => $topic,
|
||||
'posts' => $posts,
|
||||
'page_title' => $topic->topic,
|
||||
'page_meta_description' => \Illuminate\Support\Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160),
|
||||
'page_meta_keywords' => 'forum, topic, skinbase',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single artwork by id with author and category
|
||||
* Returns null on failure.
|
||||
@@ -555,7 +439,7 @@ class LegacyService
|
||||
$thumb_600 = $appUrl . '/files/thumb/' . $nid_new . '/600_' . $row->id . '.jpg';
|
||||
// Prefer new CDN when possible
|
||||
try {
|
||||
$art = \App\Models\Artwork::find($row->id);
|
||||
$art = Artwork::find($row->id);
|
||||
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
|
||||
$thumb_file = $present['url'];
|
||||
$thumb_file_300 = $present['srcset'] ? explode(' ', explode(',', $present['srcset'])[0])[0] : ($present['url'] ?? null);
|
||||
|
||||
51
app/Services/Security/RecaptchaVerifier.php
Normal file
51
app/Services/Security/RecaptchaVerifier.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Security;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RecaptchaVerifier
|
||||
{
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (bool) config('services.recaptcha.enabled', false);
|
||||
}
|
||||
|
||||
public function verify(string $token, ?string $ip = null): bool
|
||||
{
|
||||
if (! $this->isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$secret = (string) config('services.recaptcha.secret', '');
|
||||
if ($secret === '' || $token === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var \Illuminate\Http\Client\Response $response */
|
||||
$response = Http::asForm()
|
||||
->timeout((int) config('services.recaptcha.timeout', 5))
|
||||
->post((string) config('services.recaptcha.verify_url'), [
|
||||
'secret' => $secret,
|
||||
'response' => $token,
|
||||
'remoteip' => $ip,
|
||||
]);
|
||||
|
||||
if ($response->status() < 200 || $response->status() >= 300) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response->body(), true);
|
||||
|
||||
return (bool) data_get(is_array($payload) ? $payload : [], 'success', false);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('recaptcha verification request failed', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/Services/UsernameApprovalService.php
Normal file
48
app/Services/UsernameApprovalService.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class UsernameApprovalService
|
||||
{
|
||||
public function submit(?User $user, string $username, string $context, array $payload = []): ?int
|
||||
{
|
||||
if (! Schema::hasTable('username_approval_requests')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$similar = UsernamePolicy::similarReserved($normalized);
|
||||
if ($similar === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$existingId = DB::table('username_approval_requests')
|
||||
->where('requested_username', $normalized)
|
||||
->where('context', $context)
|
||||
->where('status', 'pending')
|
||||
->when($user !== null, fn ($q) => $q->where('user_id', (int) $user->id), fn ($q) => $q->whereNull('user_id'))
|
||||
->value('id');
|
||||
|
||||
if ($existingId) {
|
||||
return (int) $existingId;
|
||||
}
|
||||
|
||||
return (int) DB::table('username_approval_requests')->insertGetId([
|
||||
'user_id' => $user?->id,
|
||||
'requested_username' => $normalized,
|
||||
'context' => $context,
|
||||
'similar_to' => $similar,
|
||||
'status' => 'pending',
|
||||
'payload' => $payload === [] ? null : json_encode($payload),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,13 @@ class AvatarUrl
|
||||
return self::default();
|
||||
}
|
||||
|
||||
$base = rtrim((string) config('cdn.avatar_url', 'https://file.skinbase.org'), '/');
|
||||
$base = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
|
||||
|
||||
return sprintf('%s/avatars/%d/%d.webp?v=%s', $base, $userId, $size, $avatarHash);
|
||||
// Use hash-based path: avatars/ab/cd/{hash}/{size}.webp?v={hash}
|
||||
$p1 = substr($avatarHash, 0, 2);
|
||||
$p2 = substr($avatarHash, 2, 2);
|
||||
|
||||
return sprintf('%s/avatars/%s/%s/%s/%d.webp?v=%s', $base, $p1, $p2, $avatarHash, $size, $avatarHash);
|
||||
}
|
||||
|
||||
public static function default(): string
|
||||
|
||||
36
app/Support/ForumPostContent.php
Normal file
36
app/Support/ForumPostContent.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class ForumPostContent
|
||||
{
|
||||
public static function render(?string $raw): string
|
||||
{
|
||||
$content = (string) ($raw ?? '');
|
||||
|
||||
if ($content === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$allowedTags = '<p><br><strong><em><b><i><u><ul><ol><li><blockquote><code><pre><a><img>';
|
||||
$sanitized = strip_tags($content, $allowedTags);
|
||||
|
||||
$sanitized = preg_replace('/\son\w+\s*=\s*"[^"]*"/i', '', $sanitized) ?? $sanitized;
|
||||
$sanitized = preg_replace('/\son\w+\s*=\s*\'[^\']*\'/i', '', $sanitized) ?? $sanitized;
|
||||
$sanitized = preg_replace('/\s(href|src)\s*=\s*"\s*javascript:[^"]*"/i', ' $1="#"', $sanitized) ?? $sanitized;
|
||||
$sanitized = preg_replace('/\s(href|src)\s*=\s*\'\s*javascript:[^\']*\'/i', ' $1="#"', $sanitized) ?? $sanitized;
|
||||
|
||||
$linked = preg_replace_callback(
|
||||
'/(?<!["\'>])(https?:\/\/[^\s<]+)/i',
|
||||
static function (array $matches): string {
|
||||
$url = $matches[1] ?? '';
|
||||
$escapedUrl = e($url);
|
||||
|
||||
return '<a href="' . $escapedUrl . '" target="_blank" rel="noopener noreferrer" class="text-sky-300 hover:text-sky-200 underline">' . $escapedUrl . '</a>';
|
||||
},
|
||||
$sanitized,
|
||||
);
|
||||
|
||||
return (string) ($linked ?? $sanitized);
|
||||
}
|
||||
}
|
||||
128
app/Support/UsernamePolicy.php
Normal file
128
app/Support/UsernamePolicy.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class UsernamePolicy
|
||||
{
|
||||
public static function min(): int
|
||||
{
|
||||
return (int) config('usernames.min', 3);
|
||||
}
|
||||
|
||||
public static function max(): int
|
||||
{
|
||||
return (int) config('usernames.max', 20);
|
||||
}
|
||||
|
||||
public static function regex(): string
|
||||
{
|
||||
return (string) config('usernames.regex', '/^[a-zA-Z0-9_-]+$/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function reserved(): array
|
||||
{
|
||||
return array_values(array_unique(array_map(static fn (string $v): string => strtolower(trim($v)), (array) config('usernames.reserved', []))));
|
||||
}
|
||||
|
||||
public static function normalize(string $value): string
|
||||
{
|
||||
return strtolower(trim($value));
|
||||
}
|
||||
|
||||
public static function sanitizeLegacy(string $value): string
|
||||
{
|
||||
$value = Str::ascii($value);
|
||||
$value = strtolower(trim($value));
|
||||
$value = preg_replace('/[^a-z0-9_-]+/', '_', $value) ?? '';
|
||||
$value = trim($value, '_-');
|
||||
|
||||
if ($value === '') {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
return substr($value, 0, self::max());
|
||||
}
|
||||
|
||||
public static function isReserved(string $username): bool
|
||||
{
|
||||
return in_array(self::normalize($username), self::reserved(), true);
|
||||
}
|
||||
|
||||
public static function similarReserved(string $username): ?string
|
||||
{
|
||||
$normalized = self::normalize($username);
|
||||
$reduced = self::reduceForSimilarity($normalized);
|
||||
$threshold = (int) config('usernames.similarity_threshold', 2);
|
||||
|
||||
foreach (self::reserved() as $reserved) {
|
||||
if (levenshtein($reduced, self::reduceForSimilarity($reserved)) <= $threshold) {
|
||||
return $reserved;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function hasApprovedOverride(string $username, ?int $userId = null): bool
|
||||
{
|
||||
if (! Schema::hasTable('username_approval_requests')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalized = self::normalize($username);
|
||||
|
||||
return DB::table('username_approval_requests')
|
||||
->where('requested_username', $normalized)
|
||||
->where('status', 'approved')
|
||||
->when($userId !== null, fn ($q) => $q->where(function ($sub) use ($userId) {
|
||||
$sub->where('user_id', $userId)->orWhereNull('user_id');
|
||||
}))
|
||||
->exists();
|
||||
}
|
||||
|
||||
public static function uniqueCandidate(string $base, ?int $ignoreUserId = null): string
|
||||
{
|
||||
$base = self::sanitizeLegacy($base);
|
||||
if ($base === '' || self::isReserved($base) || self::similarReserved($base) !== null) {
|
||||
$base = 'user';
|
||||
}
|
||||
|
||||
$max = self::max();
|
||||
$candidate = substr($base, 0, $max);
|
||||
$suffix = 1;
|
||||
|
||||
while (self::exists($candidate, $ignoreUserId) || self::isReserved($candidate) || self::similarReserved($candidate) !== null) {
|
||||
$suffixStr = (string) $suffix;
|
||||
$prefixLen = max(1, $max - strlen($suffixStr));
|
||||
$candidate = substr($base, 0, $prefixLen) . $suffixStr;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private static function exists(string $username, ?int $ignoreUserId = null): bool
|
||||
{
|
||||
$query = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)]);
|
||||
if ($ignoreUserId !== null) {
|
||||
$query->where('id', '!=', $ignoreUserId);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
private static function reduceForSimilarity(string $value): string
|
||||
{
|
||||
return preg_replace('/[0-9_-]+/', '', strtolower($value)) ?? strtolower($value);
|
||||
}
|
||||
}
|
||||
@@ -852,7 +852,7 @@ index b1b1cd0..9c70607 100644
|
||||
|
||||
return [
|
||||
'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'),
|
||||
+ 'avatar_url' => env('AVATAR_CDN_URL', 'https://file.skinbase.org'),
|
||||
+ 'avatar_url' => env('AVATAR_CDN_URL', 'https://files.skinbase.org'),
|
||||
];
|
||||
diff --git a/resources/views/components/avatar.blade.php b/resources/views/components/avatar.blade.php
|
||||
index 0b90950..bae1a02 100644
|
||||
|
||||
@@ -18,6 +18,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
|
||||
$middleware->alias([
|
||||
'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class,
|
||||
'ensure.onboarding.complete' => \App\Http\Middleware\EnsureOnboardingComplete::class,
|
||||
'normalize.username' => \App\Http\Middleware\NormalizeUsername::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
||||
9
config/antispam.php
Normal file
9
config/antispam.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'register' => [
|
||||
'ip_per_minute' => (int) env('REGISTER_IP_PER_MINUTE', 20),
|
||||
'email_per_minute' => (int) env('REGISTER_EMAIL_PER_MINUTE', 6),
|
||||
'resend_cooldown_seconds' => (int) env('REGISTER_RESEND_COOLDOWN_SECONDS', 60),
|
||||
],
|
||||
];
|
||||
@@ -4,5 +4,5 @@ declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'),
|
||||
'avatar_url' => env('AVATAR_CDN_URL', 'https://file.skinbase.org'),
|
||||
'avatar_url' => env('AVATAR_CDN_URL', 'https://files.skinbase.org'),
|
||||
];
|
||||
|
||||
@@ -65,12 +65,12 @@ return [
|
||||
|
||||
'legacy' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('LEGACY_DB_URL'),
|
||||
'host' => env('LEGACY_DB_HOST', env('DB_HOST', '127.0.0.1')),
|
||||
'port' => env('LEGACY_DB_PORT', env('DB_PORT', '3306')),
|
||||
'database' => env('LEGACY_DB_DATABASE', 'projekti_old_skinbase'),
|
||||
'username' => env('LEGACY_DB_USERNAME', env('DB_USERNAME', 'root')),
|
||||
'password' => env('LEGACY_DB_PASSWORD', env('DB_PASSWORD', '')),
|
||||
'url' => env('DB_LEGACY_URL', env('LEGACY_DB_URL')),
|
||||
'host' => env('DB_LEGACY_HOST', env('LEGACY_DB_HOST', env('DB_HOST', '127.0.0.1'))),
|
||||
'port' => env('DB_LEGACY_PORT', env('LEGACY_DB_PORT', env('DB_PORT', '3306'))),
|
||||
'database' => env('DB_LEGACY_DATABASE', env('LEGACY_DB_DATABASE', 'projekti_old_skinbase')),
|
||||
'username' => env('DB_LEGACY_USERNAME', env('LEGACY_DB_USERNAME', env('DB_USERNAME', 'root'))),
|
||||
'password' => env('DB_LEGACY_PASSWORD', env('LEGACY_DB_PASSWORD', env('DB_PASSWORD', ''))),
|
||||
'unix_socket' => env('LEGACY_DB_SOCKET', ''),
|
||||
'charset' => env('LEGACY_DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('LEGACY_DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
|
||||
10
config/forum.php
Normal file
10
config/forum.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'preview_images' => [
|
||||
'default' => '/images/forum/default.jpg',
|
||||
'map' => [
|
||||
// 'announcements' => '/images/forum/defaults/announcements.jpg',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -39,4 +39,12 @@ return [
|
||||
'driver' => env('IMAGE_DRIVER', 'gd'),
|
||||
],
|
||||
|
||||
'recaptcha' => [
|
||||
'enabled' => env('RECAPTCHA_ENABLED', false),
|
||||
'site_key' => env('RECAPTCHA_SITE_KEY'),
|
||||
'secret' => env('RECAPTCHA_SECRET_KEY'),
|
||||
'verify_url' => env('RECAPTCHA_VERIFY_URL', 'https://www.google.com/recaptcha/api/siteverify'),
|
||||
'timeout' => (int) env('RECAPTCHA_TIMEOUT', 5),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
44
config/usernames.php
Normal file
44
config/usernames.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'min' => 3,
|
||||
'max' => 20,
|
||||
'regex' => '/^[a-zA-Z0-9_-]+$/',
|
||||
'rename_cooldown_days' => 90,
|
||||
'similarity_threshold' => 2,
|
||||
'reserved' => [
|
||||
'admin',
|
||||
'root',
|
||||
'support',
|
||||
'staff',
|
||||
'moderator',
|
||||
'mod',
|
||||
'system',
|
||||
'api',
|
||||
'www',
|
||||
'mail',
|
||||
'ftp',
|
||||
'skinbase',
|
||||
'official',
|
||||
'help',
|
||||
'security',
|
||||
'login',
|
||||
'register',
|
||||
'auth',
|
||||
'dashboard',
|
||||
'settings',
|
||||
'forum',
|
||||
'gallery',
|
||||
'upload',
|
||||
'search',
|
||||
'static',
|
||||
'cdn',
|
||||
'assets',
|
||||
'images',
|
||||
'profile',
|
||||
'user',
|
||||
'users',
|
||||
],
|
||||
];
|
||||
@@ -23,7 +23,17 @@ class UserFactory extends Factory
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$username = Str::lower(Str::slug(fake()->unique()->userName(), '-'));
|
||||
$username = preg_replace('/[^a-z0-9_-]/', '-', $username) ?: 'user';
|
||||
$username = substr(trim($username, '-_'), 0, 20);
|
||||
if ($username === '') {
|
||||
$username = 'user' . fake()->unique()->numberBetween(100, 99999);
|
||||
}
|
||||
|
||||
return [
|
||||
'username' => $username,
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('forum_post_reports', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('post_id')->constrained('forum_posts')->cascadeOnDelete();
|
||||
$table->foreignId('thread_id')->constrained('forum_threads')->cascadeOnDelete();
|
||||
$table->foreignId('reporter_user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('status', 20)->default('open');
|
||||
$table->string('reason', 500)->nullable();
|
||||
$table->string('source_url', 1024)->nullable();
|
||||
$table->timestamp('reported_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['post_id', 'reporter_user_id'], 'forum_post_reports_unique_reporter_per_post');
|
||||
$table->index(['thread_id', 'status']);
|
||||
$table->index('reported_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('forum_post_reports');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasColumn('users', 'username')) {
|
||||
$this->normalizeExistingUsernames();
|
||||
}
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('users', 'username')) {
|
||||
$table->string('username', 20)->nullable()->change();
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('users', 'username_changed_at')) {
|
||||
$table->timestamp('username_changed_at')->nullable()->after('username');
|
||||
}
|
||||
});
|
||||
|
||||
$sm = Schema::getConnection()->getSchemaBuilder();
|
||||
$indexes = $sm->getIndexes('users');
|
||||
$hasUsernameUnique = collect($indexes)->contains(function ($index): bool {
|
||||
$columns = array_map('strtolower', (array) ($index['columns'] ?? []));
|
||||
return (bool) ($index['unique'] ?? false) && $columns === ['username'];
|
||||
});
|
||||
|
||||
if (! $hasUsernameUnique && Schema::hasColumn('users', 'username')) {
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->unique('username', 'users_username_unique');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('users', 'username_changed_at')) {
|
||||
$table->dropColumn('username_changed_at');
|
||||
}
|
||||
if (Schema::hasColumn('users', 'username')) {
|
||||
$table->string('username', 80)->nullable()->change();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function normalizeExistingUsernames(): void
|
||||
{
|
||||
$rows = DB::table('users')
|
||||
->select('id', 'username')
|
||||
->whereNotNull('username')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
$used = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$raw = strtolower(trim((string) $row->username));
|
||||
$base = preg_replace('/[^a-z0-9_-]+/', '_', $raw) ?? '';
|
||||
$base = trim($base, '_-');
|
||||
if ($base === '') {
|
||||
$base = 'user' . (int) $row->id;
|
||||
}
|
||||
|
||||
$base = substr($base, 0, 20);
|
||||
if ($base === '') {
|
||||
$base = 'user';
|
||||
}
|
||||
|
||||
$candidate = $base;
|
||||
$suffix = 1;
|
||||
|
||||
while (isset($used[$candidate])) {
|
||||
$suffixValue = (string) $suffix;
|
||||
$prefixLen = max(1, 20 - strlen($suffixValue));
|
||||
$candidate = substr($base, 0, $prefixLen) . $suffixValue;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
$used[$candidate] = true;
|
||||
$resolved[(int) $row->id] = $candidate;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
DB::table('users')
|
||||
->where('id', (int) $row->id)
|
||||
->update(['username' => 'tmpu' . (int) $row->id]);
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$final = $resolved[(int) $row->id] ?? null;
|
||||
if ($final === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('users')
|
||||
->where('id', (int) $row->id)
|
||||
->update(['username' => $final]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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('username_history', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('old_username', 20);
|
||||
$table->timestamp('changed_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'changed_at']);
|
||||
$table->index('old_username');
|
||||
});
|
||||
|
||||
Schema::create('username_redirects', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('old_username', 20)->unique();
|
||||
$table->string('new_username', 20);
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('new_username');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('username_redirects');
|
||||
Schema::dropIfExists('username_history');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('username_approval_requests', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('requested_username', 20);
|
||||
$table->string('context', 32)->default('unknown');
|
||||
$table->string('similar_to', 20)->nullable();
|
||||
$table->string('status', 20)->default('pending');
|
||||
$table->json('payload')->nullable();
|
||||
$table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('reviewed_at')->nullable();
|
||||
$table->text('review_note')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status', 'created_at']);
|
||||
$table->index(['requested_username', 'status']);
|
||||
$table->index(['user_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('username_approval_requests');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
<?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('users', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('users', 'email_verified_at')) {
|
||||
$table->timestamp('email_verified_at')->nullable()->after('email');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('users', 'onboarding_step')) {
|
||||
$table->enum('onboarding_step', ['email', 'verified', 'password', 'username', 'complete'])
|
||||
->nullable()
|
||||
->after('email_verified_at');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('users', 'username_changed_at')) {
|
||||
$table->timestamp('username_changed_at')->nullable()->after('username');
|
||||
}
|
||||
});
|
||||
|
||||
if (! Schema::hasTable('user_verification_tokens')) {
|
||||
Schema::create('user_verification_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('token', 128)->unique();
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'expires_at']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('user_verification_tokens')) {
|
||||
Schema::drop('user_verification_tokens');
|
||||
}
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('users', 'onboarding_step')) {
|
||||
$table->dropColumn('onboarding_step');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('users', 'username')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('users')
|
||||
->whereNotNull('username')
|
||||
->update(['username' => DB::raw('LOWER(username)')]);
|
||||
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
try {
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement(
|
||||
'ALTER TABLE users ADD CONSTRAINT users_username_lowercase_check CHECK (username IS NULL OR BINARY username = LOWER(username))'
|
||||
);
|
||||
} elseif ($driver === 'pgsql') {
|
||||
DB::statement(
|
||||
'ALTER TABLE users ADD CONSTRAINT users_username_lowercase_check CHECK (username IS NULL OR username = LOWER(username))'
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if (! str_contains(strtolower($e->getMessage()), 'already exists')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
try {
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('ALTER TABLE users DROP CHECK users_username_lowercase_check');
|
||||
} elseif ($driver === 'pgsql') {
|
||||
DB::statement('ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_lowercase_check');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if (! str_contains(strtolower($e->getMessage()), 'check') && ! str_contains(strtolower($e->getMessage()), 'constraint')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,7 @@ This project serves avatars from the avatar CDN domain.
|
||||
|
||||
## Required env variables
|
||||
|
||||
- `AVATAR_CDN_URL=https://file.skinbase.org`
|
||||
- `AVATAR_CDN_URL=https://files.skinbase.org`
|
||||
- `AVATAR_DISK=s3` (production)
|
||||
- `AVATAR_WEBP_QUALITY=85`
|
||||
|
||||
@@ -12,7 +12,7 @@ This project serves avatars from the avatar CDN domain.
|
||||
|
||||
Avatars are rendered via:
|
||||
|
||||
- `https://file.skinbase.org/avatars/{user_id}/{size}.webp?v={avatar_hash}`
|
||||
- `https://files.skinbase.org/avatars/{user_id}/{size}.webp?v={avatar_hash}`
|
||||
|
||||
Sizes generated server-side:
|
||||
|
||||
|
||||
@@ -492,13 +492,50 @@ if ($('.switch').length && $.fn.bootstrapSwitch) {
|
||||
}
|
||||
|
||||
/**** Datepicker ****/
|
||||
if ($('.datepicker').length && $.fn.datepicker) {
|
||||
$('.datepicker').each(function () {
|
||||
var datepicker_inline = $(this).data('inline') ? $(this).data('inline') : false;
|
||||
$(this).datepicker({
|
||||
inline: datepicker_inline
|
||||
/**
|
||||
* Datepicker initialization helper.
|
||||
* Ensures any newly added .datepicker inside a popup/modal is initialized.
|
||||
*/
|
||||
function initDatepickers(root) {
|
||||
if (!$.fn.datepicker) return;
|
||||
root = root || document;
|
||||
var $root = $(root instanceof jQuery ? root[0] : root);
|
||||
// Initialize datepickers that don't yet have the jQuery UI marker class
|
||||
$root.find('.datepicker').each(function () {
|
||||
var $el = $(this);
|
||||
if ($el.hasClass('hasDatepicker')) return;
|
||||
var datepicker_inline = $el.data('inline') ? $el.data('inline') : false;
|
||||
$el.datepicker({ inline: datepicker_inline });
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on initial page load
|
||||
initDatepickers(document);
|
||||
|
||||
// Re-initialize when Bootstrap modals are shown (common popup pattern)
|
||||
$(document).on('shown.bs.modal', '.modal', function () {
|
||||
initDatepickers(this);
|
||||
});
|
||||
|
||||
// Observe DOM mutations and initialize datepickers for added nodes (covers custom popups)
|
||||
if (window.MutationObserver) {
|
||||
var _dpObserver = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (m) {
|
||||
m.addedNodes && Array.prototype.forEach.call(m.addedNodes, function (node) {
|
||||
if (node.nodeType !== 1) return;
|
||||
// If the added node itself has .datepicker or contains them, init
|
||||
var $node = $(node);
|
||||
if ($node.is('.datepicker') || $node.find('.datepicker').length) {
|
||||
initDatepickers(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
try {
|
||||
_dpObserver.observe(document.body, { childList: true, subtree: true });
|
||||
} catch (e) {
|
||||
// ignore observer errors on very old browsers
|
||||
}
|
||||
}
|
||||
|
||||
/**** Datetimepicker ****/
|
||||
|
||||
6
resources/js/Pages/Admin/UsernameQueue.jsx
Normal file
6
resources/js/Pages/Admin/UsernameQueue.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import AdminUsernameQueue from '../../components/admin/AdminUsernameQueue'
|
||||
|
||||
export default function UsernameQueuePage() {
|
||||
return <AdminUsernameQueue />
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import './bootstrap';
|
||||
import './username-availability';
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
import React from 'react';
|
||||
|
||||
92
resources/js/components/admin/AdminUsernameQueue.jsx
Normal file
92
resources/js/components/admin/AdminUsernameQueue.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
export default function AdminUsernameQueue() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notes, setNotes] = useState({})
|
||||
|
||||
const loadPending = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await window.axios.get('/api/admin/usernames/pending')
|
||||
setItems(Array.isArray(response?.data?.data) ? response.data.data : [])
|
||||
} catch (loadError) {
|
||||
setError(loadError?.response?.data?.message || 'Failed to load username moderation queue.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPending()
|
||||
}, [])
|
||||
|
||||
const moderate = async (id, action) => {
|
||||
try {
|
||||
const payload = { note: String(notes[id] || '') }
|
||||
await window.axios.post(`/api/admin/usernames/${id}/${action}`, payload)
|
||||
setItems((prev) => prev.filter((item) => item.id !== id))
|
||||
} catch (moderateError) {
|
||||
setError(moderateError?.response?.data?.message || `Failed to ${action} username request.`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Username moderation queue" className="mx-auto w-full max-w-5xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">Pending Username Approvals</h2>
|
||||
<button type="button" onClick={loadPending} className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? <p role="status" className="text-sm text-white/70">Loading…</p> : null}
|
||||
{error ? <p role="alert" className="mb-3 text-sm text-rose-200">{error}</p> : null}
|
||||
{!loading && items.length === 0 ? <p role="status" className="text-sm text-white/60">No pending username requests.</p> : null}
|
||||
|
||||
<ul className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{item.requested_username}</div>
|
||||
<div className="mt-1 text-xs text-white/65">Request #{item.id} · {item.context}</div>
|
||||
{item.similar_to ? <div className="mt-1 text-xs text-amber-200">Similar to reserved: {item.similar_to}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Moderation note for request ${item.id}`}
|
||||
value={notes[item.id] || ''}
|
||||
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
|
||||
placeholder="Review note"
|
||||
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moderate(item.id, 'approve')}
|
||||
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moderate(item.id, 'reject')}
|
||||
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
62
resources/js/username-availability.js
Normal file
62
resources/js/username-availability.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const debounce = (fn, wait = 350) => {
|
||||
let timeoutId
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => fn(...args), wait)
|
||||
}
|
||||
}
|
||||
|
||||
const setStatus = (target, message, tone = 'neutral') => {
|
||||
if (!target) return
|
||||
target.textContent = message || ''
|
||||
target.classList.remove('text-green-600', 'text-red-600', 'text-gray-500')
|
||||
|
||||
if (tone === 'success') target.classList.add('text-green-600')
|
||||
else if (tone === 'error') target.classList.add('text-red-600')
|
||||
else target.classList.add('text-gray-500')
|
||||
}
|
||||
|
||||
const initUsernameAvailability = () => {
|
||||
const fields = document.querySelectorAll('[data-username-field="true"]')
|
||||
|
||||
fields.forEach((field) => {
|
||||
const url = field.getAttribute('data-availability-url') || '/api/username/availability'
|
||||
const statusId = field.getAttribute('data-availability-target')
|
||||
const statusEl = statusId ? document.getElementById(statusId) : null
|
||||
|
||||
const check = debounce(async () => {
|
||||
const raw = String(field.value || '')
|
||||
const username = raw.trim().toLowerCase()
|
||||
|
||||
if (!username) {
|
||||
setStatus(statusEl, '')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus(statusEl, 'Checking availability...')
|
||||
|
||||
try {
|
||||
const response = await window.axios.get(url, { params: { username } })
|
||||
const data = response?.data || {}
|
||||
|
||||
if (data.available) {
|
||||
setStatus(statusEl, `Available: ${data.normalized || username}`, 'success')
|
||||
} else {
|
||||
setStatus(statusEl, `Taken: ${data.normalized || username}`, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.response?.status === 422) {
|
||||
const message = error?.response?.data?.errors?.username?.[0] || 'Invalid username.'
|
||||
setStatus(statusEl, message, 'error')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus(statusEl, 'Could not check availability right now.', 'error')
|
||||
}
|
||||
})
|
||||
|
||||
field.addEventListener('input', check)
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initUsernameAvailability)
|
||||
@@ -54,7 +54,7 @@
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
|
||||
<ul class="space-y-2 sb-scrollbar max-h-56 overflow-auto pr-2">
|
||||
<ul class="space-y-2 pr-2">
|
||||
@foreach($subcategories as $sub)
|
||||
<li><a class="hover:text-white {{ $category && $sub->id === $category->id ? 'font-semibold text-white' : 'text-neutral-400' }}" href="{{ $sub->url }}">{{ $sub->name }}</a></li>
|
||||
@endforeach
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<x-guest-layout>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
|
||||
</div>
|
||||
@extends('layouts.nova')
|
||||
|
||||
<form method="POST" action="{{ route('password.confirm') }}">
|
||||
@section('content')
|
||||
<div class="px-4 py-8 md:px-6 md:py-10">
|
||||
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
|
||||
<h1 class="text-2xl font-semibold text-white">Confirm Password</h1>
|
||||
<p class="mt-2 text-sm text-sb-muted">Please confirm your password before continuing.</p>
|
||||
|
||||
<form method="POST" action="{{ route('password.confirm') }}" class="mt-4">
|
||||
@csrf
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
<x-input-label for="password" :value="__('Password')" class="text-sb-muted" />
|
||||
|
||||
<x-text-input id="password" class="block mt-1 w-full"
|
||||
<x-text-input id="password" class="block mt-1 w-full bg-black/20 border-sb-line text-white"
|
||||
type="password"
|
||||
name="password"
|
||||
required autocomplete="current-password" />
|
||||
@@ -24,4 +26,6 @@
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-guest-layout>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<x-guest-layout>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
|
||||
</div>
|
||||
@extends('layouts.nova')
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="mb-4" :status="session('status')" />
|
||||
@section('content')
|
||||
<div class="px-4 py-8 md:px-6 md:py-10">
|
||||
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
|
||||
<h1 class="text-2xl font-semibold text-white">Reset Password</h1>
|
||||
<p class="mt-2 text-sm text-sb-muted">Enter your email and we'll send a link to reset your password.</p>
|
||||
|
||||
<form method="POST" action="{{ route('password.email') }}">
|
||||
<x-auth-session-status class="mt-4 mb-2 text-green-300" :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('password.email') }}" class="mt-4">
|
||||
@csrf
|
||||
|
||||
<!-- Email Address -->
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus />
|
||||
<x-input-label for="email" :value="__('Email')" class="text-sb-muted" />
|
||||
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email')" required autofocus />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
@@ -22,4 +23,6 @@
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-guest-layout>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,47 +1,49 @@
|
||||
<x-guest-layout>
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="mb-4" :status="session('status')" />
|
||||
@extends('layouts.nova')
|
||||
|
||||
<form method="POST" action="{{ route('login') }}">
|
||||
@section('content')
|
||||
<div class="px-4 py-8 md:px-6 md:py-10">
|
||||
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
|
||||
<h1 class="text-2xl font-semibold text-white">Log in</h1>
|
||||
<p class="mt-2 text-sm text-sb-muted">Sign in to continue to your Skinbase account.</p>
|
||||
|
||||
<x-auth-session-status class="mt-4 mb-2 text-green-300" :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('login') }}" class="mt-4 space-y-4">
|
||||
@csrf
|
||||
|
||||
<!-- Email Address -->
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
|
||||
<x-input-label for="email" :value="__('Email')" class="text-sb-muted" />
|
||||
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
|
||||
<x-text-input id="password" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password"
|
||||
required autocomplete="current-password" />
|
||||
|
||||
<div>
|
||||
<x-input-label for="password" :value="__('Password')" class="text-sb-muted" />
|
||||
<x-text-input id="password" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="password" name="password" required autocomplete="current-password" />
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Remember Me -->
|
||||
<div class="block mt-4">
|
||||
<div class="block mt-1">
|
||||
<label for="remember_me" class="inline-flex items-center">
|
||||
<input id="remember_me" type="checkbox" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" name="remember">
|
||||
<span class="ms-2 text-sm text-gray-600">{{ __('Remember me') }}</span>
|
||||
<input id="remember_me" type="checkbox" class="rounded border-sb-line bg-black/20 text-sb-blue shadow-sm focus:ring-sb-blue" name="remember">
|
||||
<span class="ms-2 text-sm text-sb-muted">{{ __('Remember me') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
@if (Route::has('password.request'))
|
||||
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('password.request') }}">
|
||||
<a class="underline text-sm text-sb-muted hover:text-white" href="{{ route('password.request') }}">
|
||||
{{ __('Forgot your password?') }}
|
||||
</a>
|
||||
@else
|
||||
<span></span>
|
||||
@endif
|
||||
|
||||
<x-primary-button class="ms-3">
|
||||
<x-primary-button class="justify-center">
|
||||
{{ __('Log in') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-guest-layout>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
27
resources/views/auth/partials/onboarding-progress.blade.php
Normal file
27
resources/views/auth/partials/onboarding-progress.blade.php
Normal file
@@ -0,0 +1,27 @@
|
||||
@php
|
||||
$steps = [
|
||||
'email' => 'Email',
|
||||
'verified' => 'Verified',
|
||||
'password' => 'Password',
|
||||
'complete' => 'Username',
|
||||
];
|
||||
|
||||
$currentIndex = array_search($currentStep ?? 'email', array_keys($steps), true);
|
||||
if ($currentIndex === false) {
|
||||
$currentIndex = 0;
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between text-xs sm:text-sm text-gray-600">
|
||||
@foreach($steps as $key => $label)
|
||||
@php $idx = array_search($key, array_keys($steps), true); @endphp
|
||||
<span class="{{ $idx <= $currentIndex ? 'text-gray-900 font-semibold' : '' }}">
|
||||
{{ $label }}
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="mt-2 h-2 w-full bg-gray-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gray-900 rounded-full" style="width: {{ (($currentIndex + 1) / count($steps)) * 100 }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
86
resources/views/auth/register-notice.blade.php
Normal file
86
resources/views/auth/register-notice.blade.php
Normal file
@@ -0,0 +1,86 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="px-4 py-8 md:px-6 md:py-10">
|
||||
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
|
||||
<div class="mb-4 text-sm text-sb-muted">
|
||||
<p class="font-medium text-white">Check your inbox</p>
|
||||
@if($email !== '')
|
||||
<p class="mt-1">We sent a verification link to <strong class="text-white">{{ $email }}</strong>.</p>
|
||||
<p class="mt-1">Click the link in that email to continue setup.</p>
|
||||
@else
|
||||
<p class="mt-1">Enter your email to resend verification if needed.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (session('status'))
|
||||
<div class="mb-4 rounded-md border border-green-700/60 bg-green-900/20 px-3 py-2 text-sm text-green-300">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 rounded-md border border-red-700/60 bg-red-900/20 px-3 py-2 text-sm text-red-300">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('register.resend') }}" class="space-y-4" id="resend-form" data-resend-seconds="{{ (int) $resendSeconds }}">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<x-input-label for="email" value="Email" class="text-sb-muted" />
|
||||
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email', $email)" required autocomplete="email" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
|
||||
<a class="underline text-sm text-sb-muted hover:text-white" href="{{ route('login') }}">Back to login</a>
|
||||
<a class="underline text-sm text-sb-muted hover:text-white" href="{{ route('register', ['email' => $email]) }}">Change email</a>
|
||||
</div>
|
||||
|
||||
<x-primary-button id="resend-btn" class="justify-center" type="submit">
|
||||
Resend verification email
|
||||
</x-primary-button>
|
||||
</div>
|
||||
|
||||
<p id="resend-timer" class="text-xs text-sb-muted"></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const form = document.getElementById('resend-form');
|
||||
const button = document.getElementById('resend-btn');
|
||||
const timerText = document.getElementById('resend-timer');
|
||||
|
||||
if (!form || !button || !timerText) return;
|
||||
|
||||
let remaining = parseInt(form.dataset.resendSeconds || '0', 10);
|
||||
|
||||
const render = () => {
|
||||
if (remaining > 0) {
|
||||
button.setAttribute('disabled', 'disabled');
|
||||
timerText.textContent = `You can resend in ${remaining}s.`;
|
||||
} else {
|
||||
button.removeAttribute('disabled');
|
||||
timerText.textContent = 'Did not receive it? You can resend now.';
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
if (remaining <= 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
remaining -= 1;
|
||||
render();
|
||||
|
||||
if (remaining <= 0) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
@@ -1,52 +1,41 @@
|
||||
<x-guest-layout>
|
||||
<form method="POST" action="{{ route('register') }}">
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="px-4 py-8 md:px-6 md:py-10">
|
||||
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
|
||||
<h1 class="text-2xl font-semibold text-white">Create Account</h1>
|
||||
<p class="mt-2 text-sm text-sb-muted">Start with your email. You will set your password and username after verification.</p>
|
||||
|
||||
<form method="POST" action="{{ route('register') }}" class="mt-6 space-y-4">
|
||||
@csrf
|
||||
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<x-input-label for="name" :value="__('Name')" />
|
||||
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
|
||||
<x-input-error :messages="$errors->get('name')" class="mt-2" />
|
||||
<div style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;" aria-hidden="true">
|
||||
<label for="website">Website</label>
|
||||
<input id="website" type="text" name="website" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<!-- Email Address -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" class="text-sb-muted" />
|
||||
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email', $prefillEmail ?? '')" required autofocus autocomplete="email" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
@if(config('services.recaptcha.enabled'))
|
||||
<input type="hidden" name="g-recaptcha-response" value="{{ old('g-recaptcha-response') }}" />
|
||||
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
|
||||
@endif
|
||||
|
||||
<x-text-input id="password" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password"
|
||||
required autocomplete="new-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
|
||||
|
||||
<x-text-input id="password_confirmation" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password_confirmation" required autocomplete="new-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('login') }}">
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<a class="underline text-sm text-sb-muted hover:text-white" href="{{ route('login') }}">
|
||||
{{ __('Already registered?') }}
|
||||
</a>
|
||||
|
||||
<x-primary-button class="ms-4">
|
||||
<x-primary-button class="justify-center">
|
||||
{{ __('Register') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-guest-layout>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<x-guest-layout>
|
||||
<form method="POST" action="{{ route('password.store') }}">
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="px-4 py-8 md:px-6 md:py-10">
|
||||
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
|
||||
<h1 class="text-2xl font-semibold text-white">Reset Password</h1>
|
||||
<p class="mt-2 text-sm text-sb-muted">Choose a new password for your account.</p>
|
||||
|
||||
<form method="POST" action="{{ route('password.store') }}" class="mt-4">
|
||||
@csrf
|
||||
|
||||
<!-- Password Reset Token -->
|
||||
@@ -7,23 +14,23 @@
|
||||
|
||||
<!-- Email Address -->
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
|
||||
<x-input-label for="email" :value="__('Email')" class="text-sb-muted" />
|
||||
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
<x-text-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
|
||||
<x-input-label for="password" :value="__('Password')" class="text-sb-muted" />
|
||||
<x-text-input id="password" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="password" name="password" required autocomplete="new-password" />
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
|
||||
<x-input-label for="password_confirmation" :value="__('Confirm Password')" class="text-sb-muted" />
|
||||
|
||||
<x-text-input id="password_confirmation" class="block mt-1 w-full"
|
||||
<x-text-input id="password_confirmation" class="block mt-1 w-full bg-black/20 border-sb-line text-white"
|
||||
type="password"
|
||||
name="password_confirmation" required autocomplete="new-password" />
|
||||
|
||||
@@ -36,4 +43,6 @@
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-guest-layout>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
44
resources/views/auth/setup-password.blade.php
Normal file
44
resources/views/auth/setup-password.blade.php
Normal file
@@ -0,0 +1,44 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="px-4 py-8 md:px-6 md:py-10">
|
||||
<div class="mx-auto w-full max-w-2xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
|
||||
<h1 class="text-2xl font-semibold text-white">Set Your Password</h1>
|
||||
<div class="mt-4 text-white/90">
|
||||
@include('auth.partials.onboarding-progress', ['currentStep' => 'verified'])
|
||||
|
||||
@if (session('status'))
|
||||
<div class="mb-4 rounded-md border border-green-700/60 bg-green-900/20 px-3 py-2 text-sm text-green-300">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="mb-4 text-sm text-sb-muted">
|
||||
{{ __('Create a password for ') }}<strong>{{ $email }}</strong>
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ route('setup.password.store') }}">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<x-input-label for="password" :value="__('Password')" class="text-sb-muted" />
|
||||
<x-text-input id="password" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="password" name="password" required autocomplete="new-password" />
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
<p class="mt-2 text-xs text-sb-muted">{{ __('Minimum 10 characters, include at least one number and one symbol.') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password_confirmation" :value="__('Confirm Password')" class="text-sb-muted" />
|
||||
<x-text-input id="password_confirmation" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="password" name="password_confirmation" required autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-primary-button class="w-full sm:w-auto justify-center">
|
||||
{{ __('Continue') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
52
resources/views/auth/setup-username.blade.php
Normal file
52
resources/views/auth/setup-username.blade.php
Normal file
@@ -0,0 +1,52 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="px-4 py-8 md:px-6 md:py-10">
|
||||
<div class="mx-auto w-full max-w-2xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
|
||||
<h1 class="text-2xl font-semibold text-white">Choose Username</h1>
|
||||
<div class="mt-4 text-white/90">
|
||||
@include('auth.partials.onboarding-progress', ['currentStep' => 'password'])
|
||||
|
||||
@if (session('status'))
|
||||
<div class="mb-4 rounded-md border border-green-700/60 bg-green-900/20 px-3 py-2 text-sm text-green-300">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 rounded-md border border-red-700/60 bg-red-900/20 px-3 py-2 text-sm text-red-300">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('setup.username.store') }}">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<x-input-label for="username" :value="__('Username')" class="text-sb-muted" />
|
||||
<x-text-input
|
||||
id="username"
|
||||
class="block mt-1 w-full bg-black/20 border-sb-line text-white"
|
||||
type="text"
|
||||
name="username"
|
||||
:value="old('username', $username)"
|
||||
required
|
||||
autocomplete="username"
|
||||
data-username-field="true"
|
||||
data-availability-url="{{ route('api.username.availability') }}"
|
||||
data-availability-target="setup-username-availability"
|
||||
/>
|
||||
<p id="setup-username-availability" class="mt-1 text-xs text-sb-muted"></p>
|
||||
<x-input-error :messages="$errors->get('username')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-primary-button class="w-full sm:w-auto justify-center">
|
||||
{{ __('Complete Setup') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,10 +1,13 @@
|
||||
<x-guest-layout>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
{{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
|
||||
</div>
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="px-4 py-8 md:px-6 md:py-10">
|
||||
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
|
||||
<h1 class="text-2xl font-semibold text-white">Verify Your Email</h1>
|
||||
<p class="mt-2 text-sm text-sb-muted">Before getting started, please verify your email address by clicking the link we sent you.</p>
|
||||
|
||||
@if (session('status') == 'verification-link-sent')
|
||||
<div class="mb-4 font-medium text-sm text-green-600">
|
||||
<div class="mb-4 rounded-md border border-green-700/60 bg-green-900/20 px-3 py-2 text-sm text-green-300">
|
||||
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
||||
</div>
|
||||
@endif
|
||||
@@ -23,9 +26,11 @@
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
|
||||
<button type="submit" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<button type="submit" class="underline text-sm text-sb-muted hover:text-white rounded-md">
|
||||
{{ __('Log Out') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-guest-layout>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="legacy-page">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-white">Forum</h1>
|
||||
<p class="mt-1 text-sm text-zinc-300">Browse forum sections and latest activity.</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-white/10 bg-zinc-900/70">
|
||||
<div class="border-b border-white/10 px-4 py-3 text-sm font-semibold text-zinc-100">Forum Sections</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-white/10 text-sm">
|
||||
<thead class="bg-zinc-800/60 text-zinc-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium">Section</th>
|
||||
<th class="px-4 py-3 text-center font-medium">Posts</th>
|
||||
<th class="px-4 py-3 text-center font-medium">Topics</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Last Update</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/10 text-zinc-100">
|
||||
@forelse (($topics ?? []) as $topic)
|
||||
@php
|
||||
$topicId = (int) ($topic->topic_id ?? $topic->id ?? 0);
|
||||
$topicTitle = $topic->topic ?? $topic->title ?? $topic->name ?? 'Untitled';
|
||||
$topicSlug = Str::slug($topicTitle);
|
||||
$topicUrl = $topicId > 0 ? route('legacy.forum.topic', ['topic_id' => $topicId, 'slug' => $topicSlug]) : '#';
|
||||
@endphp
|
||||
<tr class="hover:bg-white/5">
|
||||
<td class="px-4 py-3 align-top">
|
||||
<a class="font-medium text-sky-300 hover:text-sky-200" href="{{ $topicUrl }}">{{ $topicTitle }}</a>
|
||||
@if (!empty($topic->discuss))
|
||||
<div class="mt-1 text-xs text-zinc-400">{!! Str::limit(strip_tags((string) $topic->discuss), 180) !!}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-zinc-300">{{ $topic->num_posts ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-center text-zinc-300">{{ $topic->num_subtopics ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-400">
|
||||
@if (!empty($topic->last_update))
|
||||
{{ Carbon::parse($topic->last_update)->format('d.m.Y H:i') }}
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-6 text-center text-zinc-400">No forum sections available.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,69 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$headerTitle = data_get($topic ?? null, 'topic')
|
||||
?? data_get($topic ?? null, 'title')
|
||||
?? data_get($thread ?? null, 'title')
|
||||
?? 'Thread';
|
||||
$headerDesc = data_get($topic ?? null, 'discuss')
|
||||
?? data_get($thread ?? null, 'content');
|
||||
@endphp
|
||||
|
||||
<div class="legacy-page">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('legacy.forum.index') }}" class="text-sm text-sky-300 hover:text-sky-200">← Back to forum</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-white">{{ $headerTitle }}</h1>
|
||||
@if (!empty($headerDesc))
|
||||
<p class="mt-1 text-sm text-zinc-300">{!! Str::limit(strip_tags((string) $headerDesc), 260) !!}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
@forelse (($posts ?? []) as $post)
|
||||
@php
|
||||
$authorName = $post->uname ?? data_get($post, 'user.name') ?? 'Anonymous';
|
||||
$authorId = $post->user_id ?? data_get($post, 'user.id');
|
||||
$postBody = $post->message ?? $post->content ?? '';
|
||||
$postedAt = $post->post_date ?? $post->created_at ?? null;
|
||||
@endphp
|
||||
|
||||
<article class="overflow-hidden rounded-lg border border-white/10 bg-zinc-900/70">
|
||||
<header class="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
||||
<div class="text-sm font-semibold text-zinc-100">{{ $authorName }}</div>
|
||||
<div class="text-xs text-zinc-400">
|
||||
@if (!empty($postedAt))
|
||||
{{ Carbon::parse($postedAt)->format('d.m.Y H:i') }}
|
||||
@endif
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="px-4 py-4">
|
||||
<div class="prose prose-invert max-w-none text-sm leading-6">
|
||||
{!! $postBody !!}
|
||||
</div>
|
||||
|
||||
@if (!empty($authorId))
|
||||
<div class="mt-4 text-xs text-zinc-500">
|
||||
User ID: {{ $authorId }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
@empty
|
||||
<div class="rounded-lg border border-white/10 bg-zinc-900/70 px-4 py-6 text-center text-zinc-400">
|
||||
No posts yet.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@if (isset($posts) && method_exists($posts, 'links'))
|
||||
<div class="mt-4">{{ $posts->withQueryString()->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
1
resources/views/components/forum/category-card.blade.php
Normal file
1
resources/views/components/forum/category-card.blade.php
Normal file
@@ -0,0 +1 @@
|
||||
@include('forum.components.category-card', ['category' => $category])
|
||||
@@ -0,0 +1 @@
|
||||
@include('forum.thread.components.attachment-list', ['attachments' => $attachments])
|
||||
@@ -0,0 +1 @@
|
||||
@include('forum.thread.components.author-badge', ['user' => $user])
|
||||
@@ -0,0 +1 @@
|
||||
@include('forum.thread.components.breadcrumbs', ['thread' => $thread, 'category' => $category])
|
||||
@@ -0,0 +1 @@
|
||||
@include('forum.thread.components.post-card', ['post' => $post, 'thread' => $thread ?? null, 'isOp' => $isOp ?? false])
|
||||
39
resources/views/emails/registration-verification.blade.php
Normal file
39
resources/views/emails/registration-verification.blade.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verify your email</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:20px;background:#0b0f14;font-family:system-ui,-apple-system,Segoe UI,Roboto,'Helvetica Neue',Arial;color:#e6eef6;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;margin:24px auto;border-radius:12px;border:1px solid #1f2937;background:#081016;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="padding:20px 24px;border-bottom:1px solid #111827;background:#071018;">
|
||||
<h2 style="margin:0;font-size:18px;color:#fff;font-weight:600;">{{ config('app.name', 'Skinbase') }}</h2>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px;background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0));">
|
||||
<p style="margin:0 0 12px;color:#cbd5e1;">Welcome to {{ config('app.name', 'Skinbase') }} — thanks for signing up.</p>
|
||||
<p style="margin:0 0 18px;color:#cbd5e1;">Please verify your email to continue account setup.</p>
|
||||
|
||||
<div style="text-align:center;margin:20px 0;">
|
||||
<a href="{{ $verificationUrl }}" style="display:inline-block;padding:12px 20px;background:#0ea5a9;color:#06121a;text-decoration:none;border-radius:8px;font-weight:600;">Verify Email</a>
|
||||
</div>
|
||||
|
||||
<p style="margin:0 0 8px;color:#9fb0c8;font-size:13px;">This link expires in {{ $expiresInHours }} hours.</p>
|
||||
<p style="margin:12px 0 0;color:#9fb0c8;font-size:13px;">Need help? Contact support: <a href="{{ $supportUrl }}" style="color:#8bd0d3;">{{ $supportUrl }}</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 24px;background:#040607;border-top:1px solid #0e1113;text-align:center;color:#6b7280;font-size:12px;">© {{ date('Y') }} {{ config('app.name', 'Skinbase') }}. All rights reserved.</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
27
resources/views/forum/community/edit-post.blade.php
Normal file
27
resources/views/forum/community/edit-post.blade.php
Normal file
@@ -0,0 +1,27 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="legacy-page max-w-3xl">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]) }}" class="text-sm text-sky-300 hover:text-sky-200">← Back to thread</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-white">Edit post</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('forum.post.update', ['post' => $post->id]) }}" class="space-y-4 rounded-lg border border-white/10 bg-zinc-900/70 p-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div>
|
||||
<label for="content" class="mb-1 block text-sm font-medium text-zinc-200">Content</label>
|
||||
<textarea id="content" name="content" rows="10" required class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100">{{ old('content', $post->content) }}</textarea>
|
||||
@error('content')
|
||||
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="rounded bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
34
resources/views/forum/community/new-thread.blade.php
Normal file
34
resources/views/forum/community/new-thread.blade.php
Normal file
@@ -0,0 +1,34 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="legacy-page max-w-3xl">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('forum.category.show', ['category' => $category->slug]) }}" class="text-sm text-sky-300 hover:text-sky-200">← Back to section</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-white">Create thread in {{ $category->name }}</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('forum.thread.store', ['category' => $category->slug]) }}" class="space-y-4 rounded-lg border border-white/10 bg-zinc-900/70 p-4">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label for="title" class="mb-1 block text-sm font-medium text-zinc-200">Title</label>
|
||||
<input id="title" name="title" value="{{ old('title') }}" required maxlength="255" class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100" />
|
||||
@error('title')
|
||||
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="content" class="mb-1 block text-sm font-medium text-zinc-200">Content</label>
|
||||
<textarea id="content" name="content" rows="10" required class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100">{{ old('content') }}</textarea>
|
||||
@error('content')
|
||||
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="rounded bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">Publish thread</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -8,11 +8,16 @@
|
||||
@section('content')
|
||||
<div class="legacy-page">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('legacy.forum.index') }}" class="text-sm text-sky-300 hover:text-sky-200">← Back to forum</a>
|
||||
<a href="{{ route('forum.index') }}" class="text-sm text-sky-300 hover:text-sky-200">← Back to forum</a>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-white">{{ $topic->topic ?? $topic->title ?? 'Topic' }}</h1>
|
||||
@if (!empty($topic->discuss))
|
||||
<p class="mt-1 text-sm text-zinc-300">{!! Str::limit(strip_tags((string) $topic->discuss), 220) !!}</p>
|
||||
@endif
|
||||
@if (isset($category) && auth()->check())
|
||||
<div class="mt-3">
|
||||
<a href="{{ route('forum.thread.create', ['category' => $category->slug]) }}" class="rounded bg-sky-600 px-3 py-2 text-xs font-medium text-white hover:bg-sky-500">New thread</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-white/10 bg-zinc-900/70">
|
||||
@@ -35,7 +40,7 @@
|
||||
@endphp
|
||||
<tr class="hover:bg-white/5">
|
||||
<td class="px-4 py-3 align-top">
|
||||
<a class="font-medium text-sky-300 hover:text-sky-200" href="{{ route('legacy.forum.topic', ['topic_id' => $id, 'slug' => Str::slug($title)]) }}">{{ $title }}</a>
|
||||
<a class="font-medium text-sky-300 hover:text-sky-200" href="{{ route('forum.thread.show', ['thread' => $id, 'slug' => Str::slug($title)]) }}">{{ $title }}</a>
|
||||
@if (!empty($sub->discuss))
|
||||
<div class="mt-1 text-xs text-zinc-400">{!! Str::limit(strip_tags((string) $sub->discuss), 180) !!}</div>
|
||||
@endif
|
||||
50
resources/views/forum/components/category-card.blade.php
Normal file
50
resources/views/forum/components/category-card.blade.php
Normal file
@@ -0,0 +1,50 @@
|
||||
@php
|
||||
$name = data_get($category, 'name', 'Untitled');
|
||||
$slug = data_get($category, 'slug');
|
||||
$categoryUrl = !empty($slug) ? route('forum.category.show', ['category' => $slug]) : '#';
|
||||
$threads = (int) data_get($category, 'thread_count', 0);
|
||||
$posts = (int) data_get($category, 'post_count', 0);
|
||||
$lastActivity = data_get($category, 'last_activity_at');
|
||||
$preview = data_get($category, 'preview_image', config('forum.preview_images.default'));
|
||||
@endphp
|
||||
|
||||
<a
|
||||
href="{{ $categoryUrl }}"
|
||||
aria-label="Open {{ $name }} category"
|
||||
role="listitem"
|
||||
class="group relative block overflow-hidden rounded-xl border border-white/5 bg-slate-900/80 shadow-xl backdrop-blur transition-all duration-300 hover:shadow-cyan-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
|
||||
>
|
||||
<div class="relative aspect-[4/3] sm:aspect-[16/9]">
|
||||
<img
|
||||
src="{{ $preview }}"
|
||||
alt="{{ $name }} preview"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"></div>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 p-4">
|
||||
<div class="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
|
||||
<i class="fa-solid fa-comments" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-white">{{ $name }}</h3>
|
||||
<p class="mt-1 text-xs text-white/60">
|
||||
Last activity:
|
||||
@if ($lastActivity)
|
||||
<time datetime="{{ \Illuminate\Support\Carbon::parse($lastActivity)->toIso8601String() }}">
|
||||
{{ \Illuminate\Support\Carbon::parse($lastActivity)->diffForHumans() }}
|
||||
</time>
|
||||
@else
|
||||
No activity yet
|
||||
@endif
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex items-center gap-4 text-sm text-cyan-300">
|
||||
<span>{{ number_format($posts) }} posts</span>
|
||||
<span>{{ number_format($threads) }} topics</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
24
resources/views/forum/index.blade.php
Normal file
24
resources/views/forum/index.blade.php
Normal file
@@ -0,0 +1,24 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<main class="min-h-screen bg-slate-950 px-4 py-10" aria-labelledby="forum-page-title">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-8">
|
||||
<h1 id="forum-page-title" class="text-3xl font-semibold text-white">Forum</h1>
|
||||
<p class="mt-2 text-sm text-white/60">Browse forum sections and latest activity.</p>
|
||||
</header>
|
||||
|
||||
@if (($categories ?? collect())->isEmpty())
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/60 p-8 text-center text-white/70">
|
||||
No forum categories available yet.
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3" role="list" aria-label="Forum categories">
|
||||
@foreach ($categories as $category)
|
||||
<x-forum.category-card :category="$category" />
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</main>
|
||||
@endsection
|
||||
@@ -0,0 +1,84 @@
|
||||
@php
|
||||
$attachments = collect($attachments ?? []);
|
||||
$filesBaseUrl = rtrim((string) config('cdn.files_url', ''), '/');
|
||||
|
||||
$toUrl = function (?string $path) use ($filesBaseUrl): string {
|
||||
$cleanPath = ltrim((string) $path, '/');
|
||||
return $filesBaseUrl !== '' ? ($filesBaseUrl . '/' . $cleanPath) : ('/' . $cleanPath);
|
||||
};
|
||||
|
||||
$formatBytes = function ($bytes): string {
|
||||
$size = max((int) $bytes, 0);
|
||||
if ($size < 1024) {
|
||||
return $size . ' B';
|
||||
}
|
||||
|
||||
$units = ['KB', 'MB', 'GB'];
|
||||
$value = $size / 1024;
|
||||
$unitIndex = 0;
|
||||
|
||||
while ($value >= 1024 && $unitIndex < count($units) - 1) {
|
||||
$value /= 1024;
|
||||
$unitIndex++;
|
||||
}
|
||||
|
||||
return number_format($value, 1) . ' ' . $units[$unitIndex];
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if ($attachments->isNotEmpty())
|
||||
<div class="mt-4 space-y-3 border-t border-white/10 pt-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-zinc-400">Attachments</h4>
|
||||
<ul class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
@foreach ($attachments as $attachment)
|
||||
@php
|
||||
$mime = (string) ($attachment->mime_type ?? '');
|
||||
$isImage = str_starts_with($mime, 'image/');
|
||||
$url = $toUrl($attachment->file_path ?? '');
|
||||
$modalId = 'attachment-modal-' . (string) data_get($attachment, 'id', uniqid());
|
||||
@endphp
|
||||
<li class="rounded-lg border border-white/10 bg-slate-900/60 p-3">
|
||||
@if ($isImage)
|
||||
<a href="#{{ $modalId }}" class="block overflow-hidden rounded-md border border-white/10">
|
||||
<img
|
||||
src="{{ $url }}"
|
||||
alt="Attachment preview"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-36 w-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="mt-2 flex items-center justify-between gap-3 text-xs">
|
||||
<span class="truncate text-zinc-300">{{ basename((string) ($attachment->file_path ?? 'file')) }}</span>
|
||||
<span class="text-zinc-500">{{ $formatBytes($attachment->file_size ?? 0) }}</span>
|
||||
</div>
|
||||
|
||||
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="mt-2 inline-flex text-xs font-medium text-sky-300 hover:text-sky-200">
|
||||
Download
|
||||
</a>
|
||||
|
||||
@if ($isImage)
|
||||
<div id="{{ $modalId }}" class="pointer-events-none fixed inset-0 z-50 hidden bg-black/80 p-4 target:pointer-events-auto target:block" role="dialog" aria-label="Attachment preview">
|
||||
<div class="mx-auto flex h-full w-full max-w-5xl items-center justify-center">
|
||||
<div class="w-full overflow-hidden rounded-xl border border-white/10 bg-slate-950/95">
|
||||
<div class="flex items-center justify-between border-b border-white/10 px-4 py-2">
|
||||
<span class="truncate text-xs text-zinc-300">{{ basename((string) ($attachment->file_path ?? 'file')) }}</span>
|
||||
<a href="#" class="text-xs text-zinc-400 hover:text-zinc-200">Close</a>
|
||||
</div>
|
||||
<div class="max-h-[80vh] overflow-auto p-3">
|
||||
<img src="{{ $url }}" alt="Attachment full preview" class="mx-auto h-auto max-h-[72vh] w-auto max-w-full object-contain" />
|
||||
</div>
|
||||
<div class="border-t border-white/10 px-4 py-2 text-right">
|
||||
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="text-xs font-medium text-sky-300 hover:text-sky-200">Open original</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,32 @@
|
||||
@php
|
||||
$user = $user ?? null;
|
||||
$name = data_get($user, 'name', 'Anonymous');
|
||||
$avatar = data_get($user, 'profile.avatar_url') ?? \App\Support\AvatarUrl::forUser((int) data_get($user, 'id', 0));
|
||||
$role = strtolower((string) data_get($user, 'role', 'member'));
|
||||
|
||||
$roleLabel = match ($role) {
|
||||
'admin' => 'Admin',
|
||||
'moderator' => 'Moderator',
|
||||
default => 'Member',
|
||||
};
|
||||
|
||||
$roleClasses = match ($role) {
|
||||
'admin' => 'bg-red-500/15 text-red-300',
|
||||
'moderator' => 'bg-amber-500/15 text-amber-300',
|
||||
default => 'bg-sky-500/15 text-sky-300',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
src="{{ $avatar }}"
|
||||
alt="{{ $name }} avatar"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-10 w-10 rounded-full border border-white/10 object-cover"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-zinc-100">{{ $name }}</div>
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium {{ $roleClasses }}">{{ $roleLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
@php
|
||||
$thread = $thread ?? null;
|
||||
$category = $category ?? null;
|
||||
@endphp
|
||||
|
||||
<nav class="text-sm text-zinc-400" aria-label="Breadcrumb" itemscope itemtype="https://schema.org/BreadcrumbList">
|
||||
<ol class="flex flex-wrap items-center gap-2">
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a itemprop="item" href="{{ url('/') }}" class="hover:text-zinc-200"><span itemprop="name">Home</span></a>
|
||||
<meta itemprop="position" content="1">
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a itemprop="item" href="{{ route('forum.index') }}" class="hover:text-zinc-200"><span itemprop="name">Forum</span></a>
|
||||
<meta itemprop="position" content="2">
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a itemprop="item" href="{{ isset($category) ? route('forum.category.show', ['category' => $category->slug]) : route('forum.index') }}" class="hover:text-zinc-200">
|
||||
<span itemprop="name">{{ $category->name ?? 'Category' }}</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="3">
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li class="text-zinc-200" itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<span itemprop="name">{{ $thread->title ?? 'Thread' }}</span>
|
||||
<meta itemprop="position" content="4">
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
71
resources/views/forum/thread/components/post-card.blade.php
Normal file
71
resources/views/forum/thread/components/post-card.blade.php
Normal file
@@ -0,0 +1,71 @@
|
||||
@php
|
||||
$post = $post ?? null;
|
||||
$thread = $thread ?? null;
|
||||
$isOp = (bool) ($isOp ?? false);
|
||||
$author = data_get($post, 'user');
|
||||
$postedAt = data_get($post, 'created_at');
|
||||
$editedAt = data_get($post, 'edited_at');
|
||||
$content = (string) data_get($post, 'content', '');
|
||||
$rendered = \App\Support\ForumPostContent::render($content);
|
||||
@endphp
|
||||
|
||||
<article class="overflow-hidden rounded-xl border border-white/5 bg-slate-900/70 backdrop-blur" id="post-{{ data_get($post, 'id') }}">
|
||||
<header class="border-b border-white/10 px-4 py-3 sm:px-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<x-forum.thread.author-badge :user="$author" />
|
||||
<div class="text-xs text-zinc-400">
|
||||
@if ($postedAt)
|
||||
<time datetime="{{ \Illuminate\Support\Carbon::parse($postedAt)->toIso8601String() }}">
|
||||
{{ \Illuminate\Support\Carbon::parse($postedAt)->format('d.m.Y H:i') }}
|
||||
</time>
|
||||
@endif
|
||||
@if ($isOp)
|
||||
<span class="ml-2 rounded-full bg-cyan-500/15 px-2 py-0.5 text-[11px] font-medium text-cyan-300">OP</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="px-4 py-4 sm:px-5">
|
||||
<div class="prose prose-invert max-w-none text-sm leading-6 prose-pre:overflow-x-auto">
|
||||
{!! $rendered !!}
|
||||
</div>
|
||||
|
||||
@if (data_get($post, 'is_edited') && $editedAt)
|
||||
<p class="mt-3 text-xs text-zinc-500">
|
||||
Edited <time datetime="{{ \Illuminate\Support\Carbon::parse($editedAt)->toIso8601String() }}">{{ \Illuminate\Support\Carbon::parse($editedAt)->diffForHumans() }}</time>
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<x-forum.thread.attachment-list :attachments="data_get($post, 'attachments', [])" />
|
||||
</div>
|
||||
|
||||
<footer class="flex flex-wrap items-center gap-3 border-t border-white/10 px-4 py-3 text-xs text-zinc-400 sm:px-5">
|
||||
<button type="button" disabled aria-disabled="true" title="Like coming soon" class="cursor-not-allowed rounded border border-white/10 px-2 py-0.5 text-zinc-500">Like</button>
|
||||
@if (!empty(data_get($thread, 'id')))
|
||||
<a href="{{ route('forum.thread.show', ['thread' => data_get($thread, 'id'), 'slug' => data_get($thread, 'slug'), 'quote' => data_get($post, 'id')]) }}#reply-content" class="hover:text-zinc-200">Quote</a>
|
||||
@else
|
||||
<a href="#post-{{ data_get($post, 'id') }}" class="hover:text-zinc-200">Quote</a>
|
||||
@endif
|
||||
@auth
|
||||
@if ((int) data_get($post, 'user_id') !== (int) auth()->id())
|
||||
<form method="POST" action="{{ route('forum.post.report', ['post' => data_get($post, 'id')]) }}" class="inline">
|
||||
@csrf
|
||||
<button type="submit" class="hover:text-zinc-200">Report</button>
|
||||
</form>
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="hover:text-zinc-200">Report</a>
|
||||
@endauth
|
||||
|
||||
@auth
|
||||
@if ((int) data_get($post, 'user_id') === (int) auth()->id() || Gate::allows('moderate-forum'))
|
||||
<a href="{{ route('forum.post.edit', ['post' => data_get($post, 'id')]) }}" class="hover:text-zinc-200">Edit</a>
|
||||
@endif
|
||||
@endauth
|
||||
|
||||
@can('moderate-forum')
|
||||
<span class="ml-auto text-amber-300">Moderation tools available</span>
|
||||
@endcan
|
||||
</footer>
|
||||
</article>
|
||||
124
resources/views/forum/thread/show.blade.php
Normal file
124
resources/views/forum/thread/show.blade.php
Normal file
@@ -0,0 +1,124 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<main class="min-h-screen bg-slate-950 px-4 py-8" aria-labelledby="thread-title">
|
||||
<div class="mx-auto max-w-5xl space-y-5">
|
||||
<x-forum.thread.breadcrumbs :thread="$thread" :category="$category" />
|
||||
|
||||
@if (session('status'))
|
||||
<div class="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<section class="rounded-xl border border-white/5 bg-slate-900/70 p-5 backdrop-blur">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 id="thread-title" class="text-2xl font-semibold text-white">{{ $thread->title }}</h1>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
|
||||
<span>By {{ $author->name ?? 'Unknown' }}</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<time datetime="{{ optional($thread->created_at)?->toIso8601String() }}">{{ optional($thread->created_at)?->format('d.m.Y H:i') }}</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="rounded-full bg-sky-500/15 px-2.5 py-1 text-sky-300">{{ number_format((int) ($thread->views ?? 0)) }} views</span>
|
||||
<span class="rounded-full bg-cyan-500/15 px-2.5 py-1 text-cyan-300">{{ number_format((int) ($reply_count ?? 0)) }} replies</span>
|
||||
@if ($thread->is_pinned)
|
||||
<span class="rounded-full bg-amber-500/15 px-2.5 py-1 text-amber-300">Pinned</span>
|
||||
@endif
|
||||
@if ($thread->is_locked)
|
||||
<span class="rounded-full bg-red-500/15 px-2.5 py-1 text-red-300">Locked</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('moderate-forum')
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-white/10 pt-3 text-xs">
|
||||
@if ($thread->is_locked)
|
||||
<form method="POST" action="{{ route('forum.thread.unlock', ['thread' => $thread->id]) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-md bg-red-500/15 px-2.5 py-1 text-red-300 hover:bg-red-500/25">Unlock thread</button>
|
||||
</form>
|
||||
@else
|
||||
<form method="POST" action="{{ route('forum.thread.lock', ['thread' => $thread->id]) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-md bg-red-500/15 px-2.5 py-1 text-red-300 hover:bg-red-500/25">Lock thread</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if ($thread->is_pinned)
|
||||
<form method="POST" action="{{ route('forum.thread.unpin', ['thread' => $thread->id]) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-md bg-amber-500/15 px-2.5 py-1 text-amber-300 hover:bg-amber-500/25">Unpin thread</button>
|
||||
</form>
|
||||
@else
|
||||
<form method="POST" action="{{ route('forum.thread.pin', ['thread' => $thread->id]) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-md bg-amber-500/15 px-2.5 py-1 text-amber-300 hover:bg-amber-500/25">Pin thread</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@endcan
|
||||
</section>
|
||||
|
||||
@if (isset($opPost) && $opPost)
|
||||
<x-forum.thread.post-card :post="$opPost" :thread="$thread" :is-op="true" />
|
||||
@endif
|
||||
|
||||
<section class="space-y-4" aria-label="Replies">
|
||||
@forelse ($posts as $post)
|
||||
<x-forum.thread.post-card :post="$post" :thread="$thread" />
|
||||
@empty
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/60 px-4 py-6 text-center text-zinc-400">
|
||||
No replies yet.
|
||||
</div>
|
||||
@endforelse
|
||||
</section>
|
||||
|
||||
@if (method_exists($posts, 'links'))
|
||||
<div class="sticky bottom-3 z-10 rounded-xl border border-white/10 bg-slate-900/80 p-2 backdrop-blur supports-[backdrop-filter]:bg-slate-900/70">
|
||||
{{ $posts->withQueryString()->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@auth
|
||||
@if (!$thread->is_locked)
|
||||
<form method="POST" action="{{ route('forum.thread.reply', ['thread' => $thread->id]) }}" class="space-y-3 rounded-xl border border-white/5 bg-slate-900/70 p-4 backdrop-blur">
|
||||
@csrf
|
||||
<div class="flex items-center justify-between">
|
||||
<label for="reply-content" class="text-sm font-medium text-zinc-200">Reply</label>
|
||||
<span class="text-xs text-zinc-500">Minimum 2 characters</span>
|
||||
</div>
|
||||
<div class="rounded-lg border border-white/10 bg-slate-950 p-2">
|
||||
<div class="mb-2 flex items-center gap-2 text-xs">
|
||||
<button type="button" class="rounded bg-slate-800 px-2 py-1 text-zinc-200" aria-pressed="true">Write</button>
|
||||
<span class="rounded bg-slate-900 px-2 py-1 text-zinc-500">Preview (coming soon)</span>
|
||||
</div>
|
||||
<textarea id="reply-content" name="content" rows="6" required minlength="2" maxlength="10000" class="w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:border-cyan-400 focus:outline-none focus:ring-1 focus:ring-cyan-400">{{ $reply_prefill ?? old('content') }}</textarea>
|
||||
</div>
|
||||
@error('content')
|
||||
<p class="text-xs text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
@if (!empty($quoted_post))
|
||||
<p class="text-xs text-cyan-300">Replying with quote from {{ data_get($quoted_post, 'user.name', 'Anonymous') }}.</p>
|
||||
@endif
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-zinc-500">Markdown/BBCode + attachments will be enabled in next pass</p>
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400">Post reply</button>
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
<div class="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 text-sm text-red-300">
|
||||
This thread is locked. Replies are disabled.
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="rounded-xl border border-white/10 bg-slate-900/60 px-4 py-4 text-sm text-zinc-300">
|
||||
<a href="{{ route('login') }}" class="text-sky-300 hover:text-sky-200">Sign in</a> to post a reply.
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
</main>
|
||||
@endsection
|
||||
@@ -10,18 +10,25 @@
|
||||
|
||||
<div class="pt-0">
|
||||
<div class="mx-auto w-full">
|
||||
<div class="flex min-h-[calc(100vh-64px)]">
|
||||
<div class="relative flex min-h-[calc(100vh-64px)]">
|
||||
|
||||
<button
|
||||
id="sidebar-toggle"
|
||||
type="button"
|
||||
class="hidden md:inline-flex items-center justify-center h-10 w-10 rounded-lg border border-white/10 bg-white/5 text-white/90 hover:bg-white/10 absolute top-3 z-20"
|
||||
aria-controls="sidebar"
|
||||
aria-expanded="true"
|
||||
aria-label="Toggle sidebar"
|
||||
style="left:16px;"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm">
|
||||
<div class="p-4">
|
||||
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</span>
|
||||
<span class="text-sm text-white/90">Menu</span>
|
||||
</button>
|
||||
|
||||
<div class="mt-6 text-sm text-neutral-400">
|
||||
<div class="mt-2 text-sm text-neutral-400">
|
||||
<div class="font-semibold text-white/80 mb-2">Main Categories:</div>
|
||||
<ul class="space-y-2">
|
||||
@foreach($mainCategories as $main)
|
||||
@@ -32,7 +39,7 @@
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
|
||||
<ul class="space-y-2 sb-scrollbar max-h-56 overflow-auto pr-2">
|
||||
<ul class="space-y-2 pr-2">
|
||||
@forelse($subcategories as $sub)
|
||||
@php
|
||||
$subName = $sub->category_name ?? $sub->name ?? null;
|
||||
@@ -128,9 +135,13 @@
|
||||
@media (min-width: 1024px) {
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 2600px) {
|
||||
/* Larger desktop screens: 5 columns */
|
||||
@media (min-width: 1600px) {
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 2600px) {
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
}
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
|
||||
/* Keep pagination visible when JS enhances the gallery so users
|
||||
have a clear navigation control (numeric links for length-aware
|
||||
@@ -184,4 +195,64 @@
|
||||
|
||||
@push('scripts')
|
||||
<script src="/js/legacy-gallery-init.js" defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var toggle = document.getElementById('sidebar-toggle');
|
||||
var sidebar = document.getElementById('sidebar');
|
||||
if (!toggle || !sidebar) return;
|
||||
|
||||
var collapsed = false;
|
||||
try {
|
||||
collapsed = window.localStorage.getItem('gallery.sidebar.collapsed') === '1';
|
||||
} catch (e) {
|
||||
collapsed = false;
|
||||
}
|
||||
|
||||
function applySidebarState() {
|
||||
if (collapsed) {
|
||||
sidebar.classList.add('md:hidden');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
sidebar.classList.remove('md:hidden');
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
positionToggle();
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', function () {
|
||||
collapsed = !collapsed;
|
||||
applySidebarState();
|
||||
try {
|
||||
window.localStorage.setItem('gallery.sidebar.collapsed', collapsed ? '1' : '0');
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
|
||||
function positionToggle() {
|
||||
if (!toggle || !sidebar) return;
|
||||
// when sidebar is visible, position toggle just outside its right edge
|
||||
if (!collapsed) {
|
||||
var rect = sidebar.getBoundingClientRect();
|
||||
if (rect && rect.right) {
|
||||
toggle.style.left = (rect.right + 8) + 'px';
|
||||
toggle.style.transform = '';
|
||||
} else {
|
||||
// fallback to sidebar width (18rem)
|
||||
toggle.style.left = 'calc(18rem + 8px)';
|
||||
}
|
||||
} else {
|
||||
// when collapsed, position toggle near page left edge
|
||||
toggle.style.left = '16px';
|
||||
toggle.style.transform = '';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function () { positionToggle(); });
|
||||
|
||||
applySidebarState();
|
||||
// ensure initial position set
|
||||
positionToggle();
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -127,8 +127,12 @@
|
||||
<!-- User dropdown -->
|
||||
<div class="relative">
|
||||
<button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5" data-dd="user">
|
||||
@php
|
||||
$toolbarUserId = (int) ($userId ?? Auth::id() ?? 0);
|
||||
$toolbarAvatarHash = $avatarHash ?? optional(Auth::user())->profile->avatar_hash ?? null;
|
||||
@endphp
|
||||
<img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
|
||||
src="{{ \App\Support\AvatarUrl::forUser((int) ($userId ?? (Auth::id() ?? 0)), $avatarHash ?? null, 64) }}"
|
||||
src="{{ \App\Support\AvatarUrl::forUser($toolbarUserId, $toolbarAvatarHash, 64) }}"
|
||||
alt="{{ $displayName ?? 'User' }}" />
|
||||
<span class="text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
@@ -200,6 +204,13 @@
|
||||
</a>
|
||||
|
||||
<div class="px-4 dd-section">System</div>
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-user-shield text-sb-muted"></i></span>
|
||||
Username Moderation
|
||||
</a>
|
||||
@endif
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/logout">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-sign-out text-sb-muted"></i></span>
|
||||
@@ -211,7 +222,7 @@
|
||||
@else
|
||||
<!-- Guest: show simple Join / Sign in links -->
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
<a href="/signup"
|
||||
<a href="/register"
|
||||
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Join</a>
|
||||
<a href="/login"
|
||||
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Sign in</a>
|
||||
@@ -235,6 +246,11 @@
|
||||
<a class="block py-2 border-b border-neutral-900" href="/featured-artworks">Featured</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/forum">Forum</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/profile">Profile</a>
|
||||
@auth
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="block py-2 border-b border-neutral-900" href="{{ route('admin.usernames.moderation') }}">Username Moderation</a>
|
||||
@endif
|
||||
@endauth
|
||||
<a class="block py-2" href="/settings">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
@extends('layouts.legacy')
|
||||
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
<header class="page-heading">
|
||||
<h1 class="page-header">Forum</h1>
|
||||
<p>Latest threads</p>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>Forum Threads</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Thread</th>
|
||||
<th class="text-center">Posts</th>
|
||||
<th class="text-center">Topics</th>
|
||||
<th class="text-right">Last Update</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($topics as $topic)
|
||||
<tr>
|
||||
<td>
|
||||
<h4 style="margin:0;">
|
||||
<a href="/forum/{{ $topic->topic_id }}/{{ Str::slug($topic->topic ?? '') }}">{{ $topic->topic }}</a>
|
||||
</h4>
|
||||
<div class="text-muted">{!! $topic->discuss !!}</div>
|
||||
</td>
|
||||
<td class="text-center">{{ $topic->num_posts ?? 0 }}</td>
|
||||
<td class="text-center">{{ $topic->num_subtopics ?? 0 }}</td>
|
||||
<td class="text-right">{{ $topic->last_update ? Carbon::parse($topic->last_update)->format('H:i @ d.m') : '' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4">No threads available.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,51 +0,0 @@
|
||||
@extends('layouts.legacy')
|
||||
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
<header class="page-heading">
|
||||
<div class="navigation"><a class="badge" href="/forum">Forum</a></div>
|
||||
<h1 class="page-header">{{ $topic->topic }}</h1>
|
||||
@if (!empty($topic->discuss))
|
||||
<p>{!! $topic->discuss !!}</p>
|
||||
@endif
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>Posts</strong></div>
|
||||
<div class="panel-body">
|
||||
@forelse ($posts as $post)
|
||||
<div class="panel panel-default effect2" style="overflow:hidden;">
|
||||
<div class="panel-heading clearfix">
|
||||
<div class="pull-right text-muted">{{ $post->post_date ? Carbon::parse($post->post_date)->format('d.m.Y H:i') : '' }}</div>
|
||||
<strong>{{ $post->uname ?? 'Anonymous' }}</strong>
|
||||
</div>
|
||||
<div class="panel-body" style="display:flex; gap:12px;">
|
||||
<div style="min-width:52px;">
|
||||
@if (!empty($post->user_id) && !empty($post->icon))
|
||||
<img src="{{ \App\Support\AvatarUrl::forUser((int) $post->user_id, null, 50) }}" alt="{{ $post->uname }}" width="50" height="50" class="img-thumbnail">
|
||||
@else
|
||||
<div class="img-thumbnail" style="width:50px;height:50px;"></div>
|
||||
@endif
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
{!! $post->message !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p>No posts yet.</p>
|
||||
@endforelse
|
||||
|
||||
<div class="paginationMenu text-center">
|
||||
{{ $posts->withQueryString()->links('pagination::bootstrap-4') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,56 +0,0 @@
|
||||
@extends('layouts.legacy')
|
||||
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
<header class="page-heading">
|
||||
<div class="navigation"><a class="badge" href="/forum">Forum</a></div>
|
||||
<h1 class="page-header">{{ $topic->topic }}</h1>
|
||||
@if (!empty($topic->discuss))
|
||||
<p>{!! $topic->discuss !!}</p>
|
||||
@endif
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>Topics</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Topic</th>
|
||||
<th class="text-center">Opened By</th>
|
||||
<th class="text-right">Posted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($subtopics as $sub)
|
||||
<tr>
|
||||
<td>{{ $sub->topic_id }}</td>
|
||||
<td>
|
||||
<a href="/forum/{{ $sub->topic_id }}/{{ Str::slug($sub->topic ?? '') }}">{{ $sub->topic }}</a>
|
||||
<div class="text-muted small">{!! Str::limit(strip_tags($sub->discuss ?? ''), 160) !!}</div>
|
||||
</td>
|
||||
<td class="text-center">{{ $sub->uname ?? 'Unknown' }}</td>
|
||||
<td class="text-right">{{ $sub->last_update ? Carbon::parse($sub->last_update)->format('d.m.Y H:i') : '' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4">No topics yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="paginationMenu text-center">
|
||||
{{ $subtopics->withQueryString()->links('pagination::bootstrap-4') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,6 +1,10 @@
|
||||
{{-- News and forum columns --}}
|
||||
<div class="row news-row">
|
||||
<div class="col-sm-6">
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
@endphp
|
||||
@forelse ($forumNews as $item)
|
||||
<div class="panel panel-skinbase effect2">
|
||||
<div class="panel-heading"><h4 class="panel-title">{{ $item->topic }}</h4></div>
|
||||
@@ -10,7 +14,7 @@
|
||||
</div>
|
||||
{!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!}
|
||||
<br>
|
||||
<a class="clearfix btn btn-xs btn-info" href="/forum/{{ $item->topic_id }}/{{ Str::slug($item->topic ?? '') }}" title="{{ strip_tags($item->topic) }}">More</a>
|
||||
<a class="clearfix btn btn-xs btn-info" href="{{ route('forum.thread.show', ['thread' => $item->topic_id, 'slug' => Str::slug($item->topic ?? '')]) }}" title="{{ strip_tags($item->topic) }}">More</a>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
@@ -67,7 +71,7 @@
|
||||
<div class="panel-body">
|
||||
<div class="list-group effect2">
|
||||
@forelse ($latestForumActivity as $topic)
|
||||
<a class="list-group-item" href="/forum/{{ $topic->topic_id }}/{{ Str::slug($topic->topic ?? '') }}">
|
||||
<a class="list-group-item" href="{{ route('forum.thread.show', ['thread' => $topic->topic_id, 'slug' => Str::slug($topic->topic ?? '')]) }}">
|
||||
{{ $topic->topic }} <span class="badge badge-info">{{ $topic->numPosts }}</span>
|
||||
</a>
|
||||
@empty
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
|
||||
@section('content')
|
||||
|
||||
@php
|
||||
$birthDay = $birthDay ?? null;
|
||||
$birthMonth = $birthMonth ?? null;
|
||||
$birthYear = $birthYear ?? null;
|
||||
$avatarUserId = (int) ($user->id ?? auth()->id() ?? 0);
|
||||
$avatarHash = $user->icon
|
||||
?? optional(auth()->user())->profile->avatar_hash
|
||||
?? null;
|
||||
$currentAvatarUrl = !empty($avatarHash)
|
||||
? \App\Support\AvatarUrl::forUser($avatarUserId, $avatarHash, 128)
|
||||
: \App\Support\AvatarUrl::default();
|
||||
@endphp
|
||||
|
||||
<div class="min-h-screen bg-deep text-white py-12">
|
||||
|
||||
<!-- Container -->
|
||||
@@ -146,7 +159,30 @@
|
||||
<!-- Avatar -->
|
||||
<div>
|
||||
<label class="form-label">Avatar</label>
|
||||
<input type="file" name="avatar" class="form-file">
|
||||
<div class="rounded-xl border border-sb-line bg-black/10 p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<img
|
||||
id="avatarPreviewImage"
|
||||
src="{{ $currentAvatarUrl }}"
|
||||
alt="{{ $user->name ?? $user->username ?? 'Avatar' }}"
|
||||
class="w-24 h-24 rounded-full object-cover ring-1 ring-white/10"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
id="avatarDropzone"
|
||||
class="w-full rounded-lg border border-dashed border-sb-line px-4 py-4 text-left hover:bg-white/5 focus:outline-none focus:ring-2 focus:ring-sb-blue/50"
|
||||
>
|
||||
<div class="text-sm text-white/90">Drag & drop a new avatar here</div>
|
||||
<div class="text-xs text-soft mt-1">or click to browse (JPG, PNG, WEBP)</div>
|
||||
<div id="avatarFileName" class="text-xs text-sb-muted mt-2 truncate">No file selected</div>
|
||||
</button>
|
||||
<input id="avatarInput" type="file" name="avatar" class="sr-only" accept="image/jpeg,image/png,image/webp">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emoticon -->
|
||||
@@ -245,3 +281,65 @@
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(() => {
|
||||
const input = document.getElementById('avatarInput');
|
||||
const dropzone = document.getElementById('avatarDropzone');
|
||||
const preview = document.getElementById('avatarPreviewImage');
|
||||
const fileName = document.getElementById('avatarFileName');
|
||||
if (!input || !dropzone || !preview || !fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePreview = (file) => {
|
||||
if (!file || !file.type || !file.type.startsWith('image/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
preview.src = objectUrl;
|
||||
fileName.textContent = file.name;
|
||||
preview.onload = () => URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
|
||||
dropzone.addEventListener('click', () => input.click());
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files && input.files[0] ? input.files[0] : null;
|
||||
if (file) {
|
||||
updatePreview(file);
|
||||
}
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach((eventName) => {
|
||||
dropzone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dropzone.classList.add('ring-2', 'ring-sb-blue/50');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach((eventName) => {
|
||||
dropzone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dropzone.classList.remove('ring-2', 'ring-sb-blue/50');
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', (event) => {
|
||||
const file = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files[0] : null;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
input.files = dt.files;
|
||||
updatePreview(file);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -28,6 +28,24 @@
|
||||
data-initial-src="{{ $avatarInitialSrc }}"
|
||||
></div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="username" :value="__('Username')" />
|
||||
<x-text-input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
:value="old('username', $user->username)"
|
||||
required
|
||||
autocomplete="username"
|
||||
data-username-field="true"
|
||||
data-availability-url="{{ route('api.username.availability') }}"
|
||||
data-availability-target="profile-username-availability"
|
||||
/>
|
||||
<p id="profile-username-availability" class="mt-1 text-xs text-gray-500"></p>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('username')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="name" :value="__('Name')" />
|
||||
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" />
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{{-- News and forum columns (migrated from legacy/home/news.blade.php) --}}
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
@endphp
|
||||
|
||||
<div class="row news-row">
|
||||
<div class="col-sm-6">
|
||||
@forelse ($forumNews as $item)
|
||||
@@ -10,7 +15,7 @@
|
||||
</div>
|
||||
{!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!}
|
||||
<br>
|
||||
<a class="clearfix btn btn-xs btn-info" href="/forum/{{ $item->topic_id }}/{{ Str::slug($item->topic ?? '') }}" title="{{ strip_tags($item->topic) }}">More</a>
|
||||
<a class="clearfix btn btn-xs btn-info" href="{{ route('forum.thread.show', ['thread' => $item->topic_id, 'slug' => Str::slug($item->topic ?? '')]) }}" title="{{ strip_tags($item->topic) }}">More</a>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
@@ -67,7 +72,7 @@
|
||||
<div class="panel-body">
|
||||
<div class="list-group effect2">
|
||||
@forelse ($latestForumActivity as $topic)
|
||||
<a class="list-group-item" href="/forum/{{ $topic->topic_id }}/{{ Str::slug($topic->topic ?? '') }}">
|
||||
<a class="list-group-item" href="{{ route('forum.thread.show', ['thread' => $topic->topic_id, 'slug' => Str::slug($topic->topic ?? '')]) }}">
|
||||
{{ $topic->topic }} <span class="badge badge-info">{{ $topic->numPosts }}</span>
|
||||
</a>
|
||||
@empty
|
||||
|
||||
@@ -13,7 +13,14 @@
|
||||
}
|
||||
|
||||
$title = trim((string) ($art->name ?? $art->title ?? 'Untitled artwork'));
|
||||
$author = trim((string) ($art->uname ?? $art->author_name ?? $art->author ?? 'Skinbase'));
|
||||
$author = trim((string) (
|
||||
$art->uname
|
||||
?? $art->author_name
|
||||
?? $art->author
|
||||
?? ($art->user->name ?? null)
|
||||
?? ($art->user->username ?? null)
|
||||
?? 'Skinbase'
|
||||
));
|
||||
$category = trim((string) ($art->category_name ?? $art->category ?? 'General'));
|
||||
$license = trim((string) ($art->license ?? 'Standard'));
|
||||
$resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : '')));
|
||||
|
||||
@@ -38,12 +38,16 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
->name('feed');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth'])->prefix('artworks')->name('api.artworks.')->group(function () {
|
||||
Route::middleware(['web', 'normalize.username', 'throttle:30,1'])
|
||||
->get('username/availability', \App\Http\Controllers\Api\UsernameAvailabilityController::class)
|
||||
->name('api.username.availability');
|
||||
|
||||
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('artworks')->name('api.artworks.')->group(function () {
|
||||
Route::post('/', [\App\Http\Controllers\Api\ArtworkController::class, 'store'])
|
||||
->name('store');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth'])->prefix('uploads')->name('api.uploads.')->group(function () {
|
||||
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->name('api.uploads.')->group(function () {
|
||||
Route::post('init', [\App\Http\Controllers\Api\UploadController::class, 'init'])
|
||||
->middleware('throttle:uploads-init')
|
||||
->name('init');
|
||||
@@ -102,6 +106,19 @@ Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/reports')-
|
||||
->name('feed-performance');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/usernames')->name('api.admin.usernames.')->group(function () {
|
||||
Route::get('pending', [\App\Http\Controllers\Api\Admin\UsernameApprovalController::class, 'pending'])
|
||||
->name('pending');
|
||||
|
||||
Route::post('{id}/approve', [\App\Http\Controllers\Api\Admin\UsernameApprovalController::class, 'approve'])
|
||||
->whereNumber('id')
|
||||
->name('approve');
|
||||
|
||||
Route::post('{id}/reject', [\App\Http\Controllers\Api\Admin\UsernameApprovalController::class, 'reject'])
|
||||
->whereNumber('id')
|
||||
->name('reject');
|
||||
});
|
||||
|
||||
Route::post('analytics/similar-artworks', [\App\Http\Controllers\Api\SimilarArtworkAnalyticsController::class, 'store'])
|
||||
->middleware('throttle:uploads-status')
|
||||
->name('api.analytics.similar-artworks.store');
|
||||
@@ -110,19 +127,19 @@ Route::middleware(['web', 'auth'])->post('analytics/feed', [\App\Http\Controller
|
||||
->middleware('throttle:uploads-status')
|
||||
->name('api.analytics.feed.store');
|
||||
|
||||
Route::middleware(['web', 'auth'])->prefix('discovery')->name('api.discovery.')->group(function () {
|
||||
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('discovery')->name('api.discovery.')->group(function () {
|
||||
Route::post('events', [\App\Http\Controllers\Api\DiscoveryEventController::class, 'store'])
|
||||
->middleware('throttle:uploads-status')
|
||||
->name('events.store');
|
||||
});
|
||||
|
||||
// Tag system (auth-protected; 404-only ownership checks handled in requests/controllers)
|
||||
Route::middleware(['web', 'auth'])->prefix('tags')->name('api.tags.')->group(function () {
|
||||
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('tags')->name('api.tags.')->group(function () {
|
||||
Route::get('search', [\App\Http\Controllers\Api\TagController::class, 'search'])->name('search');
|
||||
Route::get('popular', [\App\Http\Controllers\Api\TagController::class, 'popular'])->name('popular');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth'])->prefix('artworks')->name('api.artworks.tags.')->group(function () {
|
||||
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('artworks')->name('api.artworks.tags.')->group(function () {
|
||||
Route::get('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'index'])->whereNumber('id')->name('index');
|
||||
Route::post('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'store'])->whereNumber('id')->name('store');
|
||||
Route::put('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'update'])->whereNumber('id')->name('update');
|
||||
|
||||
@@ -8,14 +8,28 @@ use App\Http\Controllers\Auth\NewPasswordController;
|
||||
use App\Http\Controllers\Auth\PasswordController;
|
||||
use App\Http\Controllers\Auth\PasswordResetLinkController;
|
||||
use App\Http\Controllers\Auth\RegisteredUserController;
|
||||
use App\Http\Controllers\Auth\RegistrationVerificationController;
|
||||
use App\Http\Controllers\Auth\SetupPasswordController;
|
||||
use App\Http\Controllers\Auth\SetupUsernameController;
|
||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::middleware(['guest', 'normalize.username'])->group(function () {
|
||||
Route::get('register', [RegisteredUserController::class, 'create'])
|
||||
->name('register');
|
||||
|
||||
Route::post('register', [RegisteredUserController::class, 'store']);
|
||||
Route::get('register/notice', [RegisteredUserController::class, 'notice'])
|
||||
->name('register.notice');
|
||||
|
||||
Route::post('register', [RegisteredUserController::class, 'store'])
|
||||
->middleware('throttle:register');
|
||||
|
||||
Route::post('register/resend-verification', [RegisteredUserController::class, 'resendVerification'])
|
||||
->middleware('throttle:register')
|
||||
->name('register.resend');
|
||||
|
||||
Route::get('verify/{token}', RegistrationVerificationController::class)
|
||||
->name('registration.verify');
|
||||
|
||||
Route::get('login', [AuthenticatedSessionController::class, 'create'])
|
||||
->name('login');
|
||||
@@ -36,6 +50,18 @@ Route::middleware('guest')->group(function () {
|
||||
});
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('setup/password', [SetupPasswordController::class, 'create'])
|
||||
->name('setup.password.create');
|
||||
|
||||
Route::post('setup/password', [SetupPasswordController::class, 'store'])
|
||||
->name('setup.password.store');
|
||||
|
||||
Route::get('setup/username', [SetupUsernameController::class, 'create'])
|
||||
->name('setup.username.create');
|
||||
|
||||
Route::post('setup/username', [SetupUsernameController::class, 'store'])
|
||||
->name('setup.username.store');
|
||||
|
||||
Route::get('verify-email', EmailVerificationPromptController::class)
|
||||
->name('verification.notice');
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user