first commit

This commit is contained in:
micialware committed 2026-10-09 22:15:22 +03:00
commit 865786f210
16 files changed
+743

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
/* 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) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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();
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>VV — вертикальные видео</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="topbar">
<div class="logo">VV<span>▶</span></div>
<input id="q" class="search" type="search" placeholder="Поиск…" autocomplete="off">
<div class="seg">
<button id="btn-feed" class="on">Лента</button>
<button id="btn-grid">Все</button>
</div>
<select id="sort" class="sort" title="Сортировка">
<option value="new">Новые</option>
<option value="likes">Топ</option>
</select>
</header>
<nav id="chips" class="chips"></nav>
<main id="feed" class="feed"></main>
<main id="grid" class="grid"></main>
<form id="tagform" class="tagform">
<input id="taginput" placeholder="тег: jojo, ф1, мем…" maxlength="32" autocomplete="off">
<button type="submit">+</button>
</form>
<script src="/app.js"></script>
</body>
</html>
+93
View File
@@ -0,0 +1,93 @@
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; background: #000; color: #fff;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
overflow: hidden;
}
.topbar {
position: fixed; inset: 0 0 auto 0; z-index: 20;
display: flex; gap: 8px; align-items: center; padding: 10px 12px;
background: linear-gradient(#000c, #0000);
}
.logo { font-weight: 800; font-size: 18px; letter-spacing: 1px; }
.logo span { color: #fe2c55; }
.search {
flex: 1; max-width: 320px; background: #ffffff22; border: 0; border-radius: 20px;
color: #fff; padding: 8px 14px; outline: none;
}
.seg { display: flex; background: #ffffff22; border-radius: 20px; overflow: hidden; }
.seg button { border: 0; background: transparent; color: #fff; padding: 8px 14px; cursor: pointer; }
.seg button.on { background: #fff; color: #000; font-weight: 700; }
.sort { background: #ffffff22; color: #fff; border: 0; border-radius: 20px; padding: 8px; }
.chips {
position: fixed; top: 52px; left: 0; right: 0; z-index: 20;
display: flex; gap: 6px; overflow-x: auto; padding: 6px 12px;
}
.chips:empty { display: none; }
.chips button {
flex: none; border: 1px solid #ffffff44; background: #000a;
color: #fff; border-radius: 16px; padding: 5px 12px; cursor: pointer; font-size: 13px;
}
.chips button.on { background: #fe2c55; border-color: #fe2c55; }
.feed {
height: 100dvh; overflow-y: scroll; scroll-snap-type: y mandatory;
scrollbar-width: none;
}
.feed::-webkit-scrollbar { display: none; }
.slide {
position: relative; height: 100dvh; scroll-snap-align: start; scroll-snap-stop: always;
display: flex; align-items: center; justify-content: center; background: #111;
}
.slide video { height: 100%; max-width: 100%; object-fit: contain; background: #000; }
.slide .meta {
position: absolute; left: 12px; right: 76px; bottom: 18px; z-index: 5;
font-size: 14px; text-shadow: 0 1px 6px #000;
}
.slide .meta .fname { font-weight: 600; margin-bottom: 6px; word-break: break-word; }
.slide .meta .tag { color: #7dd3fc; margin-right: 8px; cursor: pointer; }
.actions {
position: absolute; right: 8px; bottom: 90px; z-index: 5;
display: flex; flex-direction: column; gap: 14px; align-items: center;
}
.actions button {
width: 52px; height: 52px; border-radius: 50%; border: 0; cursor: pointer;
background: #ffffff22; color: #fff; font-size: 22px; backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center; flex-direction: column;
}
.actions button small { font-size: 11px; }
.actions button.liked { background: #fe2c55; }
.hint {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
font-size: 64px; opacity: 0; pointer-events: none;
}
.grid {
display: none; height: 100dvh; overflow-y: auto; padding: 100px 12px 20px;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px;
align-content: start;
}
body.mode-grid { overflow: auto; }
body.mode-grid .feed, body.mode-grid .chips { display: none; }
body.mode-grid .grid { display: grid; }
.card { position: relative; border-radius: 12px; overflow: hidden; background: #1a1a1a; }
.card video { width: 100%; aspect-ratio: 9/16; object-fit: cover; display: block; }
.card .row {
position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 8px;
display: flex; justify-content: space-between; font-size: 12px;
background: linear-gradient(#0000, #000d);
}
.card .open { position: absolute; inset: 0; }
.tagform {
position: fixed; z-index: 30; left: 50%; bottom: 24px; transform: translateX(-50%);
display: none; gap: 6px; background: #1c1c1c; padding: 10px; border-radius: 14px;
border: 1px solid #ffffff33; width: min(92vw, 380px);
}
.tagform.show { display: flex; }
.tagform input {
flex: 1; background: #000; border: 1px solid #ffffff33; color: #fff;
border-radius: 10px; padding: 10px;
}
.tagform button { border-radius: 10px; border: 0; padding: 0 14px; cursor: pointer; }
.empty { text-align: center; padding: 40dvh 20px 0; color: #aaa; }
.more { display: block; margin: 12px auto; padding: 10px 26px; border-radius: 20px; border: 0; }