/* ========================================================================== TIERLIST: Editor und Anzeige. Das Ziehen läuft über Pointer Events statt über HTML5-Drag-and-Drop, weil letzteres auf Touchgeräten nicht funktioniert. ========================================================================== */ const TIER_PRESET = [ { label: "S", color: "#ff7f7f" }, { label: "A", color: "#ffbf7f" }, { label: "B", color: "#ffdf7f" }, { label: "C", color: "#feff7f" }, { label: "D", color: "#bfff7f" }, { label: "F", color: "#7fbfff" }, ]; const TIER_COLORS = [ "#ff7f7f", "#ffbf7f", "#ffdf7f", "#feff7f", "#bfff7f", "#7fffbf", "#7fdfff", "#7fbfff", "#bf9fff", "#ff9fdf", ]; function uid() { return "x" + Math.random().toString(36).slice(2, 9); } function emptyTierlist() { return { tiers: TIER_PRESET.map((t) => ({ id: uid(), label: t.label, color: t.color, items: [] })), pool: [], items: {}, sources: [], }; } /** Sorgt dafür, dass gespeicherte Daten immer vollständig sind. */ function normalise(d) { const base = emptyTierlist(); if (!d || !Array.isArray(d.tiers)) return base; return { tiers: d.tiers.map((t) => ({ id: t.id || uid(), label: t.label || "", color: t.color || "#7f7f7f", items: Array.isArray(t.items) ? t.items : [], })), pool: Array.isArray(d.pool) ? d.pool : [], items: d.items && typeof d.items === "object" ? d.items : {}, sources: Array.isArray(d.sources) ? d.sources : [], }; } /* --- Editor --------------------------------------------------------------- */ function TierItem({ item, onPointerDown, dragging }) { return (
{item.image ? {item.label : {(item.label || "?").slice(0, 12)}} {item.image && item.label && {item.label}}
); } function tierlistSourceSlug(item) { return item && (item.sourceSlug || (item.source && item.source.slug)) || ""; } async function uploadTierlistImage(file) { const fd = new FormData(); fd.append("file", file); fd.append("variant", "tierlist"); const res = await fetch("/api/uploads", { method: "POST", body: fd, credentials: "same-origin", }); const txt = await res.text(); const d = txt ? JSON.parse(txt) : {}; if (!res.ok) throw new Error(d.detail || "Upload fehlgeschlagen."); return d.url; } function TierlistSourcePicker({ categories, data, onChange }) { const [mode, setMode] = useState("category"); const [value, setValue] = useState(""); const [tags, setTags] = useState([]); const [candidates, setCandidates] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(""); const requestNo = useRef(0); const categoryOptions = useMemo( () => (categories || []) .filter((c) => !c.forumOnly && c.count > 0) .map((c) => ({ value: c.id, label: BB.ancestors(categories || [], c.id).map((a) => a.label).concat(c.label).join(" → "), })) .sort((a, b) => a.label.localeCompare(b.label, "de")), [categories] ); useEffect(() => { let alive = true; BB.get("/tags?limit=200") .then((list) => { if (alive) setTags(Array.isArray(list) ? list : []); }) .catch(() => {}); return () => { alive = false; }; }, []); useEffect(() => { setCandidates([]); setTotal(0); setLoadingMore(false); setError(""); if (!value) return undefined; const current = ++requestNo.current; setLoading(true); BB.get("/tierlist/sources" + BB.qs({ mode, value, limit: 100 })) .then((result) => { if (current !== requestNo.current) return; setCandidates(Array.isArray(result.items) ? result.items : []); setTotal(Number(result.total) || 0); }) .catch((err) => { if (current === requestNo.current) setError(err.message || "Die Auswahl konnte nicht geladen werden."); }) .finally(() => { if (current === requestNo.current) setLoading(false); }); return () => { requestNo.current += 1; }; }, [mode, value]); const sourceOptions = mode === "category" ? categoryOptions : tags.map((t) => ({ value: t.tag, label: `${t.tag} (${t.count})` })); const sourceLabel = sourceOptions.find((o) => o.value === value)?.label || value; const selected = useMemo( () => new Set(Object.values(data.items || {}).map(tierlistSourceSlug).filter(Boolean)), [data.items] ); const newCandidates = candidates.filter((post) => post.slug && !selected.has(post.slug)); async function loadMore() { if (loadingMore || candidates.length >= total || !value) return; const currentRequest = requestNo.current; setLoadingMore(true); try { const result = await BB.get("/tierlist/sources" + BB.qs({ mode, value, limit: 100, offset: candidates.length, })); if (currentRequest !== requestNo.current) return; setCandidates((current) => current.concat(Array.isArray(result.items) ? result.items : [])); setTotal(Number(result.total) || total); } catch (err) { if (currentRequest === requestNo.current) { setError(err.message || "Weitere Einträge konnten nicht geladen werden."); } } finally { setLoadingMore(false); } } function addCandidates(posts) { const added = {}; const ids = []; posts.forEach((post) => { if (!post || !post.slug || selected.has(post.slug)) return; const id = uid(); added[id] = { label: (post.title || post.slug).slice(0, 80), image: "", sourceSlug: post.slug, sourceTitle: post.title || post.slug, }; ids.push(id); }); if (!ids.length) return; const sources = Array.isArray(data.sources) ? data.sources : []; const hasSource = sources.some((s) => s && s.mode === mode && s.value === value); onChange({ ...data, items: { ...data.items, ...added }, pool: [...data.pool, ...ids], sources: hasSource ? sources : sources.concat({ mode, value, label: sourceLabel }), }); } return (
{BB.t("Vorauswahl aus der Wissensbasis")}
{BB.t("Kategorie oder Tag wählen. Die passenden Einträge werden als eigene Tierlist-Einträge in den Vorrat übernommen; das Bild wird separat zugeordnet.")}
{ setMode(next); setValue(""); }} options={[{ value: "category", label: "Kategorie" }, { value: "tag", label: "Tag" }]} />
{loading &&
{BB.t("Vorauswahl wird geladen …")}
} {error &&
{error}
} {!loading && value && !error && candidates.length === 0 && (
{BB.t("Keine passenden Einträge gefunden.")}
)} {!loading && candidates.length > 0 && ( <>
{total} passende Einträge{total > candidates.length ? `, ${candidates.length} angezeigt` : ""}
{candidates.map((post) => { const exists = selected.has(post.slug); return (
{post.title} {post.summary &&
{post.summary}
}
); })}
{total > candidates.length && ( )} )}
); } function TierListEditor({ value, onChange, categories }) { const data = useMemo(() => normalise(value), [value]); const [drag, setDrag] = useState(null); // { id, x, y, from } const [hover, setHover] = useState(null); // Ziel-Container unter dem Zeiger const [imageBusy, setImageBusy] = useState({}); const notify = useToast(); const patch = (next) => onChange(next); /* --- Ziehen ------------------------------------------------------------ */ function startDrag(e, id, from) { if (e.button !== undefined && e.button !== 0) return; e.preventDefault(); setDrag({ id, from, x: e.clientX, y: e.clientY }); } useEffect(() => { if (!drag) return; const move = (e) => { setDrag((d) => (d ? { ...d, x: e.clientX, y: e.clientY } : d)); const el = document.elementFromPoint(e.clientX, e.clientY); const zone = el && el.closest ? el.closest("[data-drop]") : null; setHover(zone ? zone.getAttribute("data-drop") : null); }; const up = (e) => { const el = document.elementFromPoint(e.clientX, e.clientY); const zone = el && el.closest ? el.closest("[data-drop]") : null; const target = zone ? zone.getAttribute("data-drop") : null; if (target && target !== drag.from) { const next = { ...data, tiers: data.tiers.map((t) => ({ ...t, items: t.items.filter((i) => i !== drag.id) })), pool: data.pool.filter((i) => i !== drag.id), }; if (target === "pool") next.pool = [...next.pool, drag.id]; else next.tiers = next.tiers.map((t) => (t.id === target ? { ...t, items: [...t.items, drag.id] } : t)); patch(next); } setDrag(null); setHover(null); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", up); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); }; }, [drag, data]); /* --- Reihen bearbeiten -------------------------------------------------- */ const setTier = (id, changes) => patch({ ...data, tiers: data.tiers.map((t) => (t.id === id ? { ...t, ...changes } : t)) }); function addTier() { const color = TIER_COLORS[data.tiers.length % TIER_COLORS.length]; patch({ ...data, tiers: [...data.tiers, { id: uid(), label: "Neu", color, items: [] }] }); } function removeTier(id) { const tier = data.tiers.find((t) => t.id === id); patch({ ...data, tiers: data.tiers.filter((t) => t.id !== id), pool: [...data.pool, ...(tier ? tier.items : [])], }); } function moveTier(id, dir) { const i = data.tiers.findIndex((t) => t.id === id); const j = i + dir; if (i < 0 || j < 0 || j >= data.tiers.length) return; const tiers = [...data.tiers]; [tiers[i], tiers[j]] = [tiers[j], tiers[i]]; patch({ ...data, tiers }); } /* --- Bilder ------------------------------------------------------------- */ async function addImages(files) { const list = Array.from(files || []).slice(0, 20); if (!list.length) return; const added = {}; const ids = []; for (const file of list) { try { const url = await uploadTierlistImage(file); const id = uid(); added[id] = { label: file.name.replace(/\.[^.]+$/, "").slice(0, 40), image: url }; ids.push(id); } catch (e) { notify(`${file.name}: ${e.message}`, "bad"); } } if (ids.length) { patch({ ...data, items: { ...data.items, ...added }, pool: [...data.pool, ...ids] }); } } async function addImageToItem(id, file) { if (!file || !data.items[id]) return; setImageBusy((busy) => ({ ...busy, [id]: true })); try { const image = await uploadTierlistImage(file); patch({ ...data, items: { ...data.items, [id]: { ...data.items[id], image } } }); } catch (e) { notify(`${data.items[id].label || "Bild"}: ${e.message}`, "bad"); } finally { setImageBusy((busy) => ({ ...busy, [id]: false })); } } function removeItem(id) { const items = { ...data.items }; delete items[id]; patch({ ...data, items, pool: data.pool.filter((i) => i !== id), tiers: data.tiers.map((t) => ({ ...t, items: t.items.filter((i) => i !== id) })), }); } const dragItem = drag ? data.items[drag.id] : null; return (
{data.tiers.map((tier, i) => (
setTier(tier.id, { label: e.target.value.slice(0, 12) })} aria-label={BB.t("Name der Reihe")} />
{tier.items.map((id) => data.items[id] && ( startDrag(e, id, tier.id)} /> ))}
setTier(tier.id, { color: e.target.value })} aria-label={BB.t("Farbe der Reihe")} />
))}
Vorrat {Object.keys(data.items).length} Einträge
{data.pool.map((id) => data.items[id] && (
startDrag(e, id, "pool")} />
))} {data.pool.length === 0 && {BB.t("Noch keine Bilder.")}}
{drag && dragItem && (
{dragItem.image ? : {dragItem.label}}
)}
); } /* --- Anzeige -------------------------------------------------------------- */ function TierListView({ data }) { const d = useMemo(() => normalise(data), [data]); const used = d.tiers.some((t) => t.items.length) || d.pool.length; if (!used) return null; return (
{d.tiers.map((tier) => (
{tier.label}
{tier.items.map((id) => d.items[id] && ( {}} /> ))}
))} {d.pool.length > 0 && (
offen
{d.pool.map((id) => d.items[id] && ( {}} /> ))}
)}
); } Object.assign(window, { TierListEditor, TierListView, emptyTierlist, normaliseTierlist: normalise });