/* VV frontend: лента (scroll-snap) + сетка, лайки, теги. Без фреймворков. */ const $ = (s) => document.querySelector(s); const feed = $("#feed"), grid = $("#grid"), chips = $("#chips"); const state = { tag: "", q: "", sort: "new", page: 1, pages: 1, items: [], tagFor: null, loading: false, current: null }; const liked = new Set(JSON.parse(localStorage.getItem("vv_liked") || "[]")); const saveLiked = () => localStorage.setItem("vv_liked", JSON.stringify([...liked].slice(-500))); async function api(path, opts) { const r = await fetch(path, opts); if (!r.ok) throw new Error(await r.text()); return r.status === 204 ? null : r.json(); } /* --- теги-чипсы --- */ async function loadTags() { const tags = await api("/api/tags").catch(() => []); chips.innerHTML = ""; const all = document.createElement("button"); all.textContent = "Все"; all.className = state.tag ? "" : "on"; all.onclick = () => { state.tag = ""; state.page = 1; loadTags(); reload(); }; chips.append(all); for (const t of tags.slice(0, 40)) { const b = document.createElement("button"); b.textContent = `#${t.name} ${t.count}`; b.className = state.tag === t.name ? "on" : ""; b.onclick = () => { state.tag = t.name; state.page = 1; loadTags(); reload(); }; chips.append(b); } } /* --- загрузка списка --- */ async function fetchVideos(append) { const p = new URLSearchParams({ tag: state.tag, q: state.q, sort: state.sort, page: state.page, per_page: 20, }); const data = await api("/api/videos?" + p); state.pages = data.pages; state.items = append ? state.items.concat(data.items) : data.items; return data.items; } function slideEl(v) { const s = document.createElement("section"); s.className = "slide"; s.dataset.file = v.file; const tags = v.tags.map((t) => `#${t}`).join(" "); s.innerHTML = `
❤️
${escapeHtml(v.file)}
${tags}
`; const video = s.querySelector("video"); s.querySelector(".like").onclick = () => likeVideo(v.file, s); s.querySelector(".tagbtn").onclick = () => openTagForm(v.file); s.querySelectorAll(".tag").forEach((el) => { el.onclick = () => { state.tag = el.dataset.tag; state.page = 1; loadTags(); reload(); }; }); // тап по центру = play/pause, даблтап = лайк let lastTap = 0; video.onclick = () => { const now = Date.now(); if (now - lastTap < 300) likeVideo(v.file, s); else if (video.paused) video.play().catch(() => {}); else video.pause(); lastTap = now; }; return s; } function escapeHtml(s) { return s.replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); } async function reload() { feed.innerHTML = ""; grid.innerHTML = ""; state.page = 1; state.current = null; state.loading = true; const items = await fetchVideos(false).catch(() => []); state.loading = false; if (!items.length) { feed.innerHTML = `
Ничего не найдено 😢
`; return; } renderFeed(items); renderGrid(items); observeSlides(); } async function loadMore() { if (state.loading || state.page >= state.pages) return; state.loading = true; state.page++; const before = state.items.length; const items = await fetchVideos(true).catch(() => []); state.loading = false; const fresh = state.items.slice(before); if (!fresh.length) return; // убрать старую кнопку "Ещё" перед дорисовкой feed.querySelector(".more")?.remove(); renderFeed(fresh, true); renderGrid(fresh, true); observeSlides(); void items; } function renderFeed(items, append) { if (!append) feed.innerHTML = ""; for (const v of items) feed.append(slideEl(v)); if (state.page < state.pages) { const b = document.createElement("button"); b.className = "more"; b.textContent = "Ещё ⬇"; b.onclick = () => { b.remove(); loadMore(); }; feed.append(b); } } function renderGrid(items, append) { if (!append) grid.innerHTML = ""; for (const v of items) { const c = document.createElement("div"); c.className = "card"; c.innerHTML = `
❤️ ${v.likes}${escapeHtml(shortName(v.file))}
`; c.querySelector(".open").onclick = (e) => { e.preventDefault(); setMode("feed"); const el = feed.querySelector(`[data-file="${CSS.escape(v.file)}"]`); if (el) el.scrollIntoView(); }; grid.append(c); } } function shortName(f) { return f.length > 18 ? f.slice(0, 17) + "…" : f; } /* автоплей видимого слайда */ let observer; function observeSlides() { observer && observer.disconnect(); observer = new IntersectionObserver((entries) => { for (const e of entries) { const video = e.target.querySelector("video"); if (!video) continue; if (e.intersectionRatio > 0.6) { state.current = e.target; video.muted = false; video.play().catch(() => { video.muted = true; video.play().catch(() => {}); }); // догружаем следующую страницу заранее const slides = [...feed.querySelectorAll(".slide")]; if (slides.indexOf(e.target) > slides.length - 4) loadMore(); } else { video.pause(); video.muted = true; } } }, { root: feed, threshold: [0.6] }); feed.querySelectorAll(".slide").forEach((s) => observer.observe(s)); } /* --- лайки --- */ async function likeVideo(file, slide) { const btn = slide.querySelector(".like"); const count = btn.querySelector("small"); const data = await api("/api/videos/" + encodeURIComponent(file) + "/like", { method: "POST" }) .catch(() => null); if (!data) return; count.textContent = data.likes; liked.add(file); saveLiked(); btn.classList.add("liked"); if (!data.already) { const hint = slide.querySelector(".hint"); hint.style.opacity = 1; setTimeout(() => (hint.style.opacity = 0), 500); } } /* --- теги --- */ function openTagForm(file) { state.tagFor = file; $("#tagform").classList.add("show"); $("#taginput").value = ""; $("#taginput").focus(); } $("#tagform").onsubmit = async (e) => { e.preventDefault(); const tag = $("#taginput").value.trim(); if (!tag || !state.tagFor) return; const data = await api("/api/videos/" + encodeURIComponent(state.tagFor) + "/tags", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tag }), }).catch((err) => { alert("Не вышло: " + err.message); return null; }); if (!data) return; $("#tagform").classList.remove("show"); loadTags(); reload(); }; /* --- режимы, поиск, клавиатура --- */ function setMode(m) { document.body.classList.toggle("mode-grid", m === "grid"); $("#btn-feed").classList.toggle("on", m === "feed"); $("#btn-grid").classList.toggle("on", m === "grid"); } $("#btn-feed").onclick = () => setMode("feed"); $("#btn-grid").onclick = () => setMode("grid"); let qTimer; $("#q").oninput = (e) => { clearTimeout(qTimer); qTimer = setTimeout(() => { state.q = e.target.value.trim(); reload(); }, 400); }; $("#sort").onchange = (e) => { state.sort = e.target.value; reload(); }; document.addEventListener("keydown", (e) => { if (e.target.tagName === "INPUT" || e.target.tagName === "SELECT") return; const cur = state.current && document.contains(state.current) ? state.current : $(".slide"); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); const slides = [...feed.querySelectorAll(".slide")]; const i = slides.indexOf(cur); const next = slides[i + (e.key === "ArrowDown" ? 1 : -1)]; if (next) next.scrollIntoView({ behavior: "smooth" }); } if ((e.key === "l" || e.key === "д" || e.key === "L") && cur) { likeVideo(cur.dataset.file, cur); } }); loadTags(); reload();