prepared and gallery fixes

This commit is contained in:
2026-02-19 08:36:32 +01:00
parent 8935065af1
commit c30fa5a392
36 changed files with 1437 additions and 104 deletions

View 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;
}
}

View 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');
}
}

View File

@@ -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()) {

View File

@@ -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]);
}
}

View File

@@ -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');
}

View File

@@ -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')

View File

@@ -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 {

View File

@@ -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
);
}

View File

@@ -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;

View File

@@ -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,34 +38,49 @@ class HomeController extends Controller
$latestUploads = $this->artworks->getLatestArtworks(20);
// Forum news (root forum section id 2876)
$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')
->limit(8)
->get();
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')
->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)
$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')
->selectRaw('t1.news_id, t1.headline, t1.user_id, t1.picture, t1.preview, u.uname, t1.create_date, t1.views, c.category_name, (SELECT COUNT(*) FROM news_comments WHERE news_id = t1.news_id) AS num_comments')
->orderBy('t1.create_date', 'desc')
->limit(5)
->get();
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')
->selectRaw('t1.news_id, t1.headline, t1.user_id, t1.picture, t1.preview, u.uname, t1.create_date, t1.views, c.category_name, (SELECT COUNT(*) FROM news_comments WHERE news_id = t1.news_id) AS num_comments')
->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)
$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')
->limit(10)
->get();
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')
->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',

View 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/*',
];
}

View 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');
}
}

View 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
View 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');
}
}

View 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');
}
}

View 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 '#';
}
}

View File

@@ -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();
});
}
};

View File

@@ -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);

View File

@@ -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);

View 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

View 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

View 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

View File

@@ -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;

View 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>

View File

@@ -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>
</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>
</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>

View File

@@ -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

View File

@@ -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');

View 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);