Upload beautify

This commit is contained in:
2026-02-17 17:14:43 +01:00
parent b053c0cc48
commit 41287914aa
106 changed files with 4948 additions and 906 deletions

View File

@@ -2,82 +2,124 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Services\AvatarService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Services\AvatarService;
class AvatarsMigrate extends Command class AvatarsMigrate extends Command
{ {
/** protected $signature = 'avatars:migrate {--force : Reprocess avatars even when avatar_hash is already present} {--limit=0 : Limit number of users processed}';
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'avatars:migrate {--force}';
/** protected $description = 'Migrate legacy avatars into CDN-ready WebP variants and avatar_hash metadata';
* The console command description.
*
* @var string
*/
protected $description = 'Migrate legacy avatars to new WebP avatar storage';
protected $service; public function __construct(private readonly AvatarService $service)
public function __construct(AvatarService $service)
{ {
parent::__construct(); parent::__construct();
$this->service = $service;
} }
public function handle() public function handle(): int
{ {
$force = (bool) $this->option('force');
$limit = max(0, (int) $this->option('limit'));
$this->info('Starting avatar migration...'); $this->info('Starting avatar migration...');
// Try to read legacy data from user_profiles.avatar_legacy or users.avatar_legacy or users.icon $rows = DB::table('user_profiles as p')
$rows = DB::table('user_profiles')->select('user_id', 'avatar_legacy')->whereNotNull('avatar_legacy')->get(); ->leftJoin('users as u', 'u.id', '=', 'p.user_id')
->select([
'p.user_id',
'p.avatar_hash',
'p.avatar_legacy',
'u.icon as user_icon',
])
->when(!$force, fn ($query) => $query->whereNull('p.avatar_hash'))
->where(function ($query) {
$query->whereNotNull('p.avatar_legacy')
->orWhereNotNull('u.icon');
})
->orderBy('p.user_id')
->when($limit > 0, fn ($query) => $query->limit($limit))
->get();
if ($rows->isEmpty()) { if ($rows->isEmpty()) {
// fallback to users table $this->info('No avatars require migration.');
$rows = DB::table('users')->select('user_id', 'icon as avatar_legacy')->whereNotNull('icon')->get();
return self::SUCCESS;
} }
$count = 0; $migrated = 0;
$skipped = 0;
$failed = 0;
foreach ($rows as $row) { foreach ($rows as $row) {
$userId = $row->user_id; $userId = (int) $row->user_id;
$legacy = $row->avatar_legacy ?? null; $legacyName = $this->normalizeLegacyName($row->avatar_legacy ?: $row->user_icon);
if (!$legacy) {
if ($legacyName === null) {
$skipped++;
continue; continue;
} }
// Try common legacy paths $path = $this->locateLegacyAvatarPath($userId, $legacyName);
if ($path === null) {
$failed++;
$this->warn("User {$userId}: legacy avatar not found ({$legacyName})");
continue;
}
try {
$hash = $this->service->storeFromLegacyFile($userId, $path);
if (!$hash) {
$failed++;
$this->warn("User {$userId}: unable to process legacy avatar ({$legacyName})");
continue;
}
$migrated++;
$this->line("User {$userId}: migrated ({$hash})");
} catch (\Throwable $e) {
$failed++;
$this->warn("User {$userId}: migration failed ({$e->getMessage()})");
}
}
$this->info("Avatar migration complete. Migrated={$migrated}, Skipped={$skipped}, Failed={$failed}");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function normalizeLegacyName(?string $value): ?string
{
if (!$value) {
return null;
}
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
return basename(urldecode($trimmed));
}
private function locateLegacyAvatarPath(int $userId, string $legacyName): ?string
{
$candidates = [ $candidates = [
public_path('user-picture/' . $legacy), public_path('avatar/' . $legacyName),
public_path('avatar/' . $userId . '/' . $legacy), public_path('avatar/' . $userId . '/' . $legacyName),
storage_path('app/public/user-picture/' . $legacy), public_path('user-picture/' . $legacyName),
storage_path('app/public/avatar/' . $userId . '/' . $legacy), storage_path('app/public/avatar/' . $legacyName),
storage_path('app/public/avatar/' . $userId . '/' . $legacyName),
storage_path('app/public/user-picture/' . $legacyName),
base_path('oldSite/www/files/usericons/' . $legacyName),
]; ];
$found = false; foreach ($candidates as $candidate) {
foreach ($candidates as $p) { if (is_string($candidate) && is_file($candidate) && is_readable($candidate)) {
if (file_exists($p) && is_readable($p)) { return $candidate;
$this->info("Processing user {$userId} from {$p}");
$hash = $this->service->storeFromLegacyFile($userId, $p);
if ($hash) {
$this->info(" -> migrated, hash={$hash}");
$count++;
$found = true;
break;
}
}
}
if (!$found) {
$this->warn("Legacy file not found for user {$userId}, filename={$legacy}");
} }
} }
$this->info("Migration complete. Processed: {$count}"); return null;
return 0;
} }
} }

View File

@@ -1,47 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\AvatarService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class AvatarController
{
protected $service;
public function __construct(AvatarService $service)
{
$this->service = $service;
}
/**
* Handle avatar upload request.
*/
public function upload(Request $request)
{
$user = Auth::user();
if (!$user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$rules = [
'avatar' => 'required|image|max:2048|mimes:jpg,jpeg,png,webp',
];
$validator = Validator::make($request->all(), $rules);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$file = $request->file('avatar');
try {
$hash = $this->service->storeFromUploadedFile($user->id, $file);
return response()->json(['success' => true, 'hash' => $hash], 200);
} catch (\Exception $e) {
return response()->json(['error' => 'Processing failed', 'message' => $e->getMessage()], 500);
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Community;
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';
$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);
}
}
ob_start();
\App\Banner::ShowResponsiveAd();
$adHtml = ob_get_clean();
ob_start();
$userID = $_SESSION['web_login']['user_id'] ?? null;
$chat->ShowChat(50, $userID);
$chatHtml = ob_get_clean();
try {
$smileys = DB::table('smileys')->select('code', 'picture', 'emotion')->get();
} catch (\Throwable $e) {
$smileys = collect();
}
return view('community.chat', compact('page_title', 'adHtml', 'chatHtml', 'smileys'));
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Community;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
class ForumController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->legacy = $legacy;
}
public function index()
{
$data = $this->legacy->forumIndex();
return view('community.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('shared.placeholder');
}
if (isset($data['type']) && $data['type'] === 'subtopics') {
return view('community.forum.topic', $data);
}
return view('community.forum.posts', $data);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Community;
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;
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::table('interviews_comment')->insert([
'nid' => $interviewId,
'author' => $_SESSION['web_login']['username'] ?? 'Anonymous',
'datum' => DB::raw('CURRENT_TIMESTAMP'),
'tekst' => $tekst,
]);
$ar2 = DB::table('users')
->where('uname', $_SESSION['web_login']['username'])
->first();
if (!empty($ar2->user_id)) {
DB::table('users_statistics')
->where('user_id', $ar2->user_id)
->increment('newscomment');
}
} catch (\Throwable $e) {
// fail silently
}
}
}
try {
$ar = DB::table('interviews')->where('id', $id)->first();
} catch (\Throwable $e) {
$ar = null;
}
if (! $ar) {
return redirect('/interviews');
}
try {
$artworks = DB::table('wallz')
->where('uname', $ar->username)
->inRandomOrder()
->limit(2)
->get();
} catch (\Throwable $e) {
$artworks = collect();
}
try {
$comments = DB::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();
}
$authors = $comments->pluck('author')->unique()->values()->all();
$postCounts = [];
if (!empty($authors)) {
try {
$counts = DB::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) {
}
}
$page_title = 'Interview with ' . ($ar->username ?? '');
return view('community.interview', [
'ar' => $ar,
'artworks' => $artworks,
'comments' => $comments,
'postCounts' => $postCounts,
'page_title' => $page_title,
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Community;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\ArtworkComment;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class LatestCommentsController extends Controller
{
public function index(Request $request)
{
$hits = 20;
$query = ArtworkComment::with(['user', 'artwork'])
->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at');
})
->orderByDesc('created_at');
$comments = $query->paginate($hits)->withQueryString();
$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 ? DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash') : 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('community.latest-comments', compact('page_title', 'comments'));
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Community;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
class LatestController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->artworks = $artworks;
}
public function index(Request $request)
{
$perPage = 21;
$artworks = $this->artworks->browsePublicArtworks($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',
];
});
return view('community.latest-artworks', [
'artworks' => $artworks,
'page_title' => 'Latest Artworks',
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Community;
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::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::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('community.news', compact('news', 'comments', 'page_title'));
}
}

View File

@@ -1,7 +1,8 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Http\Requests\Manage\ManageArtworkEditRequest; use App\Http\Requests\Manage\ManageArtworkEditRequest;
use App\Http\Requests\Manage\ManageArtworkUpdateRequest; use App\Http\Requests\Manage\ManageArtworkUpdateRequest;
use App\Http\Requests\Manage\ManageArtworkDestroyRequest; use App\Http\Requests\Manage\ManageArtworkDestroyRequest;
@@ -16,7 +17,6 @@ class ManageController extends Controller
$userId = $request->user()->id; $userId = $request->user()->id;
$perPage = 50; $perPage = 50;
// Use default connection query builder and join category name to avoid Eloquent model issues
$categorySub = DB::table('artwork_category as ac') $categorySub = DB::table('artwork_category as ac')
->join('categories as c', 'ac.category_id', '=', 'c.id') ->join('categories as c', 'ac.category_id', '=', 'c.id')
->select('ac.artwork_id', DB::raw('MIN(c.name) as category_name')) ->select('ac.artwork_id', DB::raw('MIN(c.name) as category_name'))
@@ -26,8 +26,17 @@ class ManageController extends Controller
->leftJoinSub($categorySub, 'cat', function ($join) { ->leftJoinSub($categorySub, 'cat', function ($join) {
$join->on('a.id', '=', 'cat.artwork_id'); $join->on('a.id', '=', 'cat.artwork_id');
}) })
->leftJoin('artwork_stats as s', 'a.id', '=', 's.artwork_id')
->where('a.user_id', $userId) ->where('a.user_id', $userId)
->select('a.*', DB::raw('cat.category_name as category_name')) ->select(
'a.*',
DB::raw('cat.category_name as category_name'),
DB::raw('COALESCE(s.rating_count, 0) as rating_num'),
DB::raw('COALESCE(s.rating_avg, 0) as rating'),
DB::raw('COALESCE(s.downloads, 0) as dls'),
DB::raw('COALESCE(s.favorites, 0) as zoom'),
DB::raw('COALESCE(s.views, 0) as views')
)
->orderByDesc('a.published_at') ->orderByDesc('a.published_at')
->orderByDesc('a.id'); ->orderByDesc('a.id');
@@ -43,7 +52,6 @@ class ManageController extends Controller
{ {
$artwork = $request->artwork(); $artwork = $request->artwork();
// If artworks no longer have a single `category` column, fetch pivot selection
$selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id'); $selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id');
$artwork->category = $selectedCategory; $artwork->category = $selectedCategory;
@@ -56,7 +64,7 @@ class ManageController extends Controller
return view('manage.edit', [ return view('manage.edit', [
'artwork' => $artwork, 'artwork' => $artwork,
'categories' => $categories, 'categories' => $categories,
'page_title' => 'Edit Artwork: ' . ($artwork->name ?? ''), 'page_title' => 'Edit Artwork: ' . ($artwork->title ?? ''),
]); ]);
} }
@@ -65,12 +73,11 @@ class ManageController extends Controller
$existing = $request->artwork(); $existing = $request->artwork();
$data = $request->validated(); $data = $request->validated();
$update = [ $update = [
'name' => $data['name'], 'title' => $data['title'],
'description' => $data['description'] ?? $existing->description, 'description' => $data['description'] ?? $existing->description,
'updated' => now(), 'updated' => now(),
]; ];
// handle artwork image upload (replacing picture)
if ($request->hasFile('artwork')) { if ($request->hasFile('artwork')) {
$file = $request->file('artwork'); $file = $request->file('artwork');
$path = $file->store('public/uploads/artworks'); $path = $file->store('public/uploads/artworks');
@@ -78,7 +85,6 @@ class ManageController extends Controller
$update['picture'] = $filename; $update['picture'] = $filename;
} }
// handle attachment upload (zip, etc.)
if ($request->hasFile('attachment')) { if ($request->hasFile('attachment')) {
$att = $request->file('attachment'); $att = $request->file('attachment');
$attPath = $att->store('public/uploads/attachments'); $attPath = $att->store('public/uploads/attachments');
@@ -87,7 +93,6 @@ class ManageController extends Controller
DB::table('artworks')->where('id', (int)$id)->update($update); DB::table('artworks')->where('id', (int)$id)->update($update);
// Update pivot: set single category selection for this artwork
if (isset($data['section'])) { if (isset($data['section'])) {
DB::table('artwork_category')->where('artwork_id', (int)$id)->delete(); DB::table('artwork_category')->where('artwork_id', (int)$id)->delete();
DB::table('artwork_category')->insert([ DB::table('artwork_category')->insert([
@@ -103,7 +108,6 @@ class ManageController extends Controller
{ {
$artwork = $request->artwork(); $artwork = $request->artwork();
// delete files if present (stored in new storage location)
if (!empty($artwork->fname)) { if (!empty($artwork->fname)) {
Storage::delete('public/uploads/attachments/' . $artwork->fname); Storage::delete('public/uploads/attachments/' . $artwork->fname);
} }

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
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;
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::table('interviews_comment')->insert([
'nid' => $interviewId,
'author' => $_SESSION['web_login']['username'] ?? 'Anonymous',
'datum' => DB::raw('CURRENT_TIMESTAMP'),
'tekst' => $tekst,
]);
$ar2 = DB::table('users')
->where('uname', $_SESSION['web_login']['username'])
->first();
if (!empty($ar2->user_id)) {
DB::table('users_statistics')
->where('user_id', $ar2->user_id)
->increment('newscomment');
}
} catch (\Throwable $e) {
// fail silently
}
}
}
try {
$ar = DB::table('interviews')->where('id', $id)->first();
} catch (\Throwable $e) {
$ar = null;
}
if (! $ar) {
return redirect('/interviews');
}
try {
$artworks = DB::table('wallz')
->where('uname', $ar->username)
->inRandomOrder()
->limit(2)
->get();
} catch (\Throwable $e) {
$artworks = collect();
}
try {
$comments = DB::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();
}
$authors = $comments->pluck('author')->unique()->values()->all();
$postCounts = [];
if (!empty($authors)) {
try {
$counts = DB::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) {
}
}
$page_title = 'Interview with ' . ($ar->username ?? '');
return view('legacy.interview', [
'ar' => $ar,
'artworks' => $artworks,
'comments' => $comments,
'postCounts' => $postCounts,
'page_title' => $page_title,
]);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
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::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'));
}
}

View File

@@ -4,64 +4,15 @@ namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use App\Support\AvatarUrl;
use Illuminate\Support\Facades\Storage;
class AvatarController extends Controller class AvatarController extends Controller
{ {
public function show(Request $request, $id, $name = null) public function show(Request $request, $id, $name = null)
{ {
$user_id = (int) $id; $userId = (int) $id;
$target = AvatarUrl::forUser($userId, null, 128);
// default avatar in project public gfx return redirect()->away($target, 301);
$defaultAvatar = public_path('gfx/avatar.jpg');
try {
$icon = DB::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);
} }
} }

View File

@@ -22,7 +22,7 @@ class BuddiesController extends Controller
->leftJoin('users as t2', 't1.user_id', '=', 't2.id') ->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id') ->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
->where('t1.friend_id', $user->id) ->where('t1.friend_id', $user->id)
->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar as icon', 't1.date_added') ->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
->orderByDesc('t1.date_added'); ->orderByDesc('t1.date_added');
$followers = $query->paginate($perPage)->withQueryString(); $followers = $query->paginate($perPage)->withQueryString();

View File

@@ -7,6 +7,7 @@ use Illuminate\Http\Request;
use App\Models\ArtworkComment; use App\Models\ArtworkComment;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
class LatestCommentsController extends Controller class LatestCommentsController extends Controller
{ {
@@ -36,7 +37,7 @@ class LatestCommentsController extends Controller
'comment_description' => $c->content, 'comment_description' => $c->content,
'commenter_id' => $c->user_id, 'commenter_id' => $c->user_id,
'country' => $user->country ?? null, 'country' => $user->country ?? null,
'icon' => $user->avatar ?? null, 'icon' => $user ? DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash') : null,
'uname' => $user->username ?? $user->name ?? 'User', 'uname' => $user->username ?? $user->name ?? 'User',
'signature' => $user->signature ?? null, 'signature' => $user->signature ?? null,
'user_type' => $user->role ?? null, 'user_type' => $user->role ?? null,

View File

@@ -23,7 +23,7 @@ class MyBuddiesController extends Controller
->leftJoin('users as t2', 't1.friend_id', '=', 't2.id') ->leftJoin('users as t2', 't1.friend_id', '=', 't2.id')
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id') ->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
->where('t1.user_id', $user->id) ->where('t1.user_id', $user->id)
->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 'p.avatar as icon', 't1.date_added') ->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
->orderByDesc('t1.date_added'); ->orderByDesc('t1.date_added');
$buddies = $query->paginate($perPage)->withQueryString(); $buddies = $query->paginate($perPage)->withQueryString();

View File

@@ -10,6 +10,7 @@ use App\Models\User;
use App\Models\Artwork; use App\Models\Artwork;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class ProfileController extends Controller class ProfileController extends Controller
{ {
@@ -63,7 +64,7 @@ class ProfileController extends Controller
'user_id' => $user->id, 'user_id' => $user->id,
'uname' => $user->name, 'uname' => $user->name,
'real_name' => $user->name, 'real_name' => $user->name,
'icon' => $user->avatar ?? null, 'icon' => DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash'),
'about_me' => $user->bio ?? null, 'about_me' => $user->bio ?? null,
]; ];

View File

@@ -4,11 +4,10 @@ namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use App\Models\User; use App\Services\AvatarService;
use Carbon\Carbon; use Carbon\Carbon;
class UserController extends Controller class UserController extends Controller
@@ -72,12 +71,12 @@ class UserController extends Controller
// Files: avatar/photo/emoticon // Files: avatar/photo/emoticon
if ($request->hasFile('avatar')) { if ($request->hasFile('avatar')) {
$f = $request->file('avatar'); try {
$name = $user->id . '.' . $f->getClientOriginalExtension(); $hash = app(AvatarService::class)->storeFromUploadedFile((int) $user->id, $request->file('avatar'));
$f->move(public_path('avatar'), $name); $user->icon = $hash;
// store filename in profile avatar (legacy field) — modern avatar pipeline will later migrate } catch (\Throwable $e) {
$profileUpdates['avatar'] = $name; $request->session()->flash('error', 'Avatar upload failed.');
$user->icon = $name; }
} }
if ($request->hasFile('personal_picture')) { if ($request->hasFile('personal_picture')) {
@@ -141,7 +140,7 @@ class UserController extends Controller
if (isset($profile->birthdate)) $user->birth = $profile->birthdate; if (isset($profile->birthdate)) $user->birth = $profile->birthdate;
if (isset($profile->gender)) $user->gender = $profile->gender; if (isset($profile->gender)) $user->gender = $profile->gender;
if (isset($profile->country_code)) $user->country_code = $profile->country_code; if (isset($profile->country_code)) $user->country_code = $profile->country_code;
if (isset($profile->avatar)) $user->icon = $profile->avatar; if (isset($profile->avatar_hash)) $user->icon = $profile->avatar_hash;
if (isset($profile->cover_image)) $user->picture = $profile->cover_image; if (isset($profile->cover_image)) $user->picture = $profile->cover_image;
if (isset($profile->signature)) $user->signature = $profile->signature; if (isset($profile->signature)) $user->signature = $profile->signature;
if (isset($profile->description)) $user->description = $profile->description; if (isset($profile->description)) $user->description = $profile->description;

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Misc;
use App\Http\Requests\AvatarUploadRequest;
use App\Services\AvatarService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use RuntimeException;
class AvatarController
{
protected $service;
public function __construct(AvatarService $service)
{
$this->service = $service;
}
public function upload(AvatarUploadRequest $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$file = $request->file('avatar');
try {
$hash = $this->service->storeFromUploadedFile($user->id, $file);
return response()->json([
'success' => true,
'hash' => $hash,
'url' => AvatarUrl::forUser((int) $user->id, $hash, 128),
], 200);
} catch (RuntimeException $e) {
logger()->warning('Avatar upload validation failed', [
'user_id' => (int) $user->id,
'message' => $e->getMessage(),
]);
return response()->json([
'error' => 'Validation failed',
'message' => $e->getMessage(),
], 422);
} catch (\Throwable $e) {
logger()->error('Avatar upload failed', [
'user_id' => (int) $user->id,
'message' => $e->getMessage(),
]);
return response()->json(['error' => 'Processing failed'], 500);
}
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use App\Services\ArtworkService;
use App\Models\ContentType;
class PhotographyController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->artworks = $artworks;
}
public function index(Request $request)
{
$segment = strtolower($request->segment(1) ?? 'photography');
$contentSlug = in_array($segment, ['photography','wallpapers','skins','other']) ? $segment : 'photography';
$group = ucfirst($contentSlug);
$id = null;
if ($contentSlug === 'photography') {
$id = 3;
}
$category = null;
try {
if ($id !== null && Schema::hasTable('artworks_categories')) {
$category = DB::table('artworks_categories')
->select('category_name', 'rootid', 'section_id', 'description', 'category_id')
->where('category_id', $id)
->first();
}
} catch (\Throwable $e) {
$category = null;
}
$ct = ContentType::where('slug', $contentSlug)->first();
$page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug));
$tidy = $category->description ?? ($ct->description ?? null);
$perPage = 40;
$sort = (string) $request->get('sort', 'latest');
try {
$artworks = $this->artworks->getArtworksByContentType($contentSlug, $perPage, $sort);
} catch (\Throwable $e) {
$artworks = new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage, 1, [
'path' => url()->current(),
]);
}
$subcategories = collect();
try {
if ($id !== null && Schema::hasTable('artworks_categories')) {
$subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $id)->orderBy('category_name')->get();
if ($subcategories->count() == 0 && !empty($category->rootid)) {
$subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $category->rootid)->orderBy('category_name')->get();
}
}
} catch (\Throwable $e) {
$subcategories = collect();
}
if (! $subcategories || $subcategories->count() === 0) {
if ($ct) {
$subcategories = $ct->rootCategories()
->orderBy('sort_order')
->orderBy('name')
->get()
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
} else {
$subcategories = collect();
}
}
if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) {
$page = (int) ($request->query('page', 1));
$artworks = new \Illuminate\Pagination\LengthAwarePaginator($artworks->values()->all(), $artworks->count(), $perPage, $page, [
'path' => url()->current(),
'query' => request()->query(),
]);
}
$contentType = ContentType::where('slug', $contentSlug)->first();
$rootCategories = $contentType
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
: collect();
$page_meta_description = $tidy;
return view('legacy.content-type', compact('contentType','rootCategories','artworks','page_title','page_meta_description','subcategories','id'));
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\AvatarUploadRequest;
use App\Services\AvatarService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use RuntimeException;
class AvatarController extends Controller
{
protected $service;
public function __construct(AvatarService $service)
{
$this->service = $service;
}
/**
* Handle avatar upload request.
*/
public function upload(AvatarUploadRequest $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$file = $request->file('avatar');
try {
$hash = $this->service->storeFromUploadedFile($user->id, $file);
return response()->json([
'success' => true,
'hash' => $hash,
'url' => AvatarUrl::forUser((int) $user->id, $hash, 128),
], 200);
} catch (RuntimeException $e) {
logger()->warning('Avatar upload validation failed', [
'user_id' => (int) $user->id,
'message' => $e->getMessage(),
]);
return response()->json([
'error' => 'Validation failed',
'message' => $e->getMessage(),
], 422);
} catch (\Throwable $e) {
logger()->error('Avatar upload failed', [
'user_id' => (int) $user->id,
'message' => $e->getMessage(),
]);
return response()->json(['error' => 'Processing failed'], 500);
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BuddiesController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
if (! $user) {
return redirect()->route('login');
}
$perPage = 50;
try {
$query = DB::table('friends_list as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
->where('t1.friend_id', $user->id)
->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
->orderByDesc('t1.date_added');
$followers = $query->paginate($perPage)->withQueryString();
} catch (\Throwable $e) {
$followers = collect();
}
$page_title = ($user->name ?? $user->username ?? 'User') . ': Followers';
return view('user.buddies', compact('followers', 'page_title'));
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use App\Models\UserFavorite;
class FavouritesController extends Controller
{
public function index(Request $request, $userId = null, $username = null)
{
$userId = $userId ? (int)$userId : ($request->user()->id ?? null);
$page = max(1, (int) $request->query('page', 1));
$hits = 20;
$start = ($page - 1) * $hits;
$total = 0;
$results = collect();
try {
$schema = DB::getSchemaBuilder();
} catch (\Throwable $e) {
$schema = null;
}
$userIdCol = Schema::hasColumn('users', 'user_id') ? 'user_id' : 'id';
$userNameCol = null;
foreach (['uname', 'username', 'name'] as $col) {
if (Schema::hasColumn('users', $col)) {
$userNameCol = $col;
break;
}
}
if ($schema && $schema->hasTable('user_favorites') && class_exists(UserFavorite::class)) {
try {
$query = UserFavorite::with(['artwork.user'])
->where('user_id', $userId)
->orderByDesc('created_at')
->orderByDesc('artwork_id');
$total = (int) $query->count();
$favorites = $query->skip($start)->take($hits)->get();
$results = $favorites->map(function ($fav) use ($userNameCol) {
$art = $fav->artwork;
if (! $art) {
return null;
}
$item = (object) $art->toArray();
$item->uname = ($userNameCol && isset($art->user)) ? ($art->user->{$userNameCol} ?? null) : null;
$item->datum = $fav->created_at;
return $item;
})->filter();
} catch (\Throwable $e) {
$total = 0;
$results = collect();
}
} else {
try {
if ($schema && $schema->hasTable('artworks_favourites')) {
$favTable = 'artworks_favourites';
} elseif ($schema && $schema->hasTable('favourites')) {
$favTable = 'favourites';
} else {
$favTable = null;
}
if ($schema && $schema->hasTable('artworks')) {
$artTable = 'artworks';
} elseif ($schema && $schema->hasTable('wallz')) {
$artTable = 'wallz';
} else {
$artTable = null;
}
} catch (\Throwable $e) {
$favTable = null;
$artTable = null;
}
if ($favTable && $artTable) {
try {
$total = (int) DB::table($favTable)->where('user_id', $userId)->count();
$t2JoinCol = 't2.' . $userIdCol;
$t2NameSelect = $userNameCol ? DB::raw("t2.{$userNameCol} as uname") : DB::raw("'' as uname");
$results = DB::table($favTable . ' as t1')
->rightJoin($artTable . ' as t3', 't1.artwork_id', '=', 't3.id')
->leftJoin('users as t2', 't3.user_id', '=', $t2JoinCol)
->where('t1.user_id', $userId)
->select('t3.*', $t2NameSelect, 't1.datum')
->orderByDesc('t1.datum')
->orderByDesc('t1.artwork_id')
->skip($start)
->take($hits)
->get();
} catch (\Throwable $e) {
$total = 0;
$results = collect();
}
}
}
$results = collect($results)->filter()->values()->transform(function ($row) {
$row->name = $row->name ?? '';
$row->slug = $row->slug ?? Str::slug($row->name);
$row->encoded = isset($row->id) ? app(\App\Helpers\Thumb::class)::encodeId((int)$row->id) : null;
return $row;
});
$page_title = ($username ?: ($userNameCol ? DB::table('users')->where($userIdCol, $userId)->value($userNameCol) : '')) . ' Favourites';
return view('user.favourites', [
'results' => $results,
'page_title' => $page_title,
'user_id' => $userId,
'page' => $page,
'hits' => $hits,
'total' => $total,
]);
}
public function destroy(Request $request, $userId, $artworkId)
{
$auth = $request->user();
if (! $auth || $auth->id != (int)$userId) {
abort(403);
}
$favTable = Schema::hasTable('user_favorites') ? 'user_favorites' : (Schema::hasTable('artworks_favourites') ? 'artworks_favourites' : 'favourites');
DB::table($favTable)->where('user_id', (int)$userId)->where('artwork_id', (int)$artworkId)->delete();
return redirect()->route('legacy.favourites', ['id' => $userId])->with('status', 'Removed from favourites');
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\User;
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('/');
}
$page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos');
$artworks = $result['artworks'] ?? collect();
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('web.browse', compact('page_title', 'artworks'));
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\User;
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::table('artwork_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('user.monthly-commentators', compact('page_title', 'rows'));
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MyBuddiesController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
if (! $user) {
return redirect()->route('login');
}
$perPage = 50;
try {
$query = DB::table('friends_list as t1')
->leftJoin('users as t2', 't1.friend_id', '=', 't2.id')
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
->where('t1.user_id', $user->id)
->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
->orderByDesc('t1.date_added');
$buddies = $query->paginate($perPage)->withQueryString();
} catch (\Throwable $e) {
$buddies = collect();
}
$page_title = ($user->name ?? $user->username ?? 'User') . ': Following List';
return view('user.mybuddies', compact('buddies', 'page_title'));
}
public function destroy(Request $request, $id)
{
$user = $request->user();
if (! $user) {
abort(403);
}
try {
$deleted = DB::table('friends_list')->where('id', $id)->where('user_id', $user->id)->delete();
if ($deleted) {
$request->session()->flash('status', 'Removed from following list.');
}
} catch (\Throwable $e) {
$request->session()->flash('error', 'Could not remove buddy.');
}
return redirect()->route('legacy.mybuddies');
}
}

View File

@@ -1,7 +1,8 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProfileUpdateRequest; use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -13,9 +14,6 @@ use Illuminate\Validation\Rules\Password as PasswordRule;
class ProfileController extends Controller class ProfileController extends Controller
{ {
/**
* Display the user's profile form.
*/
public function edit(Request $request): View public function edit(Request $request): View
{ {
return view('profile.edit', [ return view('profile.edit', [
@@ -23,25 +21,18 @@ class ProfileController extends Controller
]); ]);
} }
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse
{ {
$user = $request->user(); $user = $request->user();
// Core fields
$validated = $request->validated(); $validated = $request->validated();
logger()->debug('Profile update validated data', $validated); logger()->debug('Profile update validated data', $validated);
// Username is read-only and must not be changed here.
// Use `name` for the real/display name field.
if (isset($validated['name'])) { if (isset($validated['name'])) {
$user->name = $validated['name']; $user->name = $validated['name'];
} }
// Only allow setting email when we don't have one yet (legacy users)
if (!empty($validated['email']) && empty($user->email)) { if (!empty($validated['email']) && empty($user->email)) {
$user->email = $validated['email']; $user->email = $validated['email'];
$user->email_verified_at = null; $user->email_verified_at = null;
@@ -49,18 +40,15 @@ class ProfileController extends Controller
$user->save(); $user->save();
// Profile fields - target columns in `user_profiles` per spec
$profileUpdates = []; $profileUpdates = [];
if (!empty($validated['about'])) $profileUpdates['about'] = $validated['about']; if (!empty($validated['about'])) $profileUpdates['about'] = $validated['about'];
// website / legacy homepage
if (!empty($validated['web'])) { if (!empty($validated['web'])) {
$profileUpdates['website'] = $validated['web']; $profileUpdates['website'] = $validated['web'];
} elseif (!empty($validated['homepage'])) { } elseif (!empty($validated['homepage'])) {
$profileUpdates['website'] = $validated['homepage']; $profileUpdates['website'] = $validated['homepage'];
} }
// Birthday -> store as birthdate
$day = $validated['day'] ?? null; $day = $validated['day'] ?? null;
$month = $validated['month'] ?? null; $month = $validated['month'] ?? null;
$year = $validated['year'] ?? null; $year = $validated['year'] ?? null;
@@ -68,7 +56,6 @@ class ProfileController extends Controller
$profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$year, (int)$month, (int)$day); $profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$year, (int)$month, (int)$day);
} }
// Gender normalization -> store as provided normalized value
if (!empty($validated['gender'])) { if (!empty($validated['gender'])) {
$g = strtolower($validated['gender']); $g = strtolower($validated['gender']);
$map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X']; $map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X'];
@@ -77,7 +64,6 @@ class ProfileController extends Controller
if (!empty($validated['country'])) $profileUpdates['country_code'] = $validated['country']; if (!empty($validated['country'])) $profileUpdates['country_code'] = $validated['country'];
// Mailing and notify flags: normalize true/false when saving
if (array_key_exists('mailing', $validated)) { if (array_key_exists('mailing', $validated)) {
$profileUpdates['mlist'] = filter_var($validated['mailing'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; $profileUpdates['mlist'] = filter_var($validated['mailing'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
} }
@@ -85,21 +71,14 @@ class ProfileController extends Controller
$profileUpdates['friend_upload_notice'] = filter_var($validated['notify'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; $profileUpdates['friend_upload_notice'] = filter_var($validated['notify'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
} }
// signature/description should be stored in their own columns
if (isset($validated['signature'])) $profileUpdates['signature'] = $validated['signature']; if (isset($validated['signature'])) $profileUpdates['signature'] = $validated['signature'];
if (isset($validated['description'])) $profileUpdates['description'] = $validated['description']; if (isset($validated['description'])) $profileUpdates['description'] = $validated['description'];
// 'about' direct field (ensure explicit about wins when provided)
if (isset($validated['about'])) $profileUpdates['about'] = $validated['about']; if (isset($validated['about'])) $profileUpdates['about'] = $validated['about'];
// Files: avatar -> use AvatarService, emoticon and photo -> store to public disk
if ($request->hasFile('avatar')) { if ($request->hasFile('avatar')) {
try { try {
$hash = $avatarService->storeFromUploadedFile($user->id, $request->file('avatar')); $avatarService->storeFromUploadedFile($user->id, $request->file('avatar'));
// store returned hash into profile avatar column
if (!empty($hash)) {
$profileUpdates['avatar'] = $hash;
}
} catch (\Exception $e) { } catch (\Exception $e) {
return Redirect::back()->with('error', 'Avatar processing failed: ' . $e->getMessage()); return Redirect::back()->with('error', 'Avatar processing failed: ' . $e->getMessage());
} }
@@ -118,7 +97,6 @@ class ProfileController extends Controller
$file = $request->file('photo'); $file = $request->file('photo');
$fname = $file->getClientOriginalName(); $fname = $file->getClientOriginalName();
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname); $path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname);
// store cover image filename in user_profiles.cover_image (fallback to users.picture)
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) { if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
$profileUpdates['cover_image'] = $fname; $profileUpdates['cover_image'] = $fname;
} else { } else {
@@ -128,7 +106,6 @@ class ProfileController extends Controller
} }
} }
// Persist profile updates now that files (avatar/cover) have been handled
try { try {
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) { if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
if (!empty($profileUpdates)) { if (!empty($profileUpdates)) {
@@ -146,9 +123,6 @@ class ProfileController extends Controller
return Redirect::to('/user')->with('status', 'profile-updated'); return Redirect::to('/user')->with('status', 'profile-updated');
} }
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse public function destroy(Request $request): RedirectResponse
{ {
$request->validateWithBag('userDeletion', [ $request->validateWithBag('userDeletion', [
@@ -159,7 +133,6 @@ class ProfileController extends Controller
Auth::logout(); Auth::logout();
// Soft-delete the user (preserve record) — align with soft-delete policy.
$user->delete(); $user->delete();
$request->session()->invalidate(); $request->session()->invalidate();
@@ -168,9 +141,6 @@ class ProfileController extends Controller
return Redirect::to('/'); return Redirect::to('/');
} }
/**
* Update the user's password.
*/
public function password(Request $request): RedirectResponse public function password(Request $request): RedirectResponse
{ {
$request->validate([ $request->validate([

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ReceivedCommentsController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
if (! $user) {
return redirect()->route('login');
}
try {
$comments = app(\App\Services\LegacyService::class)->receivedComments($user->id);
} catch (\Throwable $e) {
$comments = collect();
}
return view('user.received-comments', [
'comments' => $comments,
]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class StatisticsController extends Controller
{
public function index(Request $request): View
{
$userId = $request->user()->id;
$sort = (string) $request->query('sort', 'date');
$allowed = ['date', 'name', 'dls', 'category', 'comments'];
if (! in_array($sort, $allowed, true)) {
$sort = 'date';
}
$categorySub = DB::table('artwork_category as ac')
->join('categories as c', 'ac.category_id', '=', 'c.id')
->select('ac.artwork_id', DB::raw('MIN(c.name) as category_name'))
->groupBy('ac.artwork_id');
$query = DB::table('artworks as a')
->leftJoinSub($categorySub, 'cat', function ($join) {
$join->on('a.id', '=', 'cat.artwork_id');
})
->where('a.user_id', $userId)
->select([
'a.*',
DB::raw('cat.category_name as category_name'),
])
->selectRaw('(SELECT COUNT(*) FROM artwork_comments WHERE artwork_id = a.id) AS num_comments');
if ($sort === 'name') {
$query->orderBy('a.name', 'asc');
} elseif ($sort === 'dls') {
$query->orderByDesc('a.dls');
} elseif ($sort === 'category') {
$query->orderBy('cat.category_name', 'asc');
} elseif ($sort === 'comments') {
$query->orderByDesc('num_comments');
} else {
$query->orderByDesc('a.published_at')->orderByDesc('a.id');
}
$artworks = $query->paginate(20)->appends(['sort' => $sort]);
$artworks->getCollection()->transform(function ($row) {
$thumb = ThumbnailPresenter::present($row, 'sm');
$row->thumb_url = $thumb['url'] ?? '';
$row->thumb_srcset = $thumb['srcset'] ?? null;
return $row;
});
return view('user.statistics', [
'artworks' => $artworks,
'sort' => $sort,
'page_title' => 'Artwork Statistics',
]);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\User;
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;
$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();
$paginator->getCollection()->transform(function ($row) {
$art = $row->artwork ?? null;
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;
$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('web.browse', ['page_title' => $page_title, 'artworks' => $paginator]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\User;
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::table('featured_works as t0')
->leftJoin('artworks as t1', 't0.artwork_id', '=', 't1.id')
->join('categories as t2', 't1.category', '=', 't2.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', DB::raw('t2.name as 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);
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('user.today-in-history', [
'artworks' => $artworks,
'page_title' => 'Popular on this day in history',
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\User;
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';
}
$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');
$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();
$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('user.top-authors', compact('page_title', 'authors', 'metric'));
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\User;
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::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();
}
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('user.top-favourites', ['page_title' => $page_title, 'artworks' => $paginator]);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
if (! $user) {
return redirect()->route('login');
}
try {
$profile = app(\App\Services\LegacyService::class)->userAccount($user->id);
} catch (\Throwable $e) {
$profile = null;
}
return view('user.user', [
'profile' => $profile,
]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
use Illuminate\Support\Facades\DB;
class ArtController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->legacy = $legacy;
}
public function show(Request $request, $id, $slug = null)
{
if ($request->isMethod('post') && $request->input('action') === 'store_comment') {
if (auth()->check()) {
try {
DB::table('artwork_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('shared.placeholder', ['title' => 'Artwork Not Found']);
}
try {
$comments = DB::table('artwork_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('web.art', $data);
}
}

View File

@@ -1,7 +1,8 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -9,16 +10,13 @@ class BrowseCategoriesController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
// Use Eloquent models for canonical category URLs and grouping
$contentTypes = \App\Models\ContentType::with(['rootCategories.children'])->orderBy('id')->get(); $contentTypes = \App\Models\ContentType::with(['rootCategories.children'])->orderBy('id')->get();
// Prepare categories grouped by content type and a flat list of root categories
$categoriesByType = []; $categoriesByType = [];
$categories = collect(); $categories = collect();
foreach ($contentTypes as $ct) { foreach ($contentTypes as $ct) {
$rootCats = $ct->rootCategories; $rootCats = $ct->rootCategories;
foreach ($rootCats as $cat) { foreach ($rootCats as $cat) {
// Attach subcategories
$cat->subcategories = $cat->children; $cat->subcategories = $cat->children;
$categories->push($cat); $categories->push($cat);
} }

View File

@@ -1,14 +1,15 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers\Web;
use App\Models\Category; use App\Models\Category;
use App\Models\ContentType; use App\Models\ContentType;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use App\Http\Controllers\ArtworkController as ArtworkControllerAlias;
class BrowseGalleryController extends Controller class BrowseGalleryController extends \App\Http\Controllers\Controller
{ {
private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other']; private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other'];
@@ -121,7 +122,7 @@ class BrowseGalleryController extends Controller
public function showArtwork(Request $request, string $contentTypeSlug, string $categoryPath, string $artwork) public function showArtwork(Request $request, string $contentTypeSlug, string $categoryPath, string $artwork)
{ {
return app(ArtworkController::class)->show( return app(\App\Http\Controllers\ArtController::class)->show(
$request, $request,
strtolower($contentTypeSlug), strtolower($contentTypeSlug),
trim($categoryPath, '/'), trim($categoryPath, '/'),

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ArtworkService;
use App\Models\Category;
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)
{
$path = trim($request->path(), '/');
$segments = array_values(array_filter(explode('/', $path)));
if (count($segments) < 2 || strtolower($segments[0]) !== 'category') {
return view('shared.placeholder');
}
$parts = array_slice($segments, 1);
$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);
}
}
$contentTypeSlug = array_shift($parts);
$slugs = array_merge([$contentTypeSlug], $parts);
$perPage = (int) $request->get('per_page', 40);
$sort = (string) $request->get('sort', 'latest');
try {
$artworks = $this->artworkService->getArtworksByCategoryPath($slugs, $perPage, $sort);
} catch (ModelNotFoundException $e) {
abort(404);
}
try {
$category = Category::whereHas('contentType', function ($q) use ($contentTypeSlug) {
$q->where('slug', strtolower($contentTypeSlug));
})->whereNull('parent_id')->where('slug', strtolower($parts[0] ?? ''))->first();
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) {
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('web.category', compact(
'page_title',
'page_meta_description',
'page_meta_keywords',
'group',
'category',
'subcategories',
'artworks'
));
}
public function browseCategories()
{
$data = app(\App\Services\LegacyService::class)->browseCategories();
return view('web.categories', $data);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
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) {
$arts = $this->fetchByDate($datum);
return view('web.partials.daily-uploads-grid', ['arts' => $arts])->render();
}
$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),
];
}
$recent = $this->fetchRecent();
return view('web.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',
];
});
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
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('web.featured-artworks', [
'artworks' => $artworks,
'type' => $type,
'artworkTypes' => $artworkTypes,
'page_title' => $pageTitle,
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\Schema;
class GalleryController extends Controller
{
public function show(Request $request, $userId, $username = null)
{
$user = User::find((int)$userId);
if (! $user) {
abort(404);
}
$page = max(1, (int) $request->query('page', 1));
$hits = 20;
$query = Artwork::where('user_id', $user->id)
->approved()
->published()
->public()
->orderByDesc('published_at');
$total = (int) $query->count();
$artworks = $query->skip(($page - 1) * $hits)->take($hits)->get();
return view('web.gallery', [
'user' => $user,
'artworks' => $artworks,
'page' => $page,
'hits' => $hits,
'total' => $total,
]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ArtworkService;
use Illuminate\Support\Facades\DB;
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';
$featuredResult = $this->artworks->getFeaturedArtworks(null, 39);
if ($featuredResult instanceof \Illuminate\Pagination\LengthAwarePaginator) {
$featured = $featuredResult->getCollection()->first();
} elseif (is_array($featuredResult)) {
$featured = $featuredResult[0] ?? null;
} else {
$featured = method_exists($featuredResult, 'first') ? $featuredResult->first() : $featuredResult;
}
$memberFeatured = $featured;
$latestUploads = $this->artworks->getLatestArtworks(20);
// Forum news (root forum section id 2876)
$forumNews = DB::table('forum_topics as t1')
->leftJoin('users as u', 't1.user_id', '=', 'u.user_id')
->select('t1.topic_id', 't1.topic', 'u.uname', 't1.post_date', 't1.preview')
->where('t1.root_id', 2876)
->where('t1.privilege', '<', 4)
->orderBy('t1.post_date', 'desc')
->limit(8)
->get();
// Our news (latest site news)
$ourNews = DB::table('news as t1')
->join('news_categories as c', 't1.category_id', '=', 'c.category_id')
->join('users as u', 't1.user_id', '=', 'u.user_id')
->selectRaw('t1.news_id, t1.headline, t1.user_id, t1.picture, t1.preview, u.uname, t1.create_date, t1.views, c.category_name, (SELECT COUNT(*) FROM news_comments WHERE news_id = t1.news_id) AS num_comments')
->orderBy('t1.create_date', 'desc')
->limit(5)
->get();
// Latest forum activity (exclude rootless and news root)
$latestForumActivity = DB::table('forum_topics as t1')
->selectRaw('t1.topic_id, t1.topic, (SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
->where('t1.root_id', '<>', 0)
->where('t1.root_id', '<>', 2876)
->where('t1.privilege', '<', 4)
->orderBy('t1.last_update', 'desc')
->orderBy('t1.post_date', 'desc')
->limit(10)
->get();
return view('web.home', compact(
'page_title',
'page_meta_description',
'page_meta_keywords',
'featured',
'memberFeatured',
'latestUploads',
'forumNews',
'ourNews',
'latestForumActivity'
));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class AvatarUploadRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'avatar' => [
'required',
'file',
'image',
'max:2048',
'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
],
];
}
}

View File

@@ -40,7 +40,7 @@ final class ManageArtworkUpdateRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'name' => 'required|string|max:255', 'title' => 'required|string|max:255',
'section' => 'nullable|integer', 'section' => 'nullable|integer',
'description' => 'nullable|string', 'description' => 'nullable|string',
'artwork' => 'nullable|file|image', 'artwork' => 'nullable|file|image',

View File

@@ -37,7 +37,7 @@ class ProfileUpdateRequest extends FormRequest
'about' => ['nullable', 'string'], 'about' => ['nullable', 'string'],
'signature' => ['nullable', 'string'], 'signature' => ['nullable', 'string'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'avatar' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'], 'avatar' => ['nullable', 'file', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp', 'mimetypes:image/jpeg,image/png,image/webp'],
'emoticon' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'], 'emoticon' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
'photo' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'], 'photo' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
]; ];

View File

@@ -4,6 +4,7 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -55,6 +56,11 @@ class User extends Authenticatable
return $this->hasMany(Artwork::class); return $this->hasMany(Artwork::class);
} }
public function profile(): HasOne
{
return $this->hasOne(UserProfile::class, 'user_id');
}
public function hasRole(string $role): bool public function hasRole(string $role): bool
{ {
return strtolower((string) ($this->role ?? '')) === strtolower($role); return strtolower((string) ($this->role ?? '')) === strtolower($role);

View File

@@ -4,7 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage; use App\Support\AvatarUrl;
class UserProfile extends Model class UserProfile extends Model
{ {
@@ -18,7 +18,7 @@ class UserProfile extends Model
'about', 'about',
'signature', 'signature',
'description', 'description',
'avatar', 'avatar_legacy',
'avatar_hash', 'avatar_hash',
'avatar_mime', 'avatar_mime',
'avatar_updated_at', 'avatar_updated_at',
@@ -43,27 +43,12 @@ class UserProfile extends Model
return $this->belongsTo(User::class, 'user_id'); return $this->belongsTo(User::class, 'user_id');
} }
/**
* Return a public URL for the avatar when stored on the `public` disk under `avatars/`.
*/
public function getAvatarUrlAttribute(): ?string public function getAvatarUrlAttribute(): ?string
{ {
if (empty($this->avatar)) { if (empty($this->user_id)) {
return null; return null;
} }
// If the stored value already looks like a full URL, return it. return AvatarUrl::forUser((int) $this->user_id, $this->avatar_hash, 128);
if (preg_match('#^https?://#i', $this->avatar)) {
return $this->avatar;
}
// Prefer `public` disk and avatars folder.
$path = 'avatars/' . ltrim($this->avatar, '/');
if (Storage::disk('public')->exists($path)) {
return Storage::disk('public')->url($path);
}
// Fallback: return null if not found
return null;
} }
} }

View File

@@ -35,7 +35,7 @@ class AppServiceProvider extends ServiceProvider
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic) // Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) { View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
$uploadCount = $favCount = $msgCount = $noticeCount = 0; $uploadCount = $favCount = $msgCount = $noticeCount = 0;
$avatar = null; $avatarHash = null;
$displayName = null; $displayName = null;
$userId = null; $userId = null;
@@ -72,15 +72,15 @@ class AppServiceProvider extends ServiceProvider
try { try {
$profile = DB::table('user_profiles')->where('user_id', $userId)->first(); $profile = DB::table('user_profiles')->where('user_id', $userId)->first();
$avatar = $profile->avatar ?? null; $avatarHash = $profile->avatar_hash ?? null;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$avatar = null; $avatarHash = null;
} }
$displayName = Auth::user()->name ?: (Auth::user()->username ?? ''); $displayName = Auth::user()->name ?: (Auth::user()->username ?? '');
} }
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatar', 'displayName')); $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatarHash', 'displayName'));
}); });
} }

View File

@@ -2,15 +2,22 @@
namespace App\Services; namespace App\Services;
use Illuminate\Support\Facades\Storage; use App\Models\UserProfile;
use Illuminate\Support\Facades\DB;
use Intervention\Image\ImageManagerStatic as Image;
use RuntimeException;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
class AvatarService class AvatarService
{ {
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
protected $sizes = [ protected $sizes = [
'xs' => 32, 'xs' => 32,
'sm' => 64, 'sm' => 64,
@@ -21,149 +28,234 @@ class AvatarService
protected $quality = 85; protected $quality = 85;
private ?ImageManager $manager = null;
public function __construct() public function __construct()
{ {
// Guard: if Intervention Image is not installed, defer error until actual use $configuredSizes = array_values(array_filter((array) config('avatars.sizes', [32, 64, 128, 256, 512]), static fn ($size) => (int) $size > 0));
if (class_exists(\Intervention\Image\ImageManagerStatic::class)) { if ($configuredSizes !== []) {
try { $this->sizes = array_fill_keys(array_map('strval', $configuredSizes), null);
Image::configure(['driver' => extension_loaded('gd') ? 'gd' : 'imagick']); $this->sizes = array_combine(array_keys($this->sizes), $configuredSizes);
$this->imageAvailable = true;
} catch (\Throwable $e) {
// If configuration fails, treat as unavailable and log for diagnostics
logger()->warning('Intervention Image present but configuration failed: '.$e->getMessage());
$this->imageAvailable = false;
} }
} else {
$this->imageAvailable = false; $this->quality = (int) config('avatars.quality', 85);
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable $e) {
logger()->warning('Avatar image manager configuration failed: ' . $e->getMessage());
$this->manager = null;
} }
} }
/**
* Process an uploaded file for a user and store webp sizes.
* Returns the computed sha1 hash.
*
* @param int $userId
* @param UploadedFile $file
* @return string sha1 hash
*/
public function storeFromUploadedFile(int $userId, UploadedFile $file): string public function storeFromUploadedFile(int $userId, UploadedFile $file): string
{ {
if (! $this->imageAvailable) { $this->assertImageManagerAvailable();
throw new RuntimeException('Intervention Image is not available. If you just installed the package, restart your PHP process (php artisan serve or PHP-FPM) and run `composer dump-autoload -o`.'); $this->assertStorageIsAllowed();
$this->assertSecureImageUpload($file);
$binary = file_get_contents($file->getRealPath());
if ($binary === false || $binary === '') {
throw new RuntimeException('Uploaded avatar file is empty or unreadable.');
} }
// Load image and re-encode to webp after validating return $this->storeFromBinary($userId, $binary);
try {
$img = Image::make($file->getRealPath());
} catch (\Throwable $e) {
throw new RuntimeException('Failed to read uploaded image: '.$e->getMessage());
} }
// Ensure square center crop per spec
$max = max($img->width(), $img->height());
$img->fit($max, $max);
$basePath = "avatars/{$userId}";
Storage::disk('public')->makeDirectory($basePath);
// Save original as webp
$originalData = (string) $img->encode('webp', $this->quality);
Storage::disk('public')->put($basePath . '/original.webp', $originalData);
// Generate sizes
foreach ($this->sizes as $name => $size) {
$resized = $img->resize($size, $size, function ($constraint) {
$constraint->upsize();
})->encode('webp', $this->quality);
Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized);
}
$hash = sha1($originalData);
$mime = 'image/webp';
// Persist metadata to user_profiles if exists, otherwise users table fallbacks
if (SchemaHasTable('user_profiles')) {
DB::table('user_profiles')->where('user_id', $userId)->update([
'avatar_hash' => $hash,
'avatar_updated_at' => Carbon::now(),
'avatar_mime' => $mime,
]);
} else {
DB::table('users')->where('id', $userId)->update([
'avatar_hash' => $hash,
'avatar_updated_at' => Carbon::now(),
'avatar_mime' => $mime,
]);
}
return $hash;
}
/**
* Process a legacy file path for a user (path-to-file).
* Returns sha1 or null when missing.
*
* @param int $userId
* @param string $path Absolute filesystem path
* @return string|null
*/
public function storeFromLegacyFile(int $userId, string $path): ?string public function storeFromLegacyFile(int $userId, string $path): ?string
{ {
$this->assertImageManagerAvailable();
$this->assertStorageIsAllowed();
if (!file_exists($path) || !is_readable($path)) { if (!file_exists($path) || !is_readable($path)) {
return null; return null;
} }
try { $binary = file_get_contents($path);
$img = Image::make($path); if ($binary === false || $binary === '') {
} catch (\Exception $e) {
return null; return null;
} }
$max = max($img->width(), $img->height()); return $this->storeFromBinary($userId, $binary);
$img->fit($max, $max); }
private function storeFromBinary(int $userId, string $binary): string
{
$image = $this->readImageFromBinary($binary);
$image = $this->normalizeImage($image);
$diskName = (string) config('avatars.disk', 's3');
$disk = Storage::disk($diskName);
$basePath = "avatars/{$userId}"; $basePath = "avatars/{$userId}";
Storage::disk('public')->makeDirectory($basePath);
$originalData = (string) $img->encode('webp', $this->quality); $hashSeed = '';
Storage::disk('public')->put($basePath . '/original.webp', $originalData); foreach ($this->sizes as $size) {
$variant = $image->cover($size, $size);
$encoded = (string) $variant->encode(new WebpEncoder($this->quality));
$disk->put("{$basePath}/{$size}.webp", $encoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
foreach ($this->sizes as $name => $size) { if ($size === 128) {
$resized = $img->resize($size, $size, function ($constraint) { $hashSeed = $encoded;
$constraint->upsize(); }
})->encode('webp', $this->quality);
Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized);
} }
$hash = sha1($originalData); if ($hashSeed === '') {
$mime = 'image/webp'; throw new RuntimeException('Avatar processing failed to generate a hash seed.');
if (SchemaHasTable('user_profiles')) {
DB::table('user_profiles')->where('user_id', $userId)->update([
'avatar_hash' => $hash,
'avatar_updated_at' => Carbon::now(),
'avatar_mime' => $mime,
]);
} else {
DB::table('users')->where('id', $userId)->update([
'avatar_hash' => $hash,
'avatar_updated_at' => Carbon::now(),
'avatar_mime' => $mime,
]);
} }
$hash = hash('sha256', $hashSeed);
$this->updateProfileMetadata($userId, $hash);
return $hash; return $hash;
} }
}
/** private function normalizeImage($image)
* Helper: check for table existence without importing Schema facade repeatedly {
*/
function SchemaHasTable(string $name): bool
{
try { try {
return \Illuminate\Support\Facades\Schema::hasTable($name); $core = $image->getCore();
$isImagickCore = is_object($core) && strtolower(get_class($core)) === 'imagick';
if ($isImagickCore) {
try {
$core->stripImage();
} catch (\Throwable $_) {
}
try {
$colorSpaceRgb = defined('\\Imagick::COLORSPACE_RGB') ? constant('\\Imagick::COLORSPACE_RGB') : null;
$colorSpaceSRgb = defined('\\Imagick::COLORSPACE_SRGB') ? constant('\\Imagick::COLORSPACE_SRGB') : null;
if (is_int($colorSpaceRgb)) {
$core->setImageColorspace($colorSpaceRgb);
} elseif (is_int($colorSpaceSRgb)) {
$core->setImageColorspace($colorSpaceSRgb);
}
} catch (\Throwable $_) {
}
try {
$alphaRemove = defined('\\Imagick::ALPHACHANNEL_REMOVE') ? constant('\\Imagick::ALPHACHANNEL_REMOVE') : null;
if (is_int($alphaRemove)) {
$core->setImageAlphaChannel($alphaRemove);
}
} catch (\Throwable $_) {
}
try {
$core->setBackgroundColor('white');
$layerFlatten = defined('\\Imagick::LAYERMETHOD_FLATTEN') ? constant('\\Imagick::LAYERMETHOD_FLATTEN') : null;
$flattened = is_int($layerFlatten) ? $core->mergeImageLayers($layerFlatten) : null;
if (is_object($flattened) && strtolower(get_class($flattened)) === 'imagick') {
$core->clear();
$core->destroy();
$image = $this->manager->read((string) $flattened->getImageBlob());
}
} catch (\Throwable $_) {
}
return $image;
}
$isGdCore = is_resource($core) || (is_object($core) && strtolower(get_class($core)) === 'gdimage');
if ($isGdCore) {
$width = imagesx($core);
$height = imagesy($core);
if ($width > 0 && $height > 0) {
$flattened = imagecreatetruecolor($width, $height);
if ($flattened !== false) {
$white = imagecolorallocate($flattened, 255, 255, 255);
imagefilledrectangle($flattened, 0, 0, $width, $height, $white);
imagecopy($flattened, $core, 0, 0, 0, 0, $width, $height);
ob_start();
imagepng($flattened);
$pngBinary = (string) ob_get_clean();
imagedestroy($flattened);
if ($pngBinary !== '') {
return $this->manager->read($pngBinary);
}
}
}
}
} catch (\Throwable $_) {
}
return $image;
}
private function readImageFromBinary(string $binary)
{
try {
return $this->manager->read($binary);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return false; throw new RuntimeException('Failed to decode uploaded image.');
}
}
private function updateProfileMetadata(int $userId, string $hash): void
{
UserProfile::query()->updateOrCreate(
['user_id' => $userId],
[
'avatar_hash' => $hash,
'avatar_mime' => 'image/webp',
'avatar_updated_at' => Carbon::now(),
]
);
}
private function assertImageManagerAvailable(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Avatar image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
if (!app()->environment('production')) {
return;
}
$diskName = (string) config('avatars.disk', 's3');
if (in_array($diskName, ['local', 'public'], true)) {
throw new RuntimeException('Production avatar storage must use object storage, not local/public disks.');
}
}
private function assertSecureImageUpload(UploadedFile $file): void
{
$extension = strtolower((string) $file->getClientOriginalExtension());
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
throw new RuntimeException('Unsupported avatar file extension.');
}
$detectedMime = (string) $file->getMimeType();
if (!in_array($detectedMime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported avatar MIME type.');
}
$binary = file_get_contents($file->getRealPath());
if ($binary === false || $binary === '') {
throw new RuntimeException('Unable to read uploaded avatar data.');
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$finfoMime = (string) $finfo->buffer($binary);
if (!in_array($finfoMime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Avatar content did not match allowed image MIME types.');
}
$dimensions = @getimagesizefromstring($binary);
if (!is_array($dimensions) || ($dimensions[0] ?? 0) < 1 || ($dimensions[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded avatar is not a valid image.');
}
} }
} }

50
app/Support/AvatarUrl.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\DB;
class AvatarUrl
{
private static array $hashCache = [];
public static function forUser(int $userId, ?string $hash = null, int $size = 128): string
{
if ($userId <= 0) {
return self::default();
}
$avatarHash = $hash ?: self::resolveHash($userId);
if (!$avatarHash) {
return self::default();
}
$base = rtrim((string) config('cdn.avatar_url', 'https://file.skinbase.org'), '/');
return sprintf('%s/avatars/%d/%d.webp?v=%s', $base, $userId, $size, $avatarHash);
}
public static function default(): string
{
return asset('img/default-avatar.webp');
}
private static function resolveHash(int $userId): ?string
{
if (array_key_exists($userId, self::$hashCache)) {
return self::$hashCache[$userId];
}
try {
$value = DB::table('user_profiles')
->where('user_id', $userId)
->value('avatar_hash');
} catch (\Throwable $e) {
$value = null;
}
self::$hashCache[$userId] = $value ? (string) $value : null;
return self::$hashCache[$userId];
}
}

1185
avatar_patch.diff Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,12 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"intervention/image": "^3.11",
"inertiajs/inertia-laravel": "^1.0", "inertiajs/inertia-laravel": "^1.0",
"intervention/image": "^3.11",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1" "laravel/scout": "^10.24",
"laravel/tinker": "^2.10.1",
"meilisearch/meilisearch-php": "^1.16"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",

321
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "2c96d87d3df9f5da68d6593ba44cb150", "content-hash": "d725824144ac43bf1938e16a5653dcf4",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -1546,6 +1546,86 @@
}, },
"time": "2026-02-06T12:17:10+00:00" "time": "2026-02-06T12:17:10+00:00"
}, },
{
"name": "laravel/scout",
"version": "v10.24.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/scout.git",
"reference": "f9864d9a727a0c0d6b95e08ed92df8c301ae6d2c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/scout/zipball/f9864d9a727a0c0d6b95e08ed92df8c301ae6d2c",
"reference": "f9864d9a727a0c0d6b95e08ed92df8c301ae6d2c",
"shasum": ""
},
"require": {
"illuminate/bus": "^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/contracts": "^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/database": "^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/http": "^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/pagination": "^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/queue": "^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0",
"symfony/console": "^6.0|^7.0|^8.0"
},
"conflict": {
"algolia/algoliasearch-client-php": "<3.2.0|>=5.0.0"
},
"require-dev": {
"algolia/algoliasearch-client-php": "^3.2|^4.0",
"meilisearch/meilisearch-php": "^1.0",
"mockery/mockery": "^1.0",
"orchestra/testbench": "^7.31|^8.36|^9.15|^10.8|^11.0",
"php-http/guzzle7-adapter": "^1.0",
"phpstan/phpstan": "^1.10",
"typesense/typesense-php": "^4.9.3"
},
"suggest": {
"algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).",
"meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).",
"typesense/typesense-php": "Required to use the Typesense engine (^4.9)."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Scout\\ScoutServiceProvider"
]
},
"branch-alias": {
"dev-master": "10.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Scout\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Scout provides a driver based solution to searching your Eloquent models.",
"keywords": [
"algolia",
"laravel",
"search"
],
"support": {
"issues": "https://github.com/laravel/scout/issues",
"source": "https://github.com/laravel/scout"
},
"time": "2026-02-10T18:44:39+00:00"
},
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
"version": "v2.0.9", "version": "v2.0.9",
@@ -2232,6 +2312,86 @@
], ],
"time": "2026-01-15T06:54:53+00:00" "time": "2026-01-15T06:54:53+00:00"
}, },
{
"name": "meilisearch/meilisearch-php",
"version": "v1.16.1",
"source": {
"type": "git",
"url": "https://github.com/meilisearch/meilisearch-php.git",
"reference": "f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/meilisearch/meilisearch-php/zipball/f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6",
"reference": "f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.4 || ^8.0",
"php-http/discovery": "^1.7",
"psr/http-client": "^1.0",
"symfony/polyfill-php81": "^1.33"
},
"require-dev": {
"http-interop/http-factory-guzzle": "^1.2.0",
"php-cs-fixer/shim": "^3.59.3",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.5 || ^10.5",
"symfony/http-client": "^5.4|^6.0|^7.0"
},
"suggest": {
"guzzlehttp/guzzle": "Use Guzzle ^7 as HTTP client",
"http-interop/http-factory-guzzle": "Factory for guzzlehttp/guzzle",
"symfony/http-client": "Use Symfony Http client"
},
"type": "library",
"autoload": {
"psr-4": {
"MeiliSearch\\": "src/",
"Meilisearch\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Clémentine Urquizar",
"email": "clementine@meilisearch.com"
},
{
"name": "Bruno Casali",
"email": "bruno@meilisearch.com"
},
{
"name": "Laurent Cazanove",
"email": "lau.cazanove@gmail.com"
},
{
"name": "Tomas Norkūnas",
"email": "norkunas.tom@gmail.com"
}
],
"description": "PHP wrapper for the Meilisearch API",
"keywords": [
"api",
"client",
"instant",
"meilisearch",
"php",
"search"
],
"support": {
"issues": "https://github.com/meilisearch/meilisearch-php/issues",
"source": "https://github.com/meilisearch/meilisearch-php/tree/v1.16.1"
},
"time": "2025-09-18T10:15:45+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -2739,6 +2899,85 @@
], ],
"time": "2025-11-20T02:34:59+00:00" "time": "2025-11-20T02:34:59+00:00"
}, },
{
"name": "php-http/discovery",
"version": "1.20.0",
"source": {
"type": "git",
"url": "https://github.com/php-http/discovery.git",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
"shasum": ""
},
"require": {
"composer-plugin-api": "^1.0|^2.0",
"php": "^7.1 || ^8.0"
},
"conflict": {
"nyholm/psr7": "<1.0",
"zendframework/zend-diactoros": "*"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "*",
"psr/http-factory-implementation": "*",
"psr/http-message-implementation": "*"
},
"require-dev": {
"composer/composer": "^1.0.2|^2.0",
"graham-campbell/phpspec-skip-example-extension": "^5.0",
"php-http/httplug": "^1.0 || ^2.0",
"php-http/message-factory": "^1.0",
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
"sebastian/comparator": "^3.0.5 || ^4.0.8",
"symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
},
"type": "composer-plugin",
"extra": {
"class": "Http\\Discovery\\Composer\\Plugin",
"plugin-optional": true
},
"autoload": {
"psr-4": {
"Http\\Discovery\\": "src/"
},
"exclude-from-classmap": [
"src/Composer/Plugin.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
"homepage": "http://php-http.org",
"keywords": [
"adapter",
"client",
"discovery",
"factory",
"http",
"message",
"psr17",
"psr7"
],
"support": {
"issues": "https://github.com/php-http/discovery/issues",
"source": "https://github.com/php-http/discovery/tree/1.20.0"
},
"time": "2024-10-02T11:20:13+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.5", "version": "1.9.5",
@@ -5005,6 +5244,86 @@
], ],
"time": "2025-01-02T08:10:11+00:00" "time": "2025-01-02T08:10:11+00:00"
}, },
{
"name": "symfony/polyfill-php81",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
"reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
"reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php81\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
},
{ {
"name": "symfony/polyfill-php83", "name": "symfony/polyfill-php83",
"version": "v1.33.0", "version": "v1.33.0",

7
config/avatars.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
return [
'disk' => env('AVATAR_DISK', env('APP_ENV') === 'production' ? 's3' : 'public'),
'sizes' => [32, 64, 128, 256, 512],
'quality' => (int) env('AVATAR_WEBP_QUALITY', 85),
];

View File

@@ -4,4 +4,5 @@ declare(strict_types=1);
return [ return [
'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'), 'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'),
'avatar_url' => env('AVATAR_CDN_URL', 'https://file.skinbase.org'),
]; ];

View File

@@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if (!Schema::hasTable('user_profiles')) {
return;
}
if (Schema::hasColumn('user_profiles', 'avatar') && !Schema::hasColumn('user_profiles', 'avatar_legacy')) {
try {
DB::statement('ALTER TABLE `user_profiles` CHANGE COLUMN `avatar` `avatar_legacy` VARCHAR(255) NULL');
} catch (\Throwable $e) {
}
}
Schema::table('user_profiles', function (Blueprint $table) {
if (!Schema::hasColumn('user_profiles', 'avatar_legacy')) {
$table->string('avatar_legacy', 255)->nullable();
}
if (!Schema::hasColumn('user_profiles', 'avatar_hash')) {
$table->char('avatar_hash', 64)->nullable()->index();
}
if (!Schema::hasColumn('user_profiles', 'avatar_mime')) {
$table->string('avatar_mime', 50)->nullable();
}
if (!Schema::hasColumn('user_profiles', 'avatar_updated_at')) {
$table->dateTime('avatar_updated_at')->nullable();
}
});
if (Schema::hasColumn('user_profiles', 'avatar_hash')) {
try {
DB::statement('ALTER TABLE `user_profiles` MODIFY COLUMN `avatar_hash` CHAR(64) NULL');
} catch (\Throwable $e) {
}
}
}
public function down(): void
{
if (!Schema::hasTable('user_profiles')) {
return;
}
if (Schema::hasColumn('user_profiles', 'avatar_hash')) {
try {
DB::statement('ALTER TABLE `user_profiles` MODIFY COLUMN `avatar_hash` CHAR(40) NULL');
} catch (\Throwable $e) {
}
}
}
};

View File

@@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if (!Schema::hasTable('user_profiles')) {
return;
}
if (Schema::hasColumn('user_profiles', 'avatar') && !Schema::hasColumn('user_profiles', 'avatar_legacy')) {
try {
DB::statement('ALTER TABLE `user_profiles` CHANGE COLUMN `avatar` `avatar_legacy` VARCHAR(255) NULL');
} catch (\Throwable $e) {
}
}
Schema::table('user_profiles', function (Blueprint $table) {
if (!Schema::hasColumn('user_profiles', 'avatar_legacy')) {
$table->string('avatar_legacy', 255)->nullable();
}
if (!Schema::hasColumn('user_profiles', 'avatar_hash')) {
$table->char('avatar_hash', 64)->nullable()->index();
}
if (!Schema::hasColumn('user_profiles', 'avatar_mime')) {
$table->string('avatar_mime', 50)->nullable();
}
if (!Schema::hasColumn('user_profiles', 'avatar_updated_at')) {
$table->dateTime('avatar_updated_at')->nullable();
}
});
if (Schema::hasColumn('user_profiles', 'avatar_hash')) {
try {
DB::statement('ALTER TABLE `user_profiles` MODIFY COLUMN `avatar_hash` CHAR(64) NULL');
} catch (\Throwable $e) {
}
}
}
public function down(): void
{
if (!Schema::hasTable('user_profiles')) {
return;
}
if (Schema::hasColumn('user_profiles', 'avatar_hash')) {
try {
DB::statement('ALTER TABLE `user_profiles` MODIFY COLUMN `avatar_hash` CHAR(40) NULL');
} catch (\Throwable $e) {
}
}
}
};

View File

@@ -0,0 +1,32 @@
# Avatar CDN Config Notes
This project serves avatars from the avatar CDN domain.
## Required env variables
- `AVATAR_CDN_URL=https://file.skinbase.org`
- `AVATAR_DISK=s3` (production)
- `AVATAR_WEBP_QUALITY=85`
## Delivery format
Avatars are rendered via:
- `https://file.skinbase.org/avatars/{user_id}/{size}.webp?v={avatar_hash}`
Sizes generated server-side:
- `32`, `64`, `128`, `256`, `512`
## Cache policy
Storage writes must set:
- `Cache-Control: public, max-age=31536000, immutable`
Hash-based query versioning (`?v={avatar_hash}`) handles cache busting.
## Production rule
- Production avatar storage must use object storage (`s3` / R2-compatible disk).
- Local/public disks are for development only.

64
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.40.0",
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
@@ -1073,6 +1074,22 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
@@ -3802,6 +3819,53 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",

View File

@@ -5,7 +5,9 @@
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"dev": "vite", "dev": "vite",
"test:ui": "vitest run" "test:ui": "vitest run",
"test:e2e": "playwright test",
"playwright:install": "npx playwright install"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",
@@ -22,7 +24,8 @@
"sass": "^1.70.0", "sass": "^1.70.0",
"tailwindcss": "^3.1.0", "tailwindcss": "^3.1.0",
"vite": "^7.0.7", "vite": "^7.0.7",
"vitest": "^2.1.8" "vitest": "^2.1.8",
"@playwright/test": "^1.40.0"
}, },
"dependencies": { "dependencies": {
"@inertiajs/core": "^1.0.4", "@inertiajs/core": "^1.0.4",

19
playwright.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30000,
expect: { timeout: 5000 },
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://skinbase26.test',
headless: true,
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

BIN
public/gfx/sb_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/gfx/sb_logo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -1,7 +1,24 @@
import './bootstrap'; import './bootstrap';
import Alpine from 'alpinejs'; import Alpine from 'alpinejs';
import React from 'react';
import { createRoot } from 'react-dom/client';
import AvatarUploader from './components/profile/AvatarUploader';
window.Alpine = Alpine; window.Alpine = Alpine;
Alpine.start(); Alpine.start();
document.querySelectorAll('[data-avatar-uploader="true"]').forEach((element) => {
const uploadUrl = element.getAttribute('data-upload-url') || '';
const initialSrc = element.getAttribute('data-initial-src') || '';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
createRoot(element).render(
React.createElement(AvatarUploader, {
uploadUrl,
initialSrc,
csrfToken,
})
);
});

View File

@@ -0,0 +1,198 @@
import React, { useMemo, useRef, useState } from 'react';
import axios from 'axios';
const MAX_BYTES = 2 * 1024 * 1024;
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
function readImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Failed to read avatar file.'));
reader.onload = () => {
const image = new Image();
image.onerror = () => reject(new Error('Invalid image data.'));
image.onload = () => resolve(image);
image.src = String(reader.result || '');
};
reader.readAsDataURL(file);
});
}
function canvasToBlob(canvas, mimeType, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to prepare avatar preview.'));
return;
}
resolve(blob);
}, mimeType, quality);
});
}
async function cropToSquareWebp(file) {
const image = await readImage(file);
const side = Math.min(image.width, image.height);
const sourceX = Math.floor((image.width - side) / 2);
const sourceY = Math.floor((image.height - side) / 2);
const outputSize = Math.min(1024, side);
const canvas = document.createElement('canvas');
canvas.width = outputSize;
canvas.height = outputSize;
const context = canvas.getContext('2d', { alpha: false });
if (!context) {
throw new Error('Browser canvas is unavailable.');
}
context.fillStyle = '#ffffff';
context.fillRect(0, 0, outputSize, outputSize);
context.drawImage(image, sourceX, sourceY, side, side, 0, 0, outputSize, outputSize);
const blob = await canvasToBlob(canvas, 'image/webp', 0.9);
return new File([blob], 'avatar.webp', { type: 'image/webp' });
}
export default function AvatarUploader({ uploadUrl, initialSrc, csrfToken }) {
const inputRef = useRef(null);
const [error, setError] = useState('');
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [avatarSrc, setAvatarSrc] = useState(initialSrc || '');
const helperText = useMemo(() => {
if (isUploading) {
return `Uploading ${progress}%...`;
}
return 'JPG, PNG, or WebP up to 2MB. Image is center-cropped to square.';
}, [isUploading, progress]);
const validateClientFile = (file) => {
if (!file) {
throw new Error('No file selected.');
}
if (!ALLOWED_TYPES.has(file.type)) {
throw new Error('Only JPG, PNG, and WebP are allowed.');
}
if (file.size > MAX_BYTES) {
throw new Error('Avatar file must be 2MB or smaller.');
}
};
const upload = async (file) => {
validateClientFile(file);
setError('');
setProgress(0);
setIsUploading(true);
try {
const squaredFile = await cropToSquareWebp(file);
const previewUrl = URL.createObjectURL(squaredFile);
setAvatarSrc(previewUrl);
const formData = new FormData();
formData.append('avatar', squaredFile);
const response = await axios.post(uploadUrl, formData, {
headers: {
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (event) => {
if (!event.total) {
return;
}
const next = Math.round((event.loaded * 100) / event.total);
setProgress(next);
},
});
const data = response?.data || {};
if (typeof data.url === 'string' && data.url.length > 0) {
setAvatarSrc(data.url);
}
} catch (uploadError) {
const message = uploadError?.response?.data?.message || uploadError?.message || 'Avatar upload failed.';
setError(message);
} finally {
setIsUploading(false);
}
};
const onDrop = async (event) => {
event.preventDefault();
setIsDragging(false);
const file = event.dataTransfer?.files?.[0];
if (file) {
await upload(file);
}
};
const onPick = async (event) => {
const file = event.target.files?.[0];
if (file) {
await upload(file);
}
if (event.target) {
event.target.value = '';
}
};
return (
<div className="space-y-3">
<p className="text-sm font-medium text-gray-900">Avatar</p>
<div className="flex items-center gap-4">
<img
src={avatarSrc || '/img/default-avatar.webp'}
alt="Current avatar preview"
width="96"
height="96"
className="h-24 w-24 rounded-full border border-gray-300 object-cover"
loading="lazy"
decoding="async"
/>
<div
role="button"
tabIndex={0}
onDragOver={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
inputRef.current?.click();
}
}}
className={`w-full rounded-lg border-2 border-dashed p-4 text-sm transition ${isDragging ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 bg-white'}`}
aria-label="Upload avatar"
>
<p className="text-gray-700">Drag & drop avatar here, or click to choose a file.</p>
<p className="mt-1 text-xs text-gray-500">{helperText}</p>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={onPick}
/>
</div>
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
</div>
);
}

View File

@@ -3,9 +3,7 @@
$size = $size ?? 128; $size = $size ?? 128;
$profile = $user->profile ?? null; $profile = $user->profile ?? null;
$hash = $profile->avatar_hash ?? null; $hash = $profile->avatar_hash ?? null;
$src = $hash $src = \App\Support\AvatarUrl::forUser((int) $user->id, $hash, (int) $size);
? asset("storage/avatars/{$user->id}/{$size}.webp?v={$hash}")
: asset('img/default-avatar.webp');
$alt = $alt ?? ($user->username ?? 'avatar'); $alt = $alt ?? ($user->username ?? 'avatar');
$class = $class ?? 'rounded-full'; $class = $class ?? 'rounded-full';
@endphp @endphp

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
<title>{{ $page_title ?? 'Skinbase' }}</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{{ $page_meta_description ?? '' }}">
<meta name="keywords" content="{{ $page_meta_keywords ?? '' }}">
@isset($page_canonical)
<link rel="canonical" href="{{ $page_canonical }}" />
@endisset
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<link rel="shortcut icon" href="/favicon.ico">
@vite(['resources/css/app.css','resources/scss/nova.scss','resources/js/nova.js'])
@stack('head')
</head>
<body class="bg-nova-900 text-white min-h-screen flex flex-col">
<div id="topbar-root"></div>
@include('layouts.nova.toolbar')
<main class="flex-1 pt-16">
<div class="mx-auto w-full max-w-7xl px-4 py-6 md:px-6 lg:px-8">
@hasSection('sidebar')
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]">
<section class="min-w-0">
@yield('content')
</section>
<aside class="xl:sticky xl:top-24 xl:self-start">
@yield('sidebar')
</aside>
</div>
@else
<section class="min-w-0">
@yield('content')
</section>
@endif
</div>
</main>
@include('layouts.nova.footer')
@stack('toolbar')
@stack('scripts')
</body>
</html>

View File

@@ -1,82 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $page_title ?? 'Skinbase' }}</title>
<meta name="description" content="{{ $page_meta_description ?? '' }}">
<meta name="keywords" content="{{ $page_meta_keywords ?? '' }}">
<meta name="robots" content="index,follow">
<meta name="revisit" content="1 day">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<base href="{{ config('app.url', '//localhost:8000') }}/">
<link rel="shortcut icon" href="/favicon.ico">
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css">
<link href="//maxcdn.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/css/select2.min.css" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="/css/style.css">
<link rel="stylesheet" type="text/css" href="/css/custom-legacy.css">
@stack('head')
</head>
<body style="padding-top:60px;">
<div id="fb-root"></div>
@include('legacy.toolbar')
<div class="wrapper">
<div class="col-top main_content">
<div id="main_box_page">
@yield('content')
</div>
{{-- Right sidebar placeholder (legacy layout) --}}
<div id="right_box_page" class="hideme">
@yield('sidebar')
</div>
</div>
<div class="push"></div>
</div>
{{-- Toolbar placeholder --}}
@stack('toolbar')
<footer id="mainFooter">
<p>&copy; 2000 - {{ date('Y') }} by SkinBase.org. All artwork copyrighted to its Author. (Dragon II Edition)</p>
<div class="footer_links">
<a href="/bug-report" title="Inform us about any bugs you found">Bug report</a> :
<a href="/rss-feeds" title="Skinbase RSS Feeds about new Artworks">RSS Feeds</a> :
<a href="/faq" title="Frequently Asked Questions">FAQ</a> :
<a href="/rules" title="Rules and Guidelines">Rules and Guidelines</a> :
<a href="/staff" title="Who is actually behind Skinbase">Staff</a> :
<a href="/privacy" title="Privacy Policy">Privacy Policy</a>
</div>
</footer>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-migrate/1.2.1/jquery-migrate.min.js"></script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery.isotope/2.2.0/isotope.pkgd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/select2.min.js"></script>
<script src="/js/stickysidebar.jquery.min.js"></script>
<script>
if (window.jQuery) {
(function($){
if (typeof $.fn.stick_in_parent === 'undefined') {
if (typeof $.fn.stickySidebar !== 'undefined') {
$.fn.stick_in_parent = function(){
return this.each(function(){ $(this).stickySidebar(); });
};
} else {
$.fn.stick_in_parent = function(){ return this; };
}
}
})(jQuery);
}
</script>
<script src="/js/script.js"></script>
@stack('scripts')
</body>
</html>

View File

@@ -1,10 +1,11 @@
<header class="fixed inset-x-0 top-0 z-50 h-16 bg-nova border-b border-panel"> <header class="fixed inset-x-0 top-0 z-50 h-16 bg-nova border-b border-panel">
<div class="mx-auto w-full h-full px-4 flex items-center gap-3"> <div class="mx-auto w-full h-full px-4 flex items-center gap-3">
<!-- Mobile hamburger --> <!-- Mobile hamburger -->
<button id="btnSidebar" class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/5"> <button id="btnSidebar"
class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/5">
<!-- bars --> <!-- bars -->
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 6h16M4 12h16M4 18h16"/> <path d="M4 6h16M4 12h16M4 18h16" />
</svg> </svg>
</button> </button>
@@ -20,13 +21,10 @@
<div class="relative"> <div class="relative">
<button class="hover:text-white inline-flex items-center gap-1" data-dd="browse"> <button class="hover:text-white inline-flex items-center gap-1" data-dd="browse">
Browse Browse
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
<path d="M6 9l6 6 6-6"/>
</svg>
</button> </button>
<div id="dd-browse" class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-visible"> <div id="dd-browse" class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-visible">
<div class="rounded-lg overflow-hidden"> <div class="rounded-lg overflow-hidden">
<div class="px-4 dd-section">Views</div> <div class="px-4 dd-section">Views</div>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments mr-3 text-sb-muted"></i>Forum</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments mr-3 text-sb-muted"></i>Forum</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/chat"><i class="fa-solid fa-message mr-3 text-sb-muted"></i>Chat</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/chat"><i class="fa-solid fa-message mr-3 text-sb-muted"></i>Chat</a>
@@ -45,19 +43,20 @@
<div class="px-4 dd-section">Statistics</div> <div class="px-4 dd-section">Statistics</div>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/downloads/today"><i class="fa-solid fa-download mr-3 text-sb-muted"></i>Todays Downloads</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/downloads/today"><i class="fa-solid fa-download mr-3 text-sb-muted"></i>Todays Downloads</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/favourites/top"><i class="fa-solid fa-heart mr-3 text-sb-muted"></i>Top Favourites</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/favourites/top"><i class="fa-solid fa-heart mr-3 text-sb-muted"></i>Top Favourites</a>
</div> <!-- end .rounded-lg -->
</div> </div> <!-- end .dd-browse -->
</div> </div> <!-- end .relative -->
</div>
<div class="relative"> <div class="relative">
<button class="hover:text-white inline-flex items-center gap-1" data-dd="cats"> <button class="hover:text-white inline-flex items-center gap-1" data-dd="cats">
Categories Categories
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<path d="M6 9l6 6 6-6"/> stroke-width="2">
<path d="M6 9l6 6 6-6" />
</svg> </svg>
</button> </button>
<div id="dd-cats" class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden"> <div id="dd-cats"
class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden">
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/browse">All Artworks</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/browse">All Artworks</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/photography">Photography</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/photography">Photography</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/wallpapers">Wallpapers</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/wallpapers">Wallpapers</a>
@@ -74,12 +73,13 @@
<div class="w-full max-w-lg relative"> <div class="w-full max-w-lg relative">
<input <input
class="w-full h-10 rounded-lg bg-black/20 border border-sb-line pl-4 pr-12 text-sm text-white placeholder:text-sb-muted/80 outline-none focus:border-sb-blue/60" class="w-full h-10 rounded-lg bg-black/20 border border-sb-line pl-4 pr-12 text-sm text-white placeholder:text-sb-muted/80 outline-none focus:border-sb-blue/60"
placeholder="Search tags, artworks, artists..." placeholder="Search tags, artworks, artists..." />
/> <button
<button class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-md hover:bg-white/5 text-sb-muted hover:text-white"> class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-md hover:bg-white/5 text-sb-muted hover:text-white">
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<circle cx="11" cy="11" r="7"/> stroke-width="2">
<path d="M20 20l-3.5-3.5"/> <circle cx="11" cy="11" r="7" />
<path d="M20 20l-3.5-3.5" />
</svg> </svg>
</button> </button>
</div> </div>
@@ -89,95 +89,120 @@
<!-- Right icon counters (authenticated users) --> <!-- Right icon counters (authenticated users) -->
<div class="hidden md:flex items-center gap-3 text-soft"> <div class="hidden md:flex items-center gap-3 text-soft">
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5"> <button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<path d="M12 5v14M5 12h14"/> stroke-width="2">
<path d="M12 5v14M5 12h14" />
</svg> </svg>
</button> </button>
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5"> <button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<path d="M12 21s-7-4.4-9-9a5.5 5.5 0 0 1 9-6 5.5 5.5 0 0 1 9 6c-2 4.6-9 9-9 9z"/> stroke-width="2">
<path d="M12 21s-7-4.4-9-9a5.5 5.5 0 0 1 9-6 5.5 5.5 0 0 1 9 6c-2 4.6-9 9-9 9z" />
</svg> </svg>
<span class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $uploadCount ?? 0 }}</span> <span
class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $uploadCount ?? 0 }}</span>
</button> </button>
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5"> <button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<path d="M4 4h16v14H5.2L4 19.2V4z"/> stroke-width="2">
<path d="M4 6l8 6 8-6"/> <path d="M4 4h16v14H5.2L4 19.2V4z" />
<path d="M4 6l8 6 8-6" />
</svg> </svg>
<span class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $favCount ?? 0 }}</span> <span
class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $favCount ?? 0 }}</span>
</button> </button>
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5"> <button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 7h18s-3 0-3-7"/> stroke-width="2">
<path d="M13.7 21a2 2 0 01-3.4 0"/> <path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 7h18s-3 0-3-7" />
<path d="M13.7 21a2 2 0 01-3.4 0" />
</svg> </svg>
<span class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $msgCount ?? 0 }}</span> <span
class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $msgCount ?? 0 }}</span>
</button> </button>
<!-- User dropdown --> <!-- User dropdown -->
<div class="relative"> <div class="relative">
<button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5" data-dd="user"> <button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5" data-dd="user">
<img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10" <img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
src="{{ url('/avatar/' . ($userId ?? Auth::id() ?? 0) . '/' . ($avatar ? rawurlencode(basename($avatar)) : '1.png')) }}" src="{{ \App\Support\AvatarUrl::forUser((int) ($userId ?? (Auth::id() ?? 0)), $avatarHash ?? null, 64) }}"
alt="{{ $displayName ?? 'User' }}" /> alt="{{ $displayName ?? 'User' }}" />
<span class="text-sm text-white/90">{{ $displayName ?? 'User' }}</span> <span class="text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<path d="M6 9l6 6 6-6"/> stroke-width="2">
<path d="M6 9l6 6 6-6" />
</svg> </svg>
</button> </button>
<div id="dd-user" class="hidden absolute right-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden"> <div id="dd-user"
class="hidden absolute right-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden">
<div class="px-4 dd-section">My Account</div>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/upload"> <a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/upload">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i class="fa-solid fa-upload text-sb-muted"></i></span> <span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-upload text-sb-muted"></i></span>
Upload Upload
</a> </a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/my/gallery">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-image text-sb-muted"></i></span>
My Gallery
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/my/artworks"> <a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/my/artworks">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i class="fa-solid fa-pencil text-sb-muted"></i></span> <span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-pencil text-sb-muted"></i></span>
Edit Artworks Edit Artworks
</a> </a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/my/stats"> <a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/my/stats">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i class="fa-solid fa-chart-line text-sb-muted"></i></span> <span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-chart-line text-sb-muted"></i></span>
Statistics Statistics
</a> </a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/my/followers">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i class="fa-solid fa-user-group text-sb-muted"></i></span>
My Followers <div class="px-4 dd-section">Community</div>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/followers">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-user-group text-sb-muted"></i></span>
Followers
</a> </a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/my/following"> <a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/following">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i class="fa-solid fa-user-plus text-sb-muted"></i></span> <span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
Who I Follow class="fa-solid fa-user-plus text-sb-muted"></i></span>
Following
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/comments">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-comments text-sb-muted"></i></span>
Comments
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/favourites">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-heart text-sb-muted"></i></span>
Favourites
</a> </a>
<div class="h-px bg-panel/80 my-1"></div>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/my/comments"> <div class="px-4 dd-section">Community</div>
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i class="fa-solid fa-comments text-sb-muted"></i></span>
Received Comments
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/my/favourites">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i class="fa-solid fa-heart text-sb-muted"></i></span>
My Favourites
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/my/gallery">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i class="fa-solid fa-image text-sb-muted"></i></span>
My Gallery
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/settings">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i class="fa-solid fa-cog text-sb-muted"></i></span>
Edit Profile
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/profile"> <a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/profile">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i class="fa-solid fa-eye text-sb-muted"></i></span> <span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-eye text-sb-muted"></i></span>
View My Profile View My Profile
</a> </a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/user">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-cog text-sb-muted"></i></span>
Edit Profile
</a>
<div class="h-px bg-panel/80 my-1"></div> <div class="px-4 dd-section">System</div>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/logout"> <a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/logout">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i class="fa-solid fa-sign-out text-sb-muted"></i></span> <span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-sign-out text-sb-muted"></i></span>
Logout Logout
</a> </a>
</div> </div>
@@ -186,8 +211,10 @@
@else @else
<!-- Guest: show simple Join / Sign in links --> <!-- Guest: show simple Join / Sign in links -->
<div class="hidden md:flex items-center gap-3"> <div class="hidden md:flex items-center gap-3">
<a href="/signup" class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Join</a> <a href="/signup"
<a href="/login" class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Sign in</a> class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Join</a>
<a href="/login"
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Sign in</a>
</div> </div>
@endauth @endauth
</div> </div>

View File

@@ -1,115 +1,2 @@
@php {{-- Shim for legacy artwork card includes. Points to new web partial. --}}
// If a Collection or array was passed accidentally, pick the first item. @include('web.partials._artwork_card')
if (isset($art) && (is_array($art) || $art instanceof Illuminate\Support\Collection || $art instanceof Illuminate\Database\Eloquent\Collection)) {
$first = null;
if (is_array($art)) {
$first = reset($art);
} elseif ($art instanceof Illuminate\Support\Collection || $art instanceof Illuminate\Database\Eloquent\Collection) {
$first = $art->first();
}
if ($first) {
$art = $first;
}
}
$title = trim((string) ($art->name ?? $art->title ?? 'Untitled artwork'));
$author = trim((string) ($art->uname ?? $art->author_name ?? $art->author ?? 'Skinbase'));
$category = trim((string) ($art->category_name ?? $art->category ?? 'General'));
$license = trim((string) ($art->license ?? 'Standard'));
$resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : '')));
// Safe integer extractor: handle numeric, arrays, Collections, or relations
$safeInt = function ($v, $fallback = 0) {
if (is_numeric($v)) return (int) $v;
if (is_array($v)) return count($v);
if (is_object($v)) {
// Eloquent Collections implement Countable and have count()
if (method_exists($v, 'count')) return (int) $v->count();
if ($v instanceof Countable) return (int) count($v);
}
return (int) $fallback;
};
$likes = $safeInt($art->likes ?? $art->favourites ?? 0);
$downloads = $safeInt($art->downloads ?? $art->downloaded ?? 0);
$img_src = (string) ($art->thumb ?? $art->thumbnail_url ?? '/images/placeholder.jpg');
$img_srcset = (string) ($art->thumb_srcset ?? $art->thumbnail_srcset ?? $img_src);
$img_avif_srcset = (string) ($art->thumb_avif_srcset ?? $img_srcset);
$img_webp_srcset = (string) ($art->thumb_webp_srcset ?? $img_srcset);
// Width/height may be stored directly or as a related object; handle safely
$resolveDimension = function ($val, $fallback) {
if (is_numeric($val)) return (int) $val;
if (is_array($val)) {
$v = reset($val);
return is_numeric($v) ? (int) $v : (int) $fallback;
}
if (is_object($val)) {
if (method_exists($val, 'first')) {
$f = $val->first();
if (is_object($f) && isset($f->width)) return (int) ($f->width ?: $fallback);
if (is_object($f) && isset($f->height)) return (int) ($f->height ?: $fallback);
}
// Try numeric cast otherwise
if (isset($val->width)) return (int) $val->width;
if (isset($val->height)) return (int) $val->height;
}
return (int) $fallback;
};
$img_width = max(1, $resolveDimension($art->width ?? null, 800));
$img_height = max(1, $resolveDimension($art->height ?? null, 600));
$contentUrl = $img_src;
$cardUrl = (string) ($art->url ?? '#');
@endphp
<article class="nova-card artwork" itemscope itemtype="https://schema.org/ImageObject">
<meta itemprop="name" content="{{ $title }}">
<meta itemprop="contentUrl" content="{{ $contentUrl }}">
<meta itemprop="creator" content="{{ $author }}">
<meta itemprop="license" content="{{ $license }}">
<a href="{{ $cardUrl }}" itemprop="url" class="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70">
@if(!empty($category))
<div class="absolute left-3 top-3 z-30 rounded-md bg-black/55 px-2 py-1 text-xs text-white backdrop-blur-sm">{{ $category }}</div>
@endif
<div class="nova-card-media relative aspect-[16/10] overflow-hidden bg-neutral-900">
<picture>
<source srcset="{{ $img_avif_srcset }}" type="image/avif">
<source srcset="{{ $img_webp_srcset }}" type="image/webp">
<img
src="{{ $img_src }}"
srcset="{{ $img_srcset }}"
sizes="(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 25vw"
loading="lazy"
decoding="async"
alt="{{ e($title) }}"
width="{{ $img_width }}"
height="{{ $img_height }}"
class="h-full w-full object-cover transition-transform duration-200 ease-out group-hover:scale-[1.04]"
itemprop="thumbnailUrl"
/>
</picture>
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
<div class="truncate text-sm font-semibold text-white">{{ $title }}</div>
<div class="mt-1 flex items-center justify-between gap-3 text-xs text-white/80">
<span class="truncate">by {{ $author }}</span>
<span class="shrink-0"> {{ $likes }} · {{ $downloads }}</span>
</div>
<div class="mt-1 text-[11px] text-white/70">
@if($resolution !== '')
{{ $resolution }}
@endif
{{ $category }} {{ $license }}
</div>
</div>
</div>
<span class="sr-only">{{ $title }} by {{ $author }}</span>
</a>
</article>

View File

@@ -28,7 +28,7 @@
<div class="panel-body" style="display:flex; gap:12px;"> <div class="panel-body" style="display:flex; gap:12px;">
<div style="min-width:52px;"> <div style="min-width:52px;">
@if (!empty($post->user_id) && !empty($post->icon)) @if (!empty($post->user_id) && !empty($post->icon))
<img src="/avatar/{{ $post->user_id }}/{{ $post->icon }}" alt="{{ $post->uname }}" width="50" height="50" class="img-thumbnail"> <img src="{{ \App\Support\AvatarUrl::forUser((int) $post->user_id, null, 50) }}" alt="{{ $post->uname }}" width="50" height="50" class="img-thumbnail">
@else @else
<div class="img-thumbnail" style="width:50px;height:50px;"></div> <div class="img-thumbnail" style="width:50px;height:50px;"></div>
@endif @endif

View File

@@ -32,7 +32,7 @@
<tr> <tr>
<td rowspan="3" valign="top" width="100" style="background:#fff"> <td rowspan="3" valign="top" width="100" style="background:#fff">
@if(!empty($comment->user_id) && !empty($comment->icon)) @if(!empty($comment->user_id) && !empty($comment->icon))
<div align="center"><a href="/profile/{{ $comment->user_id }}"><img src="/avatar/{{ $comment->user_id }}/{{ \Illuminate\Support\Str::slug($comment->author ?? '') }}" width="50" height="50" border="0" alt="" /></a></div> <div align="center"><a href="/profile/{{ $comment->user_id }}"><img src="{{ \App\Support\AvatarUrl::forUser((int) $comment->user_id, null, 50) }}" width="50" height="50" border="0" alt="" /></a></div>
@endif @endif
<br/>Posted by: <b><a href="/profile/{{ $comment->user_id ?? '' }}">{{ $comment->author }}</a></b><br/> <br/>Posted by: <b><a href="/profile/{{ $comment->user_id ?? '' }}">{{ $comment->author }}</a></b><br/>
Posts: {{ $postCounts[$comment->author] ?? 0 }} Posts: {{ $postCounts[$comment->author] ?? 0 }}

View File

@@ -22,7 +22,7 @@
<td style="width:60px;"> <td style="width:60px;">
@if(!empty($interview->icon)) @if(!empty($interview->icon))
<a href="/profile/{{ $interview->user_id }}/{{ \Illuminate\Support\Str::slug($interview->uname ?? '') }}"> <a href="/profile/{{ $interview->user_id }}/{{ \Illuminate\Support\Str::slug($interview->uname ?? '') }}">
<img src="/avatar/{{ $interview->user_id }}/{{ $interview->icon }}" width="50" height="50" alt=""> <img src="{{ \App\Support\AvatarUrl::forUser((int) $interview->user_id, null, 50) }}" width="50" height="50" alt="">
</a> </a>
@else @else
<img src="/gfx/avatar.jpg" alt=""> <img src="/gfx/avatar.jpg" alt="">

View File

@@ -15,7 +15,7 @@
<div class="comment_box effect3"> <div class="comment_box effect3">
<div class="cb_image"> <div class="cb_image">
<a href="/profile/{{ $comment->commenter_id }}/{{ rawurlencode($comment->uname) }}"> <a href="/profile/{{ $comment->commenter_id }}/{{ rawurlencode($comment->uname) }}">
<img src="/avatar/{{ (int)$comment->commenter_id }}/{{ rawurlencode($comment->uname) }}" width="50" height="50" class="comment_avatar" alt="{{ $comment->uname }}"> <img src="{{ \App\Support\AvatarUrl::forUser((int) $comment->commenter_id, null, 50) }}" width="50" height="50" class="comment_avatar" alt="{{ $comment->uname }}">
</a> </a>
</div> </div>

View File

@@ -27,7 +27,7 @@
<tr> <tr>
<td width="50" class="text-center"> <td width="50" class="text-center">
<a href="/profile/{{ (int)$row->user_id }}/{{ rawurlencode($row->uname) }}"> <a href="/profile/{{ (int)$row->user_id }}/{{ rawurlencode($row->uname) }}">
<img src="/avatar/{{ (int)$row->user_id }}/{{ rawurlencode($row->uname) }}" width="30" alt="{{ $row->uname }}"> <img src="{{ \App\Support\AvatarUrl::forUser((int) $row->user_id, null, 32) }}" width="30" alt="{{ $row->uname }}">
</a> </a>
</td> </td>
<td> <td>

View File

@@ -28,7 +28,7 @@
<div> <div>
<a href="/profile/{{ $friendId }}/{{ Str::slug($uname) }}"> <a href="/profile/{{ $friendId }}/{{ Str::slug($uname) }}">
<img src="/avatar/{{ $friendId }}/{{ rawurlencode($icon) }}" alt="{{ $uname }}"> <img src="{{ \App\Support\AvatarUrl::forUser((int) $friendId, null, 50) }}" alt="{{ $uname }}">
</a> </a>
</div> </div>

View File

@@ -36,7 +36,7 @@
@if(!empty($ar->icon)) @if(!empty($ar->icon))
<div style="float:right;padding-left:20px;"> <div style="float:right;padding-left:20px;">
<a href="/profile/{{ $ar->user_id ?? '' }}/{{ Str::slug($ar->uname ?? '') }}"> <a href="/profile/{{ $ar->user_id ?? '' }}/{{ Str::slug($ar->uname ?? '') }}">
<img src="/avatar/{{ $ar->user_id ?? '' }}/{{ $ar->icon ?? '' }}" width="50" height="50" alt=""> <img src="{{ \App\Support\AvatarUrl::forUser((int) ($ar->user_id ?? 0), null, 50) }}" width="50" height="50" alt="">
</a> </a>
</div> </div>
@endif @endif

View File

@@ -34,7 +34,7 @@
<div class="panel panel-default effect2"> <div class="panel panel-default effect2">
<div class="panel-heading"><strong>User</strong></div> <div class="panel-heading"><strong>User</strong></div>
<div class="panel-body"> <div class="panel-body">
<img src="/avatar/{{ (int)$user->user_id }}/{{ rawurlencode($user->icon ?? '') }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname }}"> <img src="{{ \App\Support\AvatarUrl::forUser((int) $user->user_id, null, 128) }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname }}">
<h3>{{ $user->uname }}</h3> <h3>{{ $user->uname }}</h3>
<p>{{ $user->about_me ?? '' }}</p> <p>{{ $user->about_me ?? '' }}</p>
</div> </div>

View File

@@ -21,7 +21,7 @@
<div class="comment_box effect3"> <div class="comment_box effect3">
<div class="cb_image"> <div class="cb_image">
<a href="/profile/{{ (int)($author->id ?? $author->user_id) }}/{{ rawurlencode($author->name ?? $author->uname ?? '') }}"> <a href="/profile/{{ (int)($author->id ?? $author->user_id) }}/{{ rawurlencode($author->name ?? $author->uname ?? '') }}">
<img src="/avatar/{{ (int)($author->id ?? $author->user_id) }}/{{ rawurlencode($author->icon ?? '') }}" width="50" height="50" class="comment_avatar" alt="{{ $author->name ?? $author->uname ?? '' }}"> <img src="{{ \App\Support\AvatarUrl::forUser((int) ($author->id ?? $author->user_id), null, 50) }}" width="50" height="50" class="comment_avatar" alt="{{ $author->name ?? $author->uname ?? '' }}">
</a> </a>
</div> </div>

View File

@@ -119,9 +119,9 @@
} }
try { try {
$profile = \Illuminate\Support\Facades\DB::table('user_profiles')->where('user_id', $userId)->first(); $profile = \Illuminate\Support\Facades\DB::table('user_profiles')->where('user_id', $userId)->first();
$avatar = $profile->avatar ?? null; $avatarHash = $profile->avatar_hash ?? null;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$avatar = null; $avatarHash = null;
} }
$displayName = auth()->user()->name ?: (auth()->user()->username ?? ''); $displayName = auth()->user()->name ?: (auth()->user()->username ?? '');
@endphp @endphp
@@ -141,9 +141,7 @@
<li class="dropdown"> <li class="dropdown">
<a href="#" class="dropdown-toggle c-white" data-toggle="dropdown" data-hover="dropdown" data-close-others="true"> <a href="#" class="dropdown-toggle c-white" data-toggle="dropdown" data-hover="dropdown" data-close-others="true">
@if($avatar) <img src="{{ \App\Support\AvatarUrl::forUser((int) $userId, $avatarHash ?? null, 32) }}" alt="{{ $displayName }}" width="18">&nbsp;&nbsp;
<img src="/storage/{{ ltrim($avatar, '/') }}" alt="{{ $displayName }}" width="18">&nbsp;&nbsp;
@endif
<span class="username">{{ $displayName }}</span> <span class="username">{{ $displayName }}</span>
&nbsp;<i class="fa fa-angle-down"></i> &nbsp;<i class="fa fa-angle-down"></i>
</a> </a>

View File

@@ -27,7 +27,7 @@
<tr> <tr>
<td width="80"> <td width="80">
<a href="/profile/{{ $u->user_id }}/{{ \Illuminate\Support\Str::slug($u->uname) }}"> <a href="/profile/{{ $u->user_id }}/{{ \Illuminate\Support\Str::slug($u->uname) }}">
<img src="/avatar/{{ $u->user_id }}/{{ rawurlencode($u->icon ?? '') }}" width="30" height="30" alt="{{ $u->uname }}"> <img src="{{ \App\Support\AvatarUrl::forUser((int) $u->user_id, null, 32) }}" width="30" height="30" alt="{{ $u->uname }}">
</a> </a>
</td> </td>
<td> <td>
@@ -60,7 +60,7 @@
<tr> <tr>
<td width="60" align="center"> <td width="60" align="center">
<a href="/profile/{{ $f->user_id }}/{{ \Illuminate\Support\Str::slug($f->uname) }}"> <a href="/profile/{{ $f->user_id }}/{{ \Illuminate\Support\Str::slug($f->uname) }}">
<img src="/avatar/{{ $f->user_id }}/{{ rawurlencode($f->uname) }}" width="30" alt="{{ $f->uname }}"> <img src="{{ \App\Support\AvatarUrl::forUser((int) $f->user_id, null, 32) }}" width="30" alt="{{ $f->uname }}">
</a> </a>
</td> </td>
<td> <td>
@@ -92,7 +92,7 @@
<tr> <tr>
<td width="50" align="center"> <td width="50" align="center">
<a href="/profile/{{ $c->user_id }}/{{ \Illuminate\Support\Str::slug($c->uname) }}"> <a href="/profile/{{ $c->user_id }}/{{ \Illuminate\Support\Str::slug($c->uname) }}">
<img src="/avatar/{{ $c->user_id }}/{{ rawurlencode($c->uname) }}" width="30" alt="{{ $c->uname }}"> <img src="{{ \App\Support\AvatarUrl::forUser((int) $c->user_id, null, 32) }}" width="30" alt="{{ $c->uname }}">
</a> </a>
</td> </td>
<td> <td>

View File

@@ -2,15 +2,15 @@
@section('content') @section('content')
<div class="container"> <div class="container">
<h2>Edit Artwork: {{ $artwork->name }}</h2> <h2>Edit Artwork: {{ $artwork->title }}</h2>
<form enctype="multipart/form-data" method="post" action="{{ route('manage.update', $artwork->id) }}"> <form enctype="multipart/form-data" method="post" action="{{ route('manage.update', $artwork->id) }}">
@csrf @csrf
<div class="row"> <div class="row">
<div class="col-md-7"> <div class="col-md-7">
<label class="label-control">Name:</label> <label class="label-control">Title:</label>
<input type="text" name="name" class="form-control" value="{{ old('name', $artwork->name) }}"> <input type="text" name="title" class="form-control" value="{{ old('title', $artwork->title) }}">
<label class="label-control">Section:</label> <label class="label-control">Section:</label>
<select name="section" class="form-control"> <select name="section" class="form-control">

View File

@@ -33,9 +33,9 @@
<button class="btn btn-xs btn-danger">Delete</button> <button class="btn btn-xs btn-danger">Delete</button>
</form> </form>
</td> </td>
<td style="cursor:pointer;" onclick="location.href='{{ route('manage.edit', $ar->id) }}'">{{ $ar->name }}</td> <td style="cursor:pointer;" onclick="location.href='{{ route('manage.edit', $ar->id) }}'">{{ $ar->title }}</td>
<td>{{ $ar->categoryRelation->category_name ?? '' }}</td> <td>{{ $ar->category_name ?? ($ar->categoryRelation->category_name ?? '') }}</td>
<td class="text-center">{{ optional($ar->datum)->format('d.m.Y') ?? (is_string($ar->datum) ? date('d.m.Y', strtotime($ar->datum)) : '') }}</td> <td class="text-center">{{ optional($ar->published_at)->format('d.m.Y') ?? (is_string($ar->published_at) ? date('d.m.Y', strtotime($ar->published_at)) : '') }}</td>
<td class="text-center">{{ $ar->rating_num }}</td> <td class="text-center">{{ $ar->rating_num }}</td>
<td class="text-center">{{ $ar->rating }}</td> <td class="text-center">{{ $ar->rating }}</td>
<td class="text-center">{{ $ar->dls }}</td> <td class="text-center">{{ $ar->dls }}</td>

View File

@@ -1,3 +1,4 @@
<x-app-layout> <x-app-layout>
<x-slot name="header"> <x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight"> <h2 class="font-semibold text-xl text-gray-800 leading-tight">

View File

@@ -17,6 +17,17 @@
@csrf @csrf
@method('patch') @method('patch')
@php
$avatarHash = optional($user->profile)->avatar_hash;
$avatarInitialSrc = \App\Support\AvatarUrl::forUser((int) $user->id, $avatarHash, 128);
@endphp
<div
data-avatar-uploader="true"
data-upload-url="{{ route('avatar.upload') }}"
data-initial-src="{{ $avatarInitialSrc }}"
></div>
<div> <div>
<x-input-label for="name" :value="__('Name')" /> <x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" /> <x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" />

View File

@@ -27,7 +27,7 @@
<div> <div>
<a href="/profile/{{ $followerId }}/{{ Str::slug($uname) }}"> <a href="/profile/{{ $followerId }}/{{ Str::slug($uname) }}">
<img src="/avatar/{{ $followerId }}/{{ rawurlencode($icon) }}" alt="{{ $uname }}"> <img src="{{ \App\Support\AvatarUrl::forUser((int) $followerId, null, 50) }}" alt="{{ $uname }}">
</a> </a>
</div> </div>
</div> </div>

View File

@@ -11,7 +11,7 @@
</div> </div>
<div style="clear:both;margin-top:10px;"> <div style="clear:both;margin-top:10px;">
<img src="/avatar/{{ $artwork->user_id ?? 0 }}/{{ urlencode($artwork->icon ?? '') }}" class="pull-left" style="padding-right:10px;max-height:50px;" alt="Avatar"> <img src="{{ \App\Support\AvatarUrl::forUser((int) ($artwork->user_id ?? 0), null, 50) }}" class="pull-left" style="padding-right:10px;max-height:50px;" alt="Avatar">
<h1 class="page-header">{{ $artwork->name }}</h1> <h1 class="page-header">{{ $artwork->name }}</h1>
<p>By <i class="fa fa-user fa-fw"></i> <a href="/profile/{{ $artwork->user_id }}/{{ \Illuminate\Support\Str::slug($artwork->uname) }}" title="Profile of member {{ $artwork->uname }}">{{ $artwork->uname }}</a></p> <p>By <i class="fa fa-user fa-fw"></i> <a href="/profile/{{ $artwork->user_id }}/{{ \Illuminate\Support\Str::slug($artwork->uname) }}" title="Profile of member {{ $artwork->uname }}">{{ $artwork->uname }}</a></p>
<hr> <hr>
@@ -30,7 +30,7 @@
<div class="comment_box effect3"> <div class="comment_box effect3">
<div class="cb_image"> <div class="cb_image">
<a href="/profile/{{ $comment->user_id }}/{{ urlencode($comment->uname) }}"> <a href="/profile/{{ $comment->user_id }}/{{ urlencode($comment->uname) }}">
<img src="/avatar/{{ $comment->user_id }}/{{ urlencode($comment->icon) }}" width="50" height="50" class="comment_avatar" alt="{{ $comment->uname }}"> <img src="{{ \App\Support\AvatarUrl::forUser((int) $comment->user_id, null, 50) }}" width="50" height="50" class="comment_avatar" alt="{{ $comment->uname }}">
</a> </a>
</div> </div>
<div class="bubble_comment panel panel-skinbase"> <div class="bubble_comment panel panel-skinbase">
@@ -53,7 +53,7 @@
<div class="comment_box effect3"> <div class="comment_box effect3">
<div class="cb_image"> <div class="cb_image">
<a href="/profile/{{ auth()->id() }}/{{ urlencode(auth()->user()->name) }}"> <a href="/profile/{{ auth()->id() }}/{{ urlencode(auth()->user()->name) }}">
<img src="/avatar/{{ auth()->id() }}/{{ urlencode(auth()->user()->avatar ?? '') }}" class="comment_avatar" width="50" height="50"> <img src="{{ \App\Support\AvatarUrl::forUser((int) auth()->id(), null, 50) }}" class="comment_avatar" width="50" height="50">
</a> </a>
<br> <br>
<a href="/profile/{{ auth()->id() }}/{{ urlencode(auth()->user()->name) }}">{{ auth()->user()->name }}</a> <a href="/profile/{{ auth()->id() }}/{{ urlencode(auth()->user()->name) }}">{{ auth()->user()->name }}</a>

View File

@@ -33,7 +33,7 @@
@if ($artworks->count()) @if ($artworks->count())
<div class="container_photo gallery_box"> <div class="container_photo gallery_box">
@foreach ($artworks as $art) @foreach ($artworks as $art)
@include('legacy._artwork_card', ['art' => $art]) @include('web.partials._artwork_card', ['art' => $art])
@endforeach @endforeach
</div> </div>
@else @else

View File

@@ -19,7 +19,7 @@
</ul> </ul>
<div id="myContent"> <div id="myContent">
@include('legacy.partials.daily-uploads-grid', ['arts' => $recent]) @include('web.partials.daily-uploads-grid', ['arts' => $recent])
</div> </div>
</div> </div>
</div> </div>

View File

@@ -34,7 +34,7 @@
'category_name' => $art->category_name ?? '', 'category_name' => $art->category_name ?? '',
]; ];
@endphp @endphp
@include('legacy._artwork_card', ['art' => $card]) @include('web.partials._artwork_card', ['art' => $card])
@endforeach @endforeach
</div> </div>
@else @else

View File

@@ -34,7 +34,7 @@
<div class="panel panel-default effect2"> <div class="panel panel-default effect2">
<div class="panel-heading"><strong>User</strong></div> <div class="panel-heading"><strong>User</strong></div>
<div class="panel-body"> <div class="panel-body">
<img src="/avatar/{{ (int)($user->user_id ?? $user->id) }}/{{ rawurlencode($user->icon ?? '') }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname ?? $user->name }}"> <img src="{{ \App\Support\AvatarUrl::forUser((int) ($user->user_id ?? $user->id), null, 128) }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname ?? $user->name }}">
<h3>{{ $user->uname ?? $user->name }}</h3> <h3>{{ $user->uname ?? $user->name }}</h3>
<p>{{ $user->about_me ?? '' }}</p> <p>{{ $user->about_me ?? '' }}</p>
</div> </div>

View File

@@ -0,0 +1,17 @@
@extends('layouts.nova')
@php
use Illuminate\Support\Str;
use Carbon\Carbon;
use App\Services\LegacyService;
@endphp
@section('content')
<div class="container-fluid legacy-page">
@include('web.home.featured')
@include('web.home.uploads')
@include('web.home.news')
</div>
@endsection

View File

@@ -0,0 +1,47 @@
{{-- Featured row (migrated from legacy/home/featured.blade.php) --}}
<div class="row featured-row">
<div class="col-md-4 col-sm-12">
<div class="featured-card effect2">
<div class="card-header">Featured Artwork</div>
<div class="card-body text-center">
<a href="/art/{{ data_get($featured, 'id') }}/{{ Str::slug(data_get($featured, 'name') ?? 'artwork') }}" class="thumb-link">
@php
$fthumb = data_get($featured, 'thumb_url') ?? data_get($featured, 'thumb');
@endphp
<img src="{{ $fthumb }}" class="img-responsive featured-img" alt="{{ data_get($featured, 'name') }}">
</a>
<div class="featured-title">{{ data_get($featured, 'name') }}</div>
<div class="featured-author">by {{ data_get($featured, 'uname') }}</div>
</div>
</div>
</div>
<div class="col-md-4 col-sm-12">
<div class="featured-card effect2">
<div class="card-header">Featured by Members Vote</div>
<div class="card-body text-center">
<a href="/art/{{ data_get($memberFeatured, 'id') }}/{{ Str::slug(data_get($memberFeatured, 'name') ?? 'artwork') }}" class="thumb-link">
@php
$mthumb = data_get($memberFeatured, 'thumb_url') ?? data_get($memberFeatured, 'thumb');
@endphp
<img src="{{ $mthumb }}" class="img-responsive featured-img" alt="{{ data_get($memberFeatured, 'name') }}">
</a>
<div class="featured-title">{{ data_get($memberFeatured, 'name') }}</div>
<div class="featured-author">by {{ data_get($memberFeatured, 'uname') }}</div>
</div>
</div>
</div>
<div class="col-md-4 col-sm-12">
<div class="featured-card join-card effect2">
<div class="card-header">Join to Skinbase World</div>
<div class="card-body text-center">
<a href="{{ route('register') }}" title="Join Skinbase">
<img src="/gfx/sb_join.jpg" alt="Join SkinBase Community" class="img-responsive join-img center-block">
</a>
<div class="join-text">Join to Skinbase and be part of our great community! We have big collection of high quality Photography, Wallpapers and Skins for popular applications.</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,80 @@
{{-- News and forum columns (migrated from legacy/home/news.blade.php) --}}
<div class="row news-row">
<div class="col-sm-6">
@forelse ($forumNews as $item)
<div class="panel panel-skinbase effect2">
<div class="panel-heading"><h4 class="panel-title">{{ $item->topic }}</h4></div>
<div class="panel-body">
<div class="text-muted news-head">
Written by {{ $item->uname }} on {{ Carbon::parse($item->post_date)->format('j F Y \@ H:i') }}
</div>
{!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!}
<br>
<a class="clearfix btn btn-xs btn-info" href="/forum/{{ $item->topic_id }}/{{ Str::slug($item->topic ?? '') }}" title="{{ strip_tags($item->topic) }}">More</a>
</div>
</div>
@empty
<p>No forum news available.</p>
@endforelse
</div>
<div class="col-sm-6">
@forelse ($ourNews as $news)
<div class="panel panel-skinbase effect2">
<div class="panel-heading"><h3 class="panel-title">{{ $news->headline }}</h3></div>
<div class="panel-body">
<div class="text-muted news-head">
<i class="fa fa-user"></i> {{ $news->uname }}
<i class="fa fa-calendar"></i> {{ Carbon::parse($news->create_date)->format('j F Y \@ H:i') }}
<i class="fa fa-info"></i> {{ $news->category_name }}
<i class="fa fa-info"></i> {{ $news->views }} reads
<i class="fa fa-comment"></i> {{ $news->num_comments }} comments
</div>
@if (!empty($news->picture))
@php $nid = floor($news->news_id / 100); @endphp
<div class="col-md-4">
<img src="/archive/news/{{ $nid }}/{{ $news->picture }}" class="img-responsive" alt="{{ $news->headline }}">
</div>
<div class="col-md-8">
{!! $news->preview !!}
</div>
@else
{!! $news->preview !!}
@endif
<a class="clearfix btn btn-xs btn-info text-white" href="/news/{{ $news->news_id }}/{{ Str::slug($news->headline ?? '') }}">More</a>
</div>
</div>
@empty
<p>No news available.</p>
@endforelse
{{-- Site info --}}
<div class="panel panel-default">
<div class="panel-heading"><strong>Info</strong></div>
<div class="panel-body">
<h4>Photography, Wallpapers and Skins... Thats Skinbase</h4>
<p>Skinbase is the site dedicated to <strong>Photography</strong>, <strong>Wallpapers</strong> and <strong>Skins</strong> for <u>popular applications</u> for every major operating system like Windows, Mac OS X, Linux, iOS and Android</p>
<em>Our members every day uploads new artworks to our site, so don&apos;t hesitate and check Skinbase frequently for updates. We also have forum where you can discuss with other members with anything.</em>
<p>On the site toolbar you can click on Categories and start browsing our atwork (<i>photo</i>, <i>desktop themes</i>, <i>pictures</i>) and of course you can <u>download</u> them for free!</p>
<p>We are also active on all major <b>social</b> sites, find us there too</p>
</div>
</div>
{{-- Latest forum activity --}}
<div class="panel panel-default activity-panel">
<div class="panel-heading"><strong>Latest Forum Activity</strong></div>
<div class="panel-body">
<div class="list-group effect2">
@forelse ($latestForumActivity as $topic)
<a class="list-group-item" href="/forum/{{ $topic->topic_id }}/{{ Str::slug($topic->topic ?? '') }}">
{{ $topic->topic }} <span class="badge badge-info">{{ $topic->numPosts }}</span>
</a>
@empty
<p>No recent forum activity.</p>
@endforelse
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,14 @@
{{-- Latest uploads grid (migrated from legacy/home/uploads.blade.php) --}}
<div class="gallery-grid">
@foreach ($latestUploads as $upload)
<div class="thumb-card effect2">
@php
$t = \App\Services\ThumbnailPresenter::present($upload, 'md');
@endphp
<a href="/art/{{ $t['id'] }}/{{ Str::slug($t['title'] ?: 'artwork') }}" title="{{ $t['title'] }}" class="thumb-link">
<img src="{{ $t['url'] }}" @if(!empty($t['srcset'])) srcset="{{ $t['srcset'] }}" @endif alt="{{ $t['title'] }}" class="img-responsive">
</a>
</div>
@endforeach
</div> <!-- end .gallery-grid -->

View File

@@ -0,0 +1,112 @@
@php
// If a Collection or array was passed accidentally, pick the first item.
if (isset($art) && (is_array($art) || $art instanceof Illuminate\Support\Collection || $art instanceof Illuminate\Database\Eloquent\Collection)) {
$first = null;
if (is_array($art)) {
$first = reset($art);
} elseif ($art instanceof Illuminate\Support\Collection || $art instanceof Illuminate\Database\Eloquent\Collection) {
$first = $art->first();
}
if ($first) {
$art = $first;
}
}
$title = trim((string) ($art->name ?? $art->title ?? 'Untitled artwork'));
$author = trim((string) ($art->uname ?? $art->author_name ?? $art->author ?? 'Skinbase'));
$category = trim((string) ($art->category_name ?? $art->category ?? 'General'));
$license = trim((string) ($art->license ?? 'Standard'));
$resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : '')));
// Safe integer extractor: handle numeric, arrays, Collections, or relations
$safeInt = function ($v, $fallback = 0) {
if (is_numeric($v)) return (int) $v;
if (is_array($v)) return count($v);
if (is_object($v)) {
if (method_exists($v, 'count')) return (int) $v->count();
if ($v instanceof Countable) return (int) count($v);
}
return (int) $fallback;
};
$likes = $safeInt($art->likes ?? $art->favourites ?? 0);
$downloads = $safeInt($art->downloads ?? $art->downloaded ?? 0);
$img_src = (string) ($art->thumb ?? $art->thumbnail_url ?? '/images/placeholder.jpg');
$img_srcset = (string) ($art->thumb_srcset ?? $art->thumbnail_srcset ?? $img_src);
$img_avif_srcset = (string) ($art->thumb_avif_srcset ?? $img_srcset);
$img_webp_srcset = (string) ($art->thumb_webp_srcset ?? $img_srcset);
$resolveDimension = function ($val, $fallback) {
if (is_numeric($val)) return (int) $val;
if (is_array($val)) {
$v = reset($val);
return is_numeric($v) ? (int) $v : (int) $fallback;
}
if (is_object($val)) {
if (method_exists($val, 'first')) {
$f = $val->first();
if (is_object($f) && isset($f->width)) return (int) ($f->width ?: $fallback);
if (is_object($f) && isset($f->height)) return (int) ($f->height ?: $fallback);
}
if (isset($val->width)) return (int) $val->width;
if (isset($val->height)) return (int) $val->height;
}
return (int) $fallback;
};
$img_width = max(1, $resolveDimension($art->width ?? null, 800));
$img_height = max(1, $resolveDimension($art->height ?? null, 600));
$contentUrl = $img_src;
$cardUrl = (string) ($art->url ?? '#');
@endphp
<article class="nova-card artwork" itemscope itemtype="https://schema.org/ImageObject">
<meta itemprop="name" content="{{ $title }}">
<meta itemprop="contentUrl" content="{{ $contentUrl }}">
<meta itemprop="creator" content="{{ $author }}">
<meta itemprop="license" content="{{ $license }}">
<a href="{{ $cardUrl }}" itemprop="url" class="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70">
@if(!empty($category))
<div class="absolute left-3 top-3 z-30 rounded-md bg-black/55 px-2 py-1 text-xs text-white backdrop-blur-sm">{{ $category }}</div>
@endif
<div class="nova-card-media relative aspect-[16/10] overflow-hidden bg-neutral-900">
<picture>
<source srcset="{{ $img_avif_srcset }}" type="image/avif">
<source srcset="{{ $img_webp_srcset }}" type="image/webp">
<img
src="{{ $img_src }}"
srcset="{{ $img_srcset }}"
sizes="(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 25vw"
loading="lazy"
decoding="async"
alt="{{ e($title) }}"
width="{{ $img_width }}"
height="{{ $img_height }}"
class="h-full w-full object-cover transition-transform duration-200 ease-out group-hover:scale-[1.04]"
itemprop="thumbnailUrl"
/>
</picture>
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
<div class="truncate text-sm font-semibold text-white">{{ $title }}</div>
<div class="mt-1 flex items-center justify-between gap-3 text-xs text-white/80">
<span class="truncate">by {{ $author }}</span>
<span class="shrink-0"> {{ $likes }} · {{ $downloads }}</span>
</div>
<div class="mt-1 text-[11px] text-white/70">
@if($resolution !== '')
{{ $resolution }}
@endif
{{ $category }} {{ $license }}
</div>
</div>
</div>
<span class="sr-only">{{ $title }} by {{ $author }}</span>
</a>
</article>

Some files were not shown because too many files have changed in this diff Show More