messages implemented

This commit is contained in:
2026-02-26 21:12:32 +01:00
parent d0aefc5ddc
commit 15b7b77d20
168 changed files with 14728 additions and 6786 deletions

View File

@@ -0,0 +1,158 @@
import React, { useCallback, useRef, useState } from 'react'
import axios from 'axios'
import EmojiPickerButton from './EmojiPickerButton'
/**
* Comment form with emoji picker and Markdown-lite support.
*
* Props:
* artworkId number Target artwork
* onPosted (comment) => void Called when comment is successfully posted
* isLoggedIn boolean
* loginUrl string Where to redirect non-authenticated users
*/
export default function CommentForm({
artworkId,
onPosted,
isLoggedIn = false,
loginUrl = '/login',
}) {
const [content, setContent] = useState('')
const [submitting, setSubmitting] = useState(false)
const [errors, setErrors] = useState([])
const textareaRef = useRef(null)
// Insert text at current cursor position
const insertAtCursor = useCallback((text) => {
const el = textareaRef.current
if (!el) {
setContent((v) => v + text)
return
}
const start = el.selectionStart ?? content.length
const end = el.selectionEnd ?? content.length
const next = content.slice(0, start) + text + content.slice(end)
setContent(next)
// Restore cursor after the inserted text
requestAnimationFrame(() => {
el.selectionStart = start + text.length
el.selectionEnd = start + text.length
el.focus()
})
}, [content])
const handleEmojiSelect = useCallback((emoji) => {
insertAtCursor(emoji)
}, [insertAtCursor])
const handleSubmit = useCallback(
async (e) => {
e.preventDefault()
if (!isLoggedIn) {
window.location.href = loginUrl
return
}
const trimmed = content.trim()
if (!trimmed) return
setSubmitting(true)
setErrors([])
try {
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, {
content: trimmed,
})
setContent('')
onPosted?.(data.data)
} catch (err) {
if (err.response?.status === 422) {
const apiErrors = err.response.data?.errors?.content ?? ['Invalid content.']
setErrors(Array.isArray(apiErrors) ? apiErrors : [apiErrors])
} else {
setErrors(['Something went wrong. Please try again.'])
}
} finally {
setSubmitting(false)
}
},
[artworkId, content, isLoggedIn, loginUrl, onPosted],
)
if (!isLoggedIn) {
return (
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] px-5 py-4 text-sm text-white/50">
<a href={loginUrl} className="text-sky-400 hover:text-sky-300 font-medium transition-colors">
Sign in
</a>{' '}
to leave a comment.
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-2">
{/* Textarea */}
<div className="relative rounded-xl border border-white/[0.1] bg-white/[0.03] focus-within:border-white/[0.2] focus-within:bg-white/[0.05] transition-colors">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write a comment… Markdown supported: **bold**, *italic*, `code`"
rows={3}
maxLength={10000}
disabled={submitting}
aria-label="Comment text"
className="w-full resize-none bg-transparent px-4 pt-3 pb-10 text-sm text-white placeholder-white/25 focus:outline-none disabled:opacity-50"
/>
{/* Toolbar at bottom-right of textarea */}
<div className="absolute bottom-2 right-3 flex items-center gap-2">
<span
className={[
'text-xs tabular-nums transition-colors',
content.length > 9000 ? 'text-amber-400' : 'text-white/20',
].join(' ')}
aria-live="polite"
>
{content.length}/10 000
</span>
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
</div>
</div>
{/* Markdown hint */}
<p className="text-xs text-white/25 px-1">
**bold** · *italic* · `code` · https://links.auto-linked · @mentions
</p>
{/* Errors */}
{errors.length > 0 && (
<ul className="space-y-1" role="alert">
{errors.map((e, i) => (
<li key={i} className="text-xs text-red-400 px-1">
{e}
</li>
))}
</ul>
)}
{/* Submit */}
<div className="flex justify-end">
<button
type="submit"
disabled={submitting || !content.trim()}
className="px-5 py-2 rounded-lg text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-colors disabled:opacity-40 disabled:pointer-events-none focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
>
{submitting ? 'Posting…' : 'Post comment'}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,287 @@
import React from 'react'
// ── Pagination ────────────────────────────────────────────────────────────────
function Pagination({ meta, onPageChange }) {
if (!meta || meta.last_page <= 1) return null
const { current_page, last_page } = meta
const pages = []
if (last_page <= 7) {
for (let i = 1; i <= last_page; i++) pages.push(i)
} else {
const around = new Set(
[1, last_page, current_page, current_page - 1, current_page + 1].filter(
(p) => p >= 1 && p <= last_page
)
)
const sorted = [...around].sort((a, b) => a - b)
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i] - sorted[i - 1] > 1) pages.push('…')
pages.push(sorted[i])
}
}
return (
<nav
aria-label="Pagination"
className="mt-10 flex items-center justify-center gap-1 flex-wrap"
>
<button
disabled={current_page <= 1}
onClick={() => onPageChange(current_page - 1)}
className="px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors"
aria-label="Previous page"
>
Prev
</button>
{pages.map((p, i) =>
p === '…' ? (
<span key={`sep-${i}`} className="px-2 text-white/25 text-sm select-none">
</span>
) : (
<button
key={p}
onClick={() => p !== current_page && onPageChange(p)}
aria-current={p === current_page ? 'page' : undefined}
className={[
'min-w-[2rem] px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
p === current_page
? 'bg-sky-600/30 text-sky-300 ring-1 ring-sky-500/40'
: 'text-white/50 hover:text-white hover:bg-white/[0.06]',
].join(' ')}
>
{p}
</button>
)
)}
<button
disabled={current_page >= last_page}
onClick={() => onPageChange(current_page + 1)}
className="px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors"
aria-label="Next page"
>
Next
</button>
</nav>
)
}
// ── Pin icon (for artwork reference) ─────────────────────────────────────────
function PinIcon() {
return (
<svg
className="w-3.5 h-3.5 shrink-0 text-white/30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
}
// ── Artwork image icon (for right panel label) ────────────────────────────────
function ImageIcon() {
return (
<svg
className="w-3 h-3 shrink-0 text-white/25"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
)
}
// ── Single comment row ────────────────────────────────────────────────────────
function CommentItem({ comment }) {
const { commenter, artwork, comment_text, time_ago, created_at } = comment
return (
<article className="flex gap-4 p-4 sm:p-5 rounded-xl border border-white/[0.065] bg-white/[0.025] hover:bg-white/[0.04] transition-colors">
{/* ── Avatar ── */}
<a
href={commenter.profile_url}
className="shrink-0 mt-0.5"
aria-label={`View ${commenter.display}'s profile`}
>
<img
src={commenter.avatar_url}
alt={commenter.display}
width={48}
height={48}
className="w-12 h-12 rounded-full object-cover ring-1 ring-white/[0.1]"
loading="lazy"
onError={(e) => {
e.currentTarget.onerror = null
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/avatars/default.webp'
}}
/>
</a>
{/* ── Main content ── */}
<div className="min-w-0 flex-1">
{/* Author + time */}
<div className="flex items-baseline gap-2 flex-wrap mb-1">
<a
href={commenter.profile_url}
className="text-sm font-bold text-white hover:text-white/80 transition-colors"
>
{commenter.display}
</a>
<time
dateTime={created_at}
title={created_at ? new Date(created_at).toLocaleString() : ''}
className="text-xs text-white/40"
>
{time_ago}
</time>
</div>
{/* Comment text — primary visual element */}
<p className="text-base text-white leading-relaxed whitespace-pre-line break-words mb-3">
{comment_text}
</p>
{/* Artwork reference link */}
{artwork && (
<a
href={artwork.url}
className="inline-flex items-center gap-1.5 text-xs text-sky-400 hover:text-sky-300 transition-colors group"
>
<PinIcon />
<span className="font-medium">{artwork.title}</span>
</a>
)}
</div>
{/* ── Right: artwork thumbnail ── */}
{artwork?.thumb && (
<a
href={artwork.url}
className="shrink-0 self-start group"
tabIndex={-1}
aria-hidden="true"
>
<div className="w-[220px] overflow-hidden rounded-lg ring-1 ring-white/[0.07] group-hover:ring-white/20 transition-all">
<img
src={artwork.thumb}
alt={artwork.title ?? 'Artwork'}
width={220}
height={96}
className="w-full h-24 object-cover"
loading="lazy"
onError={(e) => { e.currentTarget.closest('a').style.display = 'none' }}
/>
</div>
<div className="mt-1.5 px-0.5">
<div className="flex items-center gap-1 mb-0.5">
<ImageIcon />
<span className="text-[11px] text-white/45 truncate max-w-[200px]">{artwork.title}</span>
</div>
</div>
</a>
)}
</article>
)
}
// ── Loading skeleton ──────────────────────────────────────────────────────────
function FeedSkeleton() {
return (
<div className="space-y-3 animate-pulse">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="flex gap-4 p-5 rounded-xl border border-white/[0.06] bg-white/[0.02]"
>
{/* avatar */}
<div className="w-12 h-12 rounded-full bg-white/[0.07] shrink-0" />
{/* content */}
<div className="flex-1 space-y-2 pt-1">
<div className="h-3 bg-white/[0.07] rounded w-32" />
<div className="h-4 bg-white/[0.06] rounded w-full" />
<div className="h-4 bg-white/[0.05] rounded w-3/4" />
<div className="h-3 bg-white/[0.04] rounded w-24 mt-2" />
</div>
{/* thumbnail */}
<div className="w-[220px] h-24 rounded-lg bg-white/[0.05] shrink-0" />
</div>
))}
</div>
)
}
// ── Empty state ───────────────────────────────────────────────────────────────
function EmptyState() {
return (
<div className="py-16 text-center">
<svg
className="mx-auto w-10 h-10 text-white/15 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.625 9.75a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375m-13.5 3.01c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 01.778-.332 48.294 48.294 0 005.83-.498c1.585-.233 2.708-1.626 2.708-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
<p className="text-white/30 text-sm">No comments found.</p>
</div>
)
}
// ── Main export ───────────────────────────────────────────────────────────────
export default function CommentsFeed({
comments = [],
meta = {},
loading = false,
error = null,
onPageChange,
}) {
if (loading) return <FeedSkeleton />
if (error) {
return (
<div className="rounded-xl border border-red-500/20 bg-red-900/10 px-6 py-5 text-sm text-red-400">
{error}
</div>
)
}
if (!comments || comments.length === 0) return <EmptyState />
return (
<div>
<div role="feed" aria-live="polite" aria-busy={loading} className="space-y-3">
{comments.map((comment) => (
<CommentItem key={comment.comment_id} comment={comment} />
))}
</div>
<Pagination meta={meta} onPageChange={onPageChange} />
</div>
)
}

View File

@@ -0,0 +1,92 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
/**
* A button that opens a floating emoji picker.
* When the user selects an emoji, `onEmojiSelect(emojiNative)` is called
* with the native Unicode character.
*
* Props:
* onEmojiSelect (string) → void Called with the emoji character
* disabled boolean Disables the button
* className string Additional classes for the trigger button
*/
export default function EmojiPickerButton({ onEmojiSelect, disabled = false, className = '' }) {
const [open, setOpen] = useState(false)
const wrapRef = useRef(null)
// Close on outside click
useEffect(() => {
if (!open) return
function handleClick(e) {
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
// Close on Escape
useEffect(() => {
if (!open) return
function handleKey(e) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [open])
const handleSelect = useCallback(
(emoji) => {
onEmojiSelect?.(emoji.native)
setOpen(false)
},
[onEmojiSelect],
)
return (
<div ref={wrapRef} className="relative inline-block">
<button
type="button"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
aria-label="Open emoji picker"
aria-expanded={open}
className={[
'flex items-center justify-center w-8 h-8 rounded-md',
'text-white/40 hover:text-white/70 hover:bg-white/[0.07]',
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
'disabled:opacity-30 disabled:pointer-events-none',
className,
]
.filter(Boolean)
.join(' ')}
>
😊
</button>
{open && (
<div
className="absolute bottom-full mb-2 right-0 z-50 shadow-2xl rounded-xl overflow-hidden"
style={{ filter: 'drop-shadow(0 8px 32px rgba(0,0,0,0.6))' }}
>
<Picker
data={data}
onEmojiSelect={handleSelect}
theme="dark"
previewPosition="none"
skinTonePosition="none"
maxFrequentRows={2}
perLine={8}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,110 @@
import React, { useCallback, useOptimistic, useState } from 'react'
import axios from 'axios'
/**
* Reaction bar for an artwork or comment.
*
* Props:
* entityType 'artwork' | 'comment'
* entityId number
* initialTotals Record<slug, { emoji, label, count, mine }>
* isLoggedIn boolean — if false, clicking shows a prompt
*/
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) {
const [totals, setTotals] = useState(initialTotals)
const [loading, setLoading] = useState(null) // slug being toggled
const endpoint =
entityType === 'artwork'
? `/api/artworks/${entityId}/reactions`
: `/api/comments/${entityId}/reactions`
const toggle = useCallback(
async (slug) => {
if (!isLoggedIn) {
window.location.href = '/login'
return
}
if (loading) return // prevent double-click
setLoading(slug)
// Optimistic update
setTotals((prev) => {
const entry = prev[slug] ?? { count: 0, mine: false }
return {
...prev,
[slug]: {
...entry,
count: entry.mine ? entry.count - 1 : entry.count + 1,
mine: !entry.mine,
},
}
})
try {
const { data } = await axios.post(endpoint, { reaction: slug })
setTotals(data.totals)
} catch {
// Rollback
setTotals((prev) => {
const entry = prev[slug] ?? { count: 0, mine: false }
return {
...prev,
[slug]: {
...entry,
count: entry.mine ? entry.count - 1 : entry.count + 1,
mine: !entry.mine,
},
}
})
} finally {
setLoading(null)
}
},
[endpoint, isLoggedIn, loading],
)
const entries = Object.entries(totals)
if (entries.length === 0) return null
return (
<div
role="group"
aria-label="Reactions"
className="flex flex-wrap items-center gap-1.5"
>
{entries.map(([slug, info]) => {
const { emoji, label, count, mine } = info
const isProcessing = loading === slug
return (
<button
key={slug}
type="button"
disabled={isProcessing}
onClick={() => toggle(slug)}
aria-label={`${label}${count} reaction${count !== 1 ? 's' : ''}${mine ? ' (your reaction)' : ''}`}
aria-pressed={mine}
className={[
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm',
'border transition-all duration-150',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
'disabled:opacity-50 disabled:pointer-events-none',
mine
? 'border-sky-500/60 bg-sky-500/15 text-sky-300 hover:bg-sky-500/25'
: 'border-white/[0.1] bg-white/[0.03] text-white/60 hover:border-white/20 hover:text-white/80',
]
.filter(Boolean)
.join(' ')}
>
<span aria-hidden="true">{emoji}</span>
<span className="tabular-nums font-medium">{count > 0 ? count : ''}</span>
<span className="sr-only">{label}</span>
</button>
)
})}
</div>
)
}