Chciałbym miec te kanały

This commit is contained in:
dm
2025-12-15 12:36:44 +01:00
parent dc07fa083e
commit fadeee992d
2 changed files with 247 additions and 65 deletions

View File

@@ -7,6 +7,9 @@ export default function JamboxChannelsSearch() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [err, setErr] = useState(""); const [err, setErr] = useState("");
// ✅ koszyk kanałów
const [wanted, setWanted] = useState([]); // [{ name, logo_url, packages:[{id,name}] }]
const abortRef = useRef(null); const abortRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -31,13 +34,10 @@ export default function JamboxChannelsSearch() {
params.set("q", qq); params.set("q", qq);
params.set("limit", "80"); params.set("limit", "80");
const res = await fetch( const res = await fetch(`/api/jambox/jambox-channels-search?${params.toString()}`, {
`/api/jambox/jambox-channels-search?${params.toString()}`,
{
signal: ac.signal, signal: ac.signal,
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
} });
);
const json = await res.json(); const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json?.error || "API_ERROR"); if (!res.ok || !json.ok) throw new Error(json?.error || "API_ERROR");
@@ -59,13 +59,12 @@ export default function JamboxChannelsSearch() {
const meta = useMemo(() => { const meta = useMemo(() => {
const qq = q.trim(); const qq = q.trim();
if (qq.length === 0) return ""; if (qq.length === 0) return "";
// "Zacznij pisać, aby wyszukać"
if (loading) return "Szukam…"; if (loading) return "Szukam…";
if (err) return err; if (err) return err;
return `Znaleziono: ${items.length}`; return `Znaleziono: ${items.length}`;
}, [q, loading, err, items]); }, [q, loading, err, items]);
function scrollToPackage(packageName) { function scrollToPackage(packageName) {
const key = String(packageName || "").trim(); const key = String(packageName || "").trim();
if (!key) return; if (!key) return;
@@ -78,12 +77,164 @@ function scrollToPackage(packageName) {
el.scrollIntoView({ behavior: "smooth", block: "start" }); el.scrollIntoView({ behavior: "smooth", block: "start" });
el.classList.add("is-target"); el.classList.add("is-target");
window.setTimeout(() => el.classList.remove("is-target"), 5400); window.setTimeout(() => el.classList.remove("is-target"), 5400);
} }
// ==========================
// ✅ koszyk: dodaj/usuń kanał
// ==========================
const isWanted = (c) =>
wanted.some((w) => String(w.name || "").toLowerCase() === String(c.name || "").toLowerCase());
function addWanted(c) {
setWanted((prev) => {
const exists = prev.some(
(w) => String(w.name || "").toLowerCase() === String(c.name || "").toLowerCase()
);
if (exists) return prev;
return [
...prev,
{
name: c.name,
logo_url: c.logo_url || "",
packages: Array.isArray(c.packages) ? c.packages : [],
},
];
});
}
function removeWantedByName(name) {
setWanted((prev) =>
prev.filter((w) => String(w.name || "").toLowerCase() !== String(name || "").toLowerCase())
);
}
function clearWanted() {
setWanted([]);
}
// =========================================
// ✅ pakiety, które zawierają WSZYSTKIE kanały
// =========================================
const packageSuggestions = useMemo(() => {
if (!wanted.length) return { exact: [], ranked: [] };
// mapa pakietu -> { id,name,count }
const counts = new Map(); // key = packageName
for (const ch of wanted) {
const pkgs = Array.isArray(ch.packages) ? ch.packages : [];
for (const p of pkgs) {
const name = String(p?.name ?? "").trim();
if (!name) continue;
const cur = counts.get(name) || { id: p?.id ?? name, name, count: 0 };
cur.count += 1;
counts.set(name, cur);
}
}
const all = Array.from(counts.values());
// ✅ exact: pakiety z count == wanted.length (czyli zawierają wszystkie)
const exact = all
.filter((p) => p.count === wanted.length)
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
// ✅ ranked: najlepsze dopasowania gdy exact puste (malejąco count)
const ranked = all
.filter((p) => p.count < wanted.length)
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "pl"))
.slice(0, 12);
return { exact, ranked };
}, [wanted]);
return ( return (
<div class="f-chsearch"> <div class="f-chsearch">
<h1 class="f-section-title">Wyszukiwanie kanałów w pakietach telewizji</h1> <h1 class="f-section-title">Wyszukiwanie kanałów w pakietach telewizji</h1>
{/* ✅ SEK CJA "CHCIAŁBYM MIEĆ TE KANAŁY" */}
<div class="f-chsearch__wanted">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="text-lg font-semibold">Chciałbym mieć te kanały</div>
{wanted.length > 0 && (
<button type="button" class="btn btn-outline" onClick={clearWanted}>
Wyczyść
</button>
)}
</div>
{wanted.length === 0 ? (
<div class="opacity-80 mt-2">
Dodaj kanały z listy wyników pokażę pakiety, które zawierają wszystkie wybrane kanały.
</div>
) : (
<>
<div class="f-chsearch__wanted-list">
{wanted.map((w) => (
<div class="f-chsearch__wanted-chip" key={w.name}>
{w.logo_url ? (
<img src={w.logo_url} alt={w.name} class="f-chsearch__wanted-logo" loading="lazy" />
) : null}
<span class="f-chsearch__wanted-name">{w.name}</span>
<button
type="button"
class="f-chsearch__wanted-remove"
aria-label={`Usuń ${w.name}`}
onClick={() => removeWantedByName(w.name)}
>
</button>
</div>
))}
</div>
{/* ✅ SUGESTIE PAKIETÓW */}
<div class="f-chsearch__wanted-packages">
<div class="font-semibold">Pakiety pasujące do wybranych kanałów:</div>
{packageSuggestions.exact.length > 0 ? (
<div class="flex flex-wrap gap-2 mt-2">
{packageSuggestions.exact.map((p) => (
<button
type="button"
key={p.name}
class="f-chsearch-pkg"
onClick={() => scrollToPackage(p.name)}
title="Kliknij, aby przewinąć do pakietu"
>
{p.name}
</button>
))}
</div>
) : (
<>
<div class="opacity-80 mt-2">
Nie ma jednego pakietu zawierającego wszystkie wybrane kanały.
Poniżej najlepsze dopasowania:
</div>
<div class="flex flex-wrap gap-2 mt-2">
{packageSuggestions.ranked.map((p) => (
<button
type="button"
key={p.name}
class="f-chsearch-pkg"
onClick={() => scrollToPackage(p.name)}
title={`Zawiera ${p.count}/${wanted.length} wybranych kanałów`}
>
{p.name} ({p.count}/{wanted.length})
</button>
))}
</div>
</>
)}
</div>
</>
)}
</div>
{/* SEARCH */}
<div class="f-chsearch__top"> <div class="f-chsearch__top">
<div class="f-chsearch__inputwrap"> <div class="f-chsearch__inputwrap">
<input <input
@@ -111,57 +262,58 @@ function scrollToPackage(packageName) {
<div class="f-chsearch-meta">{meta}</div> <div class="f-chsearch-meta">{meta}</div>
</div> </div>
{/* LIST */}
<div class="f-chsearch__list" role="list"> <div class="f-chsearch__list" role="list">
{items.map((c) => ( {items.map((c) => {
<div const selected = isWanted(c);
class="f-chsearch__row"
role="listitem" return (
key={`${c.name}-${c.logo_url || ""}`} <div class="f-chsearch__row" role="listitem" key={`${c.name}-${c.logo_url || ""}`}>
>
{/* kolumna 1 */} {/* kolumna 1 */}
<div class="f-chsearch__left"> <div class="f-chsearch__left">
{c.logo_url && ( {c.logo_url && (
<img <img src={c.logo_url} alt={c.name} class="f-chsearch__logo" loading="lazy" />
src={c.logo_url}
alt={c.name}
class="f-chsearch__logo"
loading="lazy"
/>
)} )}
<div class="f-chsearch__channel-name">{c.name}</div> <div class="f-chsearch__channel-name">{c.name}</div>
{/* ✅ przycisk dodaj/usuń */}
<div class="mt-2">
{!selected ? (
<button type="button" class="btn btn-outline" onClick={() => addWanted(c)}>
Dodaj do Chciałbym mieć
</button>
) : (
<button type="button" class="btn btn-primary" onClick={() => removeWantedByName(c.name)}>
Usuń z listy
</button>
)}
</div>
</div> </div>
{/* kolumna 2 */} {/* kolumna 2 */}
<div class="f-chsearch__right"> <div class="f-chsearch__right">
<div <div
class="f-chsearch__desc f-chsearch__desc--html" class="f-chsearch__desc f-chsearch__desc--html"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{ __html: c.description || "<em>—</em>" }}
__html: c.description || "<em>—</em>",
}}
/> />
{Array.isArray(c.packages) && c.packages.length > 0 && ( {Array.isArray(c.packages) && c.packages.length > 0 && (
<div class="f-chsearch__packages"> <div class="f-chsearch__packages">
Dostępny w pakietach:&nbsp; Dostępny w pakietach:&nbsp;
{c.packages.map((p, i) => ( {c.packages.map((p) => (
<span key={p.id}> <span key={p.id}>
<button <button type="button" class="f-chsearch-pkg" onClick={() => scrollToPackage(p.name)}>
type="button"
class="f-chsearch-pkg"
onClick={() => scrollToPackage(p.name)}
>
{p.name} {p.name}
</button> </button>
{/* {i < c.packages.length - 1 ? ", " : ""} */}
</span> </span>
))} ))}
</div> </div>
)} )}
</div> </div>
</div> </div>
))} );
})}
{q.trim().length >= 1 && !loading && items.length === 0 && ( {q.trim().length >= 1 && !loading && items.length === 0 && (
<div class="f-chsearch-empty"> <div class="f-chsearch-empty">

View File

@@ -132,3 +132,33 @@
.f-chsearch__input[type="search"] { .f-chsearch__input[type="search"] {
appearance: none; appearance: none;
} }
/* ----- */
.f-chsearch__wanted {
@apply mb-6 p-4 rounded-2xl border border-[--f-input-border];
}
.f-chsearch__wanted-list {
@apply mt-3 flex flex-wrap gap-2;
}
.f-chsearch__wanted-chip {
@apply inline-flex items-center gap-2 rounded-full border border-[--f-border-color] px-3 py-1.5;
background: rgba(148, 163, 184, 0.08);
}
.f-chsearch__wanted-logo {
@apply w-6 h-6 rounded-full object-contain;
}
.f-chsearch__wanted-name {
@apply text-sm font-medium;
}
.f-chsearch__wanted-remove {
@apply text-sm opacity-70 hover:opacity-100;
}
.f-chsearch__wanted-packages {
@apply mt-4;
}