Commit workspace changes

This commit is contained in:
2026-04-05 19:42:33 +02:00
parent 148a3bbe43
commit 08ad757bcb
312 changed files with 35149 additions and 399 deletions

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'
import SearchOverlay from './SearchOverlay'
const ARTWORKS_API = '/api/search/artworks'
const GROUPS_API = '/api/search/groups'
const TAGS_API = '/api/tags/search'
const USERS_API = '/api/search/users'
const DEBOUNCE_MS = 300
@@ -17,10 +18,11 @@ function useDebounce(value, delay) {
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPod|iPad/.test(navigator.platform)
export default function SearchBar({ placeholder = 'Search artworks, artists, tags\u2026' }) {
export default function SearchBar({ placeholder = 'Search artworks, groups, artists, tags\u2026' }) {
const [phase, setPhase] = useState('idle') // idle | opening | open | closing
const [query, setQuery] = useState('')
const [artworks, setArtworks] = useState([])
const [groups, setGroups] = useState([])
const [tags, setTags] = useState([])
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(false)
@@ -40,12 +42,13 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
const debouncedQuery = useDebounce(query, DEBOUNCE_MS)
const isExpanded = phase === 'opening' || phase === 'open'
const isMobileOverlayVisible = mobileOverlayPhase !== 'closed'
const hasSuggestions = artworks.length > 0 || users.length > 0 || tags.length > 0
const hasSuggestions = artworks.length > 0 || groups.length > 0 || users.length > 0 || tags.length > 0
const suggestionListId = open && hasSuggestions ? 'sb-suggestions' : undefined
// flat list of navigable items: artworks → users → tags
// flat list of navigable items: artworks → groups → users → tags
const allItems = [
...artworks.map(a => ({ type: 'artwork', ...a })),
...groups.map(g => ({ type: 'group', ...g })),
...users.map(u => ({ type: 'user', ...u })),
...tags.map(t => ({ type: 'tag', ...t })),
]
@@ -70,6 +73,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
setPhase('idle')
setQuery('')
setArtworks([])
setGroups([])
setTags([])
setUsers([])
}, 160)
@@ -95,6 +99,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
setActiveIdx(-1)
setOpen(false)
setArtworks([])
setGroups([])
setTags([])
setUsers([])
}, 150)
@@ -150,7 +155,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
const fetchSuggestions = useCallback(async (q) => {
const bare = q?.replace(/^@+/, '') ?? ''
if (!bare || bare.length < 2) {
setArtworks([]); setTags([]); setUsers([]); setOpen(false); return
setArtworks([]); setGroups([]); setTags([]); setUsers([]); setOpen(false); return
}
if (abortRef.current) abortRef.current.abort()
abortRef.current = new AbortController()
@@ -162,22 +167,28 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
const fetchArt = isAtMention
? Promise.resolve(null)
: fetch(`${ARTWORKS_API}?q=${encodeURIComponent(bare)}&per_page=4`, { signal: sig })
const fetchGroups = isAtMention
? Promise.resolve(null)
: fetch(`${GROUPS_API}?q=${encodeURIComponent(bare)}&per_page=4`, { signal: sig })
const fetchUsers = fetch(`${USERS_API}?q=${encodeURIComponent(q)}&per_page=4`, { signal: sig })
const fetchTags = isAtMention
? Promise.resolve(null)
: fetch(`${TAGS_API}?q=${encodeURIComponent(bare)}&per_page=3`, { signal: sig })
const [artRes, userRes, tagRes] = await Promise.all([fetchArt, fetchUsers, fetchTags])
const [artRes, groupRes, userRes, tagRes] = await Promise.all([fetchArt, fetchGroups, fetchUsers, fetchTags])
const artJson = artRes && artRes.ok ? await artRes.json() : {}
const groupJson = groupRes && groupRes.ok ? await groupRes.json() : {}
const userJson = userRes && userRes.ok ? await userRes.json() : {}
const tagJson = tagRes && tagRes.ok ? await tagRes.json() : {}
const artItems = Array.isArray(artJson.data ?? artJson) ? (artJson.data ?? artJson).slice(0, 4) : []
const groupItems = Array.isArray(groupJson.data ?? groupJson) ? (groupJson.data ?? groupJson).slice(0, 4) : []
const userItems = Array.isArray(userJson.data ?? userJson) ? (userJson.data ?? userJson).slice(0, 4) : []
const tagItems = Array.isArray(tagJson.data ?? tagJson) ? (tagJson.data ?? tagJson).slice(0, 3) : []
setArtworks(artItems)
setGroups(groupItems)
setUsers(userItems)
setTags(tagItems)
setActiveIdx(-1)
setOpen(artItems.length > 0 || userItems.length > 0 || tagItems.length > 0)
setOpen(artItems.length > 0 || groupItems.length > 0 || userItems.length > 0 || tagItems.length > 0)
} catch (e) {
if (e.name !== 'AbortError') console.error('SearchBar fetch error', e)
} finally {
@@ -190,6 +201,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
// ── navigation helpers ───────────────────────────────────────────────────
function navigate(item) {
if (item.type === 'artwork') window.location.href = item.urls?.web ?? `/${item.slug ?? ''}`
else if (item.type === 'group') window.location.href = item.urls?.public ?? item.profile_url ?? `/groups/${item.slug ?? ''}`
else if (item.type === 'user') window.location.href = item.profile_url ?? `/@${item.username}`
else window.location.href = `/tags/${item.slug ?? item.name}`
}
@@ -240,6 +252,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
inputRef={mobileInputRef}
loading={loading}
artworks={artworks}
groups={groups}
users={users}
tags={tags}
activeIdx={activeIdx}
@@ -362,12 +375,39 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
</>
)}
{groups.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none ${artworks.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2.5'}`}>Groups</li>
{groups.map((group, j) => {
const flatIdx = artworks.length + j
return (
<li key={group.slug ?? group.id ?? j} role="option" id={`sb-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button type="button" onClick={() => navigate({ type: 'group', ...group })}
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${activeIdx === flatIdx ? 'bg-white/[0.09]' : 'hover:bg-white/[0.06]'}`}>
{group.avatar_url ? (
<img src={group.avatar_url} alt="" aria-hidden="true" className="w-9 h-9 rounded-2xl object-cover shrink-0 bg-white/[0.04] border border-white/[0.08]" loading="lazy" />
) : (
<span className="w-9 h-9 rounded-2xl bg-white/[0.04] border border-white/[0.07] inline-flex items-center justify-center shrink-0">
<i className="fa-solid fa-people-group text-white/40" />
</span>
)}
<div className="min-w-0">
<div className="text-sm font-medium text-white truncate">{group.name}</div>
<div className="text-xs text-neutral-400 truncate">{group.headline || `${Number(group.counts?.followers || group.followers_count || 0).toLocaleString()} followers`}</div>
</div>
</button>
</li>
)
})}
</>
)}
{/* Users / Creators section */}
{users.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none ${artworks.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2.5'}`}>Creators</li>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none ${artworks.length > 0 || groups.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2.5'}`}>Creators</li>
{users.map((user, j) => {
const flatIdx = artworks.length + j
const flatIdx = artworks.length + groups.length + j
return (
<li key={user.username} role="option" id={`sb-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button type="button" onClick={() => navigate({ type: 'user', ...user })}
@@ -388,9 +428,9 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
{/* Tags section */}
{tags.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none ${artworks.length > 0 || users.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2.5'}`}>Tags</li>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none ${artworks.length > 0 || groups.length > 0 || users.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2.5'}`}>Tags</li>
{tags.map((tag, j) => {
const flatIdx = artworks.length + users.length + j
const flatIdx = artworks.length + groups.length + users.length + j
return (
<li key={tag.slug ?? tag.name ?? j} role="option" id={`sb-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button type="button" onClick={() => navigate({ type: 'tag', ...tag })}

View File

@@ -8,6 +8,7 @@ export default function SearchOverlay({
inputRef,
loading,
artworks,
groups,
users,
tags,
activeIdx,
@@ -19,7 +20,7 @@ export default function SearchOverlay({
}) {
if (phase === 'closed') return null
const hasResults = artworks.length > 0 || users.length > 0 || tags.length > 0
const hasResults = artworks.length > 0 || groups.length > 0 || users.length > 0 || tags.length > 0
const isVisible = OPENING_OR_OPEN.has(phase)
return (
@@ -55,7 +56,7 @@ export default function SearchOverlay({
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Search artworks, creators, tags..."
placeholder="Search artworks, groups, creators, tags..."
aria-label="Search"
aria-autocomplete="list"
aria-controls="sb-mobile-suggestions"
@@ -116,11 +117,41 @@ export default function SearchOverlay({
</>
)}
{groups.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none ${artworks.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2'}`}>Groups</li>
{groups.map((group, j) => {
const flatIdx = artworks.length + j
return (
<li key={group.slug ?? group.id ?? j} role="option" id={`sb-mobile-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button
type="button"
onClick={() => onNavigate({ type: 'group', ...group })}
className={`w-full min-h-12 flex items-center gap-3 px-3 py-2 text-left rounded-lg transition-colors ${activeIdx === flatIdx ? 'bg-white/[0.14]' : 'hover:bg-white/[0.08]'}`}
>
{group.avatar_url ? (
<img src={group.avatar_url} alt="" aria-hidden="true" className="w-10 h-10 rounded-2xl object-cover shrink-0 bg-white/[0.04] border border-white/[0.08]" loading="lazy" />
) : (
<span className="w-10 h-10 rounded-2xl bg-white/[0.04] border border-white/[0.08] inline-flex items-center justify-center shrink-0" aria-hidden="true">
<i className="fa-solid fa-people-group text-white/45" />
</span>
)}
<div className="min-w-0">
<div className="text-sm font-medium text-white truncate">{group.name}</div>
<div className="text-xs text-neutral-400 truncate">{group.headline || `${Number(group.counts?.followers || group.followers_count || 0).toLocaleString()} followers`}</div>
</div>
</button>
</li>
)
})}
</>
)}
{users.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none ${artworks.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2'}`}>Creators</li>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none ${artworks.length > 0 || groups.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2'}`}>Creators</li>
{users.map((user, j) => {
const flatIdx = artworks.length + j
const flatIdx = artworks.length + groups.length + j
return (
<li key={user.username} role="option" id={`sb-mobile-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button
@@ -142,9 +173,9 @@ export default function SearchOverlay({
{tags.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none ${artworks.length > 0 || users.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2'}`}>Tags</li>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none ${artworks.length > 0 || groups.length > 0 || users.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2'}`}>Tags</li>
{tags.map((tag, j) => {
const flatIdx = artworks.length + users.length + j
const flatIdx = artworks.length + groups.length + users.length + j
return (
<li key={tag.slug ?? tag.name ?? j} role="option" id={`sb-mobile-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button
@@ -179,7 +210,7 @@ export default function SearchOverlay({
</ul>
) : (
<div className="mx-2 my-2 px-6 py-10 rounded-xl border border-white/[0.10] bg-nova-900/95 backdrop-blur-sm shadow-2xl text-sm text-white/60">
{query.trim().length >= 2 ? 'No results found.' : 'Start typing to search artworks, creators, and tags.'}
{query.trim().length >= 2 ? 'No results found.' : 'Start typing to search artworks, groups, creators, and tags.'}
</div>
)}
</div>