431 lines
14 KiB
JavaScript
431 lines
14 KiB
JavaScript
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:
|
||
{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:
|
||
{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
|
||
*/ |