From 09eadf9003ed2aa9394ec4968edd6cfd374ba06b Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Fri, 27 Feb 2026 11:31:32 +0100 Subject: [PATCH] feat(artwork): sidebar layout, icon actions, original download URL fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ArtworkDownloadController: fix resolveDownloadUrl() to use correct CDN path: original/{h1}/{h2}/{hash}.{file_ext} (was wrong originals/h1/h2/h3/orig.webp) Wrap incrementDownloads() in try/catch so Redis failure can't break the response - ArtworkPage: move ArtworkAuthor from left column to right sidebar Sidebar now stacks: Author → Actions → Awards (sticky top-24) Mobile block follows same order above main content - ArtworkActions: replace four stacked text buttons with a compact 4-col icon grid Like (heart, rose when active), Save (star, amber when active), Share (network icon), Report (flag icon, red on hover) Download remains full-width orange CTA - ArtworkAuthor: add icons to Profile (person) and Follow buttons Follow shows circle-check icon; Following state shows user-plus icon --- .../Api/ArtworkDownloadController.php | 42 ++++- resources/js/Pages/ArtworkPage.jsx | 15 +- resources/js/Pages/Home/HomePage.jsx | 8 +- .../js/components/artwork/ArtworkActions.jsx | 172 +++++++++++++----- .../js/components/artwork/ArtworkAuthor.jsx | 23 ++- 5 files changed, 190 insertions(+), 70 deletions(-) diff --git a/app/Http/Controllers/Api/ArtworkDownloadController.php b/app/Http/Controllers/Api/ArtworkDownloadController.php index 5748a09a..08d247cc 100644 --- a/app/Http/Controllers/Api/ArtworkDownloadController.php +++ b/app/Http/Controllers/Api/ArtworkDownloadController.php @@ -11,6 +11,7 @@ use App\Services\ThumbnailPresenter; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; /** * POST /api/art/{id}/download @@ -48,12 +49,26 @@ final class ArtworkDownloadController extends Controller $this->recordDownload($request, $artwork); // Increment counters — deferred via Redis when available. - $this->stats->incrementDownloads((int) $artwork->id, 1, defer: true); + try { + $this->stats->incrementDownloads((int) $artwork->id, 1, defer: true); + } catch (\Throwable) { + // Stats failure must never interrupt the download. + } // Resolve the highest-resolution download URL available. $url = $this->resolveDownloadUrl($artwork); - return response()->json(['ok' => true, 'url' => $url]); + // Build a user-friendly download filename: "title-slug.file_ext" + $ext = $artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'; + $slug = Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id; + $filename = $slug . '.' . $ext; + + return response()->json([ + 'ok' => true, + 'url' => $url, + 'filename' => $filename, + 'size' => (int) ($artwork->file_size ?? 0), + ]); } /** @@ -80,17 +95,34 @@ final class ArtworkDownloadController extends Controller } /** - * Resolve the best available download URL: XL → LG → MD. - * Returns an empty string if no thumbnail can be resolved. + * Resolve the original full-resolution CDN URL. + * + * Originals are stored at: {cdn}/original/{h1}/{h2}/{hash}.{file_ext} + * h1 = first 2 chars of hash, h2 = next 2 chars, filename = full hash + file_ext. + * Falls back to XL → LG → MD thumbnail when hash is unavailable. */ private function resolveDownloadUrl(Artwork $artwork): string { + $hash = $artwork->hash ?? null; + $ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.'); + + if (!empty($hash)) { + $h = strtolower(preg_replace('/[^a-f0-9]/', '', $hash)); + $h1 = substr($h, 0, 2); + $h2 = substr($h, 2, 2); + $cdn = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'); + + return sprintf('%s/original/%s/%s/%s.%s', $cdn, $h1, $h2, $h, $ext); + } + + // Fallback: best available thumbnail size foreach (['xl', 'lg', 'md'] as $size) { $thumb = ThumbnailPresenter::present($artwork, $size); - if (! empty($thumb['url'])) { + if (!empty($thumb['url'])) { return (string) $thumb['url']; } } + return ''; } } diff --git a/resources/js/Pages/ArtworkPage.jsx b/resources/js/Pages/ArtworkPage.jsx index e6a80ad0..71f23760 100644 --- a/resources/js/Pages/ArtworkPage.jsx +++ b/resources/js/Pages/ArtworkPage.jsx @@ -67,17 +67,15 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present onNext={navState.navigateNext} /> -
+
+ -
- -
+
- @@ -91,11 +89,10 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
diff --git a/resources/js/Pages/Home/HomePage.jsx b/resources/js/Pages/Home/HomePage.jsx index 94432c6e..c7c52323 100644 --- a/resources/js/Pages/Home/HomePage.jsx +++ b/resources/js/Pages/Home/HomePage.jsx @@ -86,14 +86,14 @@ function AuthHomePage(props) { return ( <> - {/* P0. Welcome/status row */} + {/* 1. Hero — flush to top */} + + + {/* P0. Welcome/status row — below hero so featured image sits at 0px */} - {/* 1. Hero */} - - {/* P2. From Creators You Follow */} }> diff --git a/resources/js/components/artwork/ArtworkActions.jsx b/resources/js/components/artwork/ArtworkActions.jsx index c30327a6..2afd6300 100644 --- a/resources/js/components/artwork/ArtworkActions.jsx +++ b/resources/js/components/artwork/ArtworkActions.jsx @@ -4,7 +4,9 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked)) const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited)) const [reporting, setReporting] = useState(false) - const downloadUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.file?.url || '#' + const [downloading, setDownloading] = useState(false) + // Fallback URL used only if the API call fails entirely + const fallbackUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.file?.url || '#' const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#') const csrfToken = typeof document !== 'undefined' ? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') @@ -23,14 +25,40 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = }).catch(() => {}) }, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps - // Fire-and-forget download tracking — does not interrupt the native download. - const trackDownload = () => { - if (!artwork?.id) return - fetch(`/api/art/${artwork.id}/download`, { - method: 'POST', - headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' }, - credentials: 'same-origin', - }).catch(() => {}) + /** + * Async download handler: + * 1. POST /api/art/{id}/download → records the event, returns { url, filename } + * 2. Programmatically clicks a hidden to trigger the save dialog + * 3. Falls back to the pre-resolved fallbackUrl if the API is unreachable + */ + const handleDownload = async (e) => { + e.preventDefault() + if (downloading || !artwork?.id) return + setDownloading(true) + try { + const res = await fetch(`/api/art/${artwork.id}/download`, { + method: 'POST', + headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' }, + credentials: 'same-origin', + }) + const data = res.ok ? await res.json() : null + const url = data?.url || fallbackUrl + const filename = data?.filename || '' + + // Trigger browser save-dialog with the correct filename + const a = document.createElement('a') + a.href = url + a.download = filename + a.rel = 'noopener noreferrer' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + } catch { + // API unreachable — open the best available URL directly + window.open(fallbackUrl, '_blank', 'noopener,noreferrer') + } finally { + setDownloading(false) + } } const postInteraction = async (url, body) => { @@ -102,58 +130,104 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =

Actions

- - Download - - + {/* Download — full-width primary CTA */} - + {/* Secondary actions — icon row */} +
+ {/* Like */} + - + {/* Favorite */} + - + {/* Share */} + + + {/* Report */} + +
{mobilePriority && (
- - Download - + {downloading ? 'Downloading…' : 'Download'} +
)}
diff --git a/resources/js/components/artwork/ArtworkAuthor.jsx b/resources/js/components/artwork/ArtworkAuthor.jsx index 76d639f9..0be18747 100644 --- a/resources/js/components/artwork/ArtworkAuthor.jsx +++ b/resources/js/components/artwork/ArtworkAuthor.jsx @@ -66,16 +66,33 @@ export default function ArtworkAuthor({ artwork, presentSq }) {
+ + + Profile