Chciałbym miec te kanały
This commit is contained in:
@@ -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,
|
||||||
{
|
headers: { Accept: "application/json" },
|
||||||
signal: ac.signal,
|
});
|
||||||
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,31 +59,182 @@ 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;
|
||||||
|
|
||||||
const el = document.getElementById(`pkg-${key}`);
|
const el = document.getElementById(`pkg-${key}`);
|
||||||
if (!el) {
|
if (!el) {
|
||||||
console.warn("❌ Nie znaleziono pakietu w DOM:", `pkg-${key}`);
|
console.warn("❌ Nie znaleziono pakietu w DOM:", `pkg-${key}`);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
el.classList.add("is-target");
|
||||||
|
window.setTimeout(() => el.classList.remove("is-target"), 5400);
|
||||||
}
|
}
|
||||||
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
// ==========================
|
||||||
el.classList.add("is-target");
|
// ✅ koszyk: dodaj/usuń kanał
|
||||||
window.setTimeout(() => el.classList.remove("is-target"), 5400);
|
// ==========================
|
||||||
}
|
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"
|
|
||||||
key={`${c.name}-${c.logo_url || ""}`}
|
|
||||||
>
|
|
||||||
{/* kolumna 1 */}
|
|
||||||
<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>
|
return (
|
||||||
</div>
|
<div class="f-chsearch__row" role="listitem" key={`${c.name}-${c.logo_url || ""}`}>
|
||||||
|
{/* kolumna 1 */}
|
||||||
|
<div class="f-chsearch__left">
|
||||||
|
{c.logo_url && (
|
||||||
|
<img src={c.logo_url} alt={c.name} class="f-chsearch__logo" loading="lazy" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* kolumna 2 */}
|
<div class="f-chsearch__channel-name">{c.name}</div>
|
||||||
<div class="f-chsearch__right">
|
|
||||||
<div
|
|
||||||
class="f-chsearch__desc f-chsearch__desc--html"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: c.description || "<em>—</em>",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{Array.isArray(c.packages) && c.packages.length > 0 && (
|
{/* ✅ przycisk dodaj/usuń */}
|
||||||
<div class="f-chsearch__packages">
|
<div class="mt-2">
|
||||||
Dostępny w pakietach:
|
{!selected ? (
|
||||||
{c.packages.map((p, i) => (
|
<button type="button" class="btn btn-outline" onClick={() => addWanted(c)}>
|
||||||
<span key={p.id}>
|
Dodaj do “Chciałbym mieć”
|
||||||
<button
|
</button>
|
||||||
type="button"
|
) : (
|
||||||
class="f-chsearch-pkg"
|
<button type="button" class="btn btn-primary" onClick={() => removeWantedByName(c.name)}>
|
||||||
onClick={() => scrollToPackage(p.name)}
|
Usuń z listy
|
||||||
>
|
</button>
|
||||||
{p.name}
|
)}
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* {i < c.packages.length - 1 ? ", " : ""} */}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* kolumna 2 */}
|
||||||
|
<div class="f-chsearch__right">
|
||||||
|
<div
|
||||||
|
class="f-chsearch__desc f-chsearch__desc--html"
|
||||||
|
dangerouslySetInnerHTML={{ __html: c.description || "<em>—</em>" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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)}>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user