Current state
This commit is contained in:
35
app/Banner.php
Normal file
35
app/Banner.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
class Banner
|
||||
{
|
||||
public static function ShowResponsiveAd()
|
||||
{
|
||||
echo '<div class="responsive_ad">';
|
||||
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
|
||||
echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>';
|
||||
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
public static function ShowBanner300x250()
|
||||
{
|
||||
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
|
||||
echo '<ins class="adsbygoogle" style="display:inline-block;width:300px;height:250px" data-ad-client="ca-pub-6457864535683080" data-ad-slot="7579263359"></ins>';
|
||||
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
|
||||
}
|
||||
|
||||
public static function ShowBanner728x90()
|
||||
{
|
||||
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
|
||||
echo '<ins class="adsbygoogle" style="display:inline-block;width:728px;height:90px" data-ad-client="ca-pub-6457864535683080" data-ad-slot="1234567890"></ins>';
|
||||
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
|
||||
}
|
||||
|
||||
public static function ShowBannerGoogle300x250()
|
||||
{
|
||||
// alias to ShowBanner300x250 for compatibility
|
||||
self::ShowBanner300x250();
|
||||
}
|
||||
}
|
||||
100
app/Chat.php
Normal file
100
app/Chat.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Chat
|
||||
{
|
||||
public $username = "";
|
||||
public $nickname = "";
|
||||
|
||||
public function Authenticate()
|
||||
{
|
||||
}
|
||||
|
||||
public function StoreMessage($tekst)
|
||||
{
|
||||
$userId = $_SESSION['web_login']['user_id'] ?? null;
|
||||
$username = $_SESSION['web_login']['username'] ?? null;
|
||||
|
||||
if (empty($userId) || empty($username) || empty($tekst)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$last = DB::connection('legacy')->table('chat')
|
||||
->select('message')
|
||||
->where('user_id', $userId)
|
||||
->orderByDesc('chat_id')
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
if (!$last || ($last->message ?? '') !== $tekst) {
|
||||
DB::connection('legacy')->table('chat')->insert([
|
||||
'time' => now(),
|
||||
'sender' => $username,
|
||||
'user_id' => $userId,
|
||||
'message' => $tekst,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function UpdateChatFile($chat_file, $num_rows)
|
||||
{
|
||||
$output = "<ul>";
|
||||
|
||||
$chats = DB::connection('legacy')->table('chat')
|
||||
->select('time', 'sender', 'message')
|
||||
->orderByDesc('chat_id')
|
||||
->limit((int)$num_rows ?: 8)
|
||||
->get();
|
||||
|
||||
$x = 0;
|
||||
foreach ($chats as $chat) {
|
||||
$x++;
|
||||
$add = ($x % 2 === 0) ? ' class="odd" ' : '';
|
||||
$datetime = date("F jS @ H:i", strtotime($chat->time));
|
||||
$message = wordwrap($chat->message, 20, " ", true);
|
||||
$ime = wordwrap($chat->sender, 12, " ", true);
|
||||
|
||||
$output .= '<li ' . $add . '><';
|
||||
$output .= '<a href="/profile.php?uname=' . rawurlencode($chat->sender) . '" title="' . htmlspecialchars($datetime, ENT_QUOTES, 'UTF-8') . ' GMT+1">';
|
||||
$output .= htmlspecialchars($ime, ENT_QUOTES, 'UTF-8');
|
||||
$output .= '</a>> ';
|
||||
$output .= htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
|
||||
$output .= '</li>';
|
||||
}
|
||||
|
||||
$output .= '</ul>';
|
||||
|
||||
@file_put_contents(base_path($chat_file), $output);
|
||||
}
|
||||
|
||||
public function ShowOnline()
|
||||
{
|
||||
echo '<div id="oboks" name="boks">Loading...</div>';
|
||||
}
|
||||
|
||||
public function ShowChat($num_rows = 10, $username = null)
|
||||
{
|
||||
echo '<div id="chat_box" name="chat_box">Loading...</div>';
|
||||
|
||||
echo '<div class="row well">';
|
||||
if (!empty($_SESSION['web_login']['status'])) {
|
||||
echo '<form action="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '" method="post">';
|
||||
echo '<div class="col-sm-10">';
|
||||
echo '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">';
|
||||
echo '</div>';
|
||||
|
||||
echo '<div class="col-sm-2">';
|
||||
echo '<button type="submit" class="btn btn-success">Say</button>';
|
||||
echo '</div>';
|
||||
|
||||
echo '<input type="hidden" name="store_chat" value="true">';
|
||||
echo '</form>';
|
||||
} else {
|
||||
echo '<div class="clear alert alert-danger">You should be logged in to join a chat!</div>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
92
app/Console/Commands/ImportArtworkHashes.php
Normal file
92
app/Console/Commands/ImportArtworkHashes.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ImportArtworkHashes extends Command
|
||||
{
|
||||
protected $signature = 'artworks:import-hashes {file=artworks_hash_skinbase.csv} {--start=0} {--limit=0}';
|
||||
protected $description = 'Import artwork hash, file_ext and thumb_ext from CSV (id,hash,file_ext,thumb_ext)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$file = $this->argument('file');
|
||||
$path = base_path($file);
|
||||
|
||||
if (!is_readable($path)) {
|
||||
$this->error("CSV file not readable: {$path}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$handle = fopen($path, 'r');
|
||||
if (!$handle) {
|
||||
$this->error('Unable to open CSV file');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Read header
|
||||
$header = fgetcsv($handle);
|
||||
if ($header === false) {
|
||||
$this->error('CSV appears empty');
|
||||
fclose($handle);
|
||||
return 1;
|
||||
}
|
||||
|
||||
$start = (int) $this->option('start');
|
||||
$limit = (int) $this->option('limit');
|
||||
$rowIndex = 0;
|
||||
$bar = null;
|
||||
|
||||
// Optionally count lines for progress bar
|
||||
if ($limit === 0) {
|
||||
// We'll not determine total to keep memory low
|
||||
}
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
$rowIndex++;
|
||||
if ($rowIndex <= $start) {
|
||||
continue;
|
||||
}
|
||||
if ($limit > 0 && ($rowIndex - $start) > $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Expecting columns: id,hash,file_ext,thumb_ext
|
||||
$id = isset($row[0]) ? trim($row[0], "\" ") : null;
|
||||
$hash = isset($row[1]) ? trim($row[1], "\" ") : null;
|
||||
$file_ext = isset($row[2]) ? trim($row[2], "\" ") : null;
|
||||
$thumb_ext = isset($row[3]) ? trim($row[3], "\" ") : null;
|
||||
|
||||
if (empty($id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize null strings
|
||||
if (strtoupper($hash) === 'NULL') $hash = null;
|
||||
if (strtoupper($file_ext) === 'NULL') $file_ext = null;
|
||||
if (strtoupper($thumb_ext) === 'NULL') $thumb_ext = null;
|
||||
|
||||
try {
|
||||
DB::table('artworks')->where('id', $id)->limit(1)->update([
|
||||
'hash' => $hash,
|
||||
'file_ext' => $file_ext,
|
||||
'thumb_ext' => $thumb_ext,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Log and continue on duplicate / other DB errors
|
||||
$this->error("Row {$rowIndex} (id={$id}) failed: " . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rowIndex % 500 === 0) {
|
||||
$this->info("Processed {$rowIndex} rows");
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
$this->info('Import complete.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
193
app/Console/Commands/ImportCategories.php
Normal file
193
app/Console/Commands/ImportCategories.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ImportCategories extends Command
|
||||
{
|
||||
protected $signature = 'import:categories {path=database/artworks_categories.csv}';
|
||||
|
||||
protected $description = 'Import categories from legacy CSV into categories table';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$path = base_path($this->argument('path'));
|
||||
|
||||
if (!file_exists($path)) {
|
||||
$this->error("CSV file not found: {$path}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$file = new \SplFileObject($path);
|
||||
$file->setFlags(\SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY);
|
||||
|
||||
$header = null;
|
||||
$rows = [];
|
||||
|
||||
foreach ($file as $index => $row) {
|
||||
if ($row === [null] || $row === false) {
|
||||
continue;
|
||||
}
|
||||
if ($index === 0) {
|
||||
$header = $row;
|
||||
continue;
|
||||
}
|
||||
|
||||
// normalize row length
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
// build a map of rows by category id to allow ancestor lookups
|
||||
$map = [];
|
||||
foreach ($rows as $r) {
|
||||
$rid = isset($r[0]) ? (int)$r[0] : null;
|
||||
if ($rid) {
|
||||
$map[$rid] = $r;
|
||||
}
|
||||
}
|
||||
|
||||
// Temporarily disable foreign key checks to allow inserting parents out of order
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=0');
|
||||
|
||||
// truncate categories table to start fresh
|
||||
DB::table('categories')->truncate();
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$this->info('Inserting categories...');
|
||||
|
||||
// helper to walk ancestor chain and find a valid content_type_id
|
||||
$findAncestorContentType = function ($startRoot) use ($map) {
|
||||
$seen = [];
|
||||
$cur = $startRoot;
|
||||
while ($cur && isset($map[$cur]) && !in_array($cur, $seen, true)) {
|
||||
$seen[] = $cur;
|
||||
$ancestor = $map[$cur];
|
||||
$candidate = isset($ancestor[7]) ? (int)$ancestor[7] : null;
|
||||
if (!empty($candidate)) {
|
||||
// verify the content type exists in DB
|
||||
if (DB::table('content_types')->where('id', $candidate)->exists()) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
$cur = isset($ancestor[8]) ? (int)$ancestor[8] : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// helper to collect ancestor chain rows for debugging dump
|
||||
$collectAncestorChain = function ($startRoot) use ($map) {
|
||||
$chain = [];
|
||||
$seen = [];
|
||||
$cur = $startRoot;
|
||||
while ($cur && isset($map[$cur]) && !in_array($cur, $seen, true)) {
|
||||
$seen[] = $cur;
|
||||
$r = $map[$cur];
|
||||
$chain[] = [
|
||||
'id' => $cur,
|
||||
'name' => $r[1] ?? null,
|
||||
'section_id' => isset($r[7]) ? (int)$r[7] : null,
|
||||
'rootid' => isset($r[8]) ? (int)$r[8] : null,
|
||||
'raw' => $r,
|
||||
];
|
||||
$cur = isset($r[8]) ? (int)$r[8] : null;
|
||||
}
|
||||
return $chain;
|
||||
};
|
||||
|
||||
foreach ($rows as $row) {
|
||||
// CSV format: category_id,category_name,num_artworks,description,official_webpage,picture,views,section_id,rootid
|
||||
$origId = isset($row[0]) ? (int)$row[0] : null;
|
||||
$name = isset($row[1]) ? trim($row[1]) : '';
|
||||
$description = isset($row[3]) && $row[3] !== '' ? $row[3] : null;
|
||||
$image = isset($row[5]) && $row[5] !== '' ? $row[5] : null;
|
||||
$contentTypeId = isset($row[7]) ? (int)$row[7] : null;
|
||||
$rootId = isset($row[8]) ? (int)$row[8] : null;
|
||||
|
||||
// If the CSV `rootid` corresponds to a content type, it's a root for that content type => parent_id NULL
|
||||
if (!empty($rootId) && DB::table('content_types')->where('id', $rootId)->exists()) {
|
||||
$parentId = null;
|
||||
} else {
|
||||
// If content_type_id == parent_id then parent_id should be NULL
|
||||
$parentId = ($contentTypeId === $rootId || $rootId === 0) ? null : $rootId;
|
||||
}
|
||||
|
||||
// resolve missing/invalid content_type_id from ancestor chain (parent/root)
|
||||
$ctExists = !empty($contentTypeId) && DB::table('content_types')->where('id', $contentTypeId)->exists();
|
||||
if (!$ctExists) {
|
||||
if (!empty($rootId)) {
|
||||
$resolved = $findAncestorContentType($rootId);
|
||||
if ($resolved) {
|
||||
$contentTypeId = $resolved;
|
||||
$this->info("Resolved content_type_id for category {$origId} from ancestor: {$contentTypeId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// after resolution, verify content_type exists
|
||||
if (empty($contentTypeId) || !DB::table('content_types')->where('id', $contentTypeId)->exists()) {
|
||||
$this->error("Missing content_type_id for category {$origId}. Dumping data and aborting import.");
|
||||
|
||||
$dump = [
|
||||
'row' => $row,
|
||||
'resolved_root' => $rootId,
|
||||
'ancestor_chain' => $rootId ? $collectAncestorChain($rootId) : [],
|
||||
];
|
||||
|
||||
$this->line(json_encode($dump, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
throw new \RuntimeException('Aborting import: unresolved content_type_id for category ' . $origId);
|
||||
}
|
||||
|
||||
$slug = Str::slug($name) ?: 'cat-' . $origId;
|
||||
|
||||
// ensure uniqueness for slug within content_type
|
||||
$existing = DB::table('categories')
|
||||
->where('content_type_id', $contentTypeId)
|
||||
->where('slug', $slug)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
$slug = $slug . '-' . $origId;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
$data = [
|
||||
'id' => $origId,
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => $parentId,
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'description' => $description,
|
||||
'image' => $image,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
DB::table('categories')->insert($data);
|
||||
$this->line("Inserted category {$origId}: {$name}");
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
$this->info('Import completed.');
|
||||
return 0;
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
$this->error('Import failed: ' . $e->getMessage());
|
||||
return 1;
|
||||
} finally {
|
||||
// re-enable foreign key checks
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=1');
|
||||
}
|
||||
}
|
||||
}
|
||||
291
app/Console/Commands/ImportLegacyArtworks.php
Normal file
291
app/Console/Commands/ImportLegacyArtworks.php
Normal file
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Import artworks from legacy `wallz` table and attach categories via `connected` table.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan skinbase:import-legacy-artworks --chunk=500 --dry-run
|
||||
*/
|
||||
class ImportLegacyArtworks extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:import-legacy-artworks
|
||||
{--chunk=500 : chunk size for processing}
|
||||
{--limit= : maximum number of legacy rows to import}
|
||||
{--dry-run : do not persist any changes}
|
||||
{--legacy-connection=legacy : name of legacy DB connection}
|
||||
{--legacy-table=wallz : legacy artworks table name}
|
||||
{--connected-table=connected : legacy artwork->category table}
|
||||
';
|
||||
|
||||
protected $description = 'Import artworks from legacy DB (wallz) into new artworks table';
|
||||
|
||||
private function coerceUnsignedInt(mixed $value, int $default = 0): int
|
||||
{
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 1 : 0;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
return max(0, $value);
|
||||
}
|
||||
|
||||
if (is_float($value)) {
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '') {
|
||||
return $default;
|
||||
}
|
||||
if (is_numeric($trimmed)) {
|
||||
return max(0, (int) $trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function coerceString(mixed $value, string $default = ''): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$stringValue = trim((string) $value);
|
||||
|
||||
return $stringValue !== '' ? $stringValue : $default;
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$limit = $this->option('limit') ? (int) $this->option('limit') : null;
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$legacyConn = $this->option('legacy-connection');
|
||||
$legacyTable = $this->option('legacy-table');
|
||||
$connectedTable = $this->option('connected-table');
|
||||
|
||||
$this->info("Starting import from {$legacyConn}.{$legacyTable} (chunk={$chunk})");
|
||||
|
||||
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('id');
|
||||
|
||||
$processed = 0;
|
||||
|
||||
$query->chunkById($chunk, function ($rows) use (&$processed, $limit, $dryRun, $legacyConn, $connectedTable) {
|
||||
foreach ($rows as $row) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
return false; // stop chunking
|
||||
}
|
||||
|
||||
$legacyId = $row->id ?? null;
|
||||
|
||||
$title = $row->name ?? $row->title ?? ($row->headline ?? ('legacy-' . ($legacyId ?? Str::random(6))));
|
||||
$description = $row->description ?? $row->desc ?? null;
|
||||
|
||||
$slugBase = Str::slug(substr((string) $title, 0, 120));
|
||||
// Use cleaned title slug directly. If no title, fallback to artwork-<id|random>.
|
||||
$slug = $slugBase ? $slugBase : 'artwork-' . ($legacyId ?? Str::random(8));
|
||||
|
||||
$publishedAt = null;
|
||||
if (! empty($row->datum)) {
|
||||
$publishedAt = date('Y-m-d H:i:s', strtotime($row->datum));
|
||||
} elseif (! empty($row->created_at)) {
|
||||
$publishedAt = $row->created_at;
|
||||
}
|
||||
|
||||
// File mapping — try common legacy fields. Normalize and ensure file_path is not null.
|
||||
$rawFileName = $row->pic ?? $row->picture ?? $row->file ?? $row->fname ?? null;
|
||||
$fileName = null;
|
||||
$filePath = '';
|
||||
if (! empty($rawFileName) && trim((string) $rawFileName) !== '') {
|
||||
$fileName = trim((string) $rawFileName);
|
||||
// store legacy path under legacy/ folder, but do not move files here — admin can handle file migration
|
||||
$filePath = 'legacy/uploads/' . ltrim($fileName, '/');
|
||||
}
|
||||
|
||||
// derive mime type if missing (use extension mapping), fallback to application/octet-stream
|
||||
$mime = $row->mimetype ?? $row->mime ?? null;
|
||||
if (empty($mime) && $fileName) {
|
||||
$ext = strtolower(pathinfo((string) $fileName, PATHINFO_EXTENSION));
|
||||
$map = [
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'bmp' => 'image/bmp',
|
||||
'webp' => 'image/webp',
|
||||
'svg' => 'image/svg+xml',
|
||||
'ico' => 'image/x-icon',
|
||||
'zip' => 'application/zip',
|
||||
'pdf' => 'application/pdf',
|
||||
];
|
||||
|
||||
$mime = $map[$ext] ?? null;
|
||||
}
|
||||
if (empty($mime)) {
|
||||
$mime = 'application/octet-stream';
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => $row->id ?? null,
|
||||
// NOTE: artworks.user_id is NOT NULL (no FK constraint, but column cannot be null)
|
||||
'user_id' => $row->user_id ?? 1,
|
||||
'title' => (string) $title,
|
||||
'slug' => (string) $slug,
|
||||
'description' => $description,
|
||||
'file_name' => $fileName,
|
||||
// ensure non-null file_path to satisfy NOT NULL DB constraints
|
||||
'file_path' => $filePath ?? '',
|
||||
// legacy DB sometimes has no filesize; default to 0 to satisfy NOT NULL
|
||||
'file_size' => isset($row->filesize) && $row->filesize !== null ? (int) $row->filesize : (isset($row->size) && $row->size !== null ? (int) $row->size : 0),
|
||||
'mime_type' => $mime,
|
||||
'width' => $row->width ?? null,
|
||||
'height' => $row->height ?? null,
|
||||
'is_public' => isset($row->visible) ? (bool) $row->visible : true,
|
||||
'is_approved' => isset($row->approved) ? (bool) $row->approved : true,
|
||||
'published_at' => $publishedAt,
|
||||
];
|
||||
|
||||
// Coerce required NOT NULL columns to safe defaults (legacy data can be messy)
|
||||
$data['user_id'] = $this->coerceUnsignedInt($data['user_id'], 1);
|
||||
$data['file_name'] = $this->coerceString($data['file_name'], 'legacy-' . ($legacyId ?? Str::random(8)));
|
||||
$data['file_path'] = $this->coerceString($data['file_path'], 'legacy/uploads/' . $data['file_name']);
|
||||
$data['mime_type'] = $this->coerceString($data['mime_type'], 'application/octet-stream');
|
||||
$data['file_size'] = $this->coerceUnsignedInt($data['file_size'], 0);
|
||||
$data['width'] = $this->coerceUnsignedInt($data['width'], 0);
|
||||
$data['height'] = $this->coerceUnsignedInt($data['height'], 0);
|
||||
|
||||
$this->line('Importing legacy id=' . ($legacyId ?? 'unknown') . ' title=' . $data['title']);
|
||||
|
||||
if ($dryRun) {
|
||||
$processed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$art = null;
|
||||
|
||||
DB::connection()->transaction(function () use (&$art, $data, $legacyId, $legacyConn, $connectedTable) {
|
||||
// create artwork (guard against unique slug collisions)
|
||||
$baseSlug = $data['slug'];
|
||||
$attempt = 0;
|
||||
$slug = $baseSlug;
|
||||
while (Artwork::where('slug', $slug)->exists()) {
|
||||
$attempt++;
|
||||
$slug = $baseSlug . '-' . $attempt;
|
||||
}
|
||||
$data['slug'] = $slug;
|
||||
|
||||
// Preserve legacy primary ID if available and safe to do so.
|
||||
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
|
||||
$preserveId = (int) $legacyId;
|
||||
if (Artwork::where('id', $preserveId)->exists()) {
|
||||
// Avoid overwriting an existing artwork with the same id.
|
||||
throw new \RuntimeException("Artwork with id {$preserveId} already exists; skipping import for this legacy id.");
|
||||
}
|
||||
$data['id'] = $preserveId;
|
||||
}
|
||||
|
||||
// If we need to preserve the legacy primary id, perform a raw insert
|
||||
// so auto-increment doesn't assign a different id. Otherwise use Eloquent.
|
||||
if (! empty($data['id'])) {
|
||||
$insert = $data;
|
||||
$ts = date('Y-m-d H:i:s');
|
||||
if (! array_key_exists('created_at', $insert)) {
|
||||
$insert['created_at'] = $ts;
|
||||
}
|
||||
if (! array_key_exists('updated_at', $insert)) {
|
||||
$insert['updated_at'] = $ts;
|
||||
}
|
||||
|
||||
DB::table('artworks')->insert($insert);
|
||||
$art = Artwork::find($insert['id']);
|
||||
} else {
|
||||
$art = Artwork::create($data);
|
||||
}
|
||||
|
||||
// attach categories if connected table exists
|
||||
if (DB::connection($legacyConn)->getSchemaBuilder()->hasTable($connectedTable)) {
|
||||
// attempt to find category ids from connected table; common column names: wallz_id, art_id, connected_id
|
||||
$rows = DB::connection($legacyConn)->table($connectedTable)
|
||||
->where(function ($q) use ($legacyId) {
|
||||
$q->where('wallz_id', $legacyId)
|
||||
->orWhere('art_id', $legacyId)
|
||||
->orWhere('item_id', $legacyId);
|
||||
})->get();
|
||||
|
||||
$categoryIds = [];
|
||||
foreach ($rows as $r) {
|
||||
$cid = $r->category_id ?? $r->cat_id ?? $r->category ?? null;
|
||||
if ($cid) {
|
||||
// try to find matching Category in new DB by id or slug
|
||||
if (is_numeric($cid) && \App\Models\Category::where('id', $cid)->exists()) {
|
||||
$categoryIds[] = (int) $cid;
|
||||
} else {
|
||||
// maybe legacy stores slug
|
||||
$cat = \App\Models\Category::where('slug', $cid)->first();
|
||||
if ($cat) {
|
||||
$categoryIds[] = $cat->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($categoryIds)) {
|
||||
$art->categories()->syncWithoutDetaching(array_values(array_unique($categoryIds)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Post-insert verification: if we attempted to preserve the legacy id,
|
||||
// confirm the row exists with that id. Log mapping if preservation failed.
|
||||
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
|
||||
$preserveId = (int) $legacyId;
|
||||
$exists = Artwork::where('id', $preserveId)->exists();
|
||||
if (! $exists) {
|
||||
// If $art was created but with a different id, log mapping for manual reconciliation
|
||||
if ($art instanceof Artwork) {
|
||||
Log::warning('Imported legacy artwork but failed to preserve id', [
|
||||
'legacy_id' => $preserveId,
|
||||
'created_id' => $art->id,
|
||||
'slug' => $art->slug ?? null,
|
||||
]);
|
||||
} else {
|
||||
Log::warning('Legacy artwork not found after import', ['legacy_id' => $preserveId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$processed++;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Failed to import legacy id=' . ($legacyId ?? 'unknown') . ': ' . $e->getMessage());
|
||||
Log::error('ImportLegacyArtworks error', [
|
||||
'legacy_id' => $legacyId,
|
||||
'error' => $e->getMessage(),
|
||||
'data' => $data ?? null,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, 'id');
|
||||
|
||||
$this->info('Import complete. Processed: ' . $processed);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
203
app/Console/Commands/ImportLegacyUsers.php
Normal file
203
app/Console/Commands/ImportLegacyUsers.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ImportLegacyUsers extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users}';
|
||||
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
|
||||
|
||||
protected array $usedUsernames = [];
|
||||
protected array $usedEmails = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->usedUsernames = User::pluck('username', 'username')->filter()->all();
|
||||
$this->usedEmails = User::pluck('email', 'email')->filter()->all();
|
||||
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
if (! DB::connection('legacy')->getPdo()) {
|
||||
$this->error('Legacy DB connection "legacy" is not configured or reachable.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
DB::connection('legacy')
|
||||
->table('users')
|
||||
->chunkById($chunk, function ($rows) use (&$imported, &$skipped) {
|
||||
$ids = $rows->pluck('user_id')->all();
|
||||
$stats = DB::connection('legacy')
|
||||
->table('users_statistics')
|
||||
->whereIn('user_id', $ids)
|
||||
->get()
|
||||
->keyBy('user_id');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$this->importRow($row, $stats[$row->user_id] ?? null);
|
||||
$imported++;
|
||||
} catch (\Throwable $e) {
|
||||
$skipped++;
|
||||
$this->warn("Skip user_id {$row->user_id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}, 'user_id');
|
||||
|
||||
$this->info("Imported: {$imported}, Skipped: {$skipped}");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function importRow($row, $statRow = null): void
|
||||
{
|
||||
$legacyId = (int) $row->user_id;
|
||||
$baseUsername = $this->sanitizeUsername($row->uname ?: ('user'.$legacyId));
|
||||
$username = $this->uniqueUsername($baseUsername);
|
||||
|
||||
$email = $this->prepareEmail($row->email ?? null, $username);
|
||||
|
||||
$legacyPassword = $row->password2 ?: $row->password ?: null;
|
||||
|
||||
// Optionally force-reset every imported user's password to a secure random value.
|
||||
if ($this->option('force-reset-all')) {
|
||||
$this->warn("Force-reset-all enabled: generating secure password for user_id {$row->user_id}.");
|
||||
$passwordHash = Hash::make(Str::random(64));
|
||||
} else {
|
||||
// Force-reset known weak default passwords (e.g. "abc123").
|
||||
if ($legacyPassword !== null && trim($legacyPassword) === 'abc123') {
|
||||
$this->warn("Weak password 'abc123' detected for user_id {$row->user_id}; forcing reset.");
|
||||
$passwordHash = Hash::make(Str::random(64));
|
||||
} else {
|
||||
$passwordHash = Hash::make($legacyPassword ?: Str::random(32));
|
||||
}
|
||||
}
|
||||
|
||||
$uploads = $this->sanitizeStatValue($statRow->uploads ?? 0);
|
||||
$downloads = $this->sanitizeStatValue($statRow->downloads ?? 0);
|
||||
$pageviews = $this->sanitizeStatValue($statRow->pageviews ?? 0);
|
||||
$awards = $this->sanitizeStatValue($statRow->awards ?? 0);
|
||||
|
||||
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
|
||||
$now = now();
|
||||
|
||||
DB::table('users')->insert([
|
||||
'id' => $legacyId,
|
||||
'username' => $username,
|
||||
'name' => $row->real_name ?: $username,
|
||||
'email' => $email,
|
||||
'password' => $passwordHash,
|
||||
'is_active' => (int) ($row->active ?? 1) === 1,
|
||||
'needs_password_reset' => true,
|
||||
'role' => 'user',
|
||||
'legacy_password_algo' => null,
|
||||
'last_visit_at' => $row->LastVisit ?: null,
|
||||
'created_at' => $row->joinDate ?: $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('user_profiles')->insert([
|
||||
'user_id' => $legacyId,
|
||||
'bio' => $row->about_me ?: $row->description ?: null,
|
||||
'avatar' => $row->picture ?: null,
|
||||
'cover_image' => $row->cover_art ?: null,
|
||||
'country' => $row->country ?: null,
|
||||
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
|
||||
'language' => $row->lang ?: null,
|
||||
'birthdate' => $row->birth ?: null,
|
||||
'gender' => $row->gender ?: 'X',
|
||||
'website' => $row->web ?: null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if (!empty($row->web)) {
|
||||
DB::table('user_social_links')->insert([
|
||||
'user_id' => $legacyId,
|
||||
'platform' => 'website',
|
||||
'url' => $row->web,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('user_statistics')->insert([
|
||||
'user_id' => $legacyId,
|
||||
'uploads' => $uploads,
|
||||
'downloads' => $downloads,
|
||||
'pageviews' => $pageviews,
|
||||
'awards' => $awards,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure statistic values are safe for unsigned DB columns.
|
||||
*/
|
||||
protected function sanitizeStatValue($value): int
|
||||
{
|
||||
$n = is_numeric($value) ? (int) $value : 0;
|
||||
if ($n < 0) {
|
||||
return 0;
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
protected function sanitizeUsername(string $username): string
|
||||
{
|
||||
$username = strtolower(trim($username));
|
||||
$username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user';
|
||||
return trim($username, '.-') ?: 'user';
|
||||
}
|
||||
|
||||
protected function uniqueUsername(string $base): string
|
||||
{
|
||||
$name = $base;
|
||||
$i = 1;
|
||||
while (isset($this->usedUsernames[$name]) || DB::table('users')->where('username', $name)->exists()) {
|
||||
$name = $base . '-' . $i;
|
||||
$i++;
|
||||
}
|
||||
$this->usedUsernames[$name] = $name;
|
||||
return $name;
|
||||
}
|
||||
|
||||
protected function prepareEmail(?string $legacyEmail, string $username): string
|
||||
{
|
||||
$legacyEmail = $legacyEmail ? strtolower(trim($legacyEmail)) : null;
|
||||
$baseLocal = $this->sanitizeEmailLocal($username);
|
||||
$domain = 'users.skinbase.org';
|
||||
|
||||
$email = $legacyEmail ?: ($baseLocal . '@' . $domain);
|
||||
$email = $this->uniqueEmail($email, $baseLocal, $domain);
|
||||
return $email;
|
||||
}
|
||||
|
||||
protected function uniqueEmail(string $email, string $baseLocal, string $domain): string
|
||||
{
|
||||
$i = 1;
|
||||
$local = explode('@', $email)[0];
|
||||
$current = $email;
|
||||
while (isset($this->usedEmails[$current]) || DB::table('users')->where('email', $current)->exists()) {
|
||||
$current = $local . $i . '@' . $domain;
|
||||
$i++;
|
||||
}
|
||||
$this->usedEmails[$current] = $current;
|
||||
return $current;
|
||||
}
|
||||
|
||||
protected function sanitizeEmailLocal(string $value): string
|
||||
{
|
||||
$local = strtolower(trim($value));
|
||||
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
|
||||
return trim($local, '.-') ?: 'user';
|
||||
}
|
||||
}
|
||||
152
app/Console/Commands/MigrateFeaturedWorks.php
Normal file
152
app/Console/Commands/MigrateFeaturedWorks.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class MigrateFeaturedWorks extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'migrate:featured-works {--dry-run : Do not write any rows} {--limit=0 : Stop after inserting this many rows} {--legacy-connection=legacy : name of legacy DB connection} {--legacy-table=featured_works : legacy table name} {--start-id=0 : Start processing from this legacy featured_id} {--chunk=500 : Chunk size for processing}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Migrate rows from legacy featured_works into artwork_features safely';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$limit = (int) $this->option('limit');
|
||||
|
||||
$this->info('Starting migration from `featured_works` to `artwork_features`');
|
||||
if ($dryRun) {
|
||||
$this->info('Running in dry-run mode; no inserts will be performed.');
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
$skipped = 0;
|
||||
$total = 0;
|
||||
|
||||
$startId = (int) $this->option('start-id');
|
||||
$chunk = (int) $this->option('chunk');
|
||||
|
||||
$mapping = [
|
||||
3 => 10, // Gold -> high priority
|
||||
2 => 20, // Silver
|
||||
1 => 30, // Bronze
|
||||
4 => 50, // Featured
|
||||
0 => 100 // default
|
||||
];
|
||||
|
||||
$legacyConn = $this->option('legacy-connection');
|
||||
$legacyTable = $this->option('legacy-table');
|
||||
|
||||
$this->info("Reading from legacy connection '{$legacyConn}' table '{$legacyTable}'");
|
||||
|
||||
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('featured_id');
|
||||
if ($startId > 0) {
|
||||
$this->info("Resuming from featured_id >= {$startId}");
|
||||
$query = $query->where('featured_id', '>=', $startId);
|
||||
}
|
||||
|
||||
$query->chunkById($chunk, function ($rows) use (&$inserted, &$skipped, &$total, $dryRun, $limit, $mapping) {
|
||||
foreach ($rows as $row) {
|
||||
$total++;
|
||||
|
||||
if ($limit > 0 && $inserted >= $limit) {
|
||||
return false; // stop chunking
|
||||
}
|
||||
|
||||
$artworkId = isset($row->artwork_id) ? (int) $row->artwork_id : 0;
|
||||
|
||||
if ($artworkId <= 0) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify artwork exists
|
||||
$exists = DB::table('artworks')->where('id', $artworkId)->exists();
|
||||
if (! $exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Avoid duplicate active feature for same artwork
|
||||
$already = DB::table('artwork_features')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
|
||||
if ($already) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine featured_at
|
||||
$postDate = $row->post_date ?? null;
|
||||
if (empty($postDate) || $postDate === '0000-00-00' || $postDate === '0000-00-00 00:00:00') {
|
||||
$featuredAt = Carbon::now();
|
||||
} else {
|
||||
try {
|
||||
$featuredAt = Carbon::parse($postDate);
|
||||
} catch (\Throwable $e) {
|
||||
$featuredAt = Carbon::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Map priority from legacy 'type'
|
||||
$type = isset($row->type) ? (int) $row->type : 0;
|
||||
$priority = $mapping[$type] ?? 100;
|
||||
|
||||
// Validate created_by: only set if a valid user id exists in new users table
|
||||
$createdBy = isset($row->user_id) ? (int) $row->user_id : null;
|
||||
if ($createdBy <= 0 || ! DB::table('users')->where('id', $createdBy)->exists()) {
|
||||
$createdBy = null;
|
||||
}
|
||||
|
||||
$record = [
|
||||
'artwork_id' => $artworkId,
|
||||
'featured_at' => $featuredAt->toDateTimeString(),
|
||||
'expires_at' => null,
|
||||
'priority' => $priority,
|
||||
'label' => null,
|
||||
'note' => $row->description ?? null,
|
||||
'is_active' => 1,
|
||||
'created_by' => $createdBy,
|
||||
'created_at' => Carbon::now()->toDateTimeString(),
|
||||
'updated_at' => Carbon::now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line('[dry] Insert: artwork_id=' . $artworkId . ' featured_at=' . $record['featured_at'] . ' priority=' . $priority);
|
||||
$inserted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::table('artwork_features')->insert($record);
|
||||
$inserted++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Failed to insert artwork_id=' . $artworkId . ' : ' . $e->getMessage());
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Return true to continue, false to stop chunking
|
||||
return ($limit > 0 && $inserted >= $limit) ? false : true;
|
||||
}, 'featured_id');
|
||||
|
||||
$this->info("Done. Processed: {$total}, Inserted: {$inserted}, Skipped: {$skipped}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
60
app/Console/Commands/ResetAllUserPasswords.php
Normal file
60
app/Console/Commands/ResetAllUserPasswords.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ResetAllUserPasswords extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'skinbase:reset-all-passwords {--chunk=500 : Chunk size for processing} {--yes : Skip confirmation}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Reset all user passwords to secure random values and flag for password reset.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! $this->option('yes') && ! $this->confirm('This will replace every user password with a secure random value and mark accounts as needing a reset. Continue?')) {
|
||||
$this->info('Aborted. No changes were made.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$count = DB::table('users')->count();
|
||||
if ($count === 0) {
|
||||
$this->info('No users found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$bar = $this->output->createProgressBar($count);
|
||||
$bar->start();
|
||||
|
||||
DB::table('users')->orderBy('id')->chunkById($chunk, function ($rows) use ($bar) {
|
||||
foreach ($rows as $row) {
|
||||
DB::table('users')->where('id', $row->id)->update([
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'needs_password_reset' => true,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$bar->advance();
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
$this->info('All user passwords reset and accounts flagged for password reset.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
41
app/Console/Kernel.php
Normal file
41
app/Console/Kernel.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console;
|
||||
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
use App\Console\Commands\ImportLegacyUsers;
|
||||
use App\Console\Commands\ImportCategories;
|
||||
use App\Console\Commands\MigrateFeaturedWorks;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
/**
|
||||
* The Artisan commands provided by your application.
|
||||
*
|
||||
* @var array<int, class-string>
|
||||
*/
|
||||
protected $commands = [
|
||||
ImportLegacyUsers::class,
|
||||
ImportCategories::class,
|
||||
MigrateFeaturedWorks::class,
|
||||
\App\Console\Commands\ResetAllUserPasswords::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Define the application's command schedule.
|
||||
*/
|
||||
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
|
||||
{
|
||||
// $schedule->command('inspire')->hourly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the commands for the application.
|
||||
*/
|
||||
protected function commands(): void
|
||||
{
|
||||
$this->load(__DIR__ . '/Commands');
|
||||
|
||||
require base_path('routes/console.php');
|
||||
}
|
||||
}
|
||||
78
app/Helpers/Thumb.php
Normal file
78
app/Helpers/Thumb.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use App\Services\ThumbnailService;
|
||||
|
||||
class Thumb
|
||||
{
|
||||
/**
|
||||
* Return a thumbnail URL.
|
||||
*
|
||||
* Usage:
|
||||
* - `Thumb::url($filePath)` - fallback mapping by filename or Storage::url
|
||||
* - `Thumb::url($filePath, $id, $ext)` - resolve hash-based CDN URL when possible
|
||||
*
|
||||
* @param string|null $filePath
|
||||
* @param int|null $id
|
||||
* @param string|null $ext
|
||||
* @param int $size // legacy size code: 4 -> small(320), 6 -> medium(600)
|
||||
* @return string
|
||||
*/
|
||||
public static function url(?string $filePath, ?int $id = null, ?string $ext = null, int $size = 6): string
|
||||
{
|
||||
return ThumbnailService::url($filePath, $id, $ext, $size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a new-style thumbnail URL using hash and extension.
|
||||
* Example: http://files.skinbase.org/md/43/f8/43f87a...360.webp
|
||||
*
|
||||
* @param string|null $hash
|
||||
* @param string|null $ext
|
||||
* @param string $sizeKey One of sm, md, lg, xl
|
||||
* @return string|null
|
||||
*/
|
||||
public static function fromHash(?string $hash, ?string $ext, string $sizeKey = 'md'): ?string
|
||||
{
|
||||
return ThumbnailService::fromHash($hash, $ext, $sizeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a simple srcset for responsive thumbnails.
|
||||
* Uses sm (320w) and md (600w) by default to match legacy sizes.
|
||||
*
|
||||
* @param string|null $hash
|
||||
* @param string|null $ext
|
||||
* @return string|null
|
||||
*/
|
||||
public static function srcsetFromHash(?string $hash, ?string $ext): ?string
|
||||
{
|
||||
return ThumbnailService::srcsetFromHash($hash, $ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return encoded id string using legacy algorithm or fallback base62.
|
||||
*/
|
||||
public static function encodeId(int $id): string
|
||||
{
|
||||
if (class_exists('\App\Services\LegacyService') && method_exists('\App\Services\LegacyService', 'encode')) {
|
||||
return \App\Services\LegacyService::encode($id);
|
||||
}
|
||||
|
||||
return self::base62encode($id);
|
||||
}
|
||||
|
||||
private static function base62encode(int $val, int $base = 62, string $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'): string
|
||||
{
|
||||
$str = '';
|
||||
if ($val < 0) return $str;
|
||||
do {
|
||||
$i = $val % $base;
|
||||
$str = $chars[$i] . $str;
|
||||
$val = intdiv($val - $i, $base);
|
||||
} while ($val > 0);
|
||||
return $str;
|
||||
}
|
||||
}
|
||||
46
app/Http/Controllers/Api/ArtworkController.php
Normal file
46
app/Http/Controllers/Api/ArtworkController.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ArtworkController extends Controller
|
||||
{
|
||||
protected ArtworkService $service;
|
||||
|
||||
public function __construct(ArtworkService $service)
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/artworks/{slug}
|
||||
* Returns a single public artwork resource by slug.
|
||||
*/
|
||||
public function show(string $slug)
|
||||
{
|
||||
$artwork = $this->service->getPublicArtworkBySlug($slug);
|
||||
|
||||
// Return the artwork instance (service already loads lightweight relations).
|
||||
// Log resolved resource for debugging failing test assertions.
|
||||
// Return the resolved payload directly to avoid JsonResource wrapping inconsistencies
|
||||
return response()->json((new ArtworkResource($artwork))->resolve(), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/categories/{slug}/artworks
|
||||
* Uses route-model binding for Category (slug). Returns paginated list resource.
|
||||
*/
|
||||
public function categoryArtworks(Request $request, Category $category)
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 24);
|
||||
|
||||
$paginator = $this->service->getCategoryArtworks($category, $perPage);
|
||||
|
||||
return ArtworkListResource::collection($paginator);
|
||||
}
|
||||
}
|
||||
78
app/Http/Controllers/Api/BrowseController.php
Normal file
78
app/Http/Controllers/Api/BrowseController.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class BrowseController extends Controller
|
||||
{
|
||||
protected ArtworkService $service;
|
||||
|
||||
public function __construct(ArtworkService $service)
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/browse
|
||||
* Public browse feed powered by authoritative artworks table.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = min(max((int) $request->get('per_page', 24), 1), 100);
|
||||
|
||||
$paginator = $this->service->browsePublicArtworks($perPage);
|
||||
|
||||
return ArtworkListResource::collection($paginator);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/browse/{content_type}
|
||||
* Browse by content type slug.
|
||||
*/
|
||||
public function byContentType(Request $request, string $contentTypeSlug)
|
||||
{
|
||||
$perPage = min(max((int) $request->get('per_page', 24), 1), 100);
|
||||
|
||||
try {
|
||||
$paginator = $this->service->getArtworksByContentType($contentTypeSlug, $perPage);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($paginator->count() === 0) {
|
||||
return response()->json(['message' => 'Gone'], 410);
|
||||
}
|
||||
|
||||
return ArtworkListResource::collection($paginator);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/browse/{content_type}/{category_path}
|
||||
* Browse by content type + category path (slug segments).
|
||||
*/
|
||||
public function byCategoryPath(Request $request, string $contentTypeSlug, string $categoryPath)
|
||||
{
|
||||
$perPage = min(max((int) $request->get('per_page', 24), 1), 100);
|
||||
|
||||
$slugs = array_merge([
|
||||
strtolower($contentTypeSlug),
|
||||
], array_values(array_filter(explode('/', trim($categoryPath, '/')))));
|
||||
|
||||
try {
|
||||
$paginator = $this->service->getArtworksByCategoryPath($slugs, $perPage);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($paginator->count() === 0) {
|
||||
return response()->json(['message' => 'Gone'], 410);
|
||||
}
|
||||
|
||||
return ArtworkListResource::collection($paginator);
|
||||
}
|
||||
}
|
||||
64
app/Http/Controllers/ArtworkController.php
Normal file
64
app/Http/Controllers/ArtworkController.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ArtworkIndexRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ArtworkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Browse artworks with optional category filtering.
|
||||
* Uses cursor pagination (no offset pagination) and only returns public, approved, not-deleted items.
|
||||
*/
|
||||
public function index(ArtworkIndexRequest $request, ?Category $category = null): View
|
||||
{
|
||||
$perPage = (int) ($request->get('per_page', 24));
|
||||
|
||||
$query = Artwork::public()->published();
|
||||
|
||||
if ($category) {
|
||||
$query->whereHas('categories', function ($q) use ($category) {
|
||||
$q->where('categories.id', $category->id);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$q = $request->get('q');
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('title', 'like', '%' . $q . '%')
|
||||
->orWhere('description', 'like', '%' . $q . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$sort = $request->get('sort', 'latest');
|
||||
if ($sort === 'oldest') {
|
||||
$query->orderBy('published_at', 'asc');
|
||||
} else {
|
||||
$query->orderByDesc('published_at');
|
||||
}
|
||||
|
||||
// Important: do NOT eager-load artwork_stats in listings
|
||||
$artworks = $query->cursorPaginate($perPage);
|
||||
|
||||
return view('artworks.index', [
|
||||
'artworks' => $artworks,
|
||||
'category' => $category,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a single artwork by slug. Ensure it's public, approved and not deleted.
|
||||
*/
|
||||
public function show(Artwork $artwork): View
|
||||
{
|
||||
if (! $artwork->is_public || ! $artwork->is_approved || $artwork->trashed()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('artworks.show', ['artwork' => $artwork]);
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
47
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
40
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
40
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
62
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/Auth/PasswordController.php
Normal file
29
app/Http/Controllers/Auth/PasswordController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
44
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
50
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
27
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/BrowseCategoriesController.php
Normal file
34
app/Http/Controllers/BrowseCategoriesController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BrowseCategoriesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// Use Eloquent models for canonical category URLs and grouping
|
||||
$contentTypes = \App\Models\ContentType::with(['rootCategories.children'])->orderBy('id')->get();
|
||||
|
||||
// Prepare categories grouped by content type and a flat list of root categories
|
||||
$categoriesByType = [];
|
||||
$categories = collect();
|
||||
foreach ($contentTypes as $ct) {
|
||||
$rootCats = $ct->rootCategories;
|
||||
foreach ($rootCats as $cat) {
|
||||
// Attach subcategories
|
||||
$cat->subcategories = $cat->children;
|
||||
$categories->push($cat);
|
||||
}
|
||||
$categoriesByType[$ct->slug] = $rootCats;
|
||||
}
|
||||
|
||||
return view('browse-categories', [
|
||||
'contentTypes' => $contentTypes,
|
||||
'categoriesByType' => $categoriesByType,
|
||||
'categories' => $categories,
|
||||
]);
|
||||
}
|
||||
}
|
||||
81
app/Http/Controllers/CategoryPageController.php
Normal file
81
app/Http/Controllers/CategoryPageController.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class CategoryPageController extends Controller
|
||||
{
|
||||
public function show(Request $request, string $contentTypeSlug, string $categoryPath = null)
|
||||
{
|
||||
$contentType = ContentType::where('slug', strtolower($contentTypeSlug))->first();
|
||||
if (! $contentType) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($categoryPath === null || $categoryPath === '') {
|
||||
// No category path: show content-type landing page (e.g., /wallpapers)
|
||||
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
|
||||
$page_title = $contentType->name;
|
||||
$page_meta_description = $contentType->description ?? ($contentType->name . ' artworks on Skinbase');
|
||||
|
||||
return view('legacy.content-type', compact(
|
||||
'contentType',
|
||||
'rootCategories',
|
||||
'page_title',
|
||||
'page_meta_description'
|
||||
));
|
||||
}
|
||||
|
||||
$segments = array_filter(explode('/', $categoryPath));
|
||||
if (empty($segments)) {
|
||||
return redirect('/browse-categories');
|
||||
}
|
||||
|
||||
// Traverse categories by slug path within the content type
|
||||
$current = Category::where('content_type_id', $contentType->id)
|
||||
->whereNull('parent_id')
|
||||
->where('slug', strtolower(array_shift($segments)))
|
||||
->first();
|
||||
|
||||
if (! $current) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
foreach ($segments as $slug) {
|
||||
$current = $current->children()->where('slug', strtolower($slug))->first();
|
||||
if (! $current) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
$category = $current;
|
||||
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
|
||||
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
|
||||
|
||||
// Placeholder artworks paginator (until artwork data is wired).
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$artworks = new LengthAwarePaginator([], 0, 40, $page, [
|
||||
'path' => $request->url(),
|
||||
'query' => $request->query(),
|
||||
]);
|
||||
|
||||
$page_title = $category->name;
|
||||
$page_meta_description = $category->description ?? ($contentType->name . ' artworks on Skinbase');
|
||||
$page_meta_keywords = strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
|
||||
|
||||
return view('legacy.category-slug', compact(
|
||||
'contentType',
|
||||
'category',
|
||||
'subcategories',
|
||||
'rootCategories',
|
||||
'artworks',
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords'
|
||||
));
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
62
app/Http/Controllers/Legacy/ArtController.php
Normal file
62
app/Http/Controllers/Legacy/ArtController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\LegacyService;
|
||||
|
||||
class ArtController extends Controller
|
||||
{
|
||||
protected LegacyService $legacy;
|
||||
|
||||
public function __construct(LegacyService $legacy)
|
||||
{
|
||||
$this->legacy = $legacy;
|
||||
}
|
||||
|
||||
public function show(Request $request, $id, $slug = null)
|
||||
{
|
||||
// handle comment POST from legacy form
|
||||
if ($request->isMethod('post') && $request->input('action') === 'store_comment') {
|
||||
if (auth()->check()) {
|
||||
try {
|
||||
\Illuminate\Support\Facades\DB::connection('legacy')->table('artworks_comments')->insert([
|
||||
'artwork_id' => (int)$id,
|
||||
'owner_user_id' => (int)($request->user()->id ?? 0),
|
||||
'user_id' => (int)$request->user()->id,
|
||||
'date' => now()->toDateString(),
|
||||
'time' => now()->toTimeString(),
|
||||
'description' => (string)$request->input('comment_text'),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// ignore DB errors for now
|
||||
}
|
||||
}
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
$data = $this->legacy->getArtwork((int) $id);
|
||||
|
||||
if (! $data || empty($data['artwork'])) {
|
||||
return view('legacy.placeholder', ['title' => 'Artwork Not Found']);
|
||||
}
|
||||
|
||||
// load comments for artwork (legacy schema)
|
||||
try {
|
||||
$comments = \Illuminate\Support\Facades\DB::connection('legacy')->table('artworks_comments as t1')
|
||||
->rightJoin('users as t2', 't1.user_id', '=', 't2.user_id')
|
||||
->select('t1.description', 't1.date', 't1.time', 't2.uname', 't2.signature', 't2.icon', 't2.user_id')
|
||||
->where('t1.artwork_id', (int)$id)
|
||||
->where('t1.user_id', '>', 0)
|
||||
->orderBy('t1.comment_id')
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$comments = collect();
|
||||
}
|
||||
|
||||
$data['comments'] = $comments;
|
||||
|
||||
return view('legacy.art', $data);
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/Legacy/AvatarController.php
Normal file
67
app/Http/Controllers/Legacy/AvatarController.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AvatarController extends Controller
|
||||
{
|
||||
public function show(Request $request, $id, $name = null)
|
||||
{
|
||||
$user_id = (int) $id;
|
||||
|
||||
// default avatar in project public gfx
|
||||
$defaultAvatar = public_path('gfx/avatar.jpg');
|
||||
|
||||
try {
|
||||
$icon = DB::connection('legacy')->table('users')->where('user_id', $user_id)->value('icon');
|
||||
} catch (\Throwable $e) {
|
||||
$icon = null;
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
if (!empty($icon)) {
|
||||
// common legacy locations to check
|
||||
$candidates[] = base_path('oldSite/www/files/usericons/' . $icon);
|
||||
$candidates[] = base_path('oldSite/www/files/usericons/' . rawurlencode($icon));
|
||||
$candidates[] = base_path('oldSite/www/files/usericons/' . basename($icon));
|
||||
$candidates[] = public_path('avatar/' . $user_id . '/' . $icon);
|
||||
$candidates[] = public_path('avatar/' . $user_id . '/' . basename($icon));
|
||||
$candidates[] = storage_path('app/public/usericons/' . $icon);
|
||||
$candidates[] = storage_path('app/public/usericons/' . basename($icon));
|
||||
}
|
||||
|
||||
// find first readable file
|
||||
$found = null;
|
||||
foreach ($candidates as $path) {
|
||||
if ($path && file_exists($path) && is_readable($path)) {
|
||||
$found = $path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($found) {
|
||||
$type = @exif_imagetype($found);
|
||||
if ($type) {
|
||||
$mime = image_type_to_mime_type($type);
|
||||
} else {
|
||||
$f = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($f, $found) ?: 'application/octet-stream';
|
||||
finfo_close($f);
|
||||
}
|
||||
|
||||
return response()->file($found, ['Content-Type' => $mime]);
|
||||
}
|
||||
|
||||
// fallback to default
|
||||
if (file_exists($defaultAvatar) && is_readable($defaultAvatar)) {
|
||||
return response()->file($defaultAvatar, ['Content-Type' => 'image/jpeg']);
|
||||
}
|
||||
|
||||
// final fallback: 404
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
85
app/Http/Controllers/Legacy/BrowseController.php
Normal file
85
app/Http/Controllers/Legacy/BrowseController.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\CursorPaginator;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BrowseController extends Controller
|
||||
{
|
||||
protected ArtworkService $artworks;
|
||||
|
||||
public function __construct(ArtworkService $artworks)
|
||||
{
|
||||
$this->artworks = $artworks;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$page_title = 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase';
|
||||
$page_meta_description = "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiasts.";
|
||||
$page_meta_keywords = 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo';
|
||||
|
||||
$perPage = (int) $request->get('per_page', 24);
|
||||
|
||||
$categoryPath = trim((string) $request->query('category', ''), '/');
|
||||
|
||||
try {
|
||||
if ($categoryPath !== '') {
|
||||
$slugs = array_values(array_filter(explode('/', $categoryPath)));
|
||||
/** @var CursorPaginator $artworks */
|
||||
$artworks = $this->artworks->getArtworksByCategoryPath($slugs, $perPage);
|
||||
} else {
|
||||
/** @var CursorPaginator $artworks */
|
||||
$artworks = $this->artworks->browsePublicArtworks($perPage);
|
||||
}
|
||||
} catch (ModelNotFoundException $e) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (count($artworks) === 0) {
|
||||
Log::warning('browse.missing_artworks', [
|
||||
'url' => $request->fullUrl(),
|
||||
'category_path' => $categoryPath ?: null,
|
||||
]);
|
||||
abort(410);
|
||||
}
|
||||
|
||||
// Shape data for the legacy Blade while using authoritative tables only.
|
||||
$artworks->getCollection()->transform(fn (Artwork $artwork) => $this->mapArtwork($artwork));
|
||||
|
||||
return view('legacy.browse', compact('page_title', 'page_meta_description', 'page_meta_keywords', 'artworks'));
|
||||
}
|
||||
|
||||
private function mapArtwork(Artwork $artwork): object
|
||||
{
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$categoryPath = $primaryCategory?->full_slug_path;
|
||||
$contentTypeSlug = $primaryCategory?->contentType?->slug;
|
||||
$webUrl = $contentTypeSlug && $categoryPath
|
||||
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $artwork->slug
|
||||
: null;
|
||||
|
||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
// Include ordering parameter used by cursor paginator so links can be generated
|
||||
'published_at' => $artwork->published_at?->toAtomString(),
|
||||
'slug' => $artwork->slug,
|
||||
'name' => $artwork->title,
|
||||
'category_name' => $primaryCategory->name ?? '',
|
||||
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'url' => $webUrl ?? '#',
|
||||
];
|
||||
}
|
||||
}
|
||||
108
app/Http/Controllers/Legacy/CategoryController.php
Normal file
108
app/Http/Controllers/Legacy/CategoryController.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
protected ArtworkService $artworkService;
|
||||
|
||||
public function __construct(ArtworkService $artworkService)
|
||||
{
|
||||
$this->artworkService = $artworkService;
|
||||
}
|
||||
|
||||
public function show(Request $request, $id, $slug = null, $group = null)
|
||||
{
|
||||
// Parse request path after '/category' to support unlimited depth and legacy routes
|
||||
$path = trim($request->path(), '/');
|
||||
$segments = array_values(array_filter(explode('/', $path)));
|
||||
|
||||
// Expecting segments like ['category', '{contentType}', '{...categorySlugs}']
|
||||
if (count($segments) < 2 || strtolower($segments[0]) !== 'category') {
|
||||
return view('legacy.placeholder');
|
||||
}
|
||||
|
||||
$parts = array_slice($segments, 1);
|
||||
|
||||
// If first part is numeric, attempt id->category resolution and redirect to canonical slug URL
|
||||
$first = $parts[0] ?? null;
|
||||
if ($first !== null && ctype_digit((string) $first)) {
|
||||
try {
|
||||
$category = Category::findOrFail((int) $first);
|
||||
$contentTypeSlug = $category->contentType->slug ?? null;
|
||||
$canonical = '/' . strtolower($contentTypeSlug) . '/' . $category->full_slug_path;
|
||||
return redirect($canonical, 301);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
// Build slug list: first element is content type slug, rest are category slugs
|
||||
$contentTypeSlug = array_shift($parts);
|
||||
$slugs = array_merge([$contentTypeSlug], $parts);
|
||||
|
||||
$perPage = (int) $request->get('per_page', 40);
|
||||
|
||||
try {
|
||||
$artworks = $this->artworkService->getArtworksByCategoryPath($slugs, $perPage);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Resolve Category model for page meta and subcategories
|
||||
// Use the contentType + path traversal to find the category
|
||||
try {
|
||||
$category = Category::whereHas('contentType', function ($q) use ($contentTypeSlug) {
|
||||
$q->where('slug', strtolower($contentTypeSlug));
|
||||
})->whereNull('parent_id')->where('slug', strtolower($parts[0] ?? ''))->first();
|
||||
|
||||
// If deeper path exists, traverse
|
||||
if ($category && count($parts) > 1) {
|
||||
$cur = $category;
|
||||
foreach (array_slice($parts, 1) as $slugPart) {
|
||||
$cur = $cur->children()->where('slug', strtolower($slugPart))->first();
|
||||
if (! $cur) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
$category = $cur;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$category = null;
|
||||
}
|
||||
|
||||
if (! $category) {
|
||||
// Category resolution failed
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
|
||||
|
||||
$page_title = $category->name;
|
||||
$page_meta_description = $category->description ?? ($category->contentType->name . ' artworks on Skinbase');
|
||||
$page_meta_keywords = strtolower($category->contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
|
||||
|
||||
return view('legacy.category', compact(
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords',
|
||||
'group',
|
||||
'category',
|
||||
'subcategories',
|
||||
'artworks'
|
||||
));
|
||||
}
|
||||
|
||||
public function browseCategories()
|
||||
{
|
||||
$data = $this->legacy->browseCategories();
|
||||
return view('legacy.categories', $data);
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/Legacy/ChatController.php
Normal file
48
app/Http/Controllers/Legacy/ChatController.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ChatController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$page_title = 'Online Chat';
|
||||
|
||||
// Handle post (store chat)
|
||||
$store = $request->input('store_chat');
|
||||
$chat_text = $request->input('chat_txt');
|
||||
|
||||
$chat = new \App\Chat();
|
||||
|
||||
if (!empty($store) && $store === 'true' && !empty($chat_text)) {
|
||||
if (!empty($_SESSION['web_login']['status'])) {
|
||||
$chat->StoreMessage($chat_text);
|
||||
$chat->UpdateChatFile('cron/chat_log.txt', 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Capture Banner output
|
||||
ob_start();
|
||||
\App\Banner::ShowResponsiveAd();
|
||||
$adHtml = ob_get_clean();
|
||||
|
||||
// Capture Chat HTML
|
||||
ob_start();
|
||||
$userID = $_SESSION['web_login']['user_id'] ?? null;
|
||||
$chat->ShowChat(50, $userID);
|
||||
$chatHtml = ob_get_clean();
|
||||
|
||||
// Load smileys from legacy DB
|
||||
try {
|
||||
$smileys = DB::connection('legacy')->table('smileys')->select('code', 'picture', 'emotion')->get();
|
||||
} catch (\Throwable $e) {
|
||||
$smileys = collect();
|
||||
}
|
||||
|
||||
return view('legacy.chat', compact('page_title', 'adHtml', 'chatHtml', 'smileys'));
|
||||
}
|
||||
}
|
||||
98
app/Http/Controllers/Legacy/DailyUploadsController.php
Normal file
98
app/Http/Controllers/Legacy/DailyUploadsController.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DailyUploadsController extends Controller
|
||||
{
|
||||
protected ArtworkService $artworks;
|
||||
|
||||
public function __construct(ArtworkService $artworks)
|
||||
{
|
||||
$this->artworks = $artworks;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$isAjax = $request->boolean('ajax');
|
||||
$datum = $request->query('datum');
|
||||
|
||||
if ($isAjax && $datum) {
|
||||
// Return partial gallery for the given date
|
||||
$arts = $this->fetchByDate($datum);
|
||||
return view('legacy.partials.daily-uploads-grid', ['arts' => $arts])->render();
|
||||
}
|
||||
|
||||
// Build date tabs (today .. -14 days)
|
||||
$dates = [];
|
||||
for ($x = 0; $x > -15; $x--) {
|
||||
$ts = strtotime(sprintf('%+d days', $x));
|
||||
$dates[] = [
|
||||
'iso' => date('Y-m-d', $ts),
|
||||
'label' => date('d. F Y', $ts),
|
||||
];
|
||||
}
|
||||
|
||||
// initial content: recent (last 7 days)
|
||||
$recent = $this->fetchRecent();
|
||||
|
||||
return view('legacy.daily-uploads', [
|
||||
'dates' => $dates,
|
||||
'recent' => $recent,
|
||||
'page_title' => 'Daily Uploads',
|
||||
]);
|
||||
}
|
||||
|
||||
private function fetchByDate(string $date)
|
||||
{
|
||||
$ars = Artwork::public()
|
||||
->published()
|
||||
->whereDate('published_at', $date)
|
||||
->orderByDesc('published_at')
|
||||
->with(['user:id,name', 'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.name', 'categories.sort_order');
|
||||
}])
|
||||
->get();
|
||||
|
||||
return $this->prepareArts($ars);
|
||||
}
|
||||
|
||||
private function fetchRecent()
|
||||
{
|
||||
$start = now()->subDays(7)->startOfDay();
|
||||
|
||||
$ars = Artwork::public()
|
||||
->published()
|
||||
->where('published_at', '>=', $start)
|
||||
->orderByDesc('published_at')
|
||||
->with(['user:id,name', 'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.name', 'categories.sort_order');
|
||||
}])
|
||||
->get();
|
||||
|
||||
return $this->prepareArts($ars);
|
||||
}
|
||||
|
||||
private function prepareArts($ars)
|
||||
{
|
||||
return $ars->map(function (Artwork $ar) {
|
||||
$primaryCategory = $ar->categories->sortBy('sort_order')->first();
|
||||
$present = \App\Services\ThumbnailPresenter::present($ar, 'md');
|
||||
|
||||
return (object) [
|
||||
'id' => $ar->id,
|
||||
'name' => $ar->title,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
|
||||
'category_name' => $primaryCategory->name ?? '',
|
||||
'uname' => $ar->user->name ?? 'Skinbase',
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Legacy/FeaturedArtworksController.php
Normal file
63
app/Http/Controllers/Legacy/FeaturedArtworksController.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class FeaturedArtworksController extends Controller
|
||||
{
|
||||
protected ArtworkService $artworks;
|
||||
|
||||
public function __construct(ArtworkService $artworks)
|
||||
{
|
||||
$this->artworks = $artworks;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = 39;
|
||||
|
||||
$type = (int) ($request->query('type', 4));
|
||||
|
||||
$typeFilter = $type === 4 ? null : $type;
|
||||
|
||||
$artworks = $this->artworks->getFeaturedArtworks($typeFilter, $perPage);
|
||||
|
||||
$artworks->getCollection()->transform(function (Artwork $artwork) {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$categoryName = $primaryCategory->name ?? '';
|
||||
$gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0;
|
||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'category_name' => $categoryName,
|
||||
'gid_num' => $gid,
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
];
|
||||
});
|
||||
|
||||
$artworkTypes = [
|
||||
1 => 'Bronze Awards',
|
||||
2 => 'Silver Awards',
|
||||
3 => 'Gold Awards',
|
||||
4 => 'Featured Artworks',
|
||||
];
|
||||
|
||||
$pageTitle = $artworkTypes[$type] ?? 'Featured Artworks';
|
||||
|
||||
return view('legacy.featured-artworks', [
|
||||
'artworks' => $artworks,
|
||||
'type' => $type,
|
||||
'artworkTypes' => $artworkTypes,
|
||||
'page_title' => $pageTitle,
|
||||
]);
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Legacy/ForumController.php
Normal file
38
app/Http/Controllers/Legacy/ForumController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\LegacyService;
|
||||
|
||||
class ForumController extends Controller
|
||||
{
|
||||
protected LegacyService $legacy;
|
||||
|
||||
public function __construct(LegacyService $legacy)
|
||||
{
|
||||
$this->legacy = $legacy;
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$data = $this->legacy->forumIndex();
|
||||
return view('legacy.forum.index', $data);
|
||||
}
|
||||
|
||||
public function topic(Request $request, $topic_id)
|
||||
{
|
||||
$data = $this->legacy->forumTopic((int) $topic_id, (int) $request->query('page', 1));
|
||||
|
||||
if (! $data) {
|
||||
return view('legacy.placeholder');
|
||||
}
|
||||
|
||||
if (isset($data['type']) && $data['type'] === 'subtopics') {
|
||||
return view('legacy.forum.topic', $data);
|
||||
}
|
||||
|
||||
return view('legacy.forum.posts', $data);
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Legacy/HomeController.php
Normal file
58
app/Http/Controllers/Legacy/HomeController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\ArtworkService;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
protected ArtworkService $artworks;
|
||||
|
||||
public function __construct(ArtworkService $artworks)
|
||||
{
|
||||
$this->artworks = $artworks;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$page_title = 'Skinbase - Photography, Skins & Wallpapers';
|
||||
$page_meta_description = 'Skinbase legacy home, rendered via Laravel.';
|
||||
$page_meta_keywords = 'wallpapers, skins, photography, community';
|
||||
|
||||
// Use new ArtworkService as primary data source
|
||||
$featuredResult = $this->artworks->getFeaturedArtworks(null, 39);
|
||||
// If service returned a paginator, extract the first model for the single "featured" slot
|
||||
if ($featuredResult instanceof \Illuminate\Pagination\LengthAwarePaginator) {
|
||||
$featured = $featuredResult->getCollection()->first();
|
||||
} elseif (is_array($featuredResult)) {
|
||||
$featured = $featuredResult[0] ?? null;
|
||||
} else {
|
||||
// Collection or single item
|
||||
$featured = method_exists($featuredResult, 'first') ? $featuredResult->first() : $featuredResult;
|
||||
}
|
||||
|
||||
// Provide a memberFeatured fallback so the legacy view always has a value
|
||||
$memberFeatured = $featured;
|
||||
|
||||
$latestUploads = $this->artworks->getLatestArtworks(20);
|
||||
|
||||
// Legacy forum/news data not available in new services yet — provide empty defaults
|
||||
$forumNews = [];
|
||||
$ourNews = [];
|
||||
$latestForumActivity = [];
|
||||
|
||||
return view('legacy.home', compact(
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords',
|
||||
'featured',
|
||||
'memberFeatured',
|
||||
'latestUploads',
|
||||
'forumNews',
|
||||
'ourNews',
|
||||
'latestForumActivity'
|
||||
));
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Legacy/InterviewController.php
Normal file
106
app/Http/Controllers/Legacy/InterviewController.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InterviewController extends Controller
|
||||
{
|
||||
public function show(Request $request, $id, $slug = null)
|
||||
{
|
||||
$id = (int) $id;
|
||||
|
||||
// Handle comment POST
|
||||
if ($request->isMethod('post')) {
|
||||
$action = $request->input('action');
|
||||
if ($action === 'store' && (!empty($_SESSION['web_login']['user_type']) && $_SESSION['web_login']['user_type'] > 1)) {
|
||||
$comment = $request->input('comment');
|
||||
$tekst = nl2br(htmlspecialchars($comment ?? '', ENT_QUOTES, 'UTF-8'));
|
||||
$interviewId = (int) $request->input('interview_id');
|
||||
|
||||
try {
|
||||
DB::connection('legacy')->table('interviews_comment')->insert([
|
||||
'nid' => $interviewId,
|
||||
'author' => $_SESSION['web_login']['username'] ?? 'Anonymous',
|
||||
'datum' => DB::raw('CURRENT_TIMESTAMP'),
|
||||
'tekst' => $tekst,
|
||||
]);
|
||||
|
||||
$ar2 = DB::connection('legacy')->table('users')
|
||||
->where('uname', $_SESSION['web_login']['username'])
|
||||
->first();
|
||||
|
||||
if (!empty($ar2->user_id)) {
|
||||
DB::connection('legacy')->table('users_statistics')
|
||||
->where('user_id', $ar2->user_id)
|
||||
->increment('newscomment');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// fail silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$ar = DB::connection('legacy')->table('interviews')->where('id', $id)->first();
|
||||
} catch (\Throwable $e) {
|
||||
$ar = null;
|
||||
}
|
||||
|
||||
if (! $ar) {
|
||||
return redirect('/interviews');
|
||||
}
|
||||
|
||||
try {
|
||||
$artworks = DB::connection('legacy')->table('wallz')
|
||||
->where('uname', $ar->username)
|
||||
->inRandomOrder()
|
||||
->limit(2)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$artworks = collect();
|
||||
}
|
||||
|
||||
try {
|
||||
$comments = DB::connection('legacy')->table('interviews_comment as c')
|
||||
->leftJoin('users as u', 'u.uname', '=', 'c.author')
|
||||
->where('c.nid', $id)
|
||||
->select('c.*', 'u.user_id', 'u.user_type', 'u.signature', 'u.icon')
|
||||
->orderBy('c.datum')
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$comments = collect();
|
||||
}
|
||||
|
||||
// compute total posts per author across interviews_comment
|
||||
$authors = $comments->pluck('author')->unique()->values()->all();
|
||||
$postCounts = [];
|
||||
if (!empty($authors)) {
|
||||
try {
|
||||
$counts = DB::connection('legacy')->table('interviews_comment')
|
||||
->select('author', DB::raw('COUNT(*) as cnt'))
|
||||
->whereIn('author', $authors)
|
||||
->groupBy('author')
|
||||
->get();
|
||||
|
||||
foreach ($counts as $c) {
|
||||
$postCounts[$c->author] = $c->cnt;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
$page_title = 'Interview with ' . ($ar->username ?? '');
|
||||
|
||||
return view('legacy.interview', [
|
||||
'ar' => $ar,
|
||||
'artworks' => $artworks,
|
||||
'comments' => $comments,
|
||||
'postCounts' => $postCounts,
|
||||
'page_title' => $page_title,
|
||||
]);
|
||||
}
|
||||
}
|
||||
28
app/Http/Controllers/Legacy/InterviewsController.php
Normal file
28
app/Http/Controllers/Legacy/InterviewsController.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InterviewsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
$interviews = DB::connection('legacy')->table('interviews AS t1')
|
||||
->select('t1.id', 't1.headline', 't2.user_id', 't2.uname', 't2.icon')
|
||||
->leftJoin('users AS t2', 't1.username', '=', 't2.uname')
|
||||
->orderByDesc('t1.datum')
|
||||
->limit(60)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$interviews = collect();
|
||||
}
|
||||
|
||||
$page_title = 'Interviews';
|
||||
|
||||
return view('legacy.interviews', compact('interviews', 'page_title'));
|
||||
}
|
||||
}
|
||||
56
app/Http/Controllers/Legacy/LatestCommentsController.php
Normal file
56
app/Http/Controllers/Legacy/LatestCommentsController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\ArtworkComment;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LatestCommentsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$hits = 20;
|
||||
|
||||
// Join artwork_comments -> artwork -> user, but only include artworks that are public, approved and published
|
||||
$query = ArtworkComment::with(['user', 'artwork'])
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
})
|
||||
->orderByDesc('created_at');
|
||||
|
||||
$comments = $query->paginate($hits)->withQueryString();
|
||||
|
||||
// Shape results for legacy view
|
||||
$comments->getCollection()->transform(function (ArtworkComment $c) {
|
||||
$art = $c->artwork;
|
||||
$user = $c->user;
|
||||
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? ($present['url']) : '/gfx/sb_join.jpg';
|
||||
|
||||
return (object) [
|
||||
'comment_id' => $c->getKey(),
|
||||
'comment_description' => $c->content,
|
||||
'commenter_id' => $c->user_id,
|
||||
'country' => $user->country ?? null,
|
||||
'icon' => $user->avatar ?? null,
|
||||
'uname' => $user->username ?? $user->name ?? 'User',
|
||||
'signature' => $user->signature ?? null,
|
||||
'user_type' => $user->role ?? null,
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->title ?? null,
|
||||
'picture' => $art->file_name ?? null,
|
||||
'thumb' => $thumb,
|
||||
'artwork_slug' => $art->slug ?? Str::slug($art->title ?? ''),
|
||||
'datetime' => $c->created_at?->toDateTimeString() ?? now()->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
|
||||
$page_title = 'Latest Comments';
|
||||
|
||||
return view('legacy.latest-comments', compact('page_title', 'comments'));
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Legacy/LatestController.php
Normal file
51
app/Http/Controllers/Legacy/LatestController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\CursorPaginator;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class LatestController extends Controller
|
||||
{
|
||||
protected ArtworkService $artworks;
|
||||
|
||||
public function __construct(ArtworkService $artworks)
|
||||
{
|
||||
$this->artworks = $artworks;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = 21;
|
||||
|
||||
/** @var CursorPaginator $artworks */
|
||||
$artworks = $this->artworks->browsePublicArtworks($perPage);
|
||||
|
||||
// Shape data for legacy view without legacy tables.
|
||||
$artworks->getCollection()->transform(function (Artwork $artwork) {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$categoryName = $primaryCategory->name ?? '';
|
||||
$gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0;
|
||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'category_name' => $categoryName,
|
||||
'gid_num' => $gid,
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
];
|
||||
});
|
||||
|
||||
return view('legacy.latest-artworks', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Latest Artworks',
|
||||
]);
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Legacy/MembersController.php
Normal file
51
app/Http/Controllers/Legacy/MembersController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\LegacyService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MembersController extends Controller
|
||||
{
|
||||
protected LegacyService $legacy;
|
||||
|
||||
public function __construct(LegacyService $legacy)
|
||||
{
|
||||
$this->legacy = $legacy;
|
||||
}
|
||||
|
||||
public function photos(Request $request, $id = null)
|
||||
{
|
||||
$id = (int) ($id ?: 545);
|
||||
|
||||
$result = $this->legacy->categoryPage('', null, $id);
|
||||
if (! $result) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
// categoryPage returns an array with keys used by legacy.browse
|
||||
$page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos');
|
||||
$artworks = $result['artworks'] ?? collect();
|
||||
|
||||
// Ensure artworks include `slug`, `thumb`, and `thumb_srcset` properties expected by the legacy view
|
||||
if ($artworks && method_exists($artworks, 'getCollection')) {
|
||||
$artworks->getCollection()->transform(function ($row) {
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null);
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null);
|
||||
return $row;
|
||||
});
|
||||
} elseif (is_iterable($artworks)) {
|
||||
$artworks = collect($artworks)->map(function ($row) {
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null);
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null);
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
return view('legacy.browse', compact('page_title', 'artworks'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MonthlyCommentatorsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$hits = 30;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$query = DB::connection('legacy')->table('artworks_comments as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
|
||||
->leftJoin('country as c', 't2.country', '=', 'c.id')
|
||||
->where('t1.user_id', '>', 0)
|
||||
->whereRaw("DATE_SUB(CURDATE(), INTERVAL 30 DAY) <= t1.date")
|
||||
->select(
|
||||
't2.user_id',
|
||||
't2.uname',
|
||||
't2.user_type',
|
||||
't2.country',
|
||||
'c.name as country_name',
|
||||
'c.flag as country_flag',
|
||||
DB::raw('COUNT(*) as num_comments')
|
||||
)
|
||||
->groupBy('t1.user_id')
|
||||
->orderByDesc('num_comments');
|
||||
|
||||
$rows = $query->paginate($hits)->withQueryString();
|
||||
|
||||
$page_title = 'Monthly Top Commentators';
|
||||
|
||||
return view('legacy.monthly-commentators', compact('page_title', 'rows'));
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Legacy/NewsController.php
Normal file
44
app/Http/Controllers/Legacy/NewsController.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class NewsController extends Controller
|
||||
{
|
||||
public function show(Request $request, $id, $slug = null)
|
||||
{
|
||||
$id = (int) $id;
|
||||
|
||||
try {
|
||||
$news = DB::connection('legacy')->table('news as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
|
||||
->where('t1.news_id', $id)
|
||||
->select('t1.*', 't2.uname', 't2.user_type', 't2.signature', 't2.icon')
|
||||
->first();
|
||||
} catch (\Throwable $e) {
|
||||
$news = null;
|
||||
}
|
||||
|
||||
if (empty($news)) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
try {
|
||||
$comments = DB::connection('legacy')->table('news_comment as c')
|
||||
->leftJoin('users as u', 'c.user_id', '=', 'u.user_id')
|
||||
->where('c.news_id', $id)
|
||||
->select('c.posted', 'c.message', 'c.user_id', 'u.user_type', 'u.signature', 'u.icon', 'u.uname')
|
||||
->orderBy('c.posted')
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$comments = collect();
|
||||
}
|
||||
|
||||
$page_title = ($news->headline ?? 'News') . ' - SkinBase News';
|
||||
|
||||
return view('legacy.news', compact('news', 'comments', 'page_title'));
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Legacy/ProfileController.php
Normal file
75
app/Http/Controllers/Legacy/ProfileController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Models\User;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
protected ArtworkService $artworkService;
|
||||
|
||||
public function __construct(ArtworkService $artworkService)
|
||||
{
|
||||
$this->artworkService = $artworkService;
|
||||
}
|
||||
|
||||
public function show(Request $request, ?int $id = null, ?string $slug = null)
|
||||
{
|
||||
// Support /profile (current user) and /profile/{id}/{slug}
|
||||
$id = $id ?? (Auth::check() ? Auth::id() : null);
|
||||
|
||||
if (! $id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$user = User::find($id);
|
||||
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Determine visibility: owner sees all, others only public+approved+published
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
|
||||
$perPage = 24;
|
||||
|
||||
// Use ArtworkService to fetch artworks for the profile
|
||||
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage);
|
||||
|
||||
// Shape data for legacy view expectations
|
||||
$artworks->getCollection()->transform(function (Artwork $art) {
|
||||
$present = \App\Services\ThumbnailPresenter::present($art, 'md');
|
||||
|
||||
return (object) [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'picture' => $art->file_name,
|
||||
'datum' => $art->published_at,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
];
|
||||
});
|
||||
|
||||
// Map new User model to legacy view shape expected by templates
|
||||
$legacyUser = (object) [
|
||||
'user_id' => $user->id,
|
||||
'uname' => $user->name,
|
||||
'real_name' => $user->name,
|
||||
'icon' => $user->avatar ?? null,
|
||||
'about_me' => $user->bio ?? null,
|
||||
];
|
||||
|
||||
return view('legacy.profile', [
|
||||
'user' => $legacyUser,
|
||||
'artworks' => $artworks,
|
||||
]);
|
||||
}
|
||||
}
|
||||
68
app/Http/Controllers/Legacy/TodayDownloadsController.php
Normal file
68
app/Http/Controllers/Legacy/TodayDownloadsController.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\ArtworkDownload;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class TodayDownloadsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$hits = 30;
|
||||
|
||||
// Filter downloads created today and join to artworks that are public, approved and published
|
||||
$today = Carbon::now()->toDateString();
|
||||
|
||||
$query = ArtworkDownload::with(['artwork'])
|
||||
->whereDate('created_at', $today)
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
})
|
||||
->selectRaw('artwork_id, COUNT(*) as num_downloads')
|
||||
->groupBy('artwork_id')
|
||||
->orderByDesc('num_downloads');
|
||||
|
||||
$paginator = $query->paginate($hits)->withQueryString();
|
||||
|
||||
// Map to the legacy browse shape
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
// $row is a stdClass with artwork_id and num_downloads
|
||||
$art = $row->artwork ?? null;
|
||||
// If Eloquent didn't eager load artwork (group queries sometimes don't), fetch it
|
||||
if (! $art && isset($row->artwork_id)) {
|
||||
$art = \App\Models\Artwork::find($row->artwork_id);
|
||||
}
|
||||
|
||||
$name = $art->title ?? null;
|
||||
$picture = $art->file_name ?? null;
|
||||
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = null; // legacy encoding unavailable; leave null
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? $present['url'] : '/gfx/sb_join.jpg';
|
||||
$categoryId = $art->categories->first()->id ?? null;
|
||||
|
||||
return (object) [
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $name,
|
||||
'picture' => $picture,
|
||||
'slug' => $art->slug ?? Str::slug($name ?? ''),
|
||||
'ext' => $ext,
|
||||
'encoded' => $encoded,
|
||||
'thumb' => $thumb,
|
||||
'thumb_srcset' => $thumb,
|
||||
'category' => $categoryId,
|
||||
'num_downloads' => $row->num_downloads ?? 0,
|
||||
'gid_num' => $categoryId ? ((int) $categoryId % 5) * 5 : 0,
|
||||
];
|
||||
});
|
||||
|
||||
$page_title = 'Today Downloaded Artworks';
|
||||
|
||||
return view('legacy.browse', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/Legacy/TodayInHistoryController.php
Normal file
54
app/Http/Controllers/Legacy/TodayInHistoryController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TodayInHistoryController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$hits = 39;
|
||||
|
||||
try {
|
||||
$base = DB::connection('legacy')->table('featured_works as t0')
|
||||
->leftJoin('artworks as t1', 't0.artwork_id', '=', 't1.id')
|
||||
->join('artworks_categories as t2', 't1.category', '=', 't2.category_id')
|
||||
->where('t1.approved', 1)
|
||||
->whereRaw('MONTH(t0.post_date) = MONTH(CURRENT_DATE())')
|
||||
->whereRaw('DAY(t0.post_date) = DAY(CURRENT_DATE())')
|
||||
->select('t1.id', 't1.name', 't1.picture', 't1.uname', 't1.category', 't2.category_name');
|
||||
|
||||
$artworks = $base->orderBy('t0.post_date','desc')->paginate($hits);
|
||||
} catch (\Throwable $e) {
|
||||
$artworks = null;
|
||||
}
|
||||
|
||||
if ($artworks && method_exists($artworks, 'getCollection')) {
|
||||
$artworks->getCollection()->transform(function ($row) {
|
||||
$row->ext = pathinfo($row->picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$row->encoded = \App\Services\LegacyService::encode($row->id);
|
||||
// Prefer new CDN when artwork exists with hash
|
||||
try {
|
||||
$art = \App\Models\Artwork::find($row->id);
|
||||
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
|
||||
$row->thumb_url = $present['url'];
|
||||
$row->thumb_srcset = $present['srcset'];
|
||||
} catch (\Throwable $e) {
|
||||
$present = \App\Services\ThumbnailPresenter::present((array) $row, 'md');
|
||||
$row->thumb_url = $present['url'];
|
||||
$row->thumb_srcset = $present['srcset'];
|
||||
}
|
||||
$row->gid_num = ((int)($row->category ?? 0) % 5) * 5;
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
return view('legacy.today-in-history', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Popular on this day in history',
|
||||
]);
|
||||
}
|
||||
}
|
||||
129
app/Http/Controllers/Legacy/TopAuthorsController.php
Normal file
129
app/Http/Controllers/Legacy/TopAuthorsController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkStats;
|
||||
use App\Models\User;
|
||||
|
||||
class TopAuthorsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = 20;
|
||||
$metric = strtolower($request->query('metric', 'views'));
|
||||
|
||||
if (! in_array($metric, ['views', 'downloads'])) {
|
||||
$metric = 'views';
|
||||
}
|
||||
|
||||
// Aggregate artwork_stats grouped by artwork.user_id, filtering only public+approved+published artworks
|
||||
$sub = Artwork::query()
|
||||
->select('artworks.user_id')
|
||||
->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->where('artworks.published_at', '<=', now())
|
||||
->whereNull('artworks.deleted_at')
|
||||
->selectRaw('artworks.user_id, SUM(artwork_stats.' . $metric . ') as total_metric, MAX(artworks.published_at) as latest_published')
|
||||
->groupBy('artworks.user_id');
|
||||
|
||||
// Join with users to fetch profile info
|
||||
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
|
||||
->mergeBindings($sub->getQuery())
|
||||
->join('users as u', 'u.id', '=', 't.user_id')
|
||||
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published')
|
||||
->orderByDesc('t.total_metric')
|
||||
->orderByDesc('t.latest_published');
|
||||
|
||||
$authors = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// Map to legacy view shape
|
||||
$authors->getCollection()->transform(function ($row) use ($metric) {
|
||||
return (object) [
|
||||
'user_id' => $row->user_id,
|
||||
'uname' => $row->uname,
|
||||
'username' => $row->username,
|
||||
'total' => (int) $row->total_metric,
|
||||
'metric' => $metric,
|
||||
];
|
||||
});
|
||||
|
||||
$page_title = 'Top Authors';
|
||||
|
||||
return view('legacy.top-authors', compact('page_title', 'authors', 'metric'));
|
||||
}
|
||||
}
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TopAuthorsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// Top users (most active)
|
||||
try {
|
||||
$topUsers = DB::connection('legacy')->table('wallz as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
|
||||
->select('t2.user_id', 't2.uname', 't2.icon', DB::raw('SUM(t1.dls) AS total_downloads'), DB::raw('COUNT(*) AS uploads'))
|
||||
->groupBy('t1.user_id')
|
||||
->orderByDesc('total_downloads')
|
||||
->limit(23)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$topUsers = collect();
|
||||
}
|
||||
|
||||
// Top followers
|
||||
try {
|
||||
$topFollowers = DB::connection('legacy')->table('friends_list as t1')
|
||||
->rightJoin('users as t2', 't1.friend_id', '=', 't2.user_id')
|
||||
->where('t1.friend_id', '>', 0)
|
||||
->select('t2.uname', 't2.user_id', DB::raw('COUNT(*) as num'))
|
||||
->groupBy('t1.friend_id')
|
||||
->orderByDesc('num')
|
||||
->limit(10)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$topFollowers = collect();
|
||||
}
|
||||
|
||||
// Top commentators
|
||||
try {
|
||||
$topCommentators = DB::connection('legacy')->table('artworks_comments as t1')
|
||||
->join('users as t2', 't1.user_id', '=', 't2.user_id')
|
||||
->where('t1.user_id', '>', 0)
|
||||
->select('t2.user_id','t2.uname','t2.user_type','t2.country', DB::raw('COUNT(*) as num_comments'))
|
||||
->groupBy('t1.user_id')
|
||||
->orderByDesc('num_comments')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// enrich with country info if available
|
||||
$topCommentators->transform(function ($c) {
|
||||
if (!empty($c->country)) {
|
||||
$cn = DB::connection('legacy')->table('country')->select('name','flag')->where('id', $c->country)->first();
|
||||
$c->country_name = $cn->name ?? null;
|
||||
$c->country_flag = $cn->flag ?? null;
|
||||
} else {
|
||||
$c->country_name = null;
|
||||
$c->country_flag = null;
|
||||
}
|
||||
return $c;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$topCommentators = collect();
|
||||
}
|
||||
|
||||
return view('legacy.top-authors', compact('topUsers', 'topFollowers', 'topCommentators'));
|
||||
}
|
||||
}
|
||||
57
app/Http/Controllers/Legacy/TopFavouritesController.php
Normal file
57
app/Http/Controllers/Legacy/TopFavouritesController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Services\LegacyService;
|
||||
|
||||
class TopFavouritesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$hits = 21;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$base = DB::connection('legacy')->table('artworks_favourites as t1')
|
||||
->rightJoin('wallz as t2', 't1.artwork_id', '=', 't2.id')
|
||||
->where('t2.approved', 1)
|
||||
->select('t2.id', 't2.name', 't2.picture', 't2.category', DB::raw('COUNT(*) as num'))
|
||||
->groupBy('t1.artwork_id');
|
||||
|
||||
try {
|
||||
$paginator = (clone $base)->orderBy('num', 'desc')->paginate($hits)->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$paginator = collect();
|
||||
}
|
||||
|
||||
// Map artworks to include expected properties for legacy card view
|
||||
if ($paginator && method_exists($paginator, 'getCollection')) {
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
$ext = pathinfo($row->picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = \App\Helpers\Thumb::encodeId((int) $row->id);
|
||||
$row->encoded = $encoded;
|
||||
$row->ext = $ext;
|
||||
try {
|
||||
$art = \App\Models\Artwork::find($row->id);
|
||||
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
|
||||
$row->thumb = $row->thumb ?? $present['url'];
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($present['srcset'] ?? $present['url']);
|
||||
} catch (\Throwable $e) {
|
||||
$present = \App\Services\ThumbnailPresenter::present((array) $row, 'md');
|
||||
$row->thumb = $row->thumb ?? $present['url'];
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($present['srcset'] ?? $present['url']);
|
||||
}
|
||||
$row->gid_num = ((int)($row->category ?? 0) % 5) * 5;
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
$page_title = 'Top Favourites';
|
||||
|
||||
return view('legacy.top-favourites', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||
}
|
||||
}
|
||||
564
app/Http/Controllers/LegacyController.php
Normal file
564
app/Http/Controllers/LegacyController.php
Normal file
@@ -0,0 +1,564 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LegacyController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$page_title = 'Skinbase - Photography, Skins & Wallpapers';
|
||||
$page_meta_description = 'Skinbase legacy home, rendered via Laravel.';
|
||||
$page_meta_keywords = 'wallpapers, skins, photography, community';
|
||||
|
||||
[$featured, $memberFeatured] = $this->featured();
|
||||
$latestUploads = $this->latestUploads();
|
||||
$forumNews = $this->forumNews();
|
||||
$ourNews = $this->ourNews();
|
||||
$latestForumActivity = $this->latestForumActivity();
|
||||
|
||||
return view('legacy.home', compact(
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords',
|
||||
'featured',
|
||||
'memberFeatured',
|
||||
'latestUploads',
|
||||
'forumNews',
|
||||
'ourNews',
|
||||
'latestForumActivity'
|
||||
));
|
||||
}
|
||||
|
||||
public function browse(Request $request)
|
||||
{
|
||||
$page_title = 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase';
|
||||
$page_meta_description = "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiastse.";
|
||||
$page_meta_keywords = 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo';
|
||||
|
||||
$perPage = 50;
|
||||
|
||||
try {
|
||||
$artworks = DB::connection('legacy')->table('wallz as w')
|
||||
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
|
||||
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
|
||||
->select('w.id', 'w.name', 'w.picture', 'w.category', 'w.datum', 'c.category_name', 'u.uname')
|
||||
->where('w.approved', 1)
|
||||
->where('w.public', 'Y')
|
||||
->orderByDesc('w.datum')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$placeholder = collect([
|
||||
(object) [
|
||||
'id' => 0,
|
||||
'name' => 'Sample Artwork',
|
||||
'picture' => 'gfx/sb_join.jpg',
|
||||
'category' => null,
|
||||
'datum' => now(),
|
||||
'category_name' => 'Photography',
|
||||
'uname' => 'Skinbase',
|
||||
],
|
||||
]);
|
||||
|
||||
$artworks = new LengthAwarePaginator(
|
||||
$placeholder,
|
||||
$placeholder->count(),
|
||||
$perPage,
|
||||
1,
|
||||
['path' => $request->url(), 'query' => $request->query()]
|
||||
);
|
||||
}
|
||||
|
||||
return view('legacy.browse', compact('page_title', 'page_meta_description', 'page_meta_keywords', 'artworks'));
|
||||
}
|
||||
|
||||
public function category(Request $request, string $group, ?string $slug = null, ?int $id = null)
|
||||
{
|
||||
$group = Str::title($group);
|
||||
$defaults = [
|
||||
'Skins' => 1,
|
||||
'Wallpapers' => 2,
|
||||
'Photography' => 3,
|
||||
'Other' => 4,
|
||||
];
|
||||
|
||||
if (!$id && $slug && ctype_digit($slug)) {
|
||||
$id = (int) $slug;
|
||||
}
|
||||
|
||||
$id = $id ?: ($defaults[$group] ?? null);
|
||||
if (!$id || $id < 1) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
$page_title = $group;
|
||||
$page_meta_description = $group . ' artworks on Skinbase';
|
||||
$page_meta_keywords = strtolower($group) . ', skinbase, artworks, wallpapers, photography, skins';
|
||||
|
||||
try {
|
||||
$category = DB::connection('legacy')->table('artworks_categories')
|
||||
->select('category_id', 'category_name', 'description', 'rootid', 'section_id')
|
||||
->where('category_id', $id)
|
||||
->first();
|
||||
} catch (\Throwable $e) {
|
||||
$category = null;
|
||||
}
|
||||
|
||||
if (!$category) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
$perPage = 40;
|
||||
|
||||
try {
|
||||
$base = DB::connection('legacy')->table('wallz as t1')
|
||||
->select('t1.id', 't1.name', 't1.picture', 't3.uname', 't1.category', 't2.category_name')
|
||||
->join('artworks_categories as t2', 't1.category', '=', 't2.category_id')
|
||||
->leftJoin('users as t3', 't1.user_id', '=', 't3.user_id')
|
||||
->where('t1.approved', 1)
|
||||
->where(function ($q) use ($id, $category) {
|
||||
$q->where('t1.category', (int) $id);
|
||||
if ($category->rootid > 0) {
|
||||
$q->orWhere('t1.rootid', (int) $id);
|
||||
}
|
||||
})
|
||||
->orderByDesc('t1.datum');
|
||||
|
||||
$artworks = $base->paginate($perPage)->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$artworks = new LengthAwarePaginator([], 0, $perPage, 1, [
|
||||
'path' => $request->url(),
|
||||
'query' => $request->query(),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$subcategories = DB::connection('legacy')->table('artworks_categories')
|
||||
->select('category_id', 'category_name')
|
||||
->where('rootid', $id)
|
||||
->orderBy('category_name')
|
||||
->get();
|
||||
|
||||
if ($subcategories->isEmpty() && $category->rootid) {
|
||||
$subcategories = DB::connection('legacy')->table('artworks_categories')
|
||||
->select('category_id', 'category_name')
|
||||
->where('rootid', $category->rootid)
|
||||
->orderBy('category_name')
|
||||
->get();
|
||||
}
|
||||
|
||||
if ($subcategories->isEmpty()) {
|
||||
$subcategories = DB::connection('legacy')->table('skupine')
|
||||
->select('category_id', 'category_name')
|
||||
->where('rootid', $id)
|
||||
->orderBy('category_name')
|
||||
->get();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$subcategories = DB::connection('legacy')->table('skupine')
|
||||
->select('category_id', 'category_name')
|
||||
->where('rootid', $id)
|
||||
->orderBy('category_name')
|
||||
->get();
|
||||
} catch (\Throwable $e2) {
|
||||
$subcategories = collect();
|
||||
}
|
||||
}
|
||||
|
||||
return view('legacy.category', compact(
|
||||
'group',
|
||||
'category',
|
||||
'artworks',
|
||||
'subcategories',
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords'
|
||||
));
|
||||
}
|
||||
|
||||
public function browseCategories()
|
||||
{
|
||||
$page_title = 'Browse Categories';
|
||||
$page_meta_description = 'Browse categories across Photography, Wallpapers, Skins and more on Skinbase.';
|
||||
$page_meta_keywords = 'categories, photography, wallpapers, skins, browse';
|
||||
|
||||
// Load top-level categories (section_id = 0 AND rootid = 0) like the legacy page
|
||||
try {
|
||||
$categories = DB::connection('legacy')->table('artworks_categories')
|
||||
->select('category_id', 'category_name', 'description')
|
||||
->where('section_id', 0)
|
||||
->where('rootid', 0)
|
||||
->orderBy('category_id')
|
||||
->get();
|
||||
|
||||
// Fallback to legacy table name if empty
|
||||
if ($categories->isEmpty()) {
|
||||
$categories = DB::connection('legacy')->table('skupine')
|
||||
->select('category_id', 'category_name', 'description')
|
||||
->where('section_id', 0)
|
||||
->where('rootid', 0)
|
||||
->orderBy('category_id')
|
||||
->get();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$categories = DB::connection('legacy')->table('skupine')
|
||||
->select('category_id', 'category_name', 'description')
|
||||
->where('section_id', 0)
|
||||
->where('rootid', 0)
|
||||
->orderBy('category_id')
|
||||
->get();
|
||||
} catch (\Throwable $e2) {
|
||||
$categories = collect();
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all subcategories in one query to avoid N+1 and group them by parent (section_id)
|
||||
$subgroups = collect();
|
||||
if ($categories->isNotEmpty()) {
|
||||
$ids = $categories->pluck('category_id')->unique()->values()->all();
|
||||
try {
|
||||
$subs = DB::connection('legacy')->table('artworks_categories')
|
||||
->select('category_id', 'category_name', 'picture', 'section_id')
|
||||
->whereIn('section_id', $ids)
|
||||
->orderBy('category_name')
|
||||
->get();
|
||||
|
||||
if ($subs->isEmpty()) {
|
||||
// fallback to skupine table naming
|
||||
$subs = DB::connection('legacy')->table('skupine')
|
||||
->select('category_id', 'category_name', 'picture', 'section_id')
|
||||
->whereIn('section_id', $ids)
|
||||
->orderBy('category_name')
|
||||
->get();
|
||||
}
|
||||
|
||||
$subgroups = $subs->groupBy('section_id');
|
||||
} catch (\Throwable $e) {
|
||||
$subgroups = collect();
|
||||
}
|
||||
}
|
||||
|
||||
return view('legacy.categories', compact(
|
||||
'categories',
|
||||
'subgroups',
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords'
|
||||
));
|
||||
}
|
||||
|
||||
public function forumIndex()
|
||||
{
|
||||
$page_title = 'Forum';
|
||||
$page_meta_description = 'Skinbase forum threads.';
|
||||
$page_meta_keywords = 'forum, discussions, topics, skinbase';
|
||||
|
||||
try {
|
||||
$topics = DB::connection('legacy')->table('forum_topics as t')
|
||||
->select(
|
||||
't.topic_id',
|
||||
't.topic',
|
||||
't.discuss',
|
||||
't.last_update',
|
||||
't.privilege',
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id IN (SELECT topic_id FROM forum_topics st WHERE st.root_id = t.topic_id)) AS num_posts'),
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_topics st WHERE st.root_id = t.topic_id) AS num_subtopics')
|
||||
)
|
||||
->where('t.root_id', 0)
|
||||
->where('t.privilege', '<', 4)
|
||||
->orderByDesc('t.last_update')
|
||||
->limit(100)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$topics = collect();
|
||||
}
|
||||
|
||||
return view('legacy.forum.index', compact(
|
||||
'topics',
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords'
|
||||
));
|
||||
}
|
||||
|
||||
public function forumTopic(Request $request, int $topic_id)
|
||||
{
|
||||
try {
|
||||
$topic = DB::connection('legacy')->table('forum_topics')->where('topic_id', $topic_id)->first();
|
||||
} catch (\Throwable $e) {
|
||||
$topic = null;
|
||||
}
|
||||
|
||||
if (!$topic) {
|
||||
return redirect('/forum');
|
||||
}
|
||||
|
||||
$page_title = $topic->topic;
|
||||
$page_meta_description = Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160);
|
||||
$page_meta_keywords = 'forum, topic, skinbase';
|
||||
|
||||
// Fetch subtopics; if none exist, fall back to posts (matches legacy behavior where some topics hold posts directly)
|
||||
try {
|
||||
$subtopics = DB::connection('legacy')->table('forum_topics as t')
|
||||
->leftJoin('users as u', 't.user_id', '=', 'u.user_id')
|
||||
->select(
|
||||
't.topic_id',
|
||||
't.topic',
|
||||
't.discuss',
|
||||
't.post_date',
|
||||
't.last_update',
|
||||
'u.uname',
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id = t.topic_id) AS num_posts')
|
||||
)
|
||||
->where('t.root_id', $topic->topic_id)
|
||||
->orderByDesc('t.last_update')
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$subtopics = new LengthAwarePaginator([], 0, 50, 1, [
|
||||
'path' => $request->url(),
|
||||
'query' => $request->query(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($subtopics->total() > 0) {
|
||||
return view('legacy.forum.topic', compact(
|
||||
'topic',
|
||||
'subtopics',
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords'
|
||||
));
|
||||
}
|
||||
|
||||
$sort = strtolower($request->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
// First try topic_id; if empty, retry using legacy tid column
|
||||
$posts = new LengthAwarePaginator([], 0, 50, 1, [
|
||||
'path' => $request->url(),
|
||||
'query' => $request->query(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$posts = DB::connection('legacy')->table('forum_posts as p')
|
||||
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
|
||||
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
|
||||
->where('p.topic_id', $topic->topic_id)
|
||||
->orderBy('p.post_date', $sort)
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
// will retry with tid
|
||||
}
|
||||
|
||||
if ($posts->total() === 0) {
|
||||
try {
|
||||
$posts = DB::connection('legacy')->table('forum_posts as p')
|
||||
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
|
||||
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
|
||||
->where('p.tid', $topic->topic_id)
|
||||
->orderBy('p.post_date', $sort)
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
// keep empty paginator
|
||||
}
|
||||
}
|
||||
|
||||
return view('legacy.forum.posts', compact(
|
||||
'topic',
|
||||
'posts',
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch featured artworks with graceful fallbacks.
|
||||
*/
|
||||
private function featured(): array
|
||||
{
|
||||
$featured = null;
|
||||
$memberFeatured = null;
|
||||
|
||||
try {
|
||||
$featured = DB::connection('legacy')->table('featured_works as fw')
|
||||
->leftJoin('wallz as w', 'fw.artwork_id', '=', 'w.id')
|
||||
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
|
||||
->select('w.id', 'w.name', 'w.picture', 'u.uname', 'fw.post_date')
|
||||
->orderByDesc('fw.post_date')
|
||||
->first();
|
||||
|
||||
$memberFeatured = DB::connection('legacy')->table('users_opinions as o')
|
||||
->leftJoin('wallz as w', 'o.artwork_id', '=', 'w.id')
|
||||
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
|
||||
->select(DB::raw('COUNT(*) AS votes'), 'w.id', 'w.name', 'w.picture', 'u.uname')
|
||||
->whereRaw('o.post_date > SUBDATE(CURRENT_DATE(), INTERVAL 30 DAY)')
|
||||
->where('o.score', 4)
|
||||
->groupBy('o.artwork_id', 'w.id', 'w.name', 'w.picture', 'u.uname')
|
||||
->orderByDesc('votes')
|
||||
->limit(1)
|
||||
->first();
|
||||
} catch (\Throwable $e) {
|
||||
// Fail soft; render placeholders
|
||||
}
|
||||
|
||||
if (!$featured) {
|
||||
$featured = (object) [
|
||||
'id' => 0,
|
||||
'name' => 'Featured Artwork',
|
||||
'picture' => '/gfx/sb_join.jpg',
|
||||
'uname' => 'Skinbase',
|
||||
];
|
||||
}
|
||||
|
||||
if (!$memberFeatured) {
|
||||
$memberFeatured = (object) [
|
||||
'id' => 0,
|
||||
'name' => 'Members Pick',
|
||||
'picture' => '/gfx/sb_join.jpg',
|
||||
'uname' => 'Skinbase',
|
||||
'votes' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [$featured, $memberFeatured];
|
||||
}
|
||||
|
||||
private function forumNews(): array
|
||||
{
|
||||
try {
|
||||
return DB::connection('legacy')->table('forum_topics as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
|
||||
->select(
|
||||
't1.topic_id',
|
||||
't1.topic',
|
||||
't1.views',
|
||||
't1.post_date',
|
||||
't1.preview',
|
||||
't2.uname'
|
||||
)
|
||||
->where('t1.root_id', 2876)
|
||||
->where('t1.privilege', '<', 4)
|
||||
->orderByDesc('t1.post_date')
|
||||
->limit(8)
|
||||
->get()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function ourNews(): array
|
||||
{
|
||||
try {
|
||||
return DB::connection('legacy')->table('news as t1')
|
||||
->join('news_categories as t2', 't1.category_id', '=', 't2.category_id')
|
||||
->join('users as t3', 't1.user_id', '=', 't3.user_id')
|
||||
->select(
|
||||
't1.news_id',
|
||||
't1.headline',
|
||||
't1.picture',
|
||||
't1.preview',
|
||||
't1.create_date',
|
||||
't1.views',
|
||||
't2.category_name',
|
||||
't3.uname',
|
||||
DB::raw('(SELECT COUNT(*) FROM news_comment WHERE news_id = t1.news_id) AS num_comments')
|
||||
)
|
||||
->orderByDesc('t1.create_date')
|
||||
->limit(5)
|
||||
->get()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function latestForumActivity(): array
|
||||
{
|
||||
try {
|
||||
return DB::connection('legacy')->table('forum_topics as t1')
|
||||
->select(
|
||||
't1.topic_id',
|
||||
't1.topic',
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
|
||||
)
|
||||
->where('t1.root_id', '<>', 0)
|
||||
->where('t1.root_id', '<>', 2876)
|
||||
->where('t1.privilege', '<', 4)
|
||||
->orderByDesc('t1.last_update')
|
||||
->orderByDesc('t1.post_date')
|
||||
->limit(10)
|
||||
->get()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load latest uploads either from cached JSON or DB.
|
||||
*/
|
||||
private function latestUploads(): array
|
||||
{
|
||||
$uploads = [];
|
||||
|
||||
// Try cache file first
|
||||
$cachePath = base_path('oldSite/www/cache/latest_uploads.json');
|
||||
if (File::exists($cachePath)) {
|
||||
$json = File::get($cachePath);
|
||||
$uploads = json_decode($json, true) ?: [];
|
||||
}
|
||||
|
||||
// Fallback to DB if cache missing
|
||||
if (empty($uploads)) {
|
||||
try {
|
||||
$uploads = DB::connection('legacy')->table('wallz as w')
|
||||
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
|
||||
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
|
||||
->where('w.approved', 1)
|
||||
->orderByDesc('w.datum')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(function ($row) {
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'name' => $row->name,
|
||||
'picture' => $row->picture,
|
||||
'uname' => $row->uname,
|
||||
'category_name' => $row->category_name ?? '',
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
// Soft fail
|
||||
$uploads = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback placeholders
|
||||
if (empty($uploads)) {
|
||||
$uploads = [
|
||||
[
|
||||
'id' => 1,
|
||||
'name' => 'Sample Artwork',
|
||||
'picture' => 'gfx/sb_join.jpg',
|
||||
'uname' => 'Skinbase',
|
||||
'category_name' => 'Photography',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $uploads;
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/ManageController.php
Normal file
114
app/Http/Controllers/ManageController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkCategory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ManageController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$perPage = 50;
|
||||
|
||||
// Use legacy connection query builder and join category name to avoid Eloquent model issues
|
||||
$query = DB::connection('legacy')->table('artworks as a')
|
||||
->leftJoin('artworks_categories as c', 'a.category', '=', 'c.category_id')
|
||||
->where('a.user_id', $userId)
|
||||
->select('a.*', 'c.category_name')
|
||||
->orderByDesc('a.datum')
|
||||
->orderByDesc('a.id');
|
||||
|
||||
$artworks = $query->paginate($perPage);
|
||||
|
||||
return view('manage.index', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Artwork Manager',
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, $id)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$artwork = DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
|
||||
if (! $artwork) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$categories = DB::connection('legacy')->table('artworks_categories')->where('section_id', 0)->orderBy('category_id')->get();
|
||||
|
||||
return view('manage.edit', [
|
||||
'artwork' => $artwork,
|
||||
'categories' => $categories,
|
||||
'page_title' => 'Edit Artwork: ' . ($artwork->name ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$existing = DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
|
||||
|
||||
if (! $existing) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'section' => 'nullable|integer',
|
||||
'description' => 'nullable|string',
|
||||
'artwork' => 'nullable|file|image',
|
||||
'attachment' => 'nullable|file',
|
||||
]);
|
||||
$update = [
|
||||
'name' => $data['name'],
|
||||
'category' => $data['section'] ?? $existing->category,
|
||||
'description' => $data['description'] ?? $existing->description,
|
||||
'updated' => now(),
|
||||
];
|
||||
|
||||
// handle artwork image upload (replacing picture)
|
||||
if ($request->hasFile('artwork')) {
|
||||
$file = $request->file('artwork');
|
||||
$path = $file->store('public/uploads/artworks');
|
||||
$filename = basename($path);
|
||||
$update['picture'] = $filename;
|
||||
}
|
||||
|
||||
// handle attachment upload (zip, etc.)
|
||||
if ($request->hasFile('attachment')) {
|
||||
$att = $request->file('attachment');
|
||||
$attPath = $att->store('public/uploads/attachments');
|
||||
$update['fname'] = basename($attPath);
|
||||
}
|
||||
|
||||
DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->update($update);
|
||||
|
||||
return redirect()->route('manage')->with('status', 'Artwork was successfully updated.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, $id)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$artwork = DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
|
||||
if (! $artwork) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// delete files if present (stored in new storage location)
|
||||
if (!empty($artwork->fname)) {
|
||||
Storage::delete('public/uploads/attachments/' . $artwork->fname);
|
||||
}
|
||||
if (!empty($artwork->picture)) {
|
||||
Storage::delete('public/uploads/artworks/' . $artwork->picture);
|
||||
}
|
||||
|
||||
DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->delete();
|
||||
|
||||
return redirect()->route('manage')->with('status', 'Artwork deleted.');
|
||||
}
|
||||
}
|
||||
61
app/Http/Controllers/ProfileController.php
Normal file
61
app/Http/Controllers/ProfileController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
// Soft-delete the user (preserve record) — align with soft-delete policy.
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Web/ArtworkController.php
Normal file
58
app/Http/Controllers/Web/ArtworkController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class ArtworkController extends Controller
|
||||
{
|
||||
protected ArtworkService $service;
|
||||
|
||||
public function __construct(ArtworkService $service)
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse artworks for a category (Blade view).
|
||||
*/
|
||||
public function category(Request $request, Category $category): View
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 24);
|
||||
|
||||
$artworks = $this->service->getCategoryArtworks($category, $perPage);
|
||||
|
||||
return view('artworks.index', [
|
||||
'artworks' => $artworks,
|
||||
'category' => $category,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show single artwork page by slug (Blade view).
|
||||
*/
|
||||
public function show(string $slug): View
|
||||
{
|
||||
try {
|
||||
$artwork = $this->service->getPublicArtworkBySlug($slug);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Prepare simple SEO meta data for Blade; keep controller thin.
|
||||
$meta = [
|
||||
'title' => $artwork->title,
|
||||
'description' => str(config($artwork->description ?? ''))->limit(160),
|
||||
'canonical' => $artwork->canonical_url ?? null,
|
||||
];
|
||||
|
||||
return view('artworks.show', [
|
||||
'artwork' => $artwork,
|
||||
'meta' => $meta,
|
||||
]);
|
||||
}
|
||||
}
|
||||
21
app/Http/Requests/ArtworkIndexRequest.php
Normal file
21
app/Http/Requests/ArtworkIndexRequest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ArtworkIndexRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
'sort' => 'nullable|in:latest,oldest',
|
||||
'q' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
}
|
||||
85
app/Http/Requests/Auth/LoginRequest.php
Normal file
85
app/Http/Requests/Auth/LoginRequest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiting throttle key for the request.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/ProfileUpdateRequest.php
Normal file
30
app/Http/Requests/ProfileUpdateRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'lowercase',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class)->ignore($this->user()->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
81
app/Http/Resources/ArtworkListResource.php
Normal file
81
app/Http/Resources/ArtworkListResource.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Http\Resources\MissingValue;
|
||||
|
||||
class ArtworkListResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array for listings (browse feed).
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
if ($this instanceof MissingValue || $this->resource instanceof MissingValue) {
|
||||
return [];
|
||||
}
|
||||
// Safe accessor to avoid magic __get which may trigger MissingValue errors
|
||||
$get = function ($key) {
|
||||
$r = $this->resource;
|
||||
if ($r instanceof MissingValue || $r === null) {
|
||||
return null;
|
||||
}
|
||||
if (method_exists($r, 'getAttribute')) {
|
||||
return $r->getAttribute($key);
|
||||
}
|
||||
if (is_array($r)) {
|
||||
return $r[$key] ?? null;
|
||||
}
|
||||
if (is_object($r)) {
|
||||
return $r->{$key} ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
$primaryCategory = $this->whenLoaded('categories', function () {
|
||||
return $this->categories->sortBy('sort_order')->first();
|
||||
});
|
||||
|
||||
// Normalize MissingValue into null so later checks are straightforward
|
||||
if ($primaryCategory instanceof MissingValue) {
|
||||
$primaryCategory = null;
|
||||
}
|
||||
|
||||
$contentTypeSlug = null;
|
||||
$categoryPath = null;
|
||||
if ($primaryCategory) {
|
||||
$contentTypeSlug = optional($primaryCategory->contentType)->slug ?? null;
|
||||
$categoryPath = $primaryCategory->full_slug_path ?? null;
|
||||
}
|
||||
$slugVal = $get('slug');
|
||||
$webUrl = $contentTypeSlug && $categoryPath && $slugVal
|
||||
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal
|
||||
: null;
|
||||
|
||||
return [
|
||||
'slug' => $slugVal,
|
||||
'title' => $get('title'),
|
||||
'description' => $this->when($request->boolean('include_description'), fn() => $get('description')),
|
||||
'dimensions' => [
|
||||
'width' => $get('width'),
|
||||
'height' => $get('height'),
|
||||
],
|
||||
'thumbnail_url' => $this->when(! empty($get('file_path')), fn() => Storage::url($get('file_path'))),
|
||||
'author' => $this->whenLoaded('user', function () {
|
||||
return [
|
||||
'name' => $this->user->name ?? null,
|
||||
];
|
||||
}),
|
||||
'category' => $primaryCategory ? [
|
||||
'slug' => $primaryCategory->slug ?? null,
|
||||
'name' => $primaryCategory->name ?? null,
|
||||
'content_type' => $contentTypeSlug,
|
||||
'url' => $webUrl,
|
||||
] : null,
|
||||
'urls' => [
|
||||
'web' => $webUrl,
|
||||
'canonical' => $webUrl,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Http/Resources/ArtworkResource.php
Normal file
64
app/Http/Resources/ArtworkResource.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Http\Resources\MissingValue;
|
||||
|
||||
class ArtworkResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
if ($this instanceof MissingValue || $this->resource instanceof MissingValue) {
|
||||
return [];
|
||||
}
|
||||
$get = function ($key) {
|
||||
$r = $this->resource;
|
||||
if ($r instanceof MissingValue || $r === null) {
|
||||
return null;
|
||||
}
|
||||
// Eloquent model: prefer getAttribute to avoid magic proxies
|
||||
if (method_exists($r, 'getAttribute')) {
|
||||
return $r->getAttribute($key);
|
||||
}
|
||||
if (is_array($r)) {
|
||||
return $r[$key] ?? null;
|
||||
}
|
||||
if (is_object($r)) {
|
||||
return $r->{$key} ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return [
|
||||
'slug' => $get('slug'),
|
||||
'title' => $get('title'),
|
||||
'description' => $get('description'),
|
||||
'width' => $get('width'),
|
||||
'height' => $get('height'),
|
||||
|
||||
// File URLs: produce public URLs without exposing internal file_path
|
||||
'file' => [
|
||||
'name' => $get('file_name') ?? null,
|
||||
'url' => $this->when(! empty($get('file_path')), fn() => Storage::url($get('file_path'))),
|
||||
'size' => $get('file_size') ?? null,
|
||||
'mime_type' => $get('mime_type') ?? null,
|
||||
],
|
||||
|
||||
'categories' => $this->whenLoaded('categories', function () {
|
||||
return $this->categories->map(fn($c) => [
|
||||
'slug' => $c->slug ?? null,
|
||||
'name' => $c->name ?? null,
|
||||
])->values();
|
||||
}),
|
||||
|
||||
'published_at' => $this->whenNotNull($get('published_at') ? $this->published_at->toAtomString() : null),
|
||||
|
||||
'urls' => [
|
||||
'canonical' => $get('canonical_url') ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
73
app/Jobs/IncrementArtworkView.php
Normal file
73
app/Jobs/IncrementArtworkView.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\ArtworkStatsService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class IncrementArtworkView implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $artworkId;
|
||||
public int $count;
|
||||
public string $eventId;
|
||||
|
||||
/**
|
||||
* Require a unique event id to make the job idempotent across retries and concurrency.
|
||||
*
|
||||
* @param int $artworkId
|
||||
* @param string $eventId Unique identifier for this view event (caller must supply)
|
||||
* @param int $count
|
||||
*/
|
||||
public function __construct(int $artworkId, string $eventId, int $count = 1)
|
||||
{
|
||||
$this->artworkId = $artworkId;
|
||||
$this->count = max(1, $count);
|
||||
$this->eventId = $eventId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
* Uses Redis setnx to ensure only one worker processes a given eventId.
|
||||
* Delegates actual DB mutation to ArtworkStatsService which uses transactions.
|
||||
*/
|
||||
public function handle(ArtworkStatsService $statsService): void
|
||||
{
|
||||
$key = 'artwork:view:processed:' . $this->eventId;
|
||||
|
||||
try {
|
||||
$didSet = false;
|
||||
try {
|
||||
$didSet = Redis::setnx($key, 1);
|
||||
if ($didSet) {
|
||||
// expire after 1 day to limit key growth
|
||||
Redis::expire($key, 86400);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Redis unavailable for IncrementArtworkView; proceeding without dedupe', ['error' => $e->getMessage()]);
|
||||
// If Redis is not available, fall back to applying delta directly.
|
||||
// This sacrifices idempotency but ensures metrics are recorded.
|
||||
$statsService->applyDelta($this->artworkId, ['views' => $this->count]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $didSet) {
|
||||
// Already processed this eventId — idempotent skip
|
||||
return;
|
||||
}
|
||||
|
||||
// Safe increment using transactional method
|
||||
$statsService->applyDelta($this->artworkId, ['views' => $this->count]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('IncrementArtworkView job failed', ['artwork_id' => $this->artworkId, 'event_id' => $this->eventId, 'error' => $e->getMessage()]);
|
||||
// Let the job be retried by throwing
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
178
app/Models/Artwork.php
Normal file
178
app/Models/Artwork.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* App\Models\Artwork
|
||||
*
|
||||
* @property-read User $user
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkTranslation[] $translations
|
||||
* @property-read ArtworkStats $stats
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkComment[] $comments
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkDownload[] $downloads
|
||||
*/
|
||||
class Artwork extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $table = 'artworks';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'title',
|
||||
'slug',
|
||||
'description',
|
||||
'file_name',
|
||||
'file_path',
|
||||
'hash',
|
||||
'file_ext',
|
||||
'thumb_ext',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
'width',
|
||||
'height',
|
||||
'is_public',
|
||||
'is_approved',
|
||||
'published_at',
|
||||
'hash',
|
||||
'thumb_ext',
|
||||
'file_ext'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_public' => 'boolean',
|
||||
'is_approved' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Thumbnail sizes and their options.
|
||||
* Keys are the size dir used in the CDN URL.
|
||||
*/
|
||||
protected const THUMB_SIZES = [
|
||||
'sm' => ['height' => 240, 'quality' => 78, 'dir' => 'sm'],
|
||||
'md' => ['height' => 360, 'quality' => 82, 'dir' => 'md'],
|
||||
'lg' => ['height' => 1200, 'quality' => 85, 'dir' => 'lg'],
|
||||
'xl' => ['height' => 2400, 'quality' => 90, 'dir' => 'xl'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the thumbnail URL for this artwork.
|
||||
* Returns null when no hash or thumb_ext is available.
|
||||
*/
|
||||
public function thumbUrl(string $size = 'md'): ?string
|
||||
{
|
||||
if (empty($this->hash) || empty($this->thumb_ext)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$size = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md';
|
||||
$h = $this->hash;
|
||||
$h1 = substr($h, 0, 2);
|
||||
$h2 = substr($h, 2, 2);
|
||||
$ext = $this->thumb_ext;
|
||||
|
||||
return "https://files.skinbase.org/{$size}/{$h1}/{$h2}/{$h}.{$ext}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor for `$art->thumb` used in legacy views (default medium size).
|
||||
*/
|
||||
public function getThumbAttribute(): string
|
||||
{
|
||||
return $this->thumbUrl('md') ?? '/gfx/sb_join.jpg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor for `$art->thumb_url` used in some views.
|
||||
*/
|
||||
public function getThumbUrlAttribute(): ?string
|
||||
{
|
||||
return $this->thumbUrl('md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a responsive `srcset` for legacy views.
|
||||
*/
|
||||
public function getThumbSrcsetAttribute(): ?string
|
||||
{
|
||||
if (empty($this->hash) || empty($this->thumb_ext)) return null;
|
||||
$sm = $this->thumbUrl('sm');
|
||||
$md = $this->thumbUrl('md');
|
||||
if (!$sm || !$md) return null;
|
||||
return $sm . ' 320w, ' . $md . ' 600w';
|
||||
}
|
||||
|
||||
// Relations
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function translations(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArtworkTranslation::class);
|
||||
}
|
||||
|
||||
public function stats(): HasOne
|
||||
{
|
||||
return $this->hasOne(ArtworkStats::class, 'artwork_id');
|
||||
}
|
||||
|
||||
public function categories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id');
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArtworkComment::class);
|
||||
}
|
||||
|
||||
public function downloads(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArtworkDownload::class);
|
||||
}
|
||||
|
||||
public function features(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArtworkFeature::class, 'artwork_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
public function scopePublic(Builder $query): Builder
|
||||
{
|
||||
// Compose approved() so behavior is consistent and composable
|
||||
$table = $this->getTable();
|
||||
return $query->approved()->where("{$table}.is_public", true);
|
||||
}
|
||||
|
||||
public function scopeApproved(Builder $query): Builder
|
||||
{
|
||||
// Respect soft deletes and mark approved content
|
||||
$table = $this->getTable();
|
||||
return $query->whereNull("{$table}.deleted_at")->where("{$table}.is_approved", true);
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
// Respect soft deletes and only include published items up to now
|
||||
$table = $this->getTable();
|
||||
return $query->whereNull("{$table}.deleted_at")
|
||||
->whereNotNull("{$table}.published_at")
|
||||
->where("{$table}.published_at", '<=', now());
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
}
|
||||
1
app/Models/ArtworkCategory.php
Normal file
1
app/Models/ArtworkCategory.php
Normal file
@@ -0,0 +1 @@
|
||||
<?php
|
||||
40
app/Models/ArtworkComment.php
Normal file
40
app/Models/ArtworkComment.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ArtworkComment
|
||||
*
|
||||
* @property-read Artwork $artwork
|
||||
* @property-read User $user
|
||||
*/
|
||||
class ArtworkComment extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'artwork_comments';
|
||||
|
||||
protected $fillable = [
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'content',
|
||||
'is_approved',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_approved' => 'boolean',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
33
app/Models/ArtworkDownload.php
Normal file
33
app/Models/ArtworkDownload.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ArtworkDownload
|
||||
*
|
||||
* @property-read Artwork $artwork
|
||||
* @property-read User|null $user
|
||||
*/
|
||||
class ArtworkDownload extends Model
|
||||
{
|
||||
protected $table = 'artwork_downloads';
|
||||
|
||||
protected $fillable = [
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'ip',
|
||||
'user_agent',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
27
app/Models/ArtworkFeature.php
Normal file
27
app/Models/ArtworkFeature.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ArtworkFeature extends Model
|
||||
{
|
||||
protected $table = 'artwork_features';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'artwork_id',
|
||||
'type',
|
||||
'featured_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'featured_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
}
|
||||
33
app/Models/ArtworkStats.php
Normal file
33
app/Models/ArtworkStats.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ArtworkStats
|
||||
*
|
||||
* @property-read Artwork $artwork
|
||||
*/
|
||||
class ArtworkStats extends Model
|
||||
{
|
||||
protected $table = 'artwork_stats';
|
||||
|
||||
protected $primaryKey = 'artwork_id';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $fillable = [
|
||||
'artwork_id',
|
||||
'views',
|
||||
'downloads',
|
||||
'favorites',
|
||||
'rating_avg',
|
||||
'rating_count',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class, 'artwork_id');
|
||||
}
|
||||
}
|
||||
30
app/Models/ArtworkTranslation.php
Normal file
30
app/Models/ArtworkTranslation.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ArtworkTranslation
|
||||
*
|
||||
* @property-read Artwork $artwork
|
||||
*/
|
||||
class ArtworkTranslation extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'artwork_translations';
|
||||
|
||||
protected $fillable = [
|
||||
'artwork_id',
|
||||
'locale',
|
||||
'title',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
}
|
||||
116
app/Models/Category.php
Normal file
116
app/Models/Category.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\{BelongsTo, HasMany, BelongsToMany, HasOne};
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'content_type_id','parent_id','name','slug',
|
||||
'description','image','is_active','sort_order'
|
||||
];
|
||||
|
||||
protected $casts = ['is_active' => 'boolean'];
|
||||
|
||||
/**
|
||||
* Ensure slug is always lowercase and valid before saving.
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::saving(function (Category $model) {
|
||||
if (isset($model->slug)) {
|
||||
$model->slug = strtolower($model->slug);
|
||||
if (!preg_match('/^[a-z0-9-]+$/', $model->slug)) {
|
||||
throw new \InvalidArgumentException('Category slug must be lowercase and contain only a-z, 0-9, and dashes.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function contentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ContentType::class);
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(Category::class, 'parent_id')
|
||||
->orderBy('sort_order')->orderBy('name');
|
||||
}
|
||||
|
||||
public function descendants(): HasMany
|
||||
{
|
||||
return $this->children()->with('descendants');
|
||||
}
|
||||
|
||||
public function seo(): HasOne
|
||||
{
|
||||
return $this->hasOne(CategorySeo::class);
|
||||
}
|
||||
|
||||
public function artworks(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Artwork::class, 'artwork_category');
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeRoots($query)
|
||||
{
|
||||
return $query->whereNull('parent_id');
|
||||
}
|
||||
|
||||
public function getFullSlugPathAttribute(): string
|
||||
{
|
||||
return $this->parent
|
||||
? $this->parent->full_slug_path . '/' . $this->slug
|
||||
: $this->slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full public URL for this category (authoritative spec).
|
||||
* Example: /photography/abstract/dark
|
||||
*/
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
$contentTypeSlug = strtolower($this->contentType->slug);
|
||||
$path = strtolower($this->full_slug_path);
|
||||
return '/' . $contentTypeSlug . ($path ? '/' . $path : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the canonical URL for SEO (authoritative spec).
|
||||
* Example: https://skinbase.org/photography/abstract/dark
|
||||
*/
|
||||
public function getCanonicalUrlAttribute(): string
|
||||
{
|
||||
return 'https://skinbase.org' . $this->url;
|
||||
}
|
||||
|
||||
public function getBreadcrumbsAttribute(): array
|
||||
{
|
||||
return $this->parent
|
||||
? array_merge($this->parent->breadcrumbs, [$this])
|
||||
: [$this];
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
}
|
||||
24
app/Models/CategorySeo.php
Normal file
24
app/Models/CategorySeo.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CategorySeo extends Model
|
||||
{
|
||||
protected $table = 'category_seo';
|
||||
protected $primaryKey = 'category_id';
|
||||
public $incrementing = false;
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'category_id','meta_title','meta_description',
|
||||
'meta_keywords','canonical_url'
|
||||
];
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
}
|
||||
23
app/Models/CategoryTranslation.php
Normal file
23
app/Models/CategoryTranslation.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class CategoryTranslation extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'category_id','locale','name','description'
|
||||
];
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
}
|
||||
26
app/Models/ContentType.php
Normal file
26
app/Models/ContentType.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ContentType extends Model
|
||||
{
|
||||
protected $fillable = ['name','slug','description'];
|
||||
|
||||
public function categories(): HasMany
|
||||
{
|
||||
return $this->hasMany(Category::class);
|
||||
}
|
||||
|
||||
public function rootCategories(): HasMany
|
||||
{
|
||||
return $this->categories()->whereNull('parent_id');
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
}
|
||||
50
app/Models/User.php
Normal file
50
app/Models/User.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, SoftDeletes;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
90
app/Policies/ArtworkPolicy.php
Normal file
90
app/Policies/ArtworkPolicy.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
|
||||
class ArtworkPolicy
|
||||
{
|
||||
/**
|
||||
* Global before hook: admins can do everything.
|
||||
* Accepts null $user to allow public checks to continue.
|
||||
*/
|
||||
public function before($user, $ability)
|
||||
{
|
||||
if (! $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->isAdmin($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function isAdmin(User $user): bool
|
||||
{
|
||||
if (isset($user->is_admin)) {
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
if (method_exists($user, 'isAdmin')) {
|
||||
return (bool) $user->isAdmin();
|
||||
}
|
||||
|
||||
if (method_exists($user, 'hasRole')) {
|
||||
return (bool) $user->hasRole('admin');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public view: only approved + public + not-deleted artworks.
|
||||
*/
|
||||
public function view(?User $user, Artwork $artwork): bool
|
||||
{
|
||||
return $artwork->is_public && $artwork->is_approved && ! $artwork->trashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Any authenticated user can create artworks.
|
||||
*/
|
||||
public function create(?User $user): bool
|
||||
{
|
||||
return (bool) $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner can update their own artwork.
|
||||
*/
|
||||
public function update(User $user, Artwork $artwork): bool
|
||||
{
|
||||
return $user->id === $artwork->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner can delete their own artwork (soft delete).
|
||||
*/
|
||||
public function delete(User $user, Artwork $artwork): bool
|
||||
{
|
||||
return $user->id === $artwork->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore: owner or admin can restore soft-deleted artwork.
|
||||
*/
|
||||
public function restore(User $user, Artwork $artwork): bool
|
||||
{
|
||||
return $user->id === $artwork->user_id || $this->isAdmin($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force delete reserved for admins only.
|
||||
*/
|
||||
public function forceDelete(User $user, Artwork $artwork): bool
|
||||
{
|
||||
return $this->isAdmin($user);
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
27
app/Providers/AuthServiceProvider.php
Normal file
27
app/Providers/AuthServiceProvider.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use App\Models\Artwork;
|
||||
use App\Policies\ArtworkPolicy;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The policy mappings for the application.
|
||||
*
|
||||
* @var array<class-string, class-string>
|
||||
*/
|
||||
protected $policies = [
|
||||
Artwork::class => ArtworkPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Register any authentication / authorization services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->registerPolicies();
|
||||
}
|
||||
}
|
||||
292
app/Services/ArtworkService.php
Normal file
292
app/Services/ArtworkService.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\ArtworkFeature;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Contracts\Pagination\CursorPaginator;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* ArtworkService
|
||||
*
|
||||
* Business logic for retrieving artworks. Controllers should remain thin and
|
||||
* delegate to this service. This service never returns JSON or accesses
|
||||
* the request() helper directly.
|
||||
*/
|
||||
class ArtworkService
|
||||
{
|
||||
protected int $cacheTtl = 3600; // seconds
|
||||
|
||||
/**
|
||||
* Fetch a single public artwork by slug.
|
||||
* Applies visibility rules (public + approved + not-deleted).
|
||||
*
|
||||
* @param string $slug
|
||||
* @return Artwork
|
||||
* @throws ModelNotFoundException
|
||||
*/
|
||||
public function getPublicArtworkBySlug(string $slug): Artwork
|
||||
{
|
||||
$key = 'artwork:' . $slug;
|
||||
|
||||
$artwork = Cache::remember($key, $this->cacheTtl, function () use ($slug) {
|
||||
$a = Artwork::where('slug', $slug)
|
||||
->public()
|
||||
->published()
|
||||
->first();
|
||||
|
||||
if (! $a) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load lightweight relations for presentation; do NOT eager-load stats here.
|
||||
$a->load(['translations', 'categories']);
|
||||
|
||||
return $a;
|
||||
});
|
||||
|
||||
if (! $artwork) {
|
||||
$e = new ModelNotFoundException();
|
||||
$e->setModel(Artwork::class, [$slug]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $artwork;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear artwork cache by model instance.
|
||||
*/
|
||||
public function clearArtworkCache(Artwork $artwork): void
|
||||
{
|
||||
$this->clearArtworkCacheBySlug($artwork->slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear artwork cache by slug.
|
||||
*/
|
||||
public function clearArtworkCacheBySlug(string $slug): void
|
||||
{
|
||||
Cache::forget('artwork:' . $slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get artworks for a given category, applying visibility rules and cursor pagination.
|
||||
* Returns a CursorPaginator so controllers/resources can render paginated feeds.
|
||||
*
|
||||
* @param Category $category
|
||||
* @param int $perPage
|
||||
* @return CursorPaginator
|
||||
*/
|
||||
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
|
||||
{
|
||||
$query = Artwork::public()->published()
|
||||
->whereHas('categories', function ($q) use ($category) {
|
||||
$q->where('categories.id', $category->id);
|
||||
})
|
||||
->orderByDesc('published_at');
|
||||
|
||||
// Important: do NOT eager-load artwork_stats in listings
|
||||
return $query->cursorPaginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the latest public artworks up to $limit.
|
||||
*
|
||||
* @param int $limit
|
||||
* @return \Illuminate\Support\Collection|EloquentCollection
|
||||
*/
|
||||
public function getLatestArtworks(int $limit = 10): Collection
|
||||
{
|
||||
return Artwork::public()->published()
|
||||
->orderByDesc('published_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse all public, approved, published artworks with pagination.
|
||||
* Uses new authoritative tables only (no legacy joins) and eager-loads
|
||||
* lightweight relations needed for presentation.
|
||||
*/
|
||||
public function browsePublicArtworks(int $perPage = 24): CursorPaginator
|
||||
{
|
||||
$query = Artwork::public()
|
||||
->published()
|
||||
->with([
|
||||
'user:id,name',
|
||||
'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->orderByDesc('published_at');
|
||||
|
||||
// Use cursor pagination for high-load browse feeds (SEO handled via canonical URLs).
|
||||
return $query->cursorPaginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse artworks scoped to a content type slug using keyset pagination.
|
||||
* Applies public + approved + published filters.
|
||||
*/
|
||||
public function getArtworksByContentType(string $slug, int $perPage): CursorPaginator
|
||||
{
|
||||
$contentType = ContentType::where('slug', strtolower($slug))->first();
|
||||
|
||||
if (! $contentType) {
|
||||
$e = new ModelNotFoundException();
|
||||
$e->setModel(ContentType::class, [$slug]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$query = Artwork::public()
|
||||
->published()
|
||||
->whereHas('categories', function ($q) use ($contentType) {
|
||||
$q->where('categories.content_type_id', $contentType->id);
|
||||
})
|
||||
->with([
|
||||
'user:id,name',
|
||||
'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->orderByDesc('published_at');
|
||||
|
||||
return $query->cursorPaginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse artworks for a category path (content type slug + nested category slugs).
|
||||
* Uses slug-only resolution and keyset pagination.
|
||||
*
|
||||
* @param array<int, string> $slugs
|
||||
*/
|
||||
public function getArtworksByCategoryPath(array $slugs, int $perPage): CursorPaginator
|
||||
{
|
||||
if (empty($slugs)) {
|
||||
$e = new ModelNotFoundException();
|
||||
$e->setModel(Category::class);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$parts = array_values(array_map('strtolower', $slugs));
|
||||
$contentTypeSlug = array_shift($parts);
|
||||
|
||||
$contentType = ContentType::where('slug', $contentTypeSlug)->first();
|
||||
if (! $contentType) {
|
||||
$e = new ModelNotFoundException();
|
||||
$e->setModel(ContentType::class, [$contentTypeSlug]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if (empty($parts)) {
|
||||
$e = new ModelNotFoundException();
|
||||
$e->setModel(Category::class, []);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Resolve the category path from roots downward within the content type.
|
||||
$current = Category::where('content_type_id', $contentType->id)
|
||||
->whereNull('parent_id')
|
||||
->where('slug', array_shift($parts))
|
||||
->first();
|
||||
|
||||
if (! $current) {
|
||||
$e = new ModelNotFoundException();
|
||||
$e->setModel(Category::class, $slugs);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
foreach ($parts as $slug) {
|
||||
$current = $current->children()->where('slug', $slug)->first();
|
||||
if (! $current) {
|
||||
$e = new ModelNotFoundException();
|
||||
$e->setModel(Category::class, $slugs);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$query = Artwork::public()
|
||||
->published()
|
||||
->whereHas('categories', function ($q) use ($current) {
|
||||
$q->where('categories.id', $current->id);
|
||||
})
|
||||
->with([
|
||||
'user:id,name',
|
||||
'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->orderByDesc('published_at');
|
||||
|
||||
return $query->cursorPaginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured artworks ordered by featured_at DESC, optionally filtered by type.
|
||||
* Uses artwork_features table and applies public/approved/published filters.
|
||||
*/
|
||||
public function getFeaturedArtworks(?int $type, int $perPage = 39): LengthAwarePaginator
|
||||
{
|
||||
$query = Artwork::query()
|
||||
->select('artworks.*')
|
||||
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
|
||||
->public()
|
||||
->published()
|
||||
->when($type !== null, function ($q) use ($type) {
|
||||
$q->where('af.type', $type);
|
||||
})
|
||||
->with([
|
||||
'user:id,name',
|
||||
'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order');
|
||||
},
|
||||
])
|
||||
->orderByDesc('af.featured_at')
|
||||
->orderByDesc('artworks.published_at');
|
||||
|
||||
return $query->paginate($perPage)->withQueryString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get artworks belonging to a specific user.
|
||||
* If the requester is the owner, return all non-deleted artworks for that user.
|
||||
* Public visitors only see public + approved + published artworks.
|
||||
*
|
||||
* @param int $userId
|
||||
* @param bool $isOwner
|
||||
* @param int $perPage
|
||||
* @return CursorPaginator
|
||||
*/
|
||||
public function getArtworksByUser(int $userId, bool $isOwner, int $perPage = 24): CursorPaginator
|
||||
{
|
||||
$query = Artwork::where('user_id', $userId)
|
||||
->with([
|
||||
'user:id,name',
|
||||
'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->orderByDesc('published_at');
|
||||
|
||||
if (! $isOwner) {
|
||||
// Apply public visibility constraints for non-owners
|
||||
$query->public()->published();
|
||||
} else {
|
||||
// Owner: include all non-deleted items (do not force published/approved)
|
||||
$query->whereNull('deleted_at');
|
||||
}
|
||||
|
||||
return $query->cursorPaginate($perPage);
|
||||
}
|
||||
}
|
||||
160
app/Services/ArtworkStatsService.php
Normal file
160
app/Services/ArtworkStatsService.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
/**
|
||||
* ArtworkStatsService
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Increment views and downloads using DB transactions
|
||||
* - Optionally defer increments into Redis for async processing
|
||||
* - Provide a processor to drain queued deltas (job-friendly)
|
||||
*/
|
||||
class ArtworkStatsService
|
||||
{
|
||||
protected string $redisKey = 'artwork_stats:deltas';
|
||||
|
||||
/**
|
||||
* Increment views for an artwork.
|
||||
* Set $defer=true to push to Redis for async processing when available.
|
||||
*/
|
||||
public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'views', $by);
|
||||
return;
|
||||
$this->applyDelta($artworkId, ['views' => $by]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment downloads for an artwork.
|
||||
*/
|
||||
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'downloads', $by);
|
||||
return;
|
||||
|
||||
/**
|
||||
* Increment views using an Artwork model. Preferred API-first signature.
|
||||
*/
|
||||
public function incrementViewsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
|
||||
{
|
||||
$this->incrementViews((int) $artwork->id, $by, $defer);
|
||||
}
|
||||
}
|
||||
$this->applyDelta($artworkId, ['downloads' => $by]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a set of deltas to the artwork_stats row inside a transaction.
|
||||
* This method is safe to call from jobs or synchronously.
|
||||
*
|
||||
* @param int $artworkId
|
||||
* @param array<string,int> $deltas
|
||||
*/
|
||||
public function applyDelta(int $artworkId, array $deltas): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($artworkId, $deltas) {
|
||||
// Ensure a stats row exists. Insert default zeros if missing.
|
||||
DB::table('artwork_stats')->insertOrIgnore([
|
||||
'artwork_id' => $artworkId,
|
||||
|
||||
/**
|
||||
* Increment downloads using an Artwork model. Preferred API-first signature.
|
||||
*/
|
||||
public function incrementDownloadsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
|
||||
{
|
||||
$this->incrementDownloads((int) $artwork->id, $by, $defer);
|
||||
}
|
||||
'views' => 0,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
|
||||
foreach ($deltas as $column => $value) {
|
||||
// Only allow known columns to avoid SQL injection
|
||||
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('artwork_stats')
|
||||
->where('artwork_id', $artworkId)
|
||||
->increment($column, (int) $value);
|
||||
}
|
||||
});
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Failed to apply artwork stats delta', ['artwork_id' => $artworkId, 'deltas' => $deltas, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a delta to Redis queue for async processing.
|
||||
*/
|
||||
protected function pushDelta(int $artworkId, string $field, int $value): void
|
||||
{
|
||||
$payload = json_encode([
|
||||
'artwork_id' => $artworkId,
|
||||
'field' => $field,
|
||||
'value' => $value,
|
||||
'ts' => time(),
|
||||
]);
|
||||
|
||||
try {
|
||||
Redis::rpush($this->redisKey, $payload);
|
||||
} catch (Throwable $e) {
|
||||
// If Redis is unavailable, fallback to immediate apply to avoid data loss
|
||||
Log::warning('Redis unavailable for artwork stats; applying immediately', ['error' => $e->getMessage()]);
|
||||
$this->applyDelta($artworkId, [$field => $value]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain and apply queued deltas from Redis. Returns number processed.
|
||||
* Designed to be invoked by a queued job or artisan command.
|
||||
*/
|
||||
public function processPendingFromRedis(int $max = 1000): int
|
||||
{
|
||||
if (! $this->redisAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
$processed = 0;
|
||||
|
||||
try {
|
||||
while ($processed < $max) {
|
||||
$item = Redis::lpop($this->redisKey);
|
||||
if (! $item) {
|
||||
break;
|
||||
}
|
||||
|
||||
$decoded = json_decode($item, true);
|
||||
if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) {
|
||||
continue;
|
||||
$this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]);
|
||||
$processed++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return $processed;
|
||||
}
|
||||
|
||||
protected function redisAvailable(): bool
|
||||
{
|
||||
try {
|
||||
// Redis facade may throw if not configured
|
||||
$pong = Redis::connection()->ping();
|
||||
return (bool) $pong;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
655
app/Services/LegacyService.php
Normal file
655
app/Services/LegacyService.php
Normal file
@@ -0,0 +1,655 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* @deprecated LegacyService contains helpers to render legacy pages and should be
|
||||
* migrated to new services. Keep in place until legacy controllers/views
|
||||
* are refactored. Instantiating the service will emit a deprecation log.
|
||||
*/
|
||||
class LegacyService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
Log::warning('App\Services\LegacyService is deprecated. Please migrate callers to modern services.');
|
||||
}
|
||||
public function featured(): array
|
||||
{
|
||||
$featured = null;
|
||||
$memberFeatured = null;
|
||||
|
||||
try {
|
||||
$featured = DB::connection('legacy')->table('featured_works as fw')
|
||||
->leftJoin('wallz as w', 'fw.artwork_id', '=', 'w.id')
|
||||
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
|
||||
->select('w.id', 'w.name', 'w.picture', 'u.uname', 'fw.post_date')
|
||||
->orderByDesc('fw.post_date')
|
||||
->first();
|
||||
|
||||
$memberFeatured = DB::connection('legacy')->table('users_opinions as o')
|
||||
->leftJoin('wallz as w', 'o.artwork_id', '=', 'w.id')
|
||||
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
|
||||
->select(DB::raw('COUNT(*) AS votes'), 'w.id', 'w.name', 'w.picture', 'u.uname')
|
||||
->whereRaw('o.post_date > SUBDATE(CURRENT_DATE(), INTERVAL 30 DAY)')
|
||||
->where('o.score', 4)
|
||||
->groupBy('o.artwork_id', 'w.id', 'w.name', 'w.picture', 'u.uname')
|
||||
->orderByDesc('votes')
|
||||
->limit(1)
|
||||
->first();
|
||||
} catch (\Throwable $e) {
|
||||
// fail soft
|
||||
}
|
||||
|
||||
if (!$featured) {
|
||||
$featured = (object) [
|
||||
'id' => 0,
|
||||
'name' => 'Featured Artwork',
|
||||
'picture' => '/gfx/sb_join.jpg',
|
||||
'uname' => 'Skinbase',
|
||||
];
|
||||
}
|
||||
|
||||
if (!$memberFeatured) {
|
||||
$memberFeatured = (object) [
|
||||
'id' => 0,
|
||||
'name' => 'Members Pick',
|
||||
'picture' => '/gfx/sb_join.jpg',
|
||||
'uname' => 'Skinbase',
|
||||
'votes' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [$featured, $memberFeatured];
|
||||
}
|
||||
|
||||
public function latestUploads(): array
|
||||
{
|
||||
$uploads = [];
|
||||
|
||||
$cachePath = base_path('oldSite/www/cache/latest_uploads.json');
|
||||
if (File::exists($cachePath)) {
|
||||
$json = File::get($cachePath);
|
||||
$uploads = json_decode($json, true) ?: [];
|
||||
}
|
||||
|
||||
if (empty($uploads)) {
|
||||
try {
|
||||
$uploads = DB::connection('legacy')->table('wallz as w')
|
||||
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
|
||||
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
|
||||
->where('w.approved', 1)
|
||||
->orderByDesc('w.datum')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(function ($row) {
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'name' => $row->name,
|
||||
'picture' => $row->picture,
|
||||
'uname' => $row->uname,
|
||||
'category_name' => $row->category_name ?? '',
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
$uploads = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($uploads)) {
|
||||
$uploads = [
|
||||
[
|
||||
'id' => 1,
|
||||
'name' => 'Sample Artwork',
|
||||
'picture' => 'gfx/sb_join.jpg',
|
||||
'uname' => 'Skinbase',
|
||||
'category_name' => 'Photography',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $uploads;
|
||||
}
|
||||
|
||||
public function forumNews(): array
|
||||
{
|
||||
try {
|
||||
return DB::connection('legacy')->table('forum_topics as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
|
||||
->select(
|
||||
't1.topic_id',
|
||||
't1.topic',
|
||||
't1.views',
|
||||
't1.post_date',
|
||||
't1.preview',
|
||||
't2.uname'
|
||||
)
|
||||
->where('t1.root_id', 2876)
|
||||
->where('t1.privilege', '<', 4)
|
||||
->orderByDesc('t1.post_date')
|
||||
->limit(8)
|
||||
->get()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function ourNews(): array
|
||||
{
|
||||
try {
|
||||
return DB::connection('legacy')->table('news as t1')
|
||||
->join('news_categories as t2', 't1.category_id', '=', 't2.category_id')
|
||||
->join('users as t3', 't1.user_id', '=', 't3.user_id')
|
||||
->select(
|
||||
't1.news_id',
|
||||
't1.headline',
|
||||
't1.picture',
|
||||
't1.preview',
|
||||
't1.create_date',
|
||||
't1.views',
|
||||
't2.category_name',
|
||||
't3.uname',
|
||||
DB::raw('(SELECT COUNT(*) FROM news_comment WHERE news_id = t1.news_id) AS num_comments')
|
||||
)
|
||||
->orderByDesc('t1.create_date')
|
||||
->limit(5)
|
||||
->get()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function latestForumActivity(): array
|
||||
{
|
||||
try {
|
||||
return DB::connection('legacy')->table('forum_topics as t1')
|
||||
->select(
|
||||
't1.topic_id',
|
||||
't1.topic',
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
|
||||
)
|
||||
->where('t1.root_id', '<>', 0)
|
||||
->where('t1.root_id', '<>', 2876)
|
||||
->where('t1.privilege', '<', 4)
|
||||
->orderByDesc('t1.last_update')
|
||||
->orderByDesc('t1.post_date')
|
||||
->limit(10)
|
||||
->get()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function browseGallery(int $perPage = 50)
|
||||
{
|
||||
try {
|
||||
return DB::connection('legacy')->table('wallz as w')
|
||||
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
|
||||
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
|
||||
->select('w.id', 'w.name', 'w.picture', 'w.category', 'w.datum', 'c.category_name', 'u.uname')
|
||||
->where('w.approved', 1)
|
||||
->where('w.public', 'Y')
|
||||
->orderByDesc('w.datum')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function categoryPage(string $group, ?string $slug = null, ?int $id = null)
|
||||
{
|
||||
$group = \Illuminate\Support\Str::title($group);
|
||||
$defaults = [
|
||||
'Skins' => 1,
|
||||
'Wallpapers' => 2,
|
||||
'Photography' => 3,
|
||||
'Other' => 4,
|
||||
];
|
||||
|
||||
if (!$id && $slug && ctype_digit($slug)) {
|
||||
$id = (int) $slug;
|
||||
}
|
||||
|
||||
$id = $id ?: ($defaults[$group] ?? null);
|
||||
if (!$id || $id < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$category = DB::connection('legacy')->table('artworks_categories')
|
||||
->select('category_id', 'category_name', 'description', 'rootid', 'section_id')
|
||||
->where('category_id', $id)
|
||||
->first();
|
||||
} catch (\Throwable $e) {
|
||||
$category = null;
|
||||
}
|
||||
|
||||
if (! $category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$perPage = 40;
|
||||
|
||||
try {
|
||||
$base = DB::connection('legacy')->table('wallz as t1')
|
||||
->select('t1.id', 't1.name', 't1.picture', 't3.uname', 't1.category', 't2.category_name')
|
||||
->join('artworks_categories as t2', 't1.category', '=', 't2.category_id')
|
||||
->leftJoin('users as t3', 't1.user_id', '=', 't3.user_id')
|
||||
->where('t1.approved', 1)
|
||||
->where(function ($q) use ($id, $category) {
|
||||
$q->where('t1.category', (int) $id);
|
||||
if ($category->rootid > 0) {
|
||||
$q->orWhere('t1.rootid', (int) $id);
|
||||
}
|
||||
})
|
||||
->orderByDesc('t1.datum');
|
||||
|
||||
$artworks = $base->paginate($perPage)->withQueryString();
|
||||
|
||||
if ($artworks && method_exists($artworks, 'getCollection')) {
|
||||
$artworks->getCollection()->transform(function ($row) {
|
||||
$row->gid_num = ((int) ($row->category ?? 0) % 5) * 5;
|
||||
if (!empty($row->picture)) {
|
||||
$ext = self::fileExtension($row->picture);
|
||||
$encoded = self::encode($row->id);
|
||||
$row->ext = $ext;
|
||||
$row->encoded = $encoded;
|
||||
// Prefer new files.skinbase.org when possible
|
||||
try {
|
||||
$art = \App\Models\Artwork::find($row->id);
|
||||
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
|
||||
$row->thumb_url = $present['url'];
|
||||
$row->thumb_srcset = $present['srcset'];
|
||||
} catch (\Throwable $e) {
|
||||
$present = \App\Services\ThumbnailPresenter::present((array) $row, 'md');
|
||||
$row->thumb_url = $present['url'];
|
||||
$row->thumb_srcset = $present['srcset'];
|
||||
}
|
||||
} else {
|
||||
$row->ext = null;
|
||||
$row->encoded = null;
|
||||
$row->thumb_url = '/gfx/sb_join.jpg';
|
||||
$row->thumb_srcset = null;
|
||||
}
|
||||
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$artworks = null;
|
||||
}
|
||||
|
||||
try {
|
||||
$subcategories = DB::connection('legacy')->table('artworks_categories')
|
||||
->select('category_id', 'category_name')
|
||||
->where('rootid', $id)
|
||||
->orderBy('category_name')
|
||||
->get();
|
||||
|
||||
if ($subcategories->isEmpty() && $category->rootid) {
|
||||
$subcategories = DB::connection('legacy')->table('artworks_categories')
|
||||
->select('category_id', 'category_name')
|
||||
->where('rootid', $category->rootid)
|
||||
->orderBy('category_name')
|
||||
->get();
|
||||
}
|
||||
|
||||
if ($subcategories->isEmpty()) {
|
||||
$subcategories = DB::connection('legacy')->table('skupine')
|
||||
->select('category_id', 'category_name')
|
||||
->where('rootid', $id)
|
||||
->orderBy('category_name')
|
||||
->get();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$subcategories = DB::connection('legacy')->table('skupine')
|
||||
->select('category_id', 'category_name')
|
||||
->where('rootid', $id)
|
||||
->orderBy('category_name')
|
||||
->get();
|
||||
} catch (\Throwable $e2) {
|
||||
$subcategories = collect();
|
||||
}
|
||||
}
|
||||
|
||||
$page_title = $group;
|
||||
$page_meta_description = $group . ' artworks on Skinbase';
|
||||
$page_meta_keywords = strtolower($group) . ', skinbase, artworks, wallpapers, skins';
|
||||
|
||||
return [
|
||||
'group' => $group,
|
||||
'category' => $category,
|
||||
'artworks' => $artworks,
|
||||
'subcategories' => $subcategories,
|
||||
'page_title' => $page_title,
|
||||
'page_meta_description' => $page_meta_description,
|
||||
'page_meta_keywords' => $page_meta_keywords,
|
||||
];
|
||||
}
|
||||
|
||||
public function browseCategories()
|
||||
{
|
||||
try {
|
||||
$categories = DB::connection('legacy')->table('artworks_categories')
|
||||
->select('category_id', 'category_name', 'description')
|
||||
->where('section_id', 0)
|
||||
->where('rootid', 0)
|
||||
->orderBy('category_id')
|
||||
->get();
|
||||
|
||||
if ($categories->isEmpty()) {
|
||||
$categories = DB::connection('legacy')->table('skupine')
|
||||
->select('category_id', 'category_name', 'description')
|
||||
->where('section_id', 0)
|
||||
->where('rootid', 0)
|
||||
->orderBy('category_id')
|
||||
->get();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$categories = DB::connection('legacy')->table('skupine')
|
||||
->select('category_id', 'category_name', 'description')
|
||||
->where('section_id', 0)
|
||||
->where('rootid', 0)
|
||||
->orderBy('category_id')
|
||||
->get();
|
||||
} catch (\Throwable $e2) {
|
||||
$categories = collect();
|
||||
}
|
||||
}
|
||||
|
||||
$subgroups = collect();
|
||||
if ($categories->isNotEmpty()) {
|
||||
$ids = $categories->pluck('category_id')->unique()->values()->all();
|
||||
try {
|
||||
$subs = DB::connection('legacy')->table('artworks_categories')
|
||||
->select('category_id', 'category_name', 'picture', 'section_id')
|
||||
->whereIn('section_id', $ids)
|
||||
->orderBy('category_name')
|
||||
->get();
|
||||
|
||||
if ($subs->isEmpty()) {
|
||||
$subs = DB::connection('legacy')->table('skupine')
|
||||
->select('category_id', 'category_name', 'picture', 'section_id')
|
||||
->whereIn('section_id', $ids)
|
||||
->orderBy('category_name')
|
||||
->get();
|
||||
}
|
||||
|
||||
$subgroups = $subs->groupBy('section_id');
|
||||
} catch (\Throwable $e) {
|
||||
$subgroups = collect();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'categories' => $categories,
|
||||
'subgroups' => $subgroups,
|
||||
'page_title' => 'Browse Categories',
|
||||
'page_meta_description' => 'Browse categories across Photography, Wallpapers, Skins and more on Skinbase.',
|
||||
'page_meta_keywords' => 'categories, photography, wallpapers, skins, browse',
|
||||
];
|
||||
}
|
||||
|
||||
public function forumIndex()
|
||||
{
|
||||
try {
|
||||
$topics = DB::connection('legacy')->table('forum_topics as t')
|
||||
->select(
|
||||
't.topic_id',
|
||||
't.topic',
|
||||
't.discuss',
|
||||
't.last_update',
|
||||
't.privilege',
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id IN (SELECT topic_id FROM forum_topics st WHERE st.root_id = t.topic_id)) AS num_posts'),
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_topics st WHERE st.root_id = t.topic_id) AS num_subtopics')
|
||||
)
|
||||
->where('t.root_id', 0)
|
||||
->where('t.privilege', '<', 4)
|
||||
->orderByDesc('t.last_update')
|
||||
->limit(100)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$topics = collect();
|
||||
}
|
||||
|
||||
return [
|
||||
'topics' => $topics,
|
||||
'page_title' => 'Forum',
|
||||
'page_meta_description' => 'Skinbase forum threads.',
|
||||
'page_meta_keywords' => 'forum, discussions, topics, skinbase',
|
||||
];
|
||||
}
|
||||
|
||||
public function forumTopic(int $topic_id, int $page = 1)
|
||||
{
|
||||
try {
|
||||
$topic = DB::connection('legacy')->table('forum_topics')->where('topic_id', $topic_id)->first();
|
||||
} catch (\Throwable $e) {
|
||||
$topic = null;
|
||||
}
|
||||
|
||||
if (! $topic) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$subtopics = DB::connection('legacy')->table('forum_topics as t')
|
||||
->leftJoin('users as u', 't.user_id', '=', 'u.user_id')
|
||||
->select(
|
||||
't.topic_id',
|
||||
't.topic',
|
||||
't.discuss',
|
||||
't.post_date',
|
||||
't.last_update',
|
||||
'u.uname',
|
||||
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id = t.topic_id) AS num_posts')
|
||||
)
|
||||
->where('t.root_id', $topic->topic_id)
|
||||
->orderByDesc('t.last_update')
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$subtopics = null;
|
||||
}
|
||||
|
||||
if ($subtopics && $subtopics->total() > 0) {
|
||||
return [
|
||||
'type' => 'subtopics',
|
||||
'topic' => $topic,
|
||||
'subtopics' => $subtopics,
|
||||
'page_title' => $topic->topic,
|
||||
'page_meta_description' => \Illuminate\Support\Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160),
|
||||
'page_meta_keywords' => 'forum, topic, skinbase',
|
||||
];
|
||||
}
|
||||
|
||||
$sort = strtolower(request()->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
try {
|
||||
$posts = DB::connection('legacy')->table('forum_posts as p')
|
||||
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
|
||||
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
|
||||
->where('p.topic_id', $topic->topic_id)
|
||||
->orderBy('p.post_date', $sort)
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$posts = null;
|
||||
}
|
||||
|
||||
if (! $posts || $posts->total() === 0) {
|
||||
try {
|
||||
$posts = DB::connection('legacy')->table('forum_posts as p')
|
||||
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
|
||||
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
|
||||
->where('p.tid', $topic->topic_id)
|
||||
->orderBy('p.post_date', $sort)
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$posts = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure $posts is always a LengthAwarePaginator so views can iterate and render pagination safely
|
||||
if (! $posts) {
|
||||
$currentPage = max(1, (int) request()->query('page', $page));
|
||||
$items = collect();
|
||||
$posts = new LengthAwarePaginator($items, 0, 50, $currentPage, [
|
||||
'path' => Paginator::resolveCurrentPath(),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'posts',
|
||||
'topic' => $topic,
|
||||
'posts' => $posts,
|
||||
'page_title' => $topic->topic,
|
||||
'page_meta_description' => \Illuminate\Support\Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160),
|
||||
'page_meta_keywords' => 'forum, topic, skinbase',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single artwork by id with author and category
|
||||
* Returns null on failure.
|
||||
*/
|
||||
public function getArtwork(int $id)
|
||||
{
|
||||
try {
|
||||
$row = DB::connection('legacy')->table('wallz as w')
|
||||
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
|
||||
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
|
||||
->select('w.*', 'u.uname', 'c.category_name')
|
||||
->where('w.id', $id)
|
||||
->first();
|
||||
} catch (\Throwable $e) {
|
||||
$row = null;
|
||||
}
|
||||
|
||||
if (! $row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// compute thumbnail/zoom paths similar to legacy code
|
||||
$nid = (int) ($row->id / 100);
|
||||
$nid_new = (int) ($row->id / 1000);
|
||||
$encoded = self::encode($row->id);
|
||||
$ext = self::fileExtension($row->picture ?? 'jpg');
|
||||
|
||||
$appUrl = rtrim(config('app.url', ''), '/');
|
||||
$shot_name = $appUrl . '/files/archive/shots/' . $nid . '/' . ($row->picture ?? '');
|
||||
$zoom_name = $appUrl . '/files/archive/zoom/' . $nid . '/' . ($row->picture ?? '');
|
||||
$thumb_600 = $appUrl . '/files/thumb/' . $nid_new . '/600_' . $row->id . '.jpg';
|
||||
// Prefer new CDN when possible
|
||||
try {
|
||||
$art = \App\Models\Artwork::find($row->id);
|
||||
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
|
||||
$thumb_file = $present['url'];
|
||||
$thumb_file_300 = $present['srcset'] ? explode(' ', explode(',', $present['srcset'])[0])[0] : ($present['url'] ?? null);
|
||||
} catch (\Throwable $e) {
|
||||
$present = \App\Services\ThumbnailPresenter::present((array) $row, 'md');
|
||||
$thumb_file = $present['url'];
|
||||
$thumb_file_300 = $present['srcset'] ? explode(' ', explode(',', $present['srcset'])[0])[0] : ($present['url'] ?? null);
|
||||
}
|
||||
|
||||
// additional stats (best-effort)
|
||||
try {
|
||||
$num_downloads = DB::connection('legacy')->table('artworks_downloads')
|
||||
->where('date', DB::raw('CURRENT_DATE'))
|
||||
->where('artwork_id', $row->id)
|
||||
->count();
|
||||
} catch (\Throwable $e) {
|
||||
$num_downloads = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$monthly_downloads = DB::connection('legacy')->table('monthly_downloads')
|
||||
->where('fname', $row->id)
|
||||
->count();
|
||||
} catch (\Throwable $e) {
|
||||
$monthly_downloads = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$num_comments = DB::connection('legacy')->table('artworks_comments')
|
||||
->where('name', $row->id)
|
||||
->where('author', '<>', '')
|
||||
->count();
|
||||
} catch (\Throwable $e) {
|
||||
$num_comments = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$num_favourites = DB::connection('legacy')->table('favourites')
|
||||
->where('artwork_id', $row->id)
|
||||
->count();
|
||||
} catch (\Throwable $e) {
|
||||
$num_favourites = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$featured = DB::connection('legacy')->table('featured_works')
|
||||
->where('rootid', $row->rootid ?? 0)
|
||||
->where('artwork_id', $row->id)
|
||||
->orderByDesc('type')
|
||||
->select('type', 'post_date')
|
||||
->first();
|
||||
$featured_type = $featured->type ?? 0;
|
||||
$featured_date = $featured->post_date ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
$featured_type = 0;
|
||||
$featured_date = null;
|
||||
}
|
||||
|
||||
$page_title = $row->name ?? 'Artwork';
|
||||
$page_meta_description = strip_tags($row->description ?? ($row->preview ?? ''));
|
||||
$page_meta_keywords = trim(($row->category_name ?? '') . ', artwork');
|
||||
|
||||
return [
|
||||
'artwork' => $row,
|
||||
'thumb_file' => $thumb_file,
|
||||
'thumb_file_300' => $thumb_file_300,
|
||||
'thumb_600' => $thumb_600,
|
||||
'shot_name' => $shot_name,
|
||||
'zoom_name' => $zoom_name,
|
||||
'num_downloads' => $num_downloads,
|
||||
'monthly_downloads' => $monthly_downloads,
|
||||
'num_comments' => $num_comments,
|
||||
'num_favourites' => $num_favourites,
|
||||
'featured_type' => $featured_type,
|
||||
'featured_date' => $featured_date,
|
||||
'page_title' => $page_title,
|
||||
'page_meta_description' => $page_meta_description,
|
||||
'page_meta_keywords' => $page_meta_keywords,
|
||||
];
|
||||
}
|
||||
|
||||
public static function encode($val, $base = 62, $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
||||
$str = '';
|
||||
if ($val < 0) return $str;
|
||||
do {
|
||||
$i = $val % $base;
|
||||
$str = $chars[$i] . $str;
|
||||
$val = ($val - $i) / $base;
|
||||
} while ($val > 0);
|
||||
return $str;
|
||||
}
|
||||
|
||||
private static function fileExtension($filename) {
|
||||
$parts = pathinfo($filename);
|
||||
return $parts['extension'] ?? 'jpg';
|
||||
}
|
||||
}
|
||||
49
app/Services/ThumbnailPresenter.php
Normal file
49
app/Services/ThumbnailPresenter.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\ThumbnailService;
|
||||
|
||||
class ThumbnailPresenter
|
||||
{
|
||||
/**
|
||||
* Present thumbnail data for an item which may be a model or an array.
|
||||
* Returns ['id' => int|null, 'title' => string, 'url' => string, 'srcset' => string|null]
|
||||
*/
|
||||
public static function present($item, string $size = 'md'): array
|
||||
{
|
||||
$uext = 'jpg';
|
||||
$isEloquent = $item instanceof \Illuminate\Database\Eloquent\Model;
|
||||
|
||||
$id = null;
|
||||
$title = '';
|
||||
|
||||
if ($isEloquent) {
|
||||
$id = $item->id ?? null;
|
||||
$title = $item->name ?? '';
|
||||
$url = $item->thumb_url ?? $item->thumb ?? '';
|
||||
$srcset = $item->thumb_srcset ?? null;
|
||||
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => $srcset];
|
||||
}
|
||||
|
||||
// If it's an object but not an Eloquent model (e.g. stdClass row), cast to array
|
||||
if (is_object($item)) {
|
||||
$item = (array) $item;
|
||||
}
|
||||
|
||||
$id = $item['id'] ?? null;
|
||||
$title = $item['name'] ?? '';
|
||||
|
||||
// If array contains direct hash/thumb_ext, use CDN fromHash
|
||||
$hash = $item['hash'] ?? null;
|
||||
$thumbExt = $item['thumb_ext'] ?? ($item['ext'] ?? $uext);
|
||||
if (!empty($hash) && !empty($thumbExt)) {
|
||||
$url = ThumbnailService::fromHash($hash, $thumbExt, $size) ?: ThumbnailService::url(null, $id, $thumbExt, 6);
|
||||
$srcset = ThumbnailService::srcsetFromHash($hash, $thumbExt);
|
||||
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => $srcset];
|
||||
}
|
||||
|
||||
// Fallback: ask ThumbnailService to resolve by id or file path
|
||||
$url = ThumbnailService::url(null, $id, $uext, 6);
|
||||
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => null];
|
||||
}
|
||||
}
|
||||
87
app/Services/ThumbnailService.php
Normal file
87
app/Services/ThumbnailService.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ThumbnailService
|
||||
{
|
||||
protected const CDN_HOST = 'http://files.skinbase.org';
|
||||
|
||||
protected const VALID_SIZES = ['sm','md','lg','xl'];
|
||||
|
||||
protected const THUMB_SIZES = [
|
||||
'sm' => ['height' => 240, 'quality' => 78, 'dir' => 'sm'],
|
||||
'md' => ['height' => 360, 'quality' => 82, 'dir' => 'md'],
|
||||
'lg' => ['height' => 1200, 'quality' => 85, 'dir' => 'lg'],
|
||||
'xl' => ['height' => 2400, 'quality' => 90, 'dir' => 'xl'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Build a thumbnail URL from a filePath/hash/id and ext.
|
||||
* Accepts either a direct hash string in $filePath, or an $id + $ext pair.
|
||||
* Legacy size codes (4 -> sm, others -> md) are supported.
|
||||
*/
|
||||
public static function url(?string $filePath, ?int $id = null, ?string $ext = null, $size = 6): string
|
||||
{
|
||||
// If $filePath seems to be a content hash and $ext is provided, build directly
|
||||
if (!empty($filePath) && !empty($ext) && preg_match('/^[0-9a-f]{16,128}$/i', $filePath)) {
|
||||
$sizeKey = is_string($size) ? $size : (($size === 4) ? 'sm' : 'md');
|
||||
return self::fromHash($filePath, $ext, $sizeKey) ?: '';
|
||||
}
|
||||
|
||||
// Resolve by id when provided
|
||||
if ($id !== null) {
|
||||
try {
|
||||
$artClass = '\\App\\Models\\Artwork';
|
||||
if (class_exists($artClass)) {
|
||||
$art = $artClass::where('id', $id)->orWhere('legacy_id', $id)->first();
|
||||
if ($art) {
|
||||
$hash = $art->hash ?? null;
|
||||
$extToUse = $ext ?? ($art->thumb_ext ?? null);
|
||||
$sizeKey = is_string($size) ? $size : (($size === 4) ? 'sm' : 'md');
|
||||
if (!empty($hash) && !empty($extToUse)) {
|
||||
return self::fromHash($hash, $extToUse, $sizeKey) ?: '';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// fallthrough to storage/filePath fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Storage::url or return provided path
|
||||
if (!empty($filePath)) {
|
||||
try {
|
||||
return Storage::url($filePath);
|
||||
} catch (\Throwable $e) {
|
||||
return $filePath;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CDN URL from hash and extension.
|
||||
*/
|
||||
public static function fromHash(?string $hash, ?string $ext, string $sizeKey = 'md'): ?string
|
||||
{
|
||||
if (empty($hash) || empty($ext)) return null;
|
||||
$sizeKey = in_array($sizeKey, self::VALID_SIZES) ? $sizeKey : 'md';
|
||||
$h = $hash;
|
||||
$h1 = substr($h, 0, 2);
|
||||
$h2 = substr($h, 2, 2);
|
||||
return sprintf('%s/%s/%s/%s/%s.%s', rtrim(self::CDN_HOST, '/'), $sizeKey, $h1, $h2, $h, $ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build srcset using sm and md sizes for legacy layouts.
|
||||
*/
|
||||
public static function srcsetFromHash(?string $hash, ?string $ext): ?string
|
||||
{
|
||||
$a = self::fromHash($hash, $ext, 'sm');
|
||||
$b = self::fromHash($hash, $ext, 'md');
|
||||
if (!$a || !$b) return null;
|
||||
return $a . ' 320w, ' . $b . ' 600w';
|
||||
}
|
||||
}
|
||||
17
app/View/Components/AppLayout.php
Normal file
17
app/View/Components/AppLayout.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AppLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.app');
|
||||
}
|
||||
}
|
||||
17
app/View/Components/GuestLayout.php
Normal file
17
app/View/Components/GuestLayout.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GuestLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.guest');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user