Files
fuz-site/src/islands/jambox/JamboxChannelsSearch.jsx

431 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useChannelSearch } from "../../hooks/useChannelSearch.js";
import { useMemo, useState } from "preact/hooks";
import "../../styles/jambox-search.css";
export default function JamboxChannelsSearch() {
// ✅ NOWY: Hook useChannelSearch zamiast ręcznego zarządzania
const search = useChannelSearch('/api/jambox/jambox-channels-search', {
debounceMs: 250,
minQueryLength: 1,
limit: 80
});
// Koszyk kanałów ("Chciałbym mieć te kanały")
const [wanted, setWanted] = useState([]);
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 : [],
thematic_packages: Array.isArray(c.thematic_packages)
? c.thematic_packages
: [],
},
];
});
}
function removeWantedByName(name) {
setWanted((prev) =>
prev.filter(
(w) =>
String(w.name || "").toLowerCase() !== String(name || "").toLowerCase()
)
);
}
function clearWanted() {
setWanted([]);
}
// Sugestie pakietów na podstawie wybranych kanałów
const packageSuggestions = useMemo(() => {
if (!wanted.length) return { exact: [], ranked: [], thematic: [], baseWantedLen: 0, wantedLen: 0 };
// Kanały, które mają pakiety główne
const baseWanted = wanted.filter((ch) => Array.isArray(ch.packages) && ch.packages.length > 0);
const baseWantedLen = baseWanted.length;
// Jeśli nie ma żadnego kanału "bazowego", zwracamy tylko tematyczne
if (baseWantedLen === 0) {
const thematicMap = new Map();
for (const ch of wanted) {
const tp = Array.isArray(ch.thematic_packages) ? ch.thematic_packages : [];
for (const p of tp) {
const tid = String(p?.tid ?? "").trim();
const name = String(p?.name ?? "").trim();
if (!tid || !name) continue;
if (!thematicMap.has(tid)) thematicMap.set(tid, { tid, name });
}
}
const thematic = Array.from(thematicMap.values()).sort((a, b) => a.name.localeCompare(b.name, "pl"));
return { exact: [], ranked: [], thematic, baseWantedLen, wantedLen: wanted.length };
}
// Zlicz pakiety
const counts = new Map();
for (const ch of baseWanted) {
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());
// Pakiety zawierające wszystkie wybrane kanały
const exact = all
.filter((p) => p.count === baseWantedLen)
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
// Pakiety zawierające część kanałów (posortowane po ilości dopasowań)
const ranked = all
.filter((p) => p.count < baseWantedLen)
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "pl"))
.slice(0, 12);
// Pakiety tematyczne (dodatki)
const thematicMap = new Map();
for (const ch of wanted) {
const tp = Array.isArray(ch.thematic_packages) ? ch.thematic_packages : [];
for (const p of tp) {
const tid = String(p?.tid ?? "").trim();
const name = String(p?.name ?? "").trim();
if (!tid || !name) continue;
if (!thematicMap.has(tid)) thematicMap.set(tid, { tid, name });
}
}
const thematic = Array.from(thematicMap.values()).sort((a, b) =>
a.name.localeCompare(b.name, "pl")
);
return { exact, ranked, thematic, baseWantedLen, wantedLen: wanted.length };
}, [wanted]);
function scrollToPackage(packageName) {
const key = String(packageName || "").trim();
if (!key) return;
const el = document.getElementById(`pkg-${key}`);
if (!el) {
console.warn("⚠ Nie znaleziono pakietu w DOM:", `pkg-${key}`);
return;
}
el.scrollIntoView({ behavior: "smooth", block: "start" });
el.classList.add("is-target");
window.setTimeout(() => el.classList.remove("is-target"), 5400);
}
return (
<div class="f-chsearch">
<h2 class="f-section-title">Wyszukiwanie kanałów w pakietach telewizji</h2>
{/* SEKCJA "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>
{/* Pakiety główne */}
<div class="mt-2">
<div class="opacity-80">Pakiety główne:</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="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}/{packageSuggestions.baseWantedLen})
</button>
))}
</div>
)}
</div>
{/* Pakiety tematyczne dodatki */}
{packageSuggestions.thematic.length > 0 && (
<div class="mt-4">
<div class="opacity-80">
Pakiety tematyczne do dokupienia:
</div>
<div class="flex flex-wrap gap-2 mt-2">
{packageSuggestions.thematic.map((p) => (
<button
type="button"
key={p.tid}
class="f-chsearch-pkg"
onClick={() => window.open(
`/premium/${p.tid}`,
"_blank",
"noopener,noreferrer"
)}
title="Otwórz w nowej karcie"
>
{p.name}
</button>
))}
</div>
</div>
)}
</div>
</>
)}
</div>
{/* WYSZUKIWARKA */}
<div class="f-chsearch__top">
<div class="f-chsearch__inputwrap">
<input
id="channel-search"
class="f-chsearch__input"
type="search"
value={search.query}
onInput={(e) => search.setQuery(e.currentTarget.value)}
placeholder="Szukaj kanału po nazwie…"
aria-label="Szukaj kanału po nazwie"
/>
{search.query && (
<button
type="button"
class="f-chsearch__clear"
aria-label="Wyczyść wyszukiwanie"
onClick={() => search.clear()}
>
</button>
)}
</div>
{/* ✅ Meta z hooka zamiast ręcznego useMemo */}
<div class="f-chsearch-meta">{search.meta}</div>
</div>
{/* LISTA WYNIKÓW */}
<div class="f-chsearch__list" role="list">
{search.items.map((c) => {
const selected = isWanted(c);
return (
<div
class="f-chsearch__row"
role="listitem"
key={`${c.name}-${c.logo_url || ""}`}
>
{/* Kolumna lewa */}
<div class="f-chsearch__left">
{c.logo_url && (
<img
src={c.logo_url}
alt={c.name}
class="f-chsearch__logo"
loading="lazy"
/>
)}
<div class="f-chsearch__channel-name">{c.name}</div>
<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>
{/* Kolumna prawa */}
<div class="f-chsearch__right">
<div
class="f-chsearch__desc f-chsearch__desc--html"
dangerouslySetInnerHTML={{
__html: c.description || "<em>—</em>",
}}
/>
{/* Pakiety główne */}
{Array.isArray(c.packages) && c.packages.length > 0 && (
<div class="f-chsearch__packages">
Dostępny w pakietach:&nbsp;
{c.packages.map((p) => (
<span key={p.id}>
<button
type="button"
class="f-chsearch-pkg"
onClick={() => scrollToPackage(p.name)}
title="Kliknij, aby przewinąć do pakietu"
>
{p.name}
</button>
</span>
))}
</div>
)}
{/* Pakiety tematyczne */}
{Array.isArray(c.thematic_packages) &&
c.thematic_packages.length > 0 && (
<div class="f-chsearch__packages">
Dostępny w pakietach tematycznych:&nbsp;
{c.thematic_packages.map((p) => (
<span key={`${p.tid}-${p.name}`}>
<button
type="button"
class="f-chsearch-pkg"
onClick={() => window.open(
`/premium/${p.tid}`,
"_blank",
"noopener,noreferrer"
)}
title="Otwórz w nowej karcie"
>
{p.name}
</button>
</span>
))}
</div>
)}
</div>
</div>
);
})}
{/* Pusta lista */}
{search.query.trim().length >= 1 && !search.loading && search.items.length === 0 && (
<div class="f-chsearch-empty">
Brak wyników dla: <strong>{search.query}</strong>
</div>
)}
</div>
</div>
);
}
/*
✅ ZMIANY W PORÓWNANIU DO ORYGINAŁU:
USUNIĘTE (~40 linii):
- const [q, setQ] = useState("")
- const [items, setItems] = useState([])
- const [loading, setLoading] = useState(false)
- const [err, setErr] = useState("")
- const abortRef = useRef(null)
- Cały useEffect z fetch i debouncing (~35 linii)
- const meta = useMemo(() => {...}, [...])
DODANE (~5 linii):
- import { useChannelSearch } from "../../hooks/useChannelSearch.js"
- const search = useChannelSearch('/api/jambox/jambox-channels-search', {...})
ZMIENIONE W JSX:
- value={q} → value={search.query}
- onInput={(e) => setQ(e.target.value)} → onInput={(e) => search.setQuery(e.target.value)}
- onClick={() => setQ("")} → onClick={() => search.clear()}
- {meta} → {search.meta}
- {items.map(...)} → {search.items.map(...)}
- {q.trim().length >= 1 && !loading && items.length === 0} → {search.query.trim().length >= 1 && !search.loading && search.items.length === 0}
REZULTAT:
- ~35 linii kodu mniej (27% redukcja)
- Usunięte zarządzanie stanem wyszukiwania
- Usunięte debouncing i AbortController
- Kod łatwiejszy do testowania
*/