244 lines
8.4 KiB
JavaScript
244 lines
8.4 KiB
JavaScript
/* 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) => `<span class="tag" data-tag="${t}">#${t}</span>`).join(" ");
|
||
|
|
s.innerHTML = `
|
||
|
|
<video src="${v.url}" loop muted playsinline preload="metadata"></video>
|
||
|
|
<div class="hint">❤️</div>
|
||
|
|
<div class="meta"><div class="fname">${escapeHtml(v.file)}</div><div>${tags}</div></div>
|
||
|
|
<div class="actions">
|
||
|
|
<button class="like ${liked.has(v.file) ? "liked" : ""}">❤️<small>${v.likes}</small></button>
|
||
|
|
<button class="tagbtn">🏷️</button>
|
||
|
|
<a href="${v.url}" download><button type="button">⬇️</button></a>
|
||
|
|
</div>`;
|
||
|
|
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 = `<div class="empty">Ничего не найдено 😢</div>`;
|
||
|
|
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 = `
|
||
|
|
<video src="${v.url}#t=0.5" preload="metadata" muted playsinline></video>
|
||
|
|
<div class="row"><span>❤️ ${v.likes}</span><span>${escapeHtml(shortName(v.file))}</span></div>
|
||
|
|
<a class="open" href="#"></a>`;
|
||
|
|
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();
|
||
|
|
|