Upload beautify
This commit is contained in:
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
app/Http/Controllers/Community/ChatController.php
Normal file
44
app/Http/Controllers/Community/ChatController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Http/Controllers/Community/ForumController.php
Normal file
38
app/Http/Controllers/Community/ForumController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Http/Controllers/Community/InterviewController.php
Normal file
103
app/Http/Controllers/Community/InterviewController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Http/Controllers/Community/LatestCommentsController.php
Normal file
54
app/Http/Controllers/Community/LatestCommentsController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Controllers/Community/LatestController.php
Normal file
47
app/Http/Controllers/Community/LatestController.php
Normal 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Http/Controllers/Community/NewsController.php
Normal file
44
app/Http/Controllers/Community/NewsController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
103
app/Http/Controllers/InterviewController.php
Normal file
103
app/Http/Controllers/InterviewController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Controllers/InterviewsController.php
Normal file
28
app/Http/Controllers/InterviewsController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
55
app/Http/Controllers/Misc/AvatarController.php
Normal file
55
app/Http/Controllers/Misc/AvatarController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/Http/Controllers/PhotographyController.php
Normal file
101
app/Http/Controllers/PhotographyController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Http/Controllers/User/AvatarController.php
Normal file
59
app/Http/Controllers/User/AvatarController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Http/Controllers/User/BuddiesController.php
Normal file
37
app/Http/Controllers/User/BuddiesController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
143
app/Http/Controllers/User/FavouritesController.php
Normal file
143
app/Http/Controllers/User/FavouritesController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Controllers/User/MembersController.php
Normal file
49
app/Http/Controllers/User/MembersController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Http/Controllers/User/MonthlyCommentatorsController.php
Normal file
39
app/Http/Controllers/User/MonthlyCommentatorsController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Http/Controllers/User/MyBuddiesController.php
Normal file
56
app/Http/Controllers/User/MyBuddiesController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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([
|
||||||
27
app/Http/Controllers/User/ReceivedCommentsController.php
Normal file
27
app/Http/Controllers/User/ReceivedCommentsController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Http/Controllers/User/StatisticsController.php
Normal file
66
app/Http/Controllers/User/StatisticsController.php
Normal 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/Http/Controllers/User/TodayDownloadsController.php
Normal file
64
app/Http/Controllers/User/TodayDownloadsController.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Http/Controllers/User/TodayInHistoryController.php
Normal file
53
app/Http/Controllers/User/TodayInHistoryController.php
Normal 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Http/Controllers/User/TopAuthorsController.php
Normal file
57
app/Http/Controllers/User/TopAuthorsController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Http/Controllers/User/TopFavouritesController.php
Normal file
56
app/Http/Controllers/User/TopFavouritesController.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Controllers/User/UserController.php
Normal file
27
app/Http/Controllers/User/UserController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Http/Controllers/Web/ArtController.php
Normal file
61
app/Http/Controllers/Web/ArtController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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, '/'),
|
||||||
100
app/Http/Controllers/Web/CategoryController.php
Normal file
100
app/Http/Controllers/Web/CategoryController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/Http/Controllers/Web/DailyUploadsController.php
Normal file
94
app/Http/Controllers/Web/DailyUploadsController.php
Normal 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',
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Http/Controllers/Web/FeaturedArtworksController.php
Normal file
62
app/Http/Controllers/Web/FeaturedArtworksController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Http/Controllers/Web/GalleryController.php
Normal file
41
app/Http/Controllers/Web/GalleryController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Http/Controllers/Web/HomeController.php
Normal file
80
app/Http/Controllers/Web/HomeController.php
Normal 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'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Requests/AvatarUploadRequest.php
Normal file
27
app/Http/Requests/AvatarUploadRequest.php
Normal 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',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
50
app/Support/AvatarUrl.php
Normal 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
1185
avatar_patch.diff
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
321
composer.lock
generated
@@ -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
7
config/avatars.php
Normal 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),
|
||||||
|
];
|
||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
32
docs/avatar-cdn-config-notes.md
Normal file
32
docs/avatar-cdn-config-notes.md
Normal 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
64
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
19
playwright.config.ts
Normal 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
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
BIN
public/gfx/sb_logo2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
public/gfx/sb_logo_white.png
Normal file
BIN
public/gfx/sb_logo_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
198
resources/js/components/profile/AvatarUploader.jsx
Normal file
198
resources/js/components/profile/AvatarUploader.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
50
resources/views/layouts/_legacy.blade.php
Normal file
50
resources/views/layouts/_legacy.blade.php
Normal 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>
|
||||||
@@ -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>© 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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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="">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
<img src="/storage/{{ ltrim($avatar, '/') }}" alt="{{ $displayName }}" width="18">
|
|
||||||
@endif
|
|
||||||
<span class="username">{{ $displayName }}</span>
|
<span class="username">{{ $displayName }}</span>
|
||||||
<i class="fa fa-angle-down"></i>
|
<i class="fa fa-angle-down"></i>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
17
resources/views/web/home.blade.php
Normal file
17
resources/views/web/home.blade.php
Normal 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
|
||||||
47
resources/views/web/home/featured.blade.php
Normal file
47
resources/views/web/home/featured.blade.php
Normal 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>
|
||||||
|
|
||||||
80
resources/views/web/home/news.blade.php
Normal file
80
resources/views/web/home/news.blade.php
Normal 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'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>
|
||||||
14
resources/views/web/home/uploads.blade.php
Normal file
14
resources/views/web/home/uploads.blade.php
Normal 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 -->
|
||||||
|
|
||||||
112
resources/views/web/partials/_artwork_card.blade.php
Normal file
112
resources/views/web/partials/_artwork_card.blade.php
Normal 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
Reference in New Issue
Block a user