Rework the media grid with a viewer, metadata banners, and load gate
This commit is contained in:
+319
-32
@@ -23,6 +23,12 @@
|
||||
loadedChannels: false,
|
||||
authors: null,
|
||||
errors: [],
|
||||
loaded: false,
|
||||
stats: null,
|
||||
metaFields: JSON.parse(
|
||||
localStorage.getItem("vk-media-meta-fields") ??
|
||||
'["user","type","date","source","resolution"]'
|
||||
),
|
||||
filters: {
|
||||
source: "",
|
||||
channelId: "",
|
||||
@@ -173,30 +179,57 @@
|
||||
if (state.view === "media") renderToolbar();
|
||||
}
|
||||
|
||||
function filterQuery() {
|
||||
const kind = state.kind === "all" ? "" : `kind=${state.kind}&`;
|
||||
const source = state.filters.source ? `source=${state.filters.source}&` : "";
|
||||
const channel = state.filters.channelId ? `channelId=${state.filters.channelId}&` : "";
|
||||
const author = state.filters.authorId ? `authorId=${state.filters.authorId}&` : "";
|
||||
const aspect = state.filters.aspect ? `aspect=${state.filters.aspect}&` : "";
|
||||
const resolution = state.filters.resolution ? `resolution=${state.filters.resolution}&` : "";
|
||||
const after = state.filters.after ? `after=${state.filters.after}T00:00:00.000Z&` : "";
|
||||
const before = state.filters.before ? `before=${state.filters.before}T23:59:59.999Z&` : "";
|
||||
return `${kind}${source}${channel}${author}${aspect}${resolution}${after}${before}`;
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
const stats = state.stats ?? { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 };
|
||||
setStatus(
|
||||
`downloaded ${stats.downloaded}/${stats.total} (images/videos) | ` +
|
||||
`cached ${stats.cached}/${stats.notDownloaded} (excludes downloaded) | ${state.items.length} loaded`
|
||||
);
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
state.stats = await window.VK.api.get(`/api/media/stats?${filterQuery()}`);
|
||||
} catch {
|
||||
state.stats = null;
|
||||
}
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
async function loadMedia(reset) {
|
||||
if (!state.loaded) return;
|
||||
|
||||
if (reset) {
|
||||
state.items = [];
|
||||
state.offset = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const kind = state.kind === "all" ? "" : `&kind=${state.kind}`;
|
||||
const source = state.filters.source ? `&source=${state.filters.source}` : "";
|
||||
const channel = state.filters.channelId ? `&channelId=${state.filters.channelId}` : "";
|
||||
const author = state.filters.authorId ? `&authorId=${state.filters.authorId}` : "";
|
||||
const aspect = state.filters.aspect ? `&aspect=${state.filters.aspect}` : "";
|
||||
const resolution = state.filters.resolution ? `&resolution=${state.filters.resolution}` : "";
|
||||
const after = state.filters.after ? `&after=${state.filters.after}T00:00:00.000Z` : "";
|
||||
const before = state.filters.before ? `&before=${state.filters.before}T23:59:59.999Z` : "";
|
||||
const items = await window.VK.api.get(
|
||||
`/api/media?limit=60&offset=${state.offset}${kind}${source}${channel}${author}${aspect}${resolution}${after}${before}`
|
||||
`/api/media?limit=60&offset=${state.offset}&${filterQuery()}`
|
||||
);
|
||||
state.items = state.items.concat(items);
|
||||
state.offset += items.length;
|
||||
setStatus(`${state.items.length} items loaded`);
|
||||
} catch (error) {
|
||||
setStatus(`media load failed: ${error.message}`);
|
||||
renderGrid();
|
||||
return;
|
||||
}
|
||||
|
||||
renderGrid();
|
||||
void loadStats();
|
||||
}
|
||||
|
||||
async function startScrape(channelId, guildId, mode, downloadDir, after, before) {
|
||||
@@ -323,32 +356,101 @@
|
||||
if (state.view === "bot") renderErrors();
|
||||
}
|
||||
|
||||
const META_FIELDS = [
|
||||
["user", "User"],
|
||||
["type", "Type"],
|
||||
["date", "Date"],
|
||||
["source", "Source"],
|
||||
["resolution", "Resolution"],
|
||||
["length", "Length"]
|
||||
];
|
||||
|
||||
/** "mp4 video", "webp image", "YouTube video", ... */
|
||||
function typeLabel(item) {
|
||||
if (item.source === "embed") return `${item.provider ?? "embed"} ${item.kind}`;
|
||||
|
||||
const match = /\.([a-z0-9]{2,5})$/i.exec((item.url ?? "").split("?")[0]);
|
||||
const extension = (match?.[1] ?? "").toLowerCase();
|
||||
return extension ? `${extension} ${item.kind}` : item.kind;
|
||||
}
|
||||
|
||||
function metaValues(item) {
|
||||
return {
|
||||
user: item.authorName ?? "unknown",
|
||||
type: typeLabel(item),
|
||||
date: item.postedAt ? new Date(item.postedAt).toLocaleString() : "—",
|
||||
source: item.provider ?? "discord",
|
||||
resolution: item.width && item.height ? `${item.width}x${item.height}` : "—",
|
||||
length: item.durationSecs ? `${item.durationSecs.toFixed(1)}s` : "—"
|
||||
};
|
||||
}
|
||||
|
||||
function metaBanner(item, fields, className) {
|
||||
const values = metaValues(item);
|
||||
const parts = fields.filter(field => field in values).map(field => values[field]);
|
||||
if (!parts.length) return null;
|
||||
return el(className ?? "figcaption", "vk-media-banner", parts.join(" · "));
|
||||
}
|
||||
|
||||
/** Grid thumbnail source: cached preview, then the local file, then the original link. */
|
||||
function gridSource(item) {
|
||||
return item.thumbUrl || item.localUrl || item.url;
|
||||
}
|
||||
|
||||
function renderCard(item) {
|
||||
const figure = el("figure", "vk-media-figure");
|
||||
const media = el("div", "vk-media-media");
|
||||
const source = gridSource(item);
|
||||
const visual = item.kind === "image" || item.kind === "gif" || item.kind === "video";
|
||||
|
||||
if (visual && /^(https?:|\/)/.test(source)) {
|
||||
const img = document.createElement("img");
|
||||
img.loading = "lazy";
|
||||
img.src = source;
|
||||
img.alt = item.kind;
|
||||
media.append(img);
|
||||
} else {
|
||||
media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
||||
}
|
||||
|
||||
if (item.kind === "video") {
|
||||
const play = el("span", "vk-media-play", "▶");
|
||||
play.title = "Play";
|
||||
play.addEventListener("click", event => {
|
||||
event.stopPropagation();
|
||||
openViewer(item, true);
|
||||
});
|
||||
media.append(play);
|
||||
}
|
||||
|
||||
media.addEventListener("click", () => openViewer(item, false));
|
||||
figure.append(media);
|
||||
|
||||
const banner = metaBanner(item, state.metaFields);
|
||||
if (banner) figure.append(banner);
|
||||
|
||||
return figure;
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
grid.replaceChildren();
|
||||
for (const item of state.items) {
|
||||
const source = item.localUrl || item.url;
|
||||
const card = el("a", "vk-media-card");
|
||||
card.href = source;
|
||||
card.target = "_blank";
|
||||
card.rel = "noreferrer";
|
||||
|
||||
const postedBy = [item.authorName, item.postedAt ? new Date(item.postedAt).toLocaleString() : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
if (postedBy) card.title = postedBy;
|
||||
|
||||
if ((item.kind === "image" || item.kind === "gif") && /^(https?:|\/)/.test(source)) {
|
||||
const img = document.createElement("img");
|
||||
img.loading = "lazy";
|
||||
img.src = item.thumbUrl || source;
|
||||
img.alt = item.kind;
|
||||
card.append(img);
|
||||
} else {
|
||||
card.append(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
||||
card.append(el("span", "vk-media-card-name", item.provider || new URL(item.url).hostname));
|
||||
}
|
||||
grid.append(card);
|
||||
if (!state.loaded) {
|
||||
const prompt = el("div", "vk-media-prompt");
|
||||
prompt.append(el("div", "vk-media-status", "Use the filters above to filter media."));
|
||||
const load = el("button", "btn btn-primary vk-media-load", "Load media");
|
||||
load.type = "button";
|
||||
load.addEventListener("click", () => {
|
||||
state.loaded = true;
|
||||
void loadMedia(true);
|
||||
});
|
||||
prompt.append(load);
|
||||
grid.append(prompt);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of state.items) grid.append(renderCard(item));
|
||||
if (!state.items.length) grid.append(el("div", "vk-media-status", "No media matches the filters."));
|
||||
}
|
||||
|
||||
function filterSelect(options, value, onChange) {
|
||||
@@ -374,6 +476,36 @@
|
||||
return input;
|
||||
}
|
||||
|
||||
/** Dropdown with the metadata-field checkboxes. */
|
||||
function metaMenu() {
|
||||
const wrap = el("span", "vk-media-meta");
|
||||
const panel = el("div", "vk-media-meta-menu");
|
||||
panel.hidden = true;
|
||||
|
||||
const toggle = button("Metadata", () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
});
|
||||
|
||||
for (const [key, label] of META_FIELDS) {
|
||||
const row = el("label", "vk-media-check");
|
||||
const box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
box.checked = state.metaFields.includes(key);
|
||||
box.addEventListener("change", () => {
|
||||
state.metaFields = box.checked
|
||||
? [...new Set([...state.metaFields, key])]
|
||||
: state.metaFields.filter(field => field !== key);
|
||||
localStorage.setItem("vk-media-meta-fields", JSON.stringify(state.metaFields));
|
||||
renderGrid();
|
||||
});
|
||||
row.append(box, document.createTextNode(` ${label}`));
|
||||
panel.append(row);
|
||||
}
|
||||
|
||||
wrap.append(toggle, panel);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderToolbar() {
|
||||
toolbar.replaceChildren();
|
||||
if (state.view === "media") {
|
||||
@@ -497,6 +629,7 @@
|
||||
})
|
||||
);
|
||||
|
||||
toolbar.append(metaMenu());
|
||||
toolbar.append(el("span", "vk-media-spacer"));
|
||||
toolbar.append(button("History", openHistoryModal));
|
||||
toolbar.append(button("New scrape…", openScrapeModal));
|
||||
@@ -519,8 +652,10 @@
|
||||
void loadScrapes().then(renderScrapes);
|
||||
void refreshErrors();
|
||||
} else {
|
||||
renderGrid();
|
||||
void loadChannels().then(renderToolbar);
|
||||
void loadAuthors().then(renderToolbar);
|
||||
void loadStats();
|
||||
void loadMedia(true);
|
||||
void loadScrapes();
|
||||
}
|
||||
@@ -855,6 +990,158 @@
|
||||
if (event.target === historyModal) historyModal.hidden = true;
|
||||
});
|
||||
|
||||
// ---- Media viewer ----
|
||||
|
||||
function embedUrlFor(item) {
|
||||
if (item.source !== "embed") return null;
|
||||
|
||||
try {
|
||||
const url = new URL(item.url);
|
||||
const host = url.hostname.replace(/^www\./, "");
|
||||
|
||||
if (host === "youtube.com" || host === "m.youtube.com") {
|
||||
const id = url.searchParams.get("v");
|
||||
if (id) return `https://www.youtube.com/embed/${id}`;
|
||||
}
|
||||
if (host === "youtu.be") return `https://www.youtube.com/embed${url.pathname}`;
|
||||
if (host === "vimeo.com") return `https://player.vimeo.com/video${url.pathname}`;
|
||||
if (host === "clips.twitch.tv") {
|
||||
return `https://player.twitch.tv/?clip=${url.pathname.slice(1)}&parent=${location.hostname}`;
|
||||
}
|
||||
if (host === "streamable.com") return `https://streamable.com/e${url.pathname}`;
|
||||
} catch {
|
||||
// not embeddable
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const viewerModal = document.createElement("div");
|
||||
viewerModal.className = "vk-media-modal";
|
||||
viewerModal.hidden = true;
|
||||
viewerModal.innerHTML = `
|
||||
<div class="vk-media-modal-box vk-media-viewer-box">
|
||||
<div class="vk-media-head">
|
||||
<strong id="viewer-title">Preview</strong>
|
||||
<span class="vk-media-spacer"></span>
|
||||
<button id="viewer-x" class="btn btn-secondary vk-small-btn" type="button">X</button>
|
||||
</div>
|
||||
<div id="viewer-body" class="vk-media-viewer wide">
|
||||
<div id="viewer-media" class="vk-media-viewer-media"></div>
|
||||
<div id="viewer-meta" class="vk-media-viewer-meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.append(viewerModal);
|
||||
|
||||
function openViewer(item, play) {
|
||||
const body = viewerModal.querySelector("#viewer-body");
|
||||
const media = viewerModal.querySelector("#viewer-media");
|
||||
const meta = viewerModal.querySelector("#viewer-meta");
|
||||
const vertical = item.aspectRatio != null && item.aspectRatio < 1;
|
||||
|
||||
viewerModal.querySelector("#viewer-title").textContent = typeLabel(item);
|
||||
body.className = `vk-media-viewer ${vertical ? "vertical" : "wide"}`;
|
||||
media.replaceChildren();
|
||||
|
||||
const source = item.localUrl || item.thumbUrl || item.url;
|
||||
const embed = play ? embedUrlFor(item) : null;
|
||||
|
||||
if (embed) {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = embed;
|
||||
frame.allow = "autoplay; fullscreen; picture-in-picture";
|
||||
frame.allowFullscreen = true;
|
||||
media.append(frame);
|
||||
} else if (item.kind === "video" && /^(https?:|\/)/.test(source)) {
|
||||
if (play) {
|
||||
const video = document.createElement("video");
|
||||
video.controls = true;
|
||||
video.autoplay = true;
|
||||
video.src = source;
|
||||
media.append(video);
|
||||
} else {
|
||||
const img = document.createElement("img");
|
||||
img.src = source;
|
||||
img.alt = item.kind;
|
||||
media.append(img);
|
||||
media.append(button("Play", () => openViewer(item, true)));
|
||||
}
|
||||
} else if (/^(https?:|\/)/.test(source)) {
|
||||
const img = document.createElement("img");
|
||||
img.src = source;
|
||||
img.alt = item.kind;
|
||||
media.append(img);
|
||||
} else {
|
||||
media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
||||
}
|
||||
|
||||
meta.replaceChildren();
|
||||
const values = metaValues(item);
|
||||
for (const [key, label] of META_FIELDS) {
|
||||
const line = el("div", "vk-media-meta-line");
|
||||
line.append(el("span", "vk-media-meta-key", `${label}: `));
|
||||
line.append(el("span", null, values[key]));
|
||||
meta.append(line);
|
||||
}
|
||||
|
||||
const link = el("button", "vk-media-link", item.url);
|
||||
link.type = "button";
|
||||
link.title = "Open the original link";
|
||||
link.addEventListener("click", () => openLinkWarning(item.url));
|
||||
meta.append(link);
|
||||
|
||||
meta.append(
|
||||
button("View full", () => window.open(item.localUrl || item.url, "_blank", "noreferrer"))
|
||||
);
|
||||
|
||||
viewerModal.hidden = false;
|
||||
}
|
||||
|
||||
viewerModal.querySelector("#viewer-x").addEventListener("click", () => {
|
||||
viewerModal.hidden = true;
|
||||
});
|
||||
viewerModal.addEventListener("click", event => {
|
||||
if (event.target === viewerModal) viewerModal.hidden = true;
|
||||
});
|
||||
|
||||
// ---- External link warning ----
|
||||
|
||||
const linkModal = document.createElement("div");
|
||||
linkModal.className = "vk-media-modal";
|
||||
linkModal.hidden = true;
|
||||
linkModal.innerHTML = `
|
||||
<div class="vk-media-modal-box">
|
||||
<strong>Open external link?</strong>
|
||||
<div id="link-target" class="vk-media-status"></div>
|
||||
<div class="vk-media-status">This opens a new website in a new tab.</div>
|
||||
<div class="vk-media-toolbar">
|
||||
<button id="link-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
|
||||
<button id="link-continue" class="btn btn-primary vk-small-btn" type="button">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.append(linkModal);
|
||||
|
||||
let pendingLink = null;
|
||||
|
||||
function openLinkWarning(url) {
|
||||
pendingLink = url;
|
||||
linkModal.querySelector("#link-target").textContent = url;
|
||||
linkModal.hidden = false;
|
||||
}
|
||||
|
||||
linkModal.querySelector("#link-cancel").addEventListener("click", () => {
|
||||
linkModal.hidden = true;
|
||||
});
|
||||
linkModal.querySelector("#link-continue").addEventListener("click", () => {
|
||||
if (pendingLink) window.open(pendingLink, "_blank", "noreferrer");
|
||||
linkModal.hidden = true;
|
||||
});
|
||||
linkModal.addEventListener("click", event => {
|
||||
if (event.target === linkModal) linkModal.hidden = true;
|
||||
});
|
||||
|
||||
// ---- Settings: media storage ----
|
||||
|
||||
function formatBytes(bytes) {
|
||||
|
||||
Reference in New Issue
Block a user