Merge branch 'feature/forum-migration-v1' into develop
This commit is contained in:
78
app/Console/Commands/ForumConvertPosts.php
Normal file
78
app/Console/Commands/ForumConvertPosts.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\ForumPost;
|
||||
use App\Services\BbcodeConverter;
|
||||
|
||||
class ForumConvertPosts extends Command
|
||||
{
|
||||
protected $signature = 'forum:convert-posts {--dry-run} {--chunk=500} {--limit=} {--report}';
|
||||
|
||||
protected $description = 'Convert migrated forum posts content from legacy BBCode to HTML in-place';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dry = $this->option('dry-run');
|
||||
$chunk = (int)$this->option('chunk');
|
||||
$limit = $this->option('limit') ? (int)$this->option('limit') : null;
|
||||
|
||||
$query = ForumPost::query()->orderBy('id');
|
||||
$total = $limit ? min($query->count(), $limit) : $query->count();
|
||||
|
||||
$this->info('Converting forum posts (dry-run='.($dry ? 'yes' : 'no').')');
|
||||
$this->info("Total posts to consider: {$total}");
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
$converter = new BbcodeConverter();
|
||||
$processed = 0;
|
||||
$changed = 0;
|
||||
|
||||
try {
|
||||
$query->chunkById($chunk, function ($posts) use (&$bar, &$processed, &$changed, $dry, $limit, $converter) {
|
||||
foreach ($posts as $post) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
throw new \RuntimeException('limit_reached');
|
||||
}
|
||||
$bar->advance();
|
||||
$processed++;
|
||||
|
||||
$old = $post->content ?? '';
|
||||
$new = $converter->convert($old);
|
||||
|
||||
if ($old === $new) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed++;
|
||||
if ($dry) {
|
||||
$this->line('[dry] would update post ' . $post->id);
|
||||
continue;
|
||||
}
|
||||
|
||||
$post->content = $new;
|
||||
$post->save();
|
||||
}
|
||||
});
|
||||
} catch (\RuntimeException $e) {
|
||||
if ($e->getMessage() !== 'limit_reached') {
|
||||
throw $e;
|
||||
}
|
||||
// intentionally stop chunking when limit reached
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->line('');
|
||||
|
||||
$this->info("Processed: {$processed} posts. Changed: {$changed} posts.");
|
||||
|
||||
if ($this->option('report')) {
|
||||
$this->info('Conversion complete');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
388
app/Console/Commands/ForumMigrateOld.php
Normal file
388
app/Console/Commands/ForumMigrateOld.php
Normal file
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\ForumCategory;
|
||||
use App\Models\ForumThread;
|
||||
use App\Models\ForumPost;
|
||||
use Exception;
|
||||
use App\Services\BbcodeConverter;
|
||||
|
||||
class ForumMigrateOld extends Command
|
||||
{
|
||||
protected $signature = 'forum:migrate-old {--dry-run} {--only=} {--limit=} {--chunk=500} {--report}';
|
||||
|
||||
protected $description = 'Migrate legacy forum data from legacy DB into new forum tables';
|
||||
|
||||
protected string $logPath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->logPath = storage_path('logs/forum_migration.log');
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Starting forum migration');
|
||||
$this->log('Starting forum migration');
|
||||
|
||||
$dry = $this->option('dry-run');
|
||||
$only = $this->option('only');
|
||||
$chunk = (int)$this->option('chunk');
|
||||
|
||||
try {
|
||||
if (!$only || $only === 'categories') {
|
||||
$this->migrateCategories($dry);
|
||||
}
|
||||
|
||||
if (!$only || $only === 'threads') {
|
||||
$this->migrateThreads($dry, $chunk);
|
||||
}
|
||||
|
||||
if (!$only || $only === 'posts') {
|
||||
$this->migratePosts($dry, $chunk);
|
||||
}
|
||||
|
||||
if (!$only || $only === 'gallery') {
|
||||
$this->migrateGallery($dry, $chunk);
|
||||
}
|
||||
|
||||
if ($this->option('report')) {
|
||||
$this->generateReport();
|
||||
}
|
||||
|
||||
$this->info('Forum migration finished');
|
||||
$this->log('Forum migration finished');
|
||||
return 0;
|
||||
} catch (Exception $e) {
|
||||
$this->error('Migration failed: ' . $e->getMessage());
|
||||
$this->log('Migration failed: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
protected function migrateCategories(bool $dry)
|
||||
{
|
||||
$this->info('Migrating categories');
|
||||
$legacy = DB::connection('legacy');
|
||||
|
||||
$roots = $legacy->table('forum_topics')
|
||||
->select('root_id')
|
||||
->distinct()
|
||||
->where('root_id', '>', 0)
|
||||
->pluck('root_id');
|
||||
|
||||
$this->info('Found ' . $roots->count() . ' legacy root ids');
|
||||
|
||||
foreach ($roots as $rootId) {
|
||||
$row = $legacy->table('forum_topics')->where('topic_id', $rootId)->first();
|
||||
$name = $row->topic ?? 'Category ' . $rootId;
|
||||
$slug = Str::slug(substr($name, 0, 150));
|
||||
|
||||
$this->line("-> root {$rootId}: {$name}");
|
||||
|
||||
if ($dry) {
|
||||
$this->log("[dry] create category {$name} ({$slug})");
|
||||
continue;
|
||||
}
|
||||
|
||||
ForumCategory::updateOrCreate(
|
||||
['id' => $rootId],
|
||||
['name' => $name, 'slug' => $slug]
|
||||
);
|
||||
}
|
||||
|
||||
$this->info('Categories migrated');
|
||||
}
|
||||
|
||||
protected function migrateThreads(bool $dry, int $chunk)
|
||||
{
|
||||
$this->info('Migrating threads');
|
||||
$legacy = DB::connection('legacy');
|
||||
|
||||
$query = $legacy->table('forum_topics')->orderBy('topic_id');
|
||||
|
||||
$total = $query->count();
|
||||
$this->info("Total threads to process: {$total}");
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
// chunk by legacy primary key `topic_id`
|
||||
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
|
||||
foreach ($rows as $r) {
|
||||
$bar->advance();
|
||||
|
||||
$data = [
|
||||
'id' => $r->topic_id,
|
||||
'category_id' => $this->resolveCategoryId($r->root_id ?? null, $r->topic_id),
|
||||
// resolve user id or assign to system user (1) when missing or not found
|
||||
'user_id' => $this->resolveUserId($r->user_id ?? null),
|
||||
'title' => $r->topic,
|
||||
'slug' => $this->uniqueSlug(Str::slug(substr($r->topic,0,200)) ?: 'thread-'.$r->topic_id, $r->topic_id),
|
||||
'content' => $r->preview ?? '',
|
||||
'views' => $r->views ?? 0,
|
||||
'is_locked' => isset($r->open) ? !((bool)$r->open) : false,
|
||||
'is_pinned' => false,
|
||||
'visibility' => $this->mapPrivilegeToVisibility($r->privilege ?? 0),
|
||||
'last_post_at' => $this->normalizeDate($r->last_update ?? null),
|
||||
];
|
||||
|
||||
if ($dry) {
|
||||
$this->log('[dry] thread: ' . $r->topic_id . ' - ' . $r->topic);
|
||||
continue;
|
||||
}
|
||||
|
||||
ForumThread::updateOrCreate(['id' => $data['id']], $data);
|
||||
}
|
||||
}, 'topic_id');
|
||||
|
||||
$bar->finish();
|
||||
$this->line('');
|
||||
$this->info('Threads migrated');
|
||||
}
|
||||
|
||||
protected function migratePosts(bool $dry, int $chunk)
|
||||
{
|
||||
$this->info('Migrating posts');
|
||||
$legacy = DB::connection('legacy');
|
||||
|
||||
$query = $legacy->table('forum_posts')->orderBy('post_id');
|
||||
$total = $query->count();
|
||||
$this->info("Total posts to process: {$total}");
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
// legacy forum_posts uses `post_id` as primary key
|
||||
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
|
||||
foreach ($rows as $r) {
|
||||
$bar->advance();
|
||||
|
||||
$data = [
|
||||
'id' => $r->post_id,
|
||||
'thread_id' => $r->topic_id,
|
||||
'user_id' => $r->user_id ?? null,
|
||||
'content' => $this->convertLegacyMessage($r->message ?? ''),
|
||||
'is_edited' => isset($r->isupdated) ? (bool)$r->isupdated : false,
|
||||
'edited_at' => $r->updated ?? null,
|
||||
];
|
||||
|
||||
if ($dry) {
|
||||
$this->log('[dry] post: ' . $r->post_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
ForumPost::updateOrCreate(['id' => $data['id']], $data);
|
||||
}
|
||||
}, 'post_id');
|
||||
|
||||
$bar->finish();
|
||||
$this->line('');
|
||||
$this->info('Posts migrated');
|
||||
}
|
||||
|
||||
protected function mapPrivilegeToVisibility($priv)
|
||||
{
|
||||
// legacy privilege: 0 public, 1 members, 4 staff? adjust mapping conservatively
|
||||
if ($priv >= 4) return 'staff';
|
||||
if ($priv >= 1) return 'members';
|
||||
return 'public';
|
||||
}
|
||||
|
||||
protected function normalizeDate($val)
|
||||
{
|
||||
if (empty($val)) return null;
|
||||
$s = trim((string)$val);
|
||||
// legacy sometimes contains sentinel invalid dates like -0001-11-30 or zero dates
|
||||
if (strpos($s, '-0001') !== false) return null;
|
||||
if (strpos($s, '0000-00-00') !== false) return null;
|
||||
if (strtotime($s) === false) return null;
|
||||
return date('Y-m-d H:i:s', strtotime($s));
|
||||
}
|
||||
|
||||
protected function uniqueSlug(string $base, int $id)
|
||||
{
|
||||
$slug = $base;
|
||||
$i = 0;
|
||||
while (ForumThread::where('slug', $slug)->where('id', '<>', $id)->exists()) {
|
||||
$i++;
|
||||
$slug = $base . '-' . $id;
|
||||
// if somehow still exists, append counter
|
||||
if (ForumThread::where('slug', $slug)->where('id', '<>', $id)->exists()) {
|
||||
$slug = $base . '-' . $id . '-' . $i;
|
||||
}
|
||||
}
|
||||
return $slug;
|
||||
}
|
||||
|
||||
protected function resolveCategoryId($rootId, $topicId)
|
||||
{
|
||||
// prefer explicit rootId
|
||||
if (!empty($rootId)) {
|
||||
// ensure category exists
|
||||
if (ForumCategory::where('id', $rootId)->exists()) return $rootId;
|
||||
}
|
||||
|
||||
// if this topic itself is a category
|
||||
if (ForumCategory::where('id', $topicId)->exists()) return $topicId;
|
||||
|
||||
// fallback: use first available category
|
||||
$first = ForumCategory::first();
|
||||
if ($first) return $first->id;
|
||||
|
||||
// as last resort, create Uncategorized
|
||||
$cat = ForumCategory::create(['name' => 'Uncategorized', 'slug' => 'uncategorized']);
|
||||
return $cat->id;
|
||||
}
|
||||
|
||||
protected function resolveUserId($userId)
|
||||
{
|
||||
if (empty($userId)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// check users table in default connection
|
||||
if (\DB::table('users')->where('id', $userId)->exists()) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
return 1; // fallback system user
|
||||
}
|
||||
|
||||
protected function convertLegacyMessage($msg)
|
||||
{
|
||||
$converter = new BbcodeConverter();
|
||||
return $converter->convert($msg);
|
||||
}
|
||||
|
||||
protected function generateReport()
|
||||
{
|
||||
$this->info('Generating migration report');
|
||||
$legacy = DB::connection('legacy');
|
||||
|
||||
$legacyCounts = [
|
||||
'categories' => $legacy->table('forum_topics')->where('root_id','>',0)->distinct('root_id')->count('root_id'),
|
||||
'threads' => $legacy->table('forum_topics')->count(),
|
||||
'posts' => $legacy->table('forum_posts')->count(),
|
||||
];
|
||||
|
||||
$newCounts = [
|
||||
'categories' => ForumCategory::count(),
|
||||
'threads' => ForumThread::count(),
|
||||
'posts' => ForumPost::count(),
|
||||
'attachments' => \DB::table('forum_attachments')->count(),
|
||||
];
|
||||
|
||||
$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));
|
||||
}
|
||||
|
||||
protected function log(string $msg)
|
||||
{
|
||||
$line = '[' . date('c') . '] ' . $msg . "\n";
|
||||
file_put_contents($this->logPath, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
protected function migrateGallery(bool $dry, int $chunk)
|
||||
{
|
||||
$this->info('Migrating gallery (forum_topics_gallery → forum_attachments)');
|
||||
$legacy = DB::connection('legacy');
|
||||
|
||||
if (!$legacy->getSchemaBuilder()->hasTable('forum_topics_gallery')) {
|
||||
$this->info('No legacy forum_topics_gallery table found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
$query = $legacy->table('forum_topics_gallery')->orderBy('id');
|
||||
$total = $query->count();
|
||||
$this->info("Total gallery items to process: {$total}");
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
|
||||
foreach ($rows as $r) {
|
||||
$bar->advance();
|
||||
|
||||
// expected legacy fields: id, name, category (topic id), folder, datum, description
|
||||
$topicId = $r->category ?? ($r->topic_id ?? null);
|
||||
$fileName = $r->name ?? null;
|
||||
if (empty($topicId) || empty($fileName)) {
|
||||
$this->log('Skipping gallery row with missing topic or name: ' . json_encode($r));
|
||||
continue;
|
||||
}
|
||||
|
||||
$nid = floor($topicId / 100);
|
||||
$relativePath = "files/news/{$nid}/{$topicId}/{$fileName}";
|
||||
$publicPath = public_path($relativePath);
|
||||
|
||||
$fileSize = null;
|
||||
$mimeType = null;
|
||||
$width = null;
|
||||
$height = null;
|
||||
|
||||
if (file_exists($publicPath)) {
|
||||
$fileSize = filesize($publicPath);
|
||||
$img = @getimagesize($publicPath);
|
||||
if ($img !== false) {
|
||||
$width = $img[0];
|
||||
$height = $img[1];
|
||||
$mimeType = $img['mime'] ?? null;
|
||||
} else {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mimeType = finfo_file($finfo, $publicPath);
|
||||
finfo_close($finfo);
|
||||
}
|
||||
}
|
||||
|
||||
// find legacy first post id for this topic
|
||||
$legacy = DB::connection('legacy');
|
||||
$firstPostId = $legacy->table('forum_posts')
|
||||
->where('topic_id', $topicId)
|
||||
->orderBy('post_date')
|
||||
->value('post_id');
|
||||
|
||||
// map to new forum_posts id (we preserved ids when migrating)
|
||||
$postId = null;
|
||||
if ($firstPostId && \App\Models\ForumPost::where('id', $firstPostId)->exists()) {
|
||||
$postId = $firstPostId;
|
||||
} else {
|
||||
// fallback: find any post in new DB for thread
|
||||
$post = \App\Models\ForumPost::where('thread_id', $topicId)->orderBy('created_at')->first();
|
||||
if ($post) $postId = $post->id;
|
||||
}
|
||||
|
||||
if (empty($postId)) {
|
||||
$this->log('No target post found for gallery item: topic ' . $topicId . ' file ' . $fileName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dry) {
|
||||
$this->log("[dry] attach {$relativePath} -> post {$postId}");
|
||||
continue;
|
||||
}
|
||||
|
||||
\App\Models\ForumAttachment::create([
|
||||
'post_id' => $postId,
|
||||
'file_path' => $relativePath,
|
||||
'file_size' => $fileSize ?? 0,
|
||||
'mime_type' => $mimeType,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$bar->finish();
|
||||
$this->line('');
|
||||
$this->info('Gallery migrated');
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\CategoryPageController;
|
||||
use App\Http\Controllers\Web\BrowseGalleryController;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ArtworkIndexRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Services\Recommendations\SimilarArtworksService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -71,15 +70,27 @@ class ArtworkController extends Controller
|
||||
$foundArtwork = Artwork::where('slug', $artworkSlug)->first();
|
||||
}
|
||||
|
||||
// When the URL can represent a nested category path (e.g. /skins/audio/winamp),
|
||||
// prefer category rendering over artwork slug collisions so same-level groups
|
||||
// behave consistently.
|
||||
if (! empty($artworkSlug)) {
|
||||
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
|
||||
$resolvedCategory = Category::findByPath($contentTypeSlug, $combinedPath);
|
||||
if ($resolvedCategory) {
|
||||
return app(BrowseGalleryController::class)->content(request(), $contentTypeSlug, $combinedPath);
|
||||
}
|
||||
}
|
||||
|
||||
// If no artwork was found, treat the request as a category path.
|
||||
// The route places the artwork slug in the last segment, so include it
|
||||
// when forwarding to CategoryPageController to support arbitrary-depth paths
|
||||
// The route places the artwork slug in the last segment, so include it.
|
||||
// Delegate to BrowseGalleryController to render the same modern gallery
|
||||
// layout used by routes like /skins/audio.
|
||||
if (! $foundArtwork) {
|
||||
$combinedPath = $categoryPath;
|
||||
if ($artworkSlug) {
|
||||
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
|
||||
}
|
||||
return app(CategoryPageController::class)->show(request(), $contentTypeSlug, $combinedPath);
|
||||
return app(BrowseGalleryController::class)->content(request(), $contentTypeSlug, $combinedPath);
|
||||
}
|
||||
|
||||
if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) {
|
||||
|
||||
@@ -41,4 +41,40 @@ class ChatController extends Controller
|
||||
|
||||
return view('community.chat', compact('page_title', 'adHtml', 'chatHtml', 'smileys'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle legacy AJAX chat posts from old JS.
|
||||
*/
|
||||
public function post(Request $request)
|
||||
{
|
||||
$message = $request->input('message') ?? $request->input('chat_txt') ?? null;
|
||||
if (empty($message)) {
|
||||
return response()->json(['ok' => false, 'error' => 'empty_message'], 400);
|
||||
}
|
||||
|
||||
// Ensure legacy $_SESSION keys exist for Chat class (best-effort sync from Laravel session/auth)
|
||||
if (empty($_SESSION['web_login']['user_id'])) {
|
||||
$webLogin = session('web_login');
|
||||
if ($webLogin && isset($webLogin['user_id'])) {
|
||||
$_SESSION['web_login'] = $webLogin;
|
||||
} elseif (auth()->check()) {
|
||||
$user = auth()->user();
|
||||
$_SESSION['web_login'] = [
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username ?? $user->name ?? null,
|
||||
'status' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$chat = new \App\Chat();
|
||||
try {
|
||||
$chat->StoreMessage($message);
|
||||
$chat->UpdateChatFile('cron/chat_log.txt', 50);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['ok' => false, 'error' => 'store_failed', 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,76 @@ class ForumController extends Controller
|
||||
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)
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -26,6 +26,19 @@ class NewsController extends Controller
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
// redirect to canonical slug for SEO if available
|
||||
try {
|
||||
$correct = \Illuminate\Support\Str::slug($news->headline ?? 'news-' . $id);
|
||||
if ($slug !== $correct) {
|
||||
$qs = $request->getQueryString();
|
||||
$url = route('legacy.news.show', ['id' => $id, 'slug' => $correct]);
|
||||
if ($qs) $url .= '?' . $qs;
|
||||
return redirect($url, 301);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
$comments = DB::table('news_comment as c')
|
||||
->leftJoin('users as u', 'c.user_id', '=', 'u.user_id')
|
||||
|
||||
@@ -18,6 +18,30 @@ class ArtController extends Controller
|
||||
|
||||
public function show(Request $request, $id, $slug = null)
|
||||
{
|
||||
// canonicalize to new artwork route when possible
|
||||
try {
|
||||
$art = \App\Models\Artwork::find((int)$id);
|
||||
if ($art && !empty($art->slug)) {
|
||||
if ($slug !== $art->slug) {
|
||||
// attempt to derive contentType and category for route
|
||||
$category = $art->categories()->with('contentType')->first();
|
||||
if ($category && $category->contentType) {
|
||||
$contentTypeSlug = $category->contentType->slug ?? 'other';
|
||||
$categoryPath = $category->slug ?? $category->category_name ?? 'other';
|
||||
return redirect(route('artworks.show', [
|
||||
'contentTypeSlug' => $contentTypeSlug,
|
||||
'categoryPath' => $categoryPath,
|
||||
'artwork' => $art->slug,
|
||||
]), 301);
|
||||
} elseif (!empty($art->slug)) {
|
||||
// fallback: redirect to artwork slug only (may be handled by router)
|
||||
return redirect('/' . $art->slug, 301);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore and continue rendering legacy view
|
||||
}
|
||||
if ($request->isMethod('post') && $request->input('action') === 'store_comment') {
|
||||
if (auth()->check()) {
|
||||
try {
|
||||
|
||||
@@ -4,10 +4,10 @@ namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Http\Controllers\ArtworkController as ArtworkControllerAlias;
|
||||
|
||||
class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
{
|
||||
@@ -120,13 +120,32 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function showArtwork(Request $request, string $contentTypeSlug, string $categoryPath, string $artwork)
|
||||
public function showArtwork(...$params)
|
||||
{
|
||||
return app(\App\Http\Controllers\ArtController::class)->show(
|
||||
$request,
|
||||
strtolower($contentTypeSlug),
|
||||
trim($categoryPath, '/'),
|
||||
$artwork
|
||||
$req = request();
|
||||
$pathSegments = array_values(array_filter(explode('/', trim($req->path(), '/'))));
|
||||
|
||||
$contentTypeSlug = $params[0] ?? ($pathSegments[0] ?? null);
|
||||
$categoryPath = $params[1] ?? null;
|
||||
$artwork = $params[2] ?? null;
|
||||
|
||||
// If artwork wasn't provided (some route invocations supply fewer args),
|
||||
// derive it from the request path's last segment.
|
||||
if ($artwork === null) {
|
||||
$artwork = end($pathSegments) ?: null;
|
||||
}
|
||||
|
||||
$contentTypeSlug = strtolower((string) $contentTypeSlug);
|
||||
$categoryPath = $categoryPath !== null ? trim((string) $categoryPath, '/') : (isset($pathSegments[1]) ? implode('/', array_slice($pathSegments, 1, max(0, count($pathSegments) - 2))) : '');
|
||||
|
||||
// Normalize artwork param if route-model binding returned an Artwork model
|
||||
$artworkSlug = $artwork instanceof Artwork ? (string) $artwork->slug : (string) $artwork;
|
||||
|
||||
return app(\App\Http\Controllers\ArtworkController::class)->show(
|
||||
$req,
|
||||
$contentTypeSlug,
|
||||
$categoryPath,
|
||||
$artworkSlug
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,19 @@ class GalleryController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// canonicalize username in URL when possible
|
||||
try {
|
||||
$correctName = $user->name ?? $user->uname ?? null;
|
||||
if ($username && $correctName && $username !== $correctName) {
|
||||
$qs = $request->getQueryString();
|
||||
$url = route('legacy.gallery', ['id' => $user->id, 'username' => $correctName]);
|
||||
if ($qs) $url .= '?' . $qs;
|
||||
return redirect($url, 301);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$hits = 20;
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
@@ -36,6 +38,7 @@ class HomeController extends Controller
|
||||
$latestUploads = $this->artworks->getLatestArtworks(20);
|
||||
|
||||
// Forum news (root forum section id 2876)
|
||||
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')
|
||||
@@ -44,8 +47,13 @@ class HomeController extends Controller
|
||||
->orderBy('t1.post_date', 'desc')
|
||||
->limit(8)
|
||||
->get();
|
||||
} catch (QueryException $e) {
|
||||
Log::warning('Forum topics table missing or DB error when loading forum news', ['exception' => $e->getMessage()]);
|
||||
$forumNews = collect();
|
||||
}
|
||||
|
||||
// Our news (latest site news)
|
||||
try {
|
||||
$ourNews = DB::table('news as t1')
|
||||
->join('news_categories as c', 't1.category_id', '=', 'c.category_id')
|
||||
->join('users as u', 't1.user_id', '=', 'u.user_id')
|
||||
@@ -53,8 +61,13 @@ class HomeController extends Controller
|
||||
->orderBy('t1.create_date', 'desc')
|
||||
->limit(5)
|
||||
->get();
|
||||
} catch (QueryException $e) {
|
||||
Log::warning('News table missing or DB error when loading our news', ['exception' => $e->getMessage()]);
|
||||
$ourNews = collect();
|
||||
}
|
||||
|
||||
// Latest forum activity (exclude rootless and news root)
|
||||
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)
|
||||
@@ -64,6 +77,10 @@ class HomeController extends Controller
|
||||
->orderBy('t1.post_date', 'desc')
|
||||
->limit(10)
|
||||
->get();
|
||||
} catch (QueryException $e) {
|
||||
Log::warning('Forum topics table missing or DB error when loading latest forum activity', ['exception' => $e->getMessage()]);
|
||||
$latestForumActivity = collect();
|
||||
}
|
||||
|
||||
return view('web.home', compact(
|
||||
'page_title',
|
||||
|
||||
18
app/Http/Middleware/VerifyCsrfToken.php
Normal file
18
app/Http/Middleware/VerifyCsrfToken.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
|
||||
|
||||
class VerifyCsrfToken extends Middleware
|
||||
{
|
||||
/**
|
||||
* The URIs that should be excluded from CSRF verification.
|
||||
* Add legacy endpoints that post from old JS which don't include tokens.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
'chat_post',
|
||||
'chat_post/*',
|
||||
];
|
||||
}
|
||||
21
app/Models/ForumAttachment.php
Normal file
21
app/Models/ForumAttachment.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ForumAttachment extends Model
|
||||
{
|
||||
protected $table = 'forum_attachments';
|
||||
|
||||
protected $fillable = [
|
||||
'id','post_id','file_path','file_size','mime_type','width','height'
|
||||
];
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
public function post()
|
||||
{
|
||||
return $this->belongsTo(ForumPost::class, 'post_id');
|
||||
}
|
||||
}
|
||||
26
app/Models/ForumCategory.php
Normal file
26
app/Models/ForumCategory.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ForumCategory extends Model
|
||||
{
|
||||
protected $table = 'forum_categories';
|
||||
|
||||
protected $fillable = [
|
||||
'id', 'name', 'slug', 'parent_id', 'position'
|
||||
];
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
public function parent()
|
||||
{
|
||||
return $this->belongsTo(ForumCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function threads()
|
||||
{
|
||||
return $this->hasMany(ForumThread::class, 'category_id');
|
||||
}
|
||||
}
|
||||
33
app/Models/ForumPost.php
Normal file
33
app/Models/ForumPost.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ForumPost extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'forum_posts';
|
||||
|
||||
protected $fillable = [
|
||||
'id','thread_id','user_id','content','is_edited','edited_at'
|
||||
];
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
protected $casts = [
|
||||
'edited_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function thread()
|
||||
{
|
||||
return $this->belongsTo(ForumThread::class, 'thread_id');
|
||||
}
|
||||
|
||||
public function attachments()
|
||||
{
|
||||
return $this->hasMany(ForumAttachment::class, 'post_id');
|
||||
}
|
||||
}
|
||||
33
app/Models/ForumThread.php
Normal file
33
app/Models/ForumThread.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ForumThread extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'forum_threads';
|
||||
|
||||
protected $fillable = [
|
||||
'id','category_id','user_id','title','slug','content','views','is_locked','is_pinned','visibility','last_post_at'
|
||||
];
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
protected $casts = [
|
||||
'last_post_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(ForumCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function posts()
|
||||
{
|
||||
return $this->hasMany(ForumPost::class, 'thread_id');
|
||||
}
|
||||
}
|
||||
103
app/Services/BbcodeConverter.php
Normal file
103
app/Services/BbcodeConverter.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class BbcodeConverter
|
||||
{
|
||||
/**
|
||||
* Convert simple BBCode to HTML. Safe-escapes content and supports basic tags.
|
||||
*/
|
||||
public function convert(?string $text): string
|
||||
{
|
||||
if ($text === null) return '';
|
||||
|
||||
// Normalize line endings
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
|
||||
// Protect code blocks first
|
||||
$codeBlocks = [];
|
||||
$text = preg_replace_callback('/\[code\](.*?)\[\/code\]/is', function ($m) use (&$codeBlocks) {
|
||||
$idx = count($codeBlocks);
|
||||
$codeBlocks[$idx] = '<pre><code>' . htmlspecialchars($m[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code></pre>';
|
||||
return "__CODEBLOCK_{$idx}__";
|
||||
}, $text);
|
||||
|
||||
// Escape remaining text to avoid XSS
|
||||
$text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
// Basic tags
|
||||
$simple = [
|
||||
'/\[b\](.*?)\[\/b\]/is' => '<strong>$1</strong>',
|
||||
'/\[i\](.*?)\[\/i\]/is' => '<em>$1</em>',
|
||||
'/\[u\](.*?)\[\/u\]/is' => '<span style="text-decoration:underline;">$1</span>',
|
||||
'/\[s\](.*?)\[\/s\]/is' => '<del>$1</del>',
|
||||
];
|
||||
|
||||
foreach ($simple as $pat => $rep) {
|
||||
$text = preg_replace($pat, $rep, $text);
|
||||
}
|
||||
|
||||
// [url=link]text[/url] and [url]link[/url]
|
||||
$text = preg_replace_callback('/\[url=(.*?)\](.*?)\[\/url\]/is', function ($m) {
|
||||
$url = $this->sanitizeUrl(html_entity_decode($m[1]));
|
||||
$label = $m[2];
|
||||
return '<a href="' . $url . '" rel="noopener noreferrer" target="_blank">' . $label . '</a>';
|
||||
}, $text);
|
||||
$text = preg_replace_callback('/\[url\](.*?)\[\/url\]/is', function ($m) {
|
||||
$url = $this->sanitizeUrl(html_entity_decode($m[1]));
|
||||
return '<a href="' . $url . '" rel="noopener noreferrer" target="_blank">' . $url . '</a>';
|
||||
}, $text);
|
||||
|
||||
// [img]url[/img]
|
||||
$text = preg_replace_callback('/\[img\](.*?)\[\/img\]/is', function ($m) {
|
||||
$src = $this->sanitizeUrl(html_entity_decode($m[1]));
|
||||
return '<img src="' . $src . '" alt="" />';
|
||||
}, $text);
|
||||
|
||||
// [quote]...[/quote]
|
||||
$text = preg_replace('/\[quote\](.*?)\[\/quote\]/is', '<blockquote>$1</blockquote>', $text);
|
||||
|
||||
// [list] and [*]
|
||||
// Convert [list]...[*]item[*]...[/list] to <ul><li>...</li></ul>
|
||||
$text = preg_replace_callback('/\[list\](.*?)\[\/list\]/is', function ($m) {
|
||||
$items = preg_split('/\[\*\]/', $m[1]);
|
||||
$out = '';
|
||||
foreach ($items as $it) {
|
||||
$it = trim($it);
|
||||
if ($it === '') continue;
|
||||
$out .= '<li>' . $it . '</li>';
|
||||
}
|
||||
return '<ul>' . $out . '</ul>';
|
||||
}, $text);
|
||||
|
||||
// sizes and colors: simple inline styles
|
||||
$text = preg_replace('/\[size=(\d+)\](.*?)\[\/size\]/is', '<span style="font-size:$1px;">$2</span>', $text);
|
||||
$text = preg_replace('/\[color=(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)\](.*?)\[\/color\]/is', '<span style="color:$1;">$2</span>', $text);
|
||||
|
||||
// Preserve line breaks
|
||||
$text = nl2br($text);
|
||||
|
||||
// Restore code blocks
|
||||
if (!empty($codeBlocks)) {
|
||||
foreach ($codeBlocks as $i => $html) {
|
||||
$text = str_replace('__CODEBLOCK_' . $i . '__', $html, $text);
|
||||
}
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
protected function sanitizeUrl($url)
|
||||
{
|
||||
$url = trim($url);
|
||||
// allow relative paths
|
||||
if (strpos($url, 'http://') === 0 || strpos($url, 'https://') === 0 || strpos($url, '/') === 0) {
|
||||
return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
// fallback: prefix with http:// if looks like domain
|
||||
if (preg_match('/^[A-Za-z0-9\-\.]+(\:[0-9]+)?(\/.*)?$/', $url)) {
|
||||
return 'http://' . htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
return '#';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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::create('forum_categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->string('name', 150);
|
||||
$table->string('slug', 150)->unique();
|
||||
|
||||
$table->foreignId('parent_id')
|
||||
->nullable()
|
||||
->constrained('forum_categories')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->integer('position')->default(0);
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('forum_categories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('forum_posts')) {
|
||||
Schema::create('forum_posts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('thread_id')->constrained('forum_threads')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->longText('content');
|
||||
|
||||
$table->boolean('is_edited')->default(false);
|
||||
$table->timestamp('edited_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['thread_id','created_at']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('forum_posts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<?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::create('forum_threads', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->string('title');
|
||||
$table->string('slug')->unique();
|
||||
|
||||
$table->longText('content');
|
||||
|
||||
$table->unsignedInteger('views')->default(0);
|
||||
|
||||
$table->boolean('is_locked')->default(false);
|
||||
$table->boolean('is_pinned')->default(false);
|
||||
|
||||
$table->enum('visibility', ['public','members','staff'])->default('public');
|
||||
|
||||
$table->timestamp('last_post_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['category_id','last_post_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('forum_threads');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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::create('forum_attachments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('post_id')->constrained('forum_posts')->cascadeOnDelete();
|
||||
|
||||
$table->string('file_path');
|
||||
$table->unsignedBigInteger('file_size');
|
||||
|
||||
$table->string('mime_type', 100)->nullable();
|
||||
|
||||
$table->unsignedInteger('width')->nullable();
|
||||
$table->unsignedInteger('height')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('post_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('forum_attachments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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('forum_threads', function (Blueprint $table) {
|
||||
// drop existing FK if present
|
||||
try {
|
||||
$table->dropForeign(['category_id']);
|
||||
} catch (\Exception $e) {
|
||||
// ignore if not exists
|
||||
}
|
||||
|
||||
$table->foreign('category_id')->references('id')->on('forum_categories')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('forum_threads', function (Blueprint $table) {
|
||||
try {
|
||||
$table->dropForeign(['category_id']);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
|
||||
$table->foreign('category_id')->references('id')->on('categories')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -230,12 +230,25 @@
|
||||
var colCount = 1;
|
||||
try {
|
||||
var cols = window.getComputedStyle(grid).getPropertyValue('grid-template-columns');
|
||||
if (cols) colCount = Math.max(1, cols.trim().split(/\s+/).length);
|
||||
if (cols) {
|
||||
var parts = cols.trim().split(/\s+/).filter(function (p) { return p.length > 0; });
|
||||
colCount = Math.max(1, parts.length);
|
||||
}
|
||||
} catch (e) {
|
||||
colCount = 1;
|
||||
}
|
||||
|
||||
var refIndex = Math.max(0, cards.length - colCount);
|
||||
if (cards.length === 0) {
|
||||
if (trigger.parentNode) trigger.parentNode.removeChild(trigger);
|
||||
grid.appendChild(trigger);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the first index of the last row, then step back one row to
|
||||
// place the trigger at the start of the penultimate row.
|
||||
var lastRowStart = Math.floor((cards.length - 1) / colCount) * colCount;
|
||||
var penultimateRowStart = Math.max(0, lastRowStart - colCount);
|
||||
var refIndex = penultimateRowStart;
|
||||
var ref = cards[refIndex] || null;
|
||||
|
||||
if (trigger.parentNode) trigger.parentNode.removeChild(trigger);
|
||||
|
||||
@@ -230,12 +230,25 @@
|
||||
var colCount = 1;
|
||||
try {
|
||||
var cols = window.getComputedStyle(grid).getPropertyValue('grid-template-columns');
|
||||
if (cols) colCount = Math.max(1, cols.trim().split(/\s+/).length);
|
||||
if (cols) {
|
||||
var parts = cols.trim().split(/\s+/).filter(function (p) { return p.length > 0; });
|
||||
colCount = Math.max(1, parts.length);
|
||||
}
|
||||
} catch (e) {
|
||||
colCount = 1;
|
||||
}
|
||||
|
||||
var refIndex = Math.max(0, cards.length - colCount);
|
||||
if (cards.length === 0) {
|
||||
if (trigger.parentNode) trigger.parentNode.removeChild(trigger);
|
||||
grid.appendChild(trigger);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the first index of the last row, then step back one row to
|
||||
// place the trigger at the start of the penultimate row.
|
||||
var lastRowStart = Math.floor((cards.length - 1) / colCount) * colCount;
|
||||
var penultimateRowStart = Math.max(0, lastRowStart - colCount);
|
||||
var refIndex = penultimateRowStart;
|
||||
var ref = cards[refIndex] || null;
|
||||
|
||||
if (trigger.parentNode) trigger.parentNode.removeChild(trigger);
|
||||
|
||||
63
resources/views/community/forum/index.blade.php
Normal file
63
resources/views/community/forum/index.blade.php
Normal file
@@ -0,0 +1,63 @@
|
||||
@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
|
||||
69
resources/views/community/forum/posts.blade.php
Normal file
69
resources/views/community/forum/posts.blade.php
Normal file
@@ -0,0 +1,69 @@
|
||||
@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
|
||||
69
resources/views/community/forum/topic.blade.php
Normal file
69
resources/views/community/forum/topic.blade.php
Normal file
@@ -0,0 +1,69 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
@endphp
|
||||
|
||||
@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>
|
||||
<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
|
||||
</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">Threads</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">Thread</th>
|
||||
<th class="px-4 py-3 text-center font-medium">Posts</th>
|
||||
<th class="px-4 py-3 text-center font-medium">By</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 (($subtopics ?? []) as $sub)
|
||||
@php
|
||||
$id = (int) ($sub->topic_id ?? $sub->id ?? 0);
|
||||
$title = $sub->topic ?? $sub->title ?? 'Untitled';
|
||||
@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>
|
||||
@if (!empty($sub->discuss))
|
||||
<div class="mt-1 text-xs text-zinc-400">{!! Str::limit(strip_tags((string) $sub->discuss), 180) !!}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-zinc-300">{{ $sub->num_posts ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-center text-zinc-300">{{ $sub->uname ?? 'Unknown' }}</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-400">
|
||||
@if (!empty($sub->last_update))
|
||||
{{ Carbon::parse($sub->last_update)->format('d.m.Y H:i') }}
|
||||
@elseif (!empty($sub->post_date))
|
||||
{{ Carbon::parse($sub->post_date)->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 threads in this section yet.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isset($subtopics) && method_exists($subtopics, 'links'))
|
||||
<div class="mt-4">{{ $subtopics->withQueryString()->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -101,7 +101,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-10" data-gallery-pagination>
|
||||
@if ($artworks instanceof \Illuminate\Contracts\Pagination\Paginator)
|
||||
@if ($artworks instanceof \Illuminate\Contracts\Pagination\Paginator || $artworks instanceof \Illuminate\Contracts\Pagination\CursorPaginator)
|
||||
{{ method_exists($artworks, 'withQueryString') ? $artworks->withQueryString()->links() : $artworks->links() }}
|
||||
@endif
|
||||
</div>
|
||||
@@ -132,7 +132,39 @@
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] { display: none; }
|
||||
/* Keep pagination visible when JS enhances the gallery so users
|
||||
have a clear navigation control (numeric links for length-aware
|
||||
paginators, prev/next for cursor paginators). Make it compact. */
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] ul {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] li a,
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] li span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: #e6eef8;
|
||||
border: 1px solid rgba(255,255,255,0.04);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
[data-gallery-skeleton].is-loading { display: grid !important; grid-template-columns: inherit; gap: 1rem; }
|
||||
.nova-skeleton-card {
|
||||
border-radius: 1rem;
|
||||
|
||||
51
resources/views/layouts/legacy.blade.php
Normal file
51
resources/views/layouts/legacy.blade.php
Normal file
@@ -0,0 +1,51 @@
|
||||
@include('layouts._legacy')
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ app()->getLocale() }}">
|
||||
<head>
|
||||
<title>{{ $page_title ?? 'Skinbase' }}</title>
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="{{ $page_meta_description ?? '' }}">
|
||||
<meta name="keywords" content="{{ $page_meta_keywords ?? '' }}">
|
||||
@isset($page_canonical)
|
||||
<link rel="canonical" href="{{ $page_canonical }}" />
|
||||
@endisset
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
|
||||
@vite(['resources/css/app.css','resources/scss/nova.scss','resources/js/nova.js'])
|
||||
@stack('head')
|
||||
</head>
|
||||
<body class="bg-nova-900 text-white min-h-screen flex flex-col">
|
||||
|
||||
<div id="topbar-root"></div>
|
||||
@include('layouts.nova.toolbar')
|
||||
|
||||
<main class="flex-1 pt-16">
|
||||
<div class="mx-auto w-full max-w-7xl px-4 py-6 md:px-6 lg:px-8">
|
||||
@hasSection('sidebar')
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<section class="min-w-0">
|
||||
@yield('content')
|
||||
</section>
|
||||
<aside class="xl:sticky xl:top-24 xl:self-start">
|
||||
@yield('sidebar')
|
||||
</aside>
|
||||
</div>
|
||||
@else
|
||||
<section class="min-w-0">
|
||||
@yield('content')
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@include('layouts.nova.footer')
|
||||
|
||||
@stack('toolbar')
|
||||
@stack('scripts')
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,46 +1,36 @@
|
||||
{{-- Featured row (migrated from legacy/home/featured.blade.php) --}}
|
||||
<div class="row featured-row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<div class="featured-card effect2">
|
||||
<div class="card-header">Featured Artwork</div>
|
||||
<div class="card-body text-center">
|
||||
<a href="/art/{{ data_get($featured, 'id') }}/{{ Str::slug(data_get($featured, 'name') ?? 'artwork') }}" class="thumb-link">
|
||||
@php
|
||||
$fthumb = data_get($featured, 'thumb_url') ?? data_get($featured, 'thumb');
|
||||
@endphp
|
||||
<img src="{{ $fthumb }}" class="img-responsive featured-img" alt="{{ data_get($featured, 'name') }}">
|
||||
</a>
|
||||
<div class="featured-title">{{ data_get($featured, 'name') }}</div>
|
||||
<div class="featured-author">by {{ data_get($featured, 'uname') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<div class="featured-card effect2">
|
||||
<div class="card-header">Featured by Members Vote</div>
|
||||
<div class="card-body text-center">
|
||||
<a href="/art/{{ data_get($memberFeatured, 'id') }}/{{ Str::slug(data_get($memberFeatured, 'name') ?? 'artwork') }}" class="thumb-link">
|
||||
@php
|
||||
$mthumb = data_get($memberFeatured, 'thumb_url') ?? data_get($memberFeatured, 'thumb');
|
||||
@endphp
|
||||
<img src="{{ $mthumb }}" class="img-responsive featured-img" alt="{{ data_get($memberFeatured, 'name') }}">
|
||||
</a>
|
||||
<div class="featured-title">{{ data_get($memberFeatured, 'name') }}</div>
|
||||
<div class="featured-author">by {{ data_get($memberFeatured, 'uname') }}</div>
|
||||
</div>
|
||||
{{-- Featured row — use Nova cards for consistent layout with browse/gallery --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
@if(!empty($featured))
|
||||
<div>
|
||||
@include('web.partials._artwork_card', ['art' => $featured])
|
||||
</div>
|
||||
@else
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>Featured Artwork</strong></div>
|
||||
<div class="panel-body text-neutral-400">No featured artwork set.</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<div class="featured-card join-card effect2">
|
||||
<div class="card-header">Join to Skinbase World</div>
|
||||
<div class="card-body text-center">
|
||||
<a href="{{ route('register') }}" title="Join Skinbase">
|
||||
<img src="/gfx/sb_join.jpg" alt="Join SkinBase Community" class="img-responsive join-img center-block">
|
||||
</a>
|
||||
<div class="join-text">Join to Skinbase and be part of our great community! We have big collection of high quality Photography, Wallpapers and Skins for popular applications.</div>
|
||||
@if(!empty($memberFeatured))
|
||||
<div>
|
||||
@include('web.partials._artwork_card', ['art' => $memberFeatured])
|
||||
</div>
|
||||
@else
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>Member Featured</strong></div>
|
||||
<div class="panel-body text-neutral-400">No member featured artwork.</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<div class="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg p-4 text-center">
|
||||
<a href="{{ route('register') }}" title="Join Skinbase" class="inline-block mb-3">
|
||||
<img src="/gfx/sb_join.jpg" alt="Join SkinBase Community" class="w-full h-40 object-cover rounded-lg">
|
||||
</a>
|
||||
<div class="text-lg font-semibold text-white/90">Join Skinbase World</div>
|
||||
<p class="mt-2 text-sm text-neutral-400">Join Skinbase and be part of our community. Upload, share and explore curated photography and skins.</p>
|
||||
<a href="{{ route('register') }}" class="mt-3 inline-block px-4 py-2 rounded-md bg-sky-500 text-white">Create an account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,47 @@
|
||||
{{-- Latest uploads grid (migrated from legacy/home/uploads.blade.php) --}}
|
||||
<div class="gallery-grid">
|
||||
@foreach ($latestUploads as $upload)
|
||||
<div class="thumb-card effect2">
|
||||
@php
|
||||
$t = \App\Services\ThumbnailPresenter::present($upload, 'md');
|
||||
@endphp
|
||||
<a href="/art/{{ $t['id'] }}/{{ Str::slug($t['title'] ?: 'artwork') }}" title="{{ $t['title'] }}" class="thumb-link">
|
||||
<img src="{{ $t['url'] }}" @if(!empty($t['srcset'])) srcset="{{ $t['srcset'] }}" @endif alt="{{ $t['title'] }}" class="img-responsive">
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div> <!-- end .gallery-grid -->
|
||||
|
||||
{{-- Latest uploads grid — use same Nova gallery layout as /browse --}}
|
||||
<section class="px-6 pb-10 pt-6 md:px-10" data-nova-gallery data-gallery-type="home-uploads">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" data-gallery-grid>
|
||||
@forelse($latestUploads as $upload)
|
||||
@include('web.partials._artwork_card', ['art' => $upload])
|
||||
@empty
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>No uploads yet</strong></div>
|
||||
<div class="panel-body text-neutral-400">No recent uploads to show.</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-10" data-gallery-pagination>
|
||||
{{-- no pagination for home grid; kept for parity with browse layout --}}
|
||||
</div>
|
||||
<div class="hidden mt-8" data-gallery-skeleton></div>
|
||||
</section>
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
grid-auto-rows: 8px;
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 2600px) {
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] { display: none; }
|
||||
[data-gallery-skeleton].is-loading { display: grid !important; grid-template-columns: inherit; gap: 1rem; }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
<script src="/js/legacy-gallery-init.js" defer></script>
|
||||
@endpush
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ Route::get('/featured', [FeaturedArtworksController::class, 'index'])->name('leg
|
||||
Route::get('/featured-artworks', [FeaturedArtworksController::class, 'index'])->name('legacy.featured_artworks');
|
||||
Route::get('/daily-uploads', [DailyUploadsController::class, 'index'])->name('legacy.daily_uploads');
|
||||
Route::get('/chat', [ChatController::class, 'index'])->name('legacy.chat');
|
||||
Route::post('/chat_post', [ChatController::class, 'post'])->name('legacy.chat.post');
|
||||
|
||||
Route::get('/browse-categories', [BrowseCategoriesController::class, 'index'])->name('browse.categories');
|
||||
|
||||
|
||||
69
scripts/check_forum_counts.php
Normal file
69
scripts/check_forum_counts.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||
$kernel->bootstrap();
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function out($s) { echo $s . PHP_EOL; }
|
||||
|
||||
try {
|
||||
$default = DB::table('forum_posts')->where('topic_id', 309)->count();
|
||||
out('default: ' . $default);
|
||||
} catch (Throwable $e) {
|
||||
out('default: ERROR - ' . $e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
$legacy = DB::connection('legacy')->table('forum_posts')->where('topic_id', 309)->count();
|
||||
out('legacy: ' . $legacy);
|
||||
} catch (Throwable $e) {
|
||||
out('legacy: ERROR - ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Also check new forum_posts table (if different name)
|
||||
try {
|
||||
$new = DB::table('forum_posts')->where('thread_id', 309)->count();
|
||||
out('default(thread_id=): ' . $new);
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
// check forum_threads existence
|
||||
try {
|
||||
$threads = DB::table('forum_threads')->where('id', 309)->exists();
|
||||
out('forum_threads has 309: ' . ($threads ? 'yes' : 'no'));
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
try {
|
||||
$threadRow = DB::table('forum_threads')->where('id', 309)->first();
|
||||
out('thread row: ' . json_encode($threadRow));
|
||||
} catch (Throwable $e) { out('thread row: ERROR - ' . $e->getMessage()); }
|
||||
|
||||
try {
|
||||
$totalPosts = DB::table('forum_posts')->count();
|
||||
out('forum_posts total: ' . $totalPosts);
|
||||
} catch (Throwable $e) { out('forum_posts total: ERROR - ' . $e->getMessage()); }
|
||||
|
||||
try {
|
||||
$top = DB::table('forum_posts')
|
||||
->select('thread_id', DB::raw('count(*) as cnt'))
|
||||
->groupBy('thread_id')
|
||||
->orderByDesc('cnt')
|
||||
->limit(10)
|
||||
->get();
|
||||
out('top thread_id counts: ' . json_encode($top));
|
||||
} catch (Throwable $e) { out('top thread_id counts: ERROR - ' . $e->getMessage()); }
|
||||
|
||||
try {
|
||||
foreach ($top as $row) {
|
||||
$t = DB::table('forum_threads')->where('id', $row->thread_id)->first();
|
||||
out('thread ' . $row->thread_id . ' -> ' . ($t ? ($t->title ?? $t->topic ?? 'no-title') : 'missing'));
|
||||
}
|
||||
} catch (Throwable $e) { out('thread title lookup: ERROR - ' . $e->getMessage()); }
|
||||
|
||||
try {
|
||||
$matches = DB::table('forum_threads')->where('title', 'like', '%General Art%')->orWhere('slug', 'like', '%general-art%')->limit(10)->get();
|
||||
out('threads matching General Art: ' . json_encode($matches));
|
||||
} catch (Throwable $e) { out('threads matching: ERROR - ' . $e->getMessage()); }
|
||||
|
||||
exit(0);
|
||||
Reference in New Issue
Block a user