Initial commit
This commit is contained in:
194
resources/js/components/RoundsOverview.tsx
Normal file
194
resources/js/components/RoundsOverview.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { Card, CardBody, Listbox, ListboxItem, type Selection } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLanguageStore } from "@/stores/languageStore";
|
||||
import { useContestStore, type RoundSummary } from "@/stores/contestStore";
|
||||
import { useContestRefreshStore } from "@/stores/contestRefreshStore";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type RoundItem = RoundSummary & {
|
||||
contest_id: number;
|
||||
description?: string | Record<string, string> | null;
|
||||
};
|
||||
|
||||
type PaginatedResponse<T> = {
|
||||
data: T[];
|
||||
};
|
||||
|
||||
type RoundsOverviewProps = {
|
||||
contestId: number | null;
|
||||
onlyActive?: boolean;
|
||||
showTests?: boolean;
|
||||
className?: string;
|
||||
roundsFromStore?: RoundSummary[] | null;
|
||||
};
|
||||
|
||||
const resolveTranslation = (field: any, locale: string): string => {
|
||||
if (!field) return "";
|
||||
if (typeof field === "string") return field;
|
||||
if (typeof field === "object") {
|
||||
if (field[locale]) return field[locale];
|
||||
if (field["en"]) return field["en"];
|
||||
const first = Object.values(field)[0];
|
||||
return typeof first === "string" ? first : "";
|
||||
}
|
||||
return String(field);
|
||||
};
|
||||
|
||||
export default function RoundsOverview({
|
||||
contestId,
|
||||
onlyActive = false,
|
||||
showTests = false,
|
||||
className,
|
||||
roundsFromStore = null,
|
||||
}: RoundsOverviewProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const locale = useLanguageStore((s) => s.locale);
|
||||
const refreshKey = useContestRefreshStore((s) => s.refreshKey);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [items, setItems] = useState<RoundItem[]>(roundsFromStore ?? []);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
|
||||
|
||||
const setSelectedRound = useContestStore((s) => s.setSelectedRound);
|
||||
const selectedRound = useContestStore((s) => s.selectedRound);
|
||||
|
||||
// sync selection with store
|
||||
useEffect(() => {
|
||||
if (selectedRound) {
|
||||
setSelectedKeys(new Set([String(selectedRound.id)]));
|
||||
} else {
|
||||
setSelectedKeys(new Set([]));
|
||||
}
|
||||
}, [selectedRound]);
|
||||
|
||||
useEffect(() => {
|
||||
// pokud máme roundsFromStore s daty, použij je a nefetchuj
|
||||
if (roundsFromStore && roundsFromStore.length > 0) {
|
||||
setItems(roundsFromStore);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contestId) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await axios.get<PaginatedResponse<RoundItem> | RoundItem[]>(
|
||||
"/api/rounds",
|
||||
{
|
||||
withCredentials: true,
|
||||
headers: { Accept: "application/json" },
|
||||
params: {
|
||||
lang: locale,
|
||||
contest_id: contestId,
|
||||
only_active: onlyActive ? 1 : 0,
|
||||
include_tests: showTests ? 1 : 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!active) return;
|
||||
|
||||
const data = Array.isArray(res.data)
|
||||
? res.data
|
||||
: (res.data as PaginatedResponse<RoundItem>).data;
|
||||
|
||||
setItems(data);
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setError(t("unable_to_load_rounds") ?? "Nepodařilo se načíst seznam kol.");
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { active = false; };
|
||||
}, [locale, t, refreshKey, roundsFromStore, contestId, onlyActive, showTests]);
|
||||
|
||||
const visibleItems = useMemo(() => items, [items]);
|
||||
|
||||
const isSelected = (id: string | number) => {
|
||||
if (selectedKeys === "all") return false;
|
||||
return Array.from(selectedKeys).some((k) => String(k) === String(id));
|
||||
};
|
||||
|
||||
const handleSelectionChange = (keys: Selection) => {
|
||||
setSelectedKeys(keys);
|
||||
if (keys === "all") return;
|
||||
|
||||
const id = Array.from(keys)[0];
|
||||
if (!id) {
|
||||
if (selectedRound) {
|
||||
setSelectedRound(null);
|
||||
if (contestId != null) navigate(`/contests/${contestId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedRound && String(selectedRound.id) === String(id)) return;
|
||||
|
||||
const selected = visibleItems.find((r) => String(r.id) === String(id));
|
||||
if (selected) {
|
||||
setSelectedRound(selected);
|
||||
navigate(`/contests/${selected.contest_id}/rounds/${selected.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardBody className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4">{t("rounds_loading") ?? "Načítám kola…"}</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-sm text-red-600">{error}</div>
|
||||
) : visibleItems.length === 0 ? (
|
||||
<div className="p-4">{t("rounds_empty") ?? "Žádná kola nejsou k dispozici."}</div>
|
||||
) : (
|
||||
<Listbox
|
||||
aria-label="Rounds overview"
|
||||
selectionMode="single"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
variant="flat"
|
||||
>
|
||||
{visibleItems.map((item) => (
|
||||
<ListboxItem key={item.id} textValue={resolveTranslation(item.name, locale)}>
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isSelected(item.id) ? "font-semibold text-primary" : "font-medium"
|
||||
}`}
|
||||
>
|
||||
{resolveTranslation(item.name, locale)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs line-clamp-2 ${
|
||||
isSelected(item.id) ? "text-primary-500" : "text-foreground-500"
|
||||
}`}
|
||||
>
|
||||
{resolveTranslation(item.description ?? null, locale) || "—"}
|
||||
</span>
|
||||
</div>
|
||||
</ListboxItem>
|
||||
))}
|
||||
</Listbox>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user