more fixes
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
816
resources/js/components/editor/StoryEditor.tsx
Normal file
816
resources/js/components/editor/StoryEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
232
resources/js/components/profile/ProfileCoverEditor.jsx
Normal file
232
resources/js/components/profile/ProfileCoverEditor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
49
resources/js/components/profile/tabs/TabStories.jsx
Normal file
49
resources/js/components/profile/tabs/TabStories.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user