181 lines
5.7 KiB
TypeScript
181 lines
5.7 KiB
TypeScript
import { useEffect, useMemo, useState, useRef } 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 ContestSummary } from "@/stores/contestStore";
|
|
import { useContestRefreshStore } from "@/stores/contestRefreshStore";
|
|
import { useNavigate } from "react-router-dom";
|
|
import RoundsOverview from "./RoundsOverview";
|
|
|
|
type ContestItem = ContestSummary & {
|
|
description?: string | null;
|
|
};
|
|
|
|
type PaginatedResponse<T> = {
|
|
data: T[];
|
|
};
|
|
|
|
type ContestsOverviewProps = {
|
|
/** Zobraz pouze aktivní závody. Default: false */
|
|
onlyActive?: boolean;
|
|
/** Zahrnout testovací závody. Default: false */
|
|
showTests?: boolean;
|
|
className?: string;
|
|
};
|
|
|
|
export default function ContestsOverview({ onlyActive = false, showTests = false, className }: ContestsOverviewProps) {
|
|
const { t } = useTranslation("common");
|
|
const locale = useLanguageStore((s) => s.locale);
|
|
const refreshKey = useContestRefreshStore((s) => s.refreshKey);
|
|
const navigate = useNavigate();
|
|
|
|
const [items, setItems] = useState<ContestItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set(["none"]));
|
|
const lastFetchKey = useRef<string | null>(null);
|
|
|
|
const setSelectedContest = useContestStore((s) => s.setSelectedContest);
|
|
const clearSelection = useContestStore((s) => s.clearSelection);
|
|
const selectedContest = useContestStore((s) => s.selectedContest);
|
|
|
|
// sync se store
|
|
useEffect(() => {
|
|
if (selectedContest) {
|
|
setSelectedKeys(new Set([String(selectedContest.id)]));
|
|
} else {
|
|
setSelectedKeys(new Set(["none"]));
|
|
}
|
|
}, [selectedContest]);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
const fetchKey = `${locale}-${refreshKey}`;
|
|
|
|
// pokud už máme data pro tento klíč a seznam není prázdný, nefetchuj
|
|
if (lastFetchKey.current === fetchKey && items.length > 0) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
lastFetchKey.current = fetchKey;
|
|
|
|
(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const res = await axios.get<PaginatedResponse<ContestItem> | ContestItem[]>(
|
|
"/api/contests",
|
|
{
|
|
withCredentials: true,
|
|
headers: { Accept: "application/json" },
|
|
params: {
|
|
lang: locale,
|
|
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<ContestItem>).data;
|
|
|
|
setItems(data);
|
|
} catch {
|
|
if (!active) return;
|
|
setError(t("unable_to_load_contests") ?? "Nepodařilo se načíst seznam závodů.");
|
|
} finally {
|
|
if (active) setLoading(false);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [locale, t, refreshKey, items.length, 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 || id === "none") {
|
|
clearSelection();
|
|
setSelectedKeys(new Set(["none"]));
|
|
navigate("/contests");
|
|
return;
|
|
}
|
|
|
|
if (selectedContest && String(selectedContest.id) === String(id)) {
|
|
return;
|
|
}
|
|
|
|
const selected = visibleItems.find((c) => String(c.id) === String(id));
|
|
if (selected) {
|
|
setSelectedContest(selected);
|
|
navigate(`/contests/${selected.id}`);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card className={className}>
|
|
<CardBody className="p-0">
|
|
{loading ? (
|
|
<div className="p-4">{t("contests_loading") ?? "Načítám závody…"}</div>
|
|
) : error ? (
|
|
<div className="p-4 text-sm text-red-600">{error}</div>
|
|
) : visibleItems.length === 0 ? (
|
|
<div className="p-4">{t("contests_empty") ?? "Žádné závody nejsou k dispozici."}</div>
|
|
) : (
|
|
<Listbox
|
|
aria-label="Contests overview"
|
|
selectionMode="single"
|
|
selectedKeys={selectedKeys}
|
|
onSelectionChange={handleSelectionChange}
|
|
variant="flat"
|
|
color="primary"
|
|
shouldHighlightOnFocus={true}
|
|
>
|
|
<ListboxItem key="none" textValue="None">
|
|
<div className="flex flex-col">
|
|
<span className="font-semibold text-sm">
|
|
{t("contest_index_page") ?? "Přehled závodů"}
|
|
</span>
|
|
</div>
|
|
</ListboxItem>
|
|
{visibleItems.map((item) => (
|
|
<ListboxItem key={item.id} textValue={item.name}>
|
|
<div className="flex flex-col">
|
|
<span
|
|
className={`text-sm ${isSelected(item.id) ? "font-semibold text-primary" : "font-medium"}`}
|
|
>
|
|
{item.name}
|
|
</span>
|
|
<span
|
|
className={`text-xs line-clamp-2 ${
|
|
isSelected(item.id) ? "text-primary-500" : "text-foreground-500"
|
|
}`}
|
|
>
|
|
{item.description || "—"}
|
|
</span>
|
|
</div>
|
|
</ListboxItem>
|
|
))}
|
|
</Listbox>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
);
|
|
}
|