/* ==========================================================================
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 || "?").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" }]}
/>
setValue(e.target.value)}
aria-label={mode === "category" ? "Kategorie für Tierlist-Vorauswahl" : "Tag für Tierlist-Vorauswahl"}
>
{mode === "category" ? "Kategorie wählen …" : "Tag wählen …"}
{sourceOptions.map((o) => {o.label} )}
{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` : ""}
addCandidates(newCandidates)}
disabled={!newCandidates.length}
>
{newCandidates.length ? `Alle ${newCandidates.length} übernehmen` : "Bereits im Vorrat"}
{candidates.map((post) => {
const exists = selected.has(post.slug);
return (
{post.title}
{post.summary &&
{post.summary}
}
addCandidates([post])}
disabled={exists}
>
{exists ? "Im Vorrat" : "Hinzufügen"}
);
})}
{total > candidates.length && (
{loadingMore ? "Weitere werden geladen …" : `Weitere ${Math.min(100, total - candidates.length)} laden`}
)}
>
)}
);
}
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) => (
))}
+ Reihe
{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 });