more fixes

This commit is contained in:
2026-03-12 07:22:38 +01:00
parent 547215cbe8
commit 4f576ceb04
226 changed files with 14380 additions and 4453 deletions

View File

@@ -102,9 +102,12 @@ function StatsCard({ stats, followerCount, user, onTabChange }) {
function AboutCard({ user, profile, socialLinks, countryName }) {
const bio = profile?.bio || profile?.about || profile?.description
const website = profile?.website || user?.website
const joined = user?.created_at
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
: null
const hasSocials = socialLinks && Object.keys(socialLinks).length > 0
const hasContent = bio || countryName || website || hasSocials
const hasContent = bio || countryName || website || joined || hasSocials
if (!hasContent) return null
@@ -119,12 +122,21 @@ function AboutCard({ user, profile, socialLinks, countryName }) {
{countryName && (
<div className="flex items-center gap-2 text-[13px] text-slate-400">
<i className="fa-solid fa-location-dot fa-fw text-slate-600 text-xs" />
<span>{countryName}</span>
<span className="text-slate-500">Location</span>
<span className="text-slate-300">{countryName}</span>
</div>
)}
{joined && (
<div className="flex items-center gap-2 text-[13px] text-slate-400">
<i className="fa-solid fa-calendar-days fa-fw text-slate-600 text-xs" />
<span className="text-slate-500">Joined</span>
<span className="text-slate-300">{joined}</span>
</div>
)}
{website && (
<div className="flex items-center gap-2 text-[13px]">
<i className="fa-solid fa-link fa-fw text-slate-600 text-xs" />
<span className="text-slate-500">Website</span>
<a
href={website.startsWith('http') ? website : `https://${website}`}
target="_blank"
@@ -365,13 +377,6 @@ export default function FeedSidebar({
}) {
return (
<div className="space-y-4">
<StatsCard
stats={stats}
followerCount={followerCount}
user={user}
onTabChange={onTabChange}
/>
<AboutCard
user={user}
profile={profile}
@@ -379,6 +384,13 @@ export default function FeedSidebar({
countryName={countryName}
/>
<StatsCard
stats={stats}
followerCount={followerCount}
user={user}
onTabChange={onTabChange}
/>
<RecentFollowersCard
recentFollowers={recentFollowers}
followerCount={followerCount}

View File

@@ -196,7 +196,7 @@ export default function PostComposer({ user, onPosted }) {
loading="lazy"
/>
<span className="text-sm text-slate-500 flex-1 bg-white/[0.04] rounded-xl px-4 py-2.5 hover:bg-white/[0.07] transition-colors">
What's on your mind, {user.name?.split(' ')[0] ?? user.username}?
Share an update with your followers.
</span>
</div>
) : (
@@ -229,7 +229,7 @@ export default function PostComposer({ user, onPosted }) {
onChange={handleBodyChange}
maxLength={2000}
rows={3}
placeholder="What's on your mind?"
placeholder="Share an update with your followers."
autoFocus
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
/>

View File

@@ -201,7 +201,6 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
setFavorited(Boolean(artwork?.viewer?.is_favorited))
}, [artwork?.id, artwork?.viewer?.is_favorited])
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')
@@ -236,22 +235,14 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
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 a = document.createElement('a')
a.href = url
a.download = data?.filename || ''
a.href = `/download/artwork/${artwork.id}`
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
} catch {
window.open(fallbackUrl, '_blank', 'noopener,noreferrer')
window.open(`/download/artwork/${artwork.id}`, '_blank', 'noopener,noreferrer')
} finally {
setDownloading(false)
}

View File

@@ -5,8 +5,6 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
const [reporting, setReporting] = useState(false)
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')
@@ -30,37 +28,20 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
}).catch(() => {})
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
/**
* Async download handler:
* 1. POST /api/art/{id}/download → records the event, returns { url, filename }
* 2. Programmatically clicks a hidden <a download="filename"> to trigger the save dialog
* 3. Falls back to the pre-resolved fallbackUrl if the API is unreachable
*/
// Download through the secure Laravel route so original files are never exposed directly.
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.href = `/download/artwork/${artwork.id}`
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')
window.open(`/download/artwork/${artwork.id}`, '_blank', 'noopener,noreferrer')
} finally {
setDownloading(false)
}

View File

@@ -0,0 +1,816 @@
// @ts-nocheck
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { EditorContent, Extension, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import Suggestion from '@tiptap/suggestion';
import { Node, mergeAttributes } from '@tiptap/core';
import tippy from 'tippy.js';
type StoryType = {
slug: string;
name: string;
};
type Artwork = {
id: number;
title: string;
url: string;
thumb: string | null;
thumbs?: {
xs?: string | null;
sm?: string | null;
md?: string | null;
lg?: string | null;
xl?: string | null;
};
};
type StoryPayload = {
id?: number;
title: string;
excerpt: string;
cover_image: string;
story_type: string;
tags_csv: string;
meta_title: string;
meta_description: string;
canonical_url: string;
og_image: string;
status: string;
scheduled_for: string;
content: Record<string, unknown>;
};
type Endpoints = {
create: string;
update: string;
autosave: string;
uploadImage: string;
artworks: string;
previewBase: string;
analyticsBase: string;
};
type Props = {
mode: 'create' | 'edit';
initialStory: StoryPayload;
storyTypes: StoryType[];
endpoints: Endpoints;
csrfToken: string;
};
const ArtworkBlock = Node.create({
name: 'artworkEmbed',
group: 'block',
atom: true,
addAttributes() {
return {
artworkId: { default: null },
title: { default: '' },
url: { default: '' },
thumb: { default: '' },
};
},
parseHTML() {
return [{ tag: 'figure[data-artwork-embed]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'figure',
mergeAttributes(HTMLAttributes, {
'data-artwork-embed': 'true',
class: 'my-4 overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70',
}),
[
'a',
{
href: HTMLAttributes.url || '#',
class: 'block',
rel: 'noopener noreferrer nofollow',
target: '_blank',
},
[
'img',
{
src: HTMLAttributes.thumb || '',
alt: HTMLAttributes.title || 'Artwork',
class: 'h-48 w-full object-cover',
loading: 'lazy',
},
],
[
'figcaption',
{ class: 'p-3 text-sm text-gray-200' },
`${HTMLAttributes.title || 'Artwork'} (#${HTMLAttributes.artworkId || 'n/a'})`,
],
],
];
},
});
const GalleryBlock = Node.create({
name: 'galleryBlock',
group: 'block',
atom: true,
addAttributes() {
return {
images: { default: [] },
};
},
parseHTML() {
return [{ tag: 'div[data-gallery-block]' }];
},
renderHTML({ HTMLAttributes }) {
const images = Array.isArray(HTMLAttributes.images) ? HTMLAttributes.images : [];
const children: Array<unknown> = images.slice(0, 6).map((src: string) => [
'img',
{ src, class: 'h-36 w-full rounded-lg object-cover', loading: 'lazy', alt: 'Gallery image' },
]);
if (children.length === 0) {
children.push(['div', { class: 'rounded-lg border border-dashed border-gray-600 p-4 text-xs text-gray-400' }, 'Empty gallery block']);
}
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-gallery-block': 'true',
class: 'my-4 grid grid-cols-2 gap-3 rounded-xl border border-gray-700 bg-gray-800/50 p-3',
}),
...children,
];
},
});
const VideoEmbedBlock = Node.create({
name: 'videoEmbed',
group: 'block',
atom: true,
addAttributes() {
return {
src: { default: '' },
title: { default: 'Embedded video' },
};
},
parseHTML() {
return [{ tag: 'figure[data-video-embed]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'figure',
mergeAttributes(HTMLAttributes, {
'data-video-embed': 'true',
class: 'my-4 overflow-hidden rounded-xl border border-gray-700 bg-gray-800/60',
}),
[
'iframe',
{
src: HTMLAttributes.src || '',
title: HTMLAttributes.title || 'Embedded video',
class: 'aspect-video w-full',
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
allowfullscreen: 'true',
frameborder: '0',
referrerpolicy: 'strict-origin-when-cross-origin',
},
],
];
},
});
const DownloadAssetBlock = Node.create({
name: 'downloadAsset',
group: 'block',
atom: true,
addAttributes() {
return {
url: { default: '' },
label: { default: 'Download asset' },
};
},
parseHTML() {
return [{ tag: 'div[data-download-asset]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-download-asset': 'true',
class: 'my-4 rounded-xl border border-gray-700 bg-gray-800/60 p-4',
}),
[
'a',
{
href: HTMLAttributes.url || '#',
class: 'inline-flex items-center rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200',
target: '_blank',
rel: 'noopener noreferrer nofollow',
download: 'true',
},
HTMLAttributes.label || 'Download asset',
],
];
},
});
function createSlashCommandExtension(insert: {
image: () => void;
artwork: () => void;
code: () => void;
quote: () => void;
divider: () => void;
gallery: () => void;
}) {
return Extension.create({
name: 'slashCommands',
addOptions() {
return {
suggestion: {
char: '/',
startOfLine: true,
items: ({ query }: { query: string }) => {
const all = [
{ title: 'Image', key: 'image' },
{ title: 'Artwork', key: 'artwork' },
{ title: 'Code', key: 'code' },
{ title: 'Quote', key: 'quote' },
{ title: 'Divider', key: 'divider' },
{ title: 'Gallery', key: 'gallery' },
];
return all.filter((item) => item.key.startsWith(query.toLowerCase()));
},
command: ({ props }: { editor: any; props: { key: string } }) => {
if (props.key === 'image') insert.image();
if (props.key === 'artwork') insert.artwork();
if (props.key === 'code') insert.code();
if (props.key === 'quote') insert.quote();
if (props.key === 'divider') insert.divider();
if (props.key === 'gallery') insert.gallery();
},
render: () => {
let popup: any;
let root: HTMLDivElement | null = null;
let selected = 0;
let items: Array<{ title: string; key: string }> = [];
let command: ((item: { title: string; key: string }) => void) | null = null;
const draw = () => {
if (!root) return;
root.innerHTML = items
.map((item, index) => {
const active = index === selected ? 'bg-sky-500/20 text-sky-200' : 'text-gray-200';
return `<button data-index="${index}" class="block w-full rounded-md px-3 py-2 text-left text-sm ${active}">/${item.key} <span class="text-gray-400">${item.title}</span></button>`;
})
.join('');
root.querySelectorAll('button').forEach((button) => {
button.addEventListener('mousedown', (event) => {
event.preventDefault();
const idx = Number((event.currentTarget as HTMLButtonElement).dataset.index || 0);
const choice = items[idx];
if (choice && command) command(choice);
});
});
};
return {
onStart: (props: any) => {
items = props.items;
command = props.command;
selected = 0;
root = document.createElement('div');
root.className = 'w-52 rounded-lg border border-gray-700 bg-gray-900 p-1 shadow-xl';
draw();
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: root,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
},
onUpdate: (props: any) => {
items = props.items;
command = props.command;
if (selected >= items.length) selected = 0;
draw();
popup?.[0]?.setProps({ getReferenceClientRect: props.clientRect });
},
onKeyDown: (props: any) => {
if (props.event.key === 'ArrowDown') {
selected = (selected + 1) % Math.max(items.length, 1);
draw();
return true;
}
if (props.event.key === 'ArrowUp') {
selected = (selected + Math.max(items.length, 1) - 1) % Math.max(items.length, 1);
draw();
return true;
}
if (props.event.key === 'Enter') {
const choice = items[selected];
if (choice && command) command(choice);
return true;
}
if (props.event.key === 'Escape') {
popup?.[0]?.hide();
return true;
}
return false;
},
onExit: () => {
popup?.[0]?.destroy();
popup = null;
root = null;
},
};
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
}
async function requestJson<T>(url: string, method: string, body: unknown, csrfToken: string): Promise<T> {
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
export default function StoryEditor({ mode, initialStory, storyTypes, endpoints, csrfToken }: Props) {
const [storyId, setStoryId] = useState<number | undefined>(initialStory.id);
const [title, setTitle] = useState(initialStory.title || '');
const [excerpt, setExcerpt] = useState(initialStory.excerpt || '');
const [coverImage, setCoverImage] = useState(initialStory.cover_image || '');
const [storyType, setStoryType] = useState(initialStory.story_type || 'creator_story');
const [tagsCsv, setTagsCsv] = useState(initialStory.tags_csv || '');
const [metaTitle, setMetaTitle] = useState(initialStory.meta_title || '');
const [metaDescription, setMetaDescription] = useState(initialStory.meta_description || '');
const [canonicalUrl, setCanonicalUrl] = useState(initialStory.canonical_url || '');
const [ogImage, setOgImage] = useState(initialStory.og_image || '');
const [status, setStatus] = useState(initialStory.status || 'draft');
const [scheduledFor, setScheduledFor] = useState(initialStory.scheduled_for || '');
const [saveStatus, setSaveStatus] = useState('Autosave idle');
const [artworkModalOpen, setArtworkModalOpen] = useState(false);
const [artworkResults, setArtworkResults] = useState<Artwork[]>([]);
const [artworkQuery, setArtworkQuery] = useState('');
const [showInsertMenu, setShowInsertMenu] = useState(false);
const [showLivePreview, setShowLivePreview] = useState(false);
const [livePreviewHtml, setLivePreviewHtml] = useState('');
const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 });
const lastSavedRef = useRef('');
const emitSaveEvent = useCallback((kind: 'autosave' | 'manual', id?: number) => {
window.dispatchEvent(new CustomEvent('story-editor:saved', {
detail: {
kind,
storyId: id,
savedAt: new Date().toISOString(),
},
}));
}, []);
const openLinkPrompt = useCallback((editor: any) => {
const prev = editor.getAttributes('link').href;
const url = window.prompt('Link URL', prev || 'https://');
if (url === null) return;
if (url.trim() === '') {
editor.chain().focus().unsetLink().run();
return;
}
editor.chain().focus().setLink({ href: url.trim() }).run();
}, []);
const fetchArtworks = useCallback(async (query: string) => {
const q = encodeURIComponent(query);
const response = await fetch(`${endpoints.artworks}?q=${q}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!response.ok) return;
const data = await response.json();
setArtworkResults(Array.isArray(data.artworks) ? data.artworks : []);
}, [endpoints.artworks]);
const uploadImageFile = useCallback(async (file: File): Promise<string | null> => {
const formData = new FormData();
formData.append('image', file);
const response = await fetch(endpoints.uploadImage, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
if (!response.ok) {
return null;
}
const data = await response.json();
return data.medium_url || data.original_url || data.thumbnail_url || null;
}, [endpoints.uploadImage, csrfToken]);
const insertActions = useMemo(() => ({
image: () => {
const url = window.prompt('Image URL', 'https://');
if (!url || !editor) return;
editor.chain().focus().setImage({ src: url }).run();
},
artwork: () => setArtworkModalOpen(true),
code: () => {
if (!editor) return;
editor.chain().focus().toggleCodeBlock().run();
},
quote: () => {
if (!editor) return;
editor.chain().focus().toggleBlockquote().run();
},
divider: () => {
if (!editor) return;
editor.chain().focus().setHorizontalRule().run();
},
gallery: () => {
if (!editor) return;
const raw = window.prompt('Gallery image URLs (comma separated)', '');
const images = (raw || '').split(',').map((value) => value.trim()).filter(Boolean);
editor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
},
video: () => {
if (!editor) return;
const src = window.prompt('Video embed URL (YouTube/Vimeo)', 'https://www.youtube.com/embed/');
if (!src) return;
editor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run();
},
download: () => {
if (!editor) return;
const url = window.prompt('Download URL', 'https://');
if (!url) return;
const label = window.prompt('Button label', 'Download asset') || 'Download asset';
editor.chain().focus().insertContent({ type: 'downloadAsset', attrs: { url, label } }).run();
},
}), []);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Image,
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-sky-300 underline',
rel: 'noopener noreferrer nofollow',
target: '_blank',
},
}),
Placeholder.configure({
placeholder: 'Start writing your story...',
}),
ArtworkBlock,
GalleryBlock,
VideoEmbedBlock,
DownloadAssetBlock,
createSlashCommandExtension(insertActions),
],
content: initialStory.content || { type: 'doc', content: [{ type: 'paragraph' }] },
editorProps: {
attributes: {
class: 'tiptap prose prose-invert max-w-none min-h-[26rem] rounded-xl border border-gray-700 bg-gray-900/80 px-6 py-5 text-gray-200 focus:outline-none',
},
handleDrop: (_view, event) => {
const file = event.dataTransfer?.files?.[0];
if (!file || !file.type.startsWith('image/')) return false;
void (async () => {
setSaveStatus('Uploading image...');
const uploaded = await uploadImageFile(file);
if (uploaded && editor) {
editor.chain().focus().setImage({ src: uploaded }).run();
setSaveStatus('Image uploaded');
} else {
setSaveStatus('Image upload failed');
}
})();
return true;
},
handlePaste: (_view, event) => {
const file = event.clipboardData?.files?.[0];
if (!file || !file.type.startsWith('image/')) return false;
void (async () => {
setSaveStatus('Uploading image...');
const uploaded = await uploadImageFile(file);
if (uploaded && editor) {
editor.chain().focus().setImage({ src: uploaded }).run();
setSaveStatus('Image uploaded');
} else {
setSaveStatus('Image upload failed');
}
})();
return true;
},
},
});
useEffect(() => {
if (!editor) return;
const updatePreview = () => {
setLivePreviewHtml(editor.getHTML());
};
updatePreview();
editor.on('update', updatePreview);
return () => {
editor.off('update', updatePreview);
};
}, [editor]);
useEffect(() => {
if (!artworkModalOpen) return;
void fetchArtworks(artworkQuery);
}, [artworkModalOpen, artworkQuery, fetchArtworks]);
useEffect(() => {
if (!editor) return;
const updateToolbar = () => {
const { from, to } = editor.state.selection;
if (from === to) {
setInlineToolbar({ visible: false, top: 0, left: 0 });
return;
}
const start = editor.view.coordsAtPos(from);
const end = editor.view.coordsAtPos(to);
setInlineToolbar({
visible: true,
top: Math.max(10, start.top + window.scrollY - 48),
left: Math.max(10, (start.left + end.right) / 2 + window.scrollX - 120),
});
};
editor.on('selectionUpdate', updateToolbar);
editor.on('blur', () => setInlineToolbar({ visible: false, top: 0, left: 0 }));
return () => {
editor.off('selectionUpdate', updateToolbar);
};
}, [editor]);
const payload = useCallback(() => ({
story_id: storyId,
title,
excerpt,
cover_image: coverImage,
story_type: storyType,
tags_csv: tagsCsv,
tags: tagsCsv.split(',').map((tag) => tag.trim()).filter(Boolean),
meta_title: metaTitle || title,
meta_description: metaDescription || excerpt,
canonical_url: canonicalUrl,
og_image: ogImage || coverImage,
status,
scheduled_for: scheduledFor || null,
content: editor?.getJSON() || { type: 'doc', content: [{ type: 'paragraph' }] },
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, canonicalUrl, ogImage, status, scheduledFor, editor]);
useEffect(() => {
if (!editor) return;
const timer = window.setInterval(async () => {
const body = payload();
const snapshot = JSON.stringify(body);
if (snapshot === lastSavedRef.current) {
return;
}
try {
setSaveStatus('Saving...');
const data = await requestJson<{ story_id?: number; message?: string }>(endpoints.autosave, 'POST', body, csrfToken);
if (data.story_id && !storyId) {
setStoryId(data.story_id);
}
lastSavedRef.current = snapshot;
setSaveStatus(data.message || 'Saved just now');
emitSaveEvent('autosave', data.story_id || storyId);
} catch {
setSaveStatus('Autosave failed');
}
}, 10000);
return () => window.clearInterval(timer);
}, [editor, payload, endpoints.autosave, csrfToken, storyId, emitSaveEvent]);
const persistStory = async (submitAction: 'save_draft' | 'submit_review' | 'publish_now' | 'schedule_publish') => {
const body = {
...payload(),
submit_action: submitAction,
status: submitAction === 'submit_review' ? 'pending_review' : submitAction === 'publish_now' ? 'published' : submitAction === 'schedule_publish' ? 'scheduled' : status,
scheduled_for: submitAction === 'schedule_publish' ? scheduledFor : null,
};
try {
setSaveStatus('Saving...');
const endpoint = storyId ? endpoints.update : endpoints.create;
const method = storyId ? 'PUT' : 'POST';
const data = await requestJson<{ story_id: number; message?: string }>(endpoint, method, body, csrfToken);
if (data.story_id) {
setStoryId(data.story_id);
}
setSaveStatus(data.message || 'Saved just now');
emitSaveEvent('manual', data.story_id || storyId);
} catch {
setSaveStatus('Save failed');
}
};
const insertArtwork = (item: Artwork) => {
if (!editor) return;
editor.chain().focus().insertContent({
type: 'artworkEmbed',
attrs: {
artworkId: item.id,
title: item.title,
url: item.url,
thumb: item.thumbs?.md || item.thumbs?.sm || item.thumb || '',
},
}).run();
setArtworkModalOpen(false);
};
return (
<div className="space-y-6">
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-4 shadow-lg">
<div className="space-y-4">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Title"
className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-3 text-2xl font-semibold text-gray-100"
/>
<div className="grid gap-3 md:grid-cols-2">
<input value={excerpt} onChange={(event) => setExcerpt(event.target.value)} placeholder="Excerpt" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<select value={storyType} onChange={(event) => setStoryType(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">
{storyTypes.map((type) => (
<option key={type.slug} value={type.slug}>{type.name}</option>
))}
</select>
<input value={tagsCsv} onChange={(event) => setTagsCsv(event.target.value)} placeholder="Tags (comma separated)" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<select value={status} onChange={(event) => setStatus(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">
<option value="draft">Draft</option>
<option value="pending_review">Pending Review</option>
<option value="published">Published</option>
<option value="scheduled">Scheduled</option>
<option value="archived">Archived</option>
</select>
<input value={coverImage} onChange={(event) => setCoverImage(event.target.value)} placeholder="Cover image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input type="datetime-local" value={scheduledFor} onChange={(event) => setScheduledFor(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={metaTitle} onChange={(event) => setMetaTitle(event.target.value)} placeholder="Meta title" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={metaDescription} onChange={(event) => setMetaDescription(event.target.value)} placeholder="Meta description" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={canonicalUrl} onChange={(event) => setCanonicalUrl(event.target.value)} placeholder="Canonical URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={ogImage} onChange={(event) => setOgImage(event.target.value)} placeholder="OG image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
</div>
</div>
</div>
<div className="relative rounded-xl border border-gray-700 bg-gray-800/60 p-4 shadow-lg">
<div className="mb-3 flex flex-wrap items-center gap-2">
<button type="button" onClick={() => setShowInsertMenu((current) => !current)} className="rounded-lg border border-gray-600 px-3 py-1 text-xs text-gray-200">+ Insert</button>
<button type="button" onClick={() => setShowLivePreview((current) => !current)} className="rounded-lg border border-gray-600 px-3 py-1 text-xs text-gray-200">{showLivePreview ? 'Hide Preview' : 'Live Preview'}</button>
<button type="button" onClick={() => persistStory('save_draft')} className="rounded-lg border border-gray-600 bg-gray-700/40 px-3 py-1 text-xs text-gray-200">Save Draft</button>
<button type="button" onClick={() => persistStory('submit_review')} className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">Submit for Review</button>
<button type="button" onClick={() => persistStory('publish_now')} className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-1 text-xs text-emerald-200">Publish Now</button>
<button type="button" onClick={() => persistStory('schedule_publish')} className="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-1 text-xs text-sky-200">Schedule Publish</button>
<span className="ml-auto text-xs text-emerald-300">{saveStatus}</span>
</div>
{showInsertMenu && (
<div className="mb-3 grid grid-cols-2 gap-2 rounded-xl border border-gray-700 bg-gray-900/90 p-2 sm:grid-cols-3">
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.image}>Image</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.artwork}>Embed Artwork</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.code}>Code Block</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.quote}>Quote</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.divider}>Divider</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.gallery}>Gallery</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.video}>Video Embed</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.download}>Download Asset</button>
</div>
)}
{editor && inlineToolbar.visible && (
<div
className="fixed z-40 flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-900 px-2 py-1 shadow-lg"
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('bold') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBold().run()}>B</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('italic') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleItalic().run()}>I</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('code') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleCode().run()}>{'</>'}</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => openLinkPrompt(editor)}>Link</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBlockquote().run()}>Quote</button>
</div>
)}
<EditorContent editor={editor} />
{showLivePreview && (
<div className="mt-4 rounded-xl border border-gray-700 bg-gray-900/60 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Live Preview</div>
<div className="prose prose-invert max-w-none prose-pre:bg-gray-900" dangerouslySetInnerHTML={{ __html: livePreviewHtml }} />
</div>
)}
</div>
<div className="flex flex-wrap gap-3">
{storyId && (
<a href={`${endpoints.previewBase}/${storyId}/preview`} className="rounded-xl border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200">Preview</a>
)}
{storyId && (
<a href={`${endpoints.analyticsBase}/${storyId}/analytics`} className="rounded-xl border border-violet-500/40 bg-violet-500/10 px-3 py-2 text-sm text-violet-200">Analytics</a>
)}
{mode === 'edit' && storyId && (
<form method="POST" action={`/creator/stories/${storyId}`} onSubmit={(event) => {
if (!window.confirm('Delete this story?')) {
event.preventDefault();
}
}}>
<input type="hidden" name="_token" value={csrfToken} />
<input type="hidden" name="_method" value="DELETE" />
<button type="submit" className="rounded-xl border border-rose-500/40 bg-rose-500/20 px-3 py-2 text-sm text-rose-200">Delete</button>
</form>
)}
</div>
{artworkModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-3xl rounded-xl border border-gray-700 bg-gray-900 p-4 shadow-lg">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Embed Artwork</h3>
<button type="button" onClick={() => setArtworkModalOpen(false)} className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200">Close</button>
</div>
<input value={artworkQuery} onChange={(event) => setArtworkQuery(event.target.value)} className="mb-3 w-full rounded-xl border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200" placeholder="Search artworks" />
<div className="grid max-h-80 gap-3 overflow-y-auto sm:grid-cols-2">
{artworkResults.map((item) => (
<button key={item.id} type="button" onClick={() => insertArtwork(item)} className="rounded-xl border border-gray-700 bg-gray-800 p-3 text-left hover:border-sky-400">
{(item.thumbs?.sm || item.thumb) && <img src={item.thumbs?.sm || item.thumb || ''} alt={item.title} className="h-28 w-full rounded-lg object-cover" />}
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
<div className="text-xs text-gray-400">#{item.id}</div>
</button>
))}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -18,6 +18,7 @@ export default function AuthorBadge({ user, size = 'md' }) {
const role = (user?.role ?? 'member').toLowerCase()
const cls = ROLE_STYLES[role] ?? ROLE_STYLES.member
const label = ROLE_LABELS[role] ?? 'Member'
const rank = user?.rank ?? null
const imgSize = size === 'sm' ? 'h-8 w-8' : 'h-10 w-10'
@@ -32,9 +33,16 @@ export default function AuthorBadge({ user, size = 'md' }) {
/>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-100">{name}</div>
<span className={`inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
{label}
</span>
<div className="mt-1 flex flex-wrap gap-1.5">
<span className={`inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
{label}
</span>
{rank && (
<span className="inline-flex rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-300">
{rank}
</span>
)}
</div>
</div>
</div>
)

View File

@@ -3,64 +3,151 @@ import React from 'react'
export default function CategoryCard({ category }) {
const name = category?.name ?? 'Untitled'
const slug = category?.slug
const categoryHref = slug ? `/forum/category/${slug}` : null
const threads = category?.thread_count ?? 0
const posts = category?.post_count ?? 0
const lastActivity = category?.last_activity_at
const preview = category?.preview_image ?? '/images/forum-default.jpg'
const href = slug ? `/forum/${slug}` : '#'
const boards = category?.boards ?? []
const boardCount = boards.length
const activeBoards = boards.filter((board) => Number(board?.topics_count ?? 0) > 0).length
const latestBoard = boards
.filter((board) => board?.latest_topic?.last_post_at)
.sort((a, b) => new Date(b.latest_topic.last_post_at) - new Date(a.latest_topic.last_post_at))[0]
const timeAgo = lastActivity ? formatTimeAgo(lastActivity) : null
return (
<a
href={href}
className="group relative block overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 shadow-xl backdrop-blur transition-all duration-300 hover:border-cyan-400/20 hover:shadow-cyan-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400"
>
<div className="group relative block overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 shadow-xl backdrop-blur transition-all duration-300 hover:border-cyan-400/20 hover:shadow-cyan-500/10 focus-within:ring-2 focus-within:ring-cyan-400">
{/* Image */}
<div className="relative aspect-[16/9]">
<img
src={preview}
alt={`${name} preview`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
{categoryHref ? (
<a href={categoryHref} className="block h-full">
<img
src={preview}
alt={`${name} preview`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
{/* Overlay content */}
<div className="absolute inset-x-0 bottom-0 p-5">
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
<div className="absolute inset-x-0 bottom-0 p-5">
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</div>
<h3 className="text-lg font-bold leading-snug text-white transition group-hover:text-cyan-200">
{name}
</h3>
{category?.description && (
<p className="mt-1 line-clamp-2 text-xs text-white/60">{category.description}</p>
)}
{timeAgo && (
<p className="mt-1 text-xs text-white/50">
Last activity: <span className="text-white/70">{timeAgo}</span>
</p>
)}
<div className="mt-3 flex items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-cyan-300">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
{number(posts)} posts
</span>
<span className="flex items-center gap-1.5 text-cyan-300/70">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{number(threads)} topics
</span>
</div>
<div className="mt-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-cyan-200 transition group-hover:text-cyan-100">
View section
</span>
</div>
</div>
</a>
) : (
<>
<img
src={preview}
alt={`${name} preview`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-5">
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</div>
<h3 className="text-lg font-bold leading-snug text-white">{name}</h3>
{category?.description && (
<p className="mt-1 line-clamp-2 text-xs text-white/60">{category.description}</p>
)}
{timeAgo && (
<p className="mt-1 text-xs text-white/50">
Last activity: <span className="text-white/70">{timeAgo}</span>
</p>
)}
<div className="mt-3 flex items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-cyan-300">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
{number(posts)} posts
</span>
<span className="flex items-center gap-1.5 text-cyan-300/70">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{number(threads)} topics
</span>
</div>
</div>
</>
)}
</div>
<div className="border-t border-white/8 p-4">
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.12em] text-white/40">Boards</div>
<div className="mt-1 text-sm font-semibold text-white">{number(boardCount)}</div>
</div>
<h3 className="text-lg font-bold text-white leading-snug">{name}</h3>
{timeAgo && (
<p className="mt-1 text-xs text-white/50">
Last activity: <span className="text-white/70">{timeAgo}</span>
</p>
)}
<div className="mt-3 flex items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-cyan-300">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
{number(posts)} posts
</span>
<span className="flex items-center gap-1.5 text-cyan-300/70">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{number(threads)} topics
</span>
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.12em] text-white/40">Topics</div>
<div className="mt-1 text-sm font-semibold text-white">{number(threads)}</div>
</div>
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.12em] text-white/40">Posts</div>
<div className="mt-1 text-sm font-semibold text-white">{number(posts)}</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-white/50">
<span>{number(activeBoards)} active boards</span>
{latestBoard?.title ? <span>Latest: {latestBoard.title}</span> : <span>No recent board activity</span>}
</div>
</div>
</a>
</div>
)
}

View File

@@ -1,9 +1,17 @@
import React, { useState } from 'react'
import AuthorBadge from './AuthorBadge'
const REACTIONS = [
{ key: 'like', label: 'Like', emoji: '👍' },
{ key: 'love', label: 'Love', emoji: '❤️' },
{ key: 'fire', label: 'Amazing', emoji: '🔥' },
{ key: 'laugh', label: 'Funny', emoji: '😂' },
{ key: 'disagree', label: 'Disagree', emoji: '👎' },
]
export default function PostCard({ post, thread, isOp = false, isAuthenticated = false, canModerate = false }) {
const [reported, setReported] = useState(false)
const [reporting, setReporting] = useState(false)
const [reactionState, setReactionState] = useState(post?.reactions ?? { summary: {}, active: null })
const [reacting, setReacting] = useState(false)
const author = post?.user
const content = post?.rendered_content ?? post?.content ?? ''
@@ -11,14 +19,13 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
const editedAt = post?.edited_at
const isEdited = post?.is_edited
const postId = post?.id
const threadId = thread?.id
const threadSlug = thread?.slug
const handleReport = async () => {
if (reporting || reported) return
setReporting(true)
const handleReaction = async (reaction) => {
if (reacting || !isAuthenticated) return
setReacting(true)
try {
const res = await fetch(`/forum/post/${postId}/report`, {
const res = await fetch(`/forum/post/${postId}/react`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -26,10 +33,14 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
'Accept': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({ reaction }),
})
if (res.ok) setReported(true)
if (res.ok) {
const json = await res.json()
setReactionState(json)
}
} catch { /* silent */ }
setReporting(false)
setReacting(false)
}
return (
@@ -82,32 +93,31 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
{/* Footer */}
<footer className="flex flex-wrap items-center gap-3 border-t border-white/[0.06] px-5 py-3 text-xs">
{/* Quote */}
{threadId && (
<a
href={`/forum/thread/${threadId}-${threadSlug ?? ''}?quote=${postId}#reply-content`}
className="rounded-lg border border-white/10 px-2.5 py-1 text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
>
Quote
</a>
)}
<div className="flex flex-wrap items-center gap-2">
{REACTIONS.map((reaction) => {
const count = reactionState?.summary?.[reaction.key] ?? 0
const isActive = reactionState?.active === reaction.key
{/* Report */}
{isAuthenticated && (post?.user_id !== post?.current_user_id) && (
<button
type="button"
onClick={handleReport}
disabled={reported || reporting}
className={[
'rounded-lg border border-white/10 px-2.5 py-1 transition-colors',
reported
? 'text-emerald-400 border-emerald-500/20 cursor-default'
: 'text-zinc-400 hover:border-white/20 hover:text-zinc-200',
].join(' ')}
>
{reported ? 'Reported ✓' : reporting ? 'Reporting…' : 'Report'}
</button>
)}
return (
<button
key={reaction.key}
type="button"
disabled={!isAuthenticated || reacting}
onClick={() => handleReaction(reaction.key)}
className={[
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 transition-colors',
isActive
? 'border-cyan-400/30 bg-cyan-400/10 text-cyan-200'
: 'border-white/10 text-zinc-400 hover:border-white/20 hover:text-zinc-200',
].join(' ')}
title={reaction.label}
>
<span>{reaction.emoji}</span>
<span>{count}</span>
</button>
)
})}
</div>
{/* Edit */}
{(post?.can_edit) && (

View File

@@ -2,7 +2,7 @@ import React, { useState, useRef, useCallback } from 'react'
import Button from '../ui/Button'
import RichTextEditor from './RichTextEditor'
export default function ReplyForm({ threadId, prefill = '', quotedAuthor = null, csrfToken }) {
export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null, csrfToken }) {
const [content, setContent] = useState(prefill)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
@@ -16,7 +16,7 @@ export default function ReplyForm({ threadId, prefill = '', quotedAuthor = null,
setError(null)
try {
const res = await fetch(`/forum/thread/${threadId}/reply`, {
const res = await fetch(`/forum/topic/${topicKey}/reply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -42,7 +42,7 @@ export default function ReplyForm({ threadId, prefill = '', quotedAuthor = null,
}
setSubmitting(false)
}, [content, threadId, csrfToken, submitting])
}, [content, topicKey, csrfToken, submitting])
return (
<form

View File

@@ -10,7 +10,7 @@ export default function ThreadRow({ thread, isFirst = false }) {
const lastUpdate = thread?.last_update ?? thread?.post_date
const isPinned = thread?.is_pinned ?? false
const href = `/forum/thread/${id}-${slug}`
const href = `/forum/topic/${slug}`
return (
<a
@@ -36,7 +36,7 @@ export default function ThreadRow({ thread, isFirst = false }) {
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold text-white group-hover:text-sky-300 transition-colors">
<h3 className="m-0 truncate text-sm font-semibold leading-tight text-white transition-colors group-hover:text-sky-300">
{title}
</h3>
{isPinned && (

View File

@@ -18,7 +18,8 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = nul
const category = (art.category_name || art.category || '').trim();
const likes = art.likes ?? art.favourites ?? 0;
const comments = art.comments_count ?? art.comment_count ?? 0;
const views = art.views ?? art.views_count ?? art.view_count ?? 0;
const downloads = art.downloads ?? art.downloads_count ?? art.download_count ?? 0;
const imgSrc = art.thumb || art.thumb_url || art.thumbnail_url || '/images/placeholder.jpg';
const imgSrcset = art.thumb_srcset || imgSrc;
@@ -74,7 +75,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = nul
const imgClass = [
'nova-card-main-image',
'absolute inset-0 h-full w-full object-cover',
'transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]',
'transition-[transform,filter] duration-150 ease-out group-hover:scale-[1.03]',
loading !== 'eager' ? 'blur-sm scale-[1.02] data-blur-preview' : '',
].join(' ');
@@ -97,7 +98,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = nul
href={cardUrl}
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20
shadow-lg shadow-black/40
transition-all duration-300 ease-out
transition-all duration-150 ease-out
hover:scale-[1.02] hover:-translate-y-px hover:ring-white/15
hover:shadow-[0_8px_30px_rgba(0,0,0,0.6),0_0_0_1px_rgba(255,255,255,0.08)]
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
@@ -112,6 +113,12 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = nul
>
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none" />
<div className="pointer-events-none absolute right-2 top-2 z-20 flex items-center gap-1.5 rounded-full border border-white/10 bg-black/45 px-2 py-1 text-[10px] text-white/85 opacity-0 transition-opacity duration-150 group-hover:opacity-100">
<span className="inline-flex items-center gap-1"><i className="fa-solid fa-heart text-[9px] text-rose-300" />{likes}</span>
<span className="inline-flex items-center gap-1"><i className="fa-solid fa-eye text-[9px] text-sky-300" />{views}</span>
<span className="inline-flex items-center gap-1"><i className="fa-solid fa-download text-[9px] text-emerald-300" />{downloads}</span>
</div>
<img
ref={imgRef}
src={imgSrc}
@@ -145,7 +152,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = nul
)}
</span>
</span>
<span className="shrink-0"> {likes} · 💬 {comments}</span>
<span className="shrink-0"> {likes} · 👁 {views} · {downloads}</span>
</div>
{metaParts.length > 0 && (
<div className="mt-1 text-[11px] text-white/70">

View File

@@ -56,10 +56,39 @@ async function fetchPageData(url) {
// JSON fast-path (if controller ever returns JSON)
if (ct.includes('application/json')) {
const json = await res.json();
// Support multiple API payload shapes across endpoints.
const artworks = Array.isArray(json.artworks)
? json.artworks
: Array.isArray(json.data)
? json.data
: Array.isArray(json.items)
? json.items
: Array.isArray(json.results)
? json.results
: [];
const nextCursor = json.next_cursor
?? json.nextCursor
?? json.meta?.next_cursor
?? null;
const nextPageUrl = json.next_page_url
?? json.nextPageUrl
?? json.meta?.next_page_url
?? null;
const hasMore = typeof json.has_more === 'boolean'
? json.has_more
: typeof json.hasMore === 'boolean'
? json.hasMore
: null;
return {
artworks: json.artworks ?? [],
nextCursor: json.next_cursor ?? null,
nextPageUrl: json.next_page_url ?? null,
artworks,
nextCursor,
nextPageUrl,
hasMore,
};
}
@@ -76,6 +105,7 @@ async function fetchPageData(url) {
artworks,
nextCursor: el.dataset.nextCursor || null,
nextPageUrl: el.dataset.nextPageUrl || null,
hasMore: null,
};
}
@@ -148,6 +178,7 @@ const SKELETON_COUNT = 10;
* rankApiEndpoint string|null /api/rank/* endpoint; used as fallback data
* source when no SSR artworks are available
* rankType string|null Ranking API ?type= param (trending|new_hot|best)
* gridClassName string|null Optional CSS class override for grid columns/gaps
*/
function MasonryGallery({
artworks: initialArtworks = [],
@@ -158,6 +189,7 @@ function MasonryGallery({
limit = 40,
rankApiEndpoint = null,
rankType = null,
gridClassName = null,
}) {
const [artworks, setArtworks] = useState(initialArtworks);
const [nextCursor, setNextCursor] = useState(initialNextCursor);
@@ -234,7 +266,7 @@ function MasonryGallery({
setLoading(true);
try {
const { artworks: newItems, nextCursor: nc, nextPageUrl: np } =
const { artworks: newItems, nextCursor: nc, nextPageUrl: np, hasMore } =
await fetchPageData(fetchUrl);
if (!newItems.length) {
@@ -243,7 +275,7 @@ function MasonryGallery({
setArtworks((prev) => [...prev, ...newItems]);
if (cursorEndpoint) {
setNextCursor(nc);
if (!nc) setDone(true);
if (hasMore === false || !nc) setDone(true);
} else {
setNextPageUrl(np);
if (!np) setDone(true);
@@ -272,7 +304,7 @@ function MasonryGallery({
// Gallery V2 spec §7: 5 col desktop / 3 tablet / 2 mobile for all gallery pages.
// Discover feeds (home/discover page) retain the same 5-col layout.
const gridClass = 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
const gridClass = gridClassName || 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
// ── Render ─────────────────────────────────────────────────────────────
return (

View File

@@ -0,0 +1,232 @@
import React, { useMemo, useRef, useState } from 'react'
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
export default function ProfileCoverEditor({
isOpen,
onClose,
coverUrl,
coverPosition,
onCoverUpdated,
onCoverRemoved,
}) {
const previewRef = useRef(null)
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [removing, setRemoving] = useState(false)
const [position, setPosition] = useState(coverPosition ?? 50)
const csrfToken = useMemo(
() => document.querySelector('meta[name="csrf-token"]')?.content ?? '',
[]
)
if (!isOpen) {
return null
}
const updatePositionFromPointer = (clientY) => {
const el = previewRef.current
if (!el) return
const rect = el.getBoundingClientRect()
if (rect.height <= 0) return
const normalized = ((clientY - rect.top) / rect.height) * 100
setPosition(Math.round(clamp(normalized, 0, 100)))
}
const handlePointerDown = (event) => {
updatePositionFromPointer(event.clientY)
const onMove = (moveEvent) => updatePositionFromPointer(moveEvent.clientY)
const onUp = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
const handleUpload = async (event) => {
const file = event.target.files?.[0]
if (!file) return
setUploading(true)
try {
const body = new FormData()
body.append('cover', file)
const response = await fetch('/api/profile/cover/upload', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
body,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Cover upload failed.')
}
const nextPosition = Number.isFinite(payload.cover_position) ? payload.cover_position : 50
setPosition(nextPosition)
onCoverUpdated(payload.cover_url, nextPosition)
} catch (error) {
window.alert(error?.message || 'Cover upload failed.')
} finally {
setUploading(false)
event.target.value = ''
}
}
const handleSavePosition = async () => {
if (!coverUrl) return
setSaving(true)
try {
const response = await fetch('/api/profile/cover/position', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
body: JSON.stringify({ position }),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Could not save position.')
}
onCoverUpdated(coverUrl, payload.cover_position ?? position)
onClose()
} catch (error) {
window.alert(error?.message || 'Could not save position.')
} finally {
setSaving(false)
}
}
const handleRemove = async () => {
if (!coverUrl) return
setRemoving(true)
try {
const response = await fetch('/api/profile/cover', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Could not remove cover.')
}
setPosition(payload.cover_position ?? 50)
onCoverRemoved()
onClose()
} catch (error) {
window.alert(error?.message || 'Could not remove cover.')
} finally {
setRemoving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-3xl rounded-2xl border border-white/10 bg-[#0d1524] shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<h3 className="text-lg font-semibold text-white">Edit Cover</h3>
<button
type="button"
onClick={onClose}
className="rounded-lg p-2 text-slate-400 hover:bg-white/10 hover:text-white"
aria-label="Close cover editor"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
<div className="space-y-4 p-5">
<div className="rounded-xl border border-dashed border-slate-600/70 bg-slate-900/50 p-3">
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">
<i className="fa-solid fa-upload" />
{uploading ? 'Uploading...' : 'Upload Cover'}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleUpload}
disabled={uploading}
/>
</label>
<p className="mt-2 text-xs text-slate-400">Allowed: JPG, PNG, WEBP. Max 5MB. Recommended: 1920x480.</p>
</div>
<div>
<p className="mb-2 text-sm text-slate-300">Drag vertically to reposition the cover.</p>
<div
ref={previewRef}
onPointerDown={handlePointerDown}
className="relative h-44 w-full cursor-ns-resize overflow-hidden rounded-xl border border-white/10 bg-[#101a2a]"
style={{
background: coverUrl
? `url('${coverUrl}') center ${position}% / cover no-repeat`
: 'linear-gradient(135deg, #0f1724 0%, #151e2e 50%, #090f1a 100%)',
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-[#0f1724]/70 to-[#0f1724]/30" />
<div
className="pointer-events-none absolute left-0 right-0 border-t border-dashed border-sky-400/80"
style={{ top: `${position}%` }}
/>
</div>
<div className="mt-2 flex items-center justify-between text-xs text-slate-400">
<span>Position</span>
<span>{position}%</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={handleRemove}
disabled={removing || !coverUrl}
className="inline-flex items-center gap-2 rounded-lg border border-red-400/30 px-4 py-2 text-sm font-medium text-red-300 hover:bg-red-500/10 disabled:opacity-50"
>
<i className={`fa-solid ${removing ? 'fa-circle-notch fa-spin' : 'fa-trash'}`} />
Remove Cover
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-white/15 px-4 py-2 text-sm text-slate-300 hover:bg-white/10"
>
Cancel
</button>
<button
type="button"
onClick={handleSavePosition}
disabled={saving || !coverUrl}
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500 disabled:opacity-50"
>
<i className={`fa-solid ${saving ? 'fa-circle-notch fa-spin' : 'fa-floppy-disk'}`} />
Save Position
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { router } from '@inertiajs/react'
import ProfileCoverEditor from './ProfileCoverEditor'
/**
* ProfileHero
@@ -10,6 +10,9 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
const [count, setCount] = useState(followerCount)
const [loading, setLoading] = useState(false)
const [hovering, setHovering] = useState(false)
const [editorOpen, setEditorOpen] = useState(false)
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
const uname = user.username || user.name || 'Unknown'
const displayName = user.name || uname
@@ -18,6 +21,8 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
: null
const bio = profile?.bio || profile?.about || ''
const toggleFollow = async () => {
if (loading) return
setLoading(true)
@@ -39,159 +44,190 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
}
return (
<div className="relative overflow-hidden border-b border-white/10">
{/* Cover / hero background */}
<div
className="w-full"
style={{
height: 'clamp(160px, 22vw, 260px)',
background: heroBgUrl
? `url('${heroBgUrl}') center/cover no-repeat`
: 'linear-gradient(135deg, #0f1724 0%, #151e2e 50%, #090f1a 100%)',
position: 'relative',
}}
>
{/* Overlay */}
<div
className="absolute inset-0"
style={{
background: heroBgUrl
? 'linear-gradient(to right, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.75) 50%, rgba(15,23,36,0.45) 100%)'
: 'radial-gradient(ellipse at 20% 50%, rgba(77,163,255,.12) 0%, transparent 60%), radial-gradient(ellipse at 80% 20%, rgba(224,122,33,.08) 0%, transparent 50%)',
}}
/>
{/* Nebula grain decoration */}
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
</div>
{/* Identity block overlaps cover at bottom */}
<div className="max-w-6xl mx-auto px-4">
<div className="relative -mt-16 pb-5 flex flex-col sm:flex-row sm:items-end gap-4">
{/* Avatar */}
<div className="shrink-0 z-10">
<img
src={user.avatar_url || '/default/avatar_default.webp'}
alt={`${uname}'s avatar`}
className="w-24 h-24 sm:w-28 sm:h-28 rounded-2xl object-cover ring-4 ring-[#0f1724] shadow-xl shadow-black/60"
/>
</div>
{/* Name + meta */}
<div className="flex-1 min-w-0 pb-1">
<h1 className="text-2xl sm:text-3xl font-bold text-white leading-tight">
{displayName}
</h1>
<p className="text-slate-400 text-sm mt-0.5 font-mono">@{uname}</p>
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-slate-500">
{countryName && (
<span className="flex items-center gap-1.5">
{profile?.country_code && (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
alt={countryName}
className="w-4 h-auto rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}
/>
)}
{countryName}
</span>
)}
{joinDate && (
<span className="flex items-center gap-1">
<i className="fa-solid fa-calendar-days fa-fw opacity-60" />
Joined {joinDate}
</span>
)}
{profile?.website && (
<a
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="flex items-center gap-1 text-sky-400 hover:text-sky-300 transition-colors"
<>
<div className="max-w-6xl mx-auto px-4 pt-4">
<div className="relative overflow-hidden rounded-2xl border border-white/10">
<div
className="w-full h-[180px] md:h-[220px] xl:h-[252px]"
style={{
background: coverUrl
? `url('${coverUrl}') center ${coverPosition}% / cover no-repeat`
: 'linear-gradient(140deg, #0f1724 0%, #101a2a 45%, #0a1220 100%)',
position: 'relative',
}}
>
{isOwner && (
<div className="absolute right-3 top-3 z-20">
<button
type="button"
onClick={() => setEditorOpen(true)}
className="inline-flex items-center gap-2 rounded-lg border border-white/20 bg-black/40 px-3 py-2 text-xs font-medium text-white hover:bg-black/60"
aria-label="Edit cover image"
>
<i className="fa-solid fa-link fa-fw" />
{(() => {
try {
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
return new URL(url).hostname
} catch {
return profile.website
}
})()}
</a>
<i className="fa-solid fa-image" />
Edit Cover
</button>
</div>
)}
<div
className="absolute inset-0"
style={{
background: coverUrl
? 'linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.62))'
: 'radial-gradient(ellipse at 16% 40%, rgba(77,163,255,.18) 0%, transparent 60%), radial-gradient(ellipse at 84% 22%, rgba(224,122,33,.12) 0%, transparent 54%)',
}}
/>
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
</div>
</div>
<div className="relative -mt-14 md:-mt-16 pb-4 px-1">
<div className="flex flex-col md:flex-row md:items-end gap-4 md:gap-5">
<div className="mx-auto md:mx-0 shrink-0 z-10">
<img
src={user.avatar_url || '/default/avatar_default.webp'}
alt={`${uname}'s avatar`}
className="w-[104px] h-[104px] md:w-[116px] md:h-[116px] rounded-full object-cover border-2 border-white/15 shadow-[0_0_0_6px_rgba(15,23,36,0.95),0_10px_32px_rgba(0,0,0,0.6)]"
/>
</div>
<div className="flex-1 min-w-0 text-center md:text-left">
<h1 className="text-[28px] md:text-[34px] font-bold text-white leading-tight tracking-tight">
{displayName}
</h1>
<p className="text-slate-400 text-sm mt-0.5 font-mono">@{uname}</p>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2.5 mt-2 text-xs text-slate-400">
{countryName && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
{profile?.country_code && (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
alt={countryName}
className="w-4 h-auto rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}
/>
)}
{countryName}
</span>
)}
{joinDate && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<i className="fa-solid fa-calendar-days fa-fw opacity-70" />
Joined {joinDate}
</span>
)}
{profile?.website && (
<a
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1 text-sky-300 hover:text-sky-200 hover:bg-white/10 transition-colors"
>
<i className="fa-solid fa-link fa-fw" />
{(() => {
try {
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
return new URL(url).hostname
} catch {
return profile.website
}
})()}
</a>
)}
</div>
{bio && (
<p className="text-sm text-slate-300/90 mt-3 max-w-2xl leading-relaxed line-clamp-2 md:line-clamp-3 mx-auto md:mx-0">
{bio}
</p>
)}
</div>
<div className="shrink-0 flex items-center justify-center md:justify-end gap-2 pb-0.5">
{isOwner ? (
<>
<a
href="/dashboard/profile"
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium border border-white/15 text-slate-300 hover:text-white hover:bg-white/5 transition-all"
aria-label="Edit profile"
>
<i className="fa-solid fa-pen fa-fw" />
Edit Profile
</a>
<a
href="/studio"
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-all shadow-lg shadow-sky-900/30"
aria-label="Open Studio"
>
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
Studio
</a>
</>
) : (
<>
<button
onClick={toggleFollow}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
disabled={loading}
aria-label={following ? 'Unfollow' : 'Follow'}
className={`inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium border transition-all ${
following
? hovering
? 'bg-red-500/10 border-red-400/40 text-red-400'
: 'bg-green-500/10 border-green-400/40 text-green-400'
: 'bg-sky-500/10 border-sky-400/40 text-sky-400 hover:bg-sky-500/20'
}`}
>
<i className={`fa-solid fa-fw ${
loading
? 'fa-circle-notch fa-spin'
: following
? hovering ? 'fa-user-minus' : 'fa-user-check'
: 'fa-user-plus'
}`} />
<span>{following ? (hovering ? 'Unfollow' : 'Following') : 'Follow'}</span>
<span className="text-xs opacity-70">{count.toLocaleString()}</span>
</button>
<button
onClick={() => {
if (navigator.share) {
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
} else {
navigator.clipboard.writeText(window.location.href)
}
}}
aria-label="Share profile"
className="p-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
<i className="fa-solid fa-share-nodes fa-fw" />
</button>
</>
)}
</div>
</div>
{/* Action buttons */}
<div className="shrink-0 flex items-center gap-2 pb-1">
{isOwner ? (
<>
<a
href="/dashboard/profile"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border border-white/15 text-slate-300 hover:text-white hover:bg-white/5 transition-all"
aria-label="Edit profile"
>
<i className="fa-solid fa-pen fa-fw" />
<span className="hidden sm:inline">Edit Profile</span>
</a>
<a
href="/studio"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-all shadow-lg shadow-sky-900/30"
aria-label="Open Studio"
>
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
<span className="hidden sm:inline">Studio</span>
</a>
</>
) : (
<>
{/* Follow button */}
<button
onClick={toggleFollow}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
disabled={loading}
aria-label={following ? 'Unfollow' : 'Follow'}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border transition-all ${
following
? hovering
? 'bg-red-500/10 border-red-400/40 text-red-400'
: 'bg-green-500/10 border-green-400/40 text-green-400'
: 'bg-sky-500/10 border-sky-400/40 text-sky-400 hover:bg-sky-500/20'
}`}
>
<i className={`fa-solid fa-fw ${
loading
? 'fa-circle-notch fa-spin'
: following
? hovering ? 'fa-user-minus' : 'fa-user-check'
: 'fa-user-plus'
}`} />
<span>{following ? (hovering ? 'Unfollow' : 'Following') : 'Follow'}</span>
<span className="text-xs opacity-60">({count.toLocaleString()})</span>
</button>
{/* Share */}
<button
onClick={() => {
if (navigator.share) {
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
} else {
navigator.clipboard.writeText(window.location.href)
}
}}
aria-label="Share profile"
className="p-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
<i className="fa-solid fa-share-nodes fa-fw" />
</button>
</>
)}
</div>
</div>
</div>
</div>
<ProfileCoverEditor
isOpen={editorOpen}
onClose={() => setEditorOpen(false)}
coverUrl={coverUrl}
coverPosition={coverPosition}
onCoverUpdated={(nextUrl, nextPosition) => {
setCoverUrl(nextUrl)
setCoverPosition(nextPosition)
}}
onCoverRemoved={() => {
setCoverUrl(null)
setCoverPosition(50)
}}
/>
</>
)
}

View File

@@ -25,9 +25,9 @@ export default function ProfileStatsRow({ stats, followerCount, onTabChange }) {
}
return (
<div className="bg-white/3 border-b border-white/10 overflow-x-auto" style={{ background: 'rgba(255,255,255,0.025)' }}>
<div className="border-b border-white/10" style={{ background: 'rgba(255,255,255,0.02)' }}>
<div className="max-w-6xl mx-auto px-4">
<div className="flex gap-1 py-2 min-w-max sm:min-w-0 sm:flex-wrap">
<div className="grid grid-cols-3 md:grid-cols-6 gap-2 py-3">
{PILLS.map((pill) => (
<button
key={pill.key}
@@ -35,19 +35,19 @@ export default function ProfileStatsRow({ stats, followerCount, onTabChange }) {
title={pill.label}
disabled={!pill.tab}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all
flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl text-sm transition-all text-center
border border-white/10 bg-white/[0.02]
${pill.tab
? 'cursor-pointer hover:bg-white/8 hover:text-white text-slate-300 group'
: 'cursor-default text-slate-400'
? 'cursor-pointer hover:bg-white/[0.06] hover:border-white/20 hover:text-white text-slate-300 group'
: 'cursor-default text-slate-400 opacity-90'
}
`}
style={{ background: 'transparent' }}
>
<i className={`fa-solid ${pill.icon} fa-fw text-xs opacity-60 group-hover:opacity-80`} />
<span className="font-bold text-white tabular-nums">
<i className={`fa-solid ${pill.icon} fa-fw text-xs ${pill.tab ? 'opacity-70 group-hover:opacity-100' : 'opacity-60'}`} />
<span className="font-bold text-white tabular-nums text-base leading-none">
{Number(values[pill.key]).toLocaleString()}
</span>
<span className="text-slate-500 text-xs hidden sm:inline">{pill.label}</span>
<span className="text-slate-500 text-[11px] uppercase tracking-wide leading-none">{pill.label}</span>
</button>
))}
</div>

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react'
export const TABS = [
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
{ id: 'stories', label: 'Stories', icon: 'fa-feather-pointed' },
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
{ id: 'about', label: 'About', icon: 'fa-id-card' },
@@ -35,7 +36,7 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
aria-label="Profile sections"
role="tablist"
>
<div className="max-w-6xl mx-auto px-3 flex gap-0 min-w-max sm:min-w-0">
<div className="max-w-6xl mx-auto px-3 flex gap-1 py-1 min-w-max sm:min-w-0">
{TABS.map((tab) => {
const isActive = activeTab === tab.id
return (
@@ -47,16 +48,16 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
aria-selected={isActive}
aria-controls={`tabpanel-${tab.id}`}
className={`
relative flex items-center gap-2 px-4 py-3.5 text-sm font-medium whitespace-nowrap
relative flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap rounded-lg
transition-colors duration-150 outline-none
focus-visible:ring-2 focus-visible:ring-sky-400/70 rounded-t
${isActive
? 'text-white'
: 'text-slate-400 hover:text-slate-200'
? 'text-white bg-white/[0.05]'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.03]'
}
`}
>
<i className={`fa-solid ${tab.icon} fa-fw text-xs ${isActive ? 'text-sky-400' : ''}`} />
<i className={`fa-solid ${tab.icon} fa-fw text-xs ${isActive ? 'text-sky-400' : 'opacity-75'}`} />
{tab.label}
{/* Active indicator bar */}
{isActive && (

View File

@@ -1,5 +1,5 @@
import React, { useState, useCallback } from 'react'
import ArtworkCard from '../../gallery/ArtworkCard'
import React, { useState } from 'react'
import MasonryGallery from '../../gallery/MasonryGallery'
const SORT_OPTIONS = [
{ value: 'latest', label: 'Latest' },
@@ -9,30 +9,6 @@ const SORT_OPTIONS = [
{ value: 'favs', label: 'Most Favourited' },
]
function ArtworkSkeleton() {
return (
<div className="rounded-2xl overflow-hidden bg-white/5 animate-pulse">
<div className="aspect-[4/3] bg-white/8" />
<div className="p-2 space-y-1.5">
<div className="h-3 bg-white/8 rounded w-3/4" />
<div className="h-2 bg-white/5 rounded w-1/2" />
</div>
</div>
)
}
function EmptyState({ username }) {
return (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500">
<i className="fa-solid fa-image text-3xl" />
</div>
<p className="text-slate-400 font-medium">No artworks yet</p>
<p className="text-slate-600 text-sm mt-1">@{username} hasn't uploaded anything yet.</p>
</div>
)
}
/**
* Featured artworks horizontal scroll strip.
*/
@@ -40,31 +16,31 @@ function FeaturedStrip({ featuredArtworks }) {
if (!featuredArtworks?.length) return null
return (
<div className="mb-6">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
<i className="fa-solid fa-star text-yellow-400 fa-fw" />
<div className="mb-7 rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-400 mb-3 flex items-center gap-2">
<i className="fa-solid fa-star text-amber-400 fa-fw" />
Featured
</h2>
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
{featuredArtworks.map((art) => (
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
{featuredArtworks.slice(0, 5).map((art) => (
<a
key={art.id}
href={`/art/${art.id}/${slugify(art.name)}`}
className="group shrink-0 snap-start w-40 sm:w-48"
className="group shrink-0 snap-start w-56 md:w-64"
>
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 aspect-[4/3] hover:ring-sky-400/40 transition-all">
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 aspect-[5/3] hover:ring-sky-400/40 transition-all">
<img
src={art.thumb}
alt={art.name}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
/>
</div>
<p className="text-xs text-slate-400 mt-1.5 truncate group-hover:text-white transition-colors">
<p className="text-sm text-slate-300 mt-2 truncate group-hover:text-white transition-colors">
{art.name}
</p>
{art.label && (
<p className="text-[10px] text-slate-600 truncate">{art.label}</p>
<p className="text-[11px] text-slate-600 truncate">{art.label}</p>
)}
</a>
))}
@@ -86,8 +62,6 @@ export default function TabArtworks({ artworks, featuredArtworks, username, isAc
const [sort, setSort] = useState('latest')
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
const [loadingMore, setLoadingMore] = useState(false)
const [isInitialLoad] = useState(false) // data SSR-loaded
const handleSort = async (newSort) => {
setSort(newSort)
@@ -104,23 +78,6 @@ export default function TabArtworks({ artworks, featuredArtworks, username, isAc
} catch (_) {}
}
const loadMore = async () => {
if (!nextCursor || loadingMore) return
setLoadingMore(true)
try {
const res = await fetch(
`/api/profile/${encodeURIComponent(username)}/artworks?sort=${sort}&cursor=${encodeURIComponent(nextCursor)}`,
{ headers: { Accept: 'application/json' } }
)
if (res.ok) {
const data = await res.json()
setItems((prev) => [...prev, ...(data.data ?? data)])
setNextCursor(data.next_cursor ?? null)
}
} catch (_) {}
setLoadingMore(false)
}
return (
<div
id="tabpanel-artworks"
@@ -151,45 +108,16 @@ export default function TabArtworks({ artworks, featuredArtworks, username, isAc
</div>
</div>
{/* Grid */}
{isInitialLoad ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => <ArtworkSkeleton key={i} />)}
</div>
) : items.length === 0 ? (
<div className="grid grid-cols-1">
<EmptyState username={username} />
</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{items.map((art, i) => (
<ArtworkCard
key={art.id ?? i}
art={art}
loading={i < 8 ? 'eager' : 'lazy'}
/>
))}
{loadingMore && Array.from({ length: 4 }).map((_, i) => <ArtworkSkeleton key={`sk-${i}`} />)}
</div>
{/* Load more */}
{nextCursor && (
<div className="mt-8 text-center">
<button
onClick={loadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm font-medium border border-white/10 transition-all"
>
{loadingMore
? <><i className="fa-solid fa-circle-notch fa-spin fa-fw" /> Loading</>
: <><i className="fa-solid fa-chevron-down fa-fw" /> Load more</>
}
</button>
</div>
)}
</>
)}
{/* Shared masonry gallery component reused from discover/explore */}
<MasonryGallery
key={`profile-${username}-${sort}`}
artworks={items}
galleryType="profile"
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
initialNextCursor={nextCursor}
limit={24}
gridClassName="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
/>
</div>
)
}

View File

@@ -57,7 +57,7 @@ export default function TabCollections({ collections }) {
</div>
<h3 className="text-lg font-bold text-white mb-2">Collections Coming Soon</h3>
<p className="text-slate-500 text-sm max-w-sm mx-auto">
Group artworks into curated collections. This feature is currently in development.
Group your artworks into curated collections.
</p>
</div>
</div>

View File

@@ -14,10 +14,10 @@ function EmptyPostsState({ isOwner, username }) {
<p className="text-slate-400 font-medium mb-1">No posts yet</p>
{isOwner ? (
<p className="text-slate-600 text-sm max-w-xs">
Share your thoughts or showcase your artworks. Your first post is a tap away.
Share updates or showcase your artworks.
</p>
) : (
<p className="text-slate-600 text-sm">@{username} hasn't posted anything yet.</p>
<p className="text-slate-600 text-sm">@{username} has not posted anything yet.</p>
)}
</div>
)

View File

@@ -0,0 +1,49 @@
import React from 'react'
export default function TabStories({ stories, username }) {
const list = Array.isArray(stories) ? stories : []
if (!list.length) {
return (
<div className="rounded-xl border border-white/10 bg-white/[0.02] px-6 py-12 text-center text-slate-300">
No stories published yet.
</div>
)
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{list.map((story) => (
<a
key={story.id}
href={`/stories/${story.slug}`}
className="group overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg transition duration-200 hover:scale-[1.01] hover:border-sky-500/40"
>
{story.cover_url ? (
<img src={story.cover_url} alt={story.title} className="h-44 w-full object-cover transition-transform duration-300 group-hover:scale-105" />
) : (
<div className="h-44 w-full bg-gradient-to-br from-gray-900 via-slate-900 to-sky-950" />
)}
<div className="space-y-2 p-4">
<h3 className="line-clamp-2 text-base font-semibold text-white">{story.title}</h3>
<p className="line-clamp-2 text-xs text-gray-300">{story.excerpt || ''}</p>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span>{story.reading_time || 1} min read</span>
<span>{story.views || 0} views</span>
<span>{story.likes_count || 0} likes</span>
</div>
</div>
</a>
))}
</div>
<a
href={`/stories/creator/${username}`}
className="inline-flex rounded-lg border border-sky-400/30 bg-sky-500/10 px-3 py-2 text-sm text-sky-300 transition hover:scale-[1.01] hover:text-sky-200"
>
View all stories
</a>
</div>
)
}