Files

833 lines
46 KiB
JavaScript

(() => {
"use strict";
const qs = (selector, root = document) => root.querySelector(selector);
const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const clone = (value) => JSON.parse(JSON.stringify(value));
const MODULES = ["site", "navigation", "composer", "threads", "recommendations", "activity"];
const MODULE_META = {
site: ["站点信息", "品牌、主题与当前账号"],
navigation: ["导航", "信息流、搜索与移动导航"],
composer: ["发布器", "发帖弹窗和字数限制"],
threads: ["串文", "主串文、引用与嵌套回复"],
recommendations: ["推荐", "关注建议与热门话题"],
activity: ["动态", "互动通知面板"]
};
const embeddedData = JSON.parse(qs("#threadsPageData").textContent);
const initialData = clone(embeddedData);
const fingerprint = (text) => {
let hash = 2166136261;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
};
const pageFingerprint = fingerprint(JSON.stringify(initialData));
const storageKey = "threads-page-data-v1:" + pageFingerprint;
const themeKey = "threads-page-theme-v1:" + pageFingerprint;
const readStorage = (key) => {
try {
return window.localStorage ? window.localStorage.getItem(key) : null;
} catch (_error) {
return null;
}
};
const writeStorage = (key, value) => {
try {
if (window.localStorage) window.localStorage.setItem(key, value);
return true;
} catch (_error) {
return false;
}
};
const removeStorage = (key) => {
try {
if (window.localStorage) window.localStorage.removeItem(key);
} catch (_error) {
/* Opaque sandbox previews may intentionally deny storage. */
}
};
const isObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
const isCompleteData = (value) => Boolean(isObject(value) && MODULES.every((name) => isObject(value[name])));
const cachedData = readStorage(storageKey);
let pageData = initialData;
if (cachedData) {
try {
const parsed = JSON.parse(cachedData);
if (isCompleteData(parsed)) pageData = parsed;
} catch (_error) {
pageData = initialData;
}
}
const escapeHTML = (value) => String(value == null ? "" : value).replace(/[&<>'"]/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;"
})[character]);
const safeRemoteUrl = (value, kind = "link") => {
const source = String(value || "").trim();
if (!source) return "";
const lowered = source.toLowerCase();
if (kind === "image" && lowered.startsWith("data:image/")) return source;
if (kind === "video" && lowered.startsWith("data:video/")) return source;
try {
const url = new URL(source);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : "";
} catch (_error) { return ""; }
};
const validateRemoteMediaTree = (value, path = "当前模块") => {
if (Array.isArray(value)) return value.forEach((item, index) => validateRemoteMediaTree(item, path + "[" + index + "]"));
if (!value || typeof value !== "object") return;
const media = value.media;
if (media && ["image", "video", "webpage"].includes(media.type)) {
if ((media.type === "image" || media.type === "video") && !safeRemoteUrl(media.mediaUrl, media.type)) throw new Error(path + ".media.mediaUrl 无效");
if (media.type === "webpage" && !safeRemoteUrl(media.pageUrl)) throw new Error(path + ".media.pageUrl 无效");
if (media.posterUrl && !safeRemoteUrl(media.posterUrl, "image")) throw new Error(path + ".media.posterUrl 无效");
}
Object.entries(value).forEach(([key, item]) => { if (key !== "media") validateRemoteMediaTree(item, path + "." + key); });
};
const safeColor = (value, fallback) => /^#[0-9a-f]{6}$/i.test(String(value || "")) ? value : fallback;
const icon = (id) => '<svg aria-hidden="true"><use href="#' + id + '"/></svg>';
const colors = (person) => {
const list = person && Array.isArray(person.colors) ? person.colors : [];
return [safeColor(list[0], "#7257f5"), safeColor(list[1], "#d94fbd")];
};
const avatar = (person, className) => {
const pair = colors(person || {});
return '<span class="avatar ' + (className || "") + '" style="--a:' + pair[0] + ";--b:" + pair[1] + '">' + escapeHTML((person && person.initial) || "T") + "</span>";
};
const metric = (value) => {
const number = Math.max(0, Number(value) || 0);
return number ? '<span class="action-count">' + number.toLocaleString("zh-CN") + "</span>" : "";
};
const toast = qs("#toast");
let toastTimer = 0;
const showToast = (message) => {
toast.textContent = message;
toast.classList.add("show");
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => toast.classList.remove("show"), 2800);
};
const saveData = () => writeStorage(storageKey, JSON.stringify(pageData));
let themeInitialized = false;
const syncThemeButtons = () => {
const dark = document.documentElement.dataset.theme === "dark";
[qs("#themeToggle"), qs("#mobileTheme")].forEach((button) => {
button.innerHTML = icon(dark ? "i-sun" : "i-moon");
button.setAttribute("aria-label", dark ? "切换浅色模式" : "切换深色模式");
});
qs('meta[name="theme-color"]').content = dark ? "#0a0a0a" : "#f5f5f3";
};
const applyInitialTheme = () => {
if (themeInitialized) return;
themeInitialized = true;
const configured = pageData.site.theme;
const stored = readStorage(themeKey);
const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme:dark)").matches;
const next = configured === "dark" || configured === "light"
? configured
: (stored === "dark" || stored === "light" ? stored : (prefersDark ? "dark" : "light"));
document.documentElement.dataset.theme = next;
syncThemeButtons();
};
const toggleTheme = () => {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
writeStorage(themeKey, next);
syncThemeButtons();
};
const renderSite = () => {
const site = pageData.site || {};
const user = site.currentUser || {};
document.title = site.pageTitle || site.brand || "Threads";
qs('meta[name="description"]').content = site.description || "";
qs("#brandText").textContent = site.brand || "Threads";
qs("#brandLink").setAttribute("aria-label", (site.brand || "Threads") + " 首页");
qs("#mobileUser").innerHTML = avatar(user, "avatar-sm");
qs("#composerUser").innerHTML = avatar(user);
qs("#modalUser").innerHTML = avatar(user);
qs("#modalUserName").textContent = user.name || "";
const stats = user.stats || {};
qs("#profileSummary").innerHTML =
'<div class="profile-summary">' + avatar(user) +
'<span class="profile-copy"><strong>' + escapeHTML(user.name) + "</strong><span>" + escapeHTML(user.handle) +
'</span></span><button class="follow-button following">主页</button></div>' +
'<div class="profile-stats"><div><strong>' + escapeHTML(stats.following) + '</strong><span>关注</span></div><div><strong>' +
escapeHTML(stats.followers) + '</strong><span>关注者</span></div><div><strong>' + escapeHTML(stats.threads) +
"</strong><span>串聊</span></div></div>";
applyInitialTheme();
};
const renderNavigation = () => {
const navigation = pageData.navigation || {};
qs("#feedTitle").textContent = navigation.feedTitle || "首页";
qs("#feedFilter").textContent = navigation.feedFilter || "为你推荐";
qs("#searchInput").placeholder = navigation.searchPlaceholder || "搜索用户或关键词";
qs("#searchHints").textContent = navigation.searchHints || "";
const tabs = Array.isArray(navigation.tabs) ? navigation.tabs : [];
qs("#feedTabs").innerHTML = tabs.map((label, index) =>
'<button class="feed-tab' + (index === 0 ? " active" : "") + '">' + escapeHTML(label) + "</button>"
).join("");
const labels = Array.isArray(navigation.mobileLabels) ? navigation.mobileLabels : [];
const items = [
["i-home", ""],
["i-search", ' data-panel="searchPanel"'],
["i-plus", " data-open-composer"],
["i-heart", ' data-panel="activityPanel"'],
["i-user", ""]
];
qs("#mobileNav").innerHTML = items.map((item, index) => {
const label = labels[index] || "";
if (index === 2) return '<button' + item[1] + ' aria-label="' + escapeHTML(label) + '"><span class="mobile-create">' + icon(item[0]) + '</span><span class="sr-only">' + escapeHTML(label) + "</span></button>";
return '<button class="' + (index === 0 ? "active" : "") + '"' + item[1] + ' aria-label="' + escapeHTML(label) + '">' + icon(item[0]) + "<span>" + escapeHTML(label) + "</span></button>";
}).join("");
const links = Array.isArray(navigation.footerLinks) ? navigation.footerLinks : [];
qs("#sideFooter").innerHTML = links.map((label) => '<a href="#footer">' + escapeHTML(label) + "</a>").join("") +
"<span>" + escapeHTML(navigation.copyright) + "</span>";
};
const renderComposer = () => {
const composer = pageData.composer || {};
qs("#composerButton").textContent = composer.inlinePlaceholder || "";
qs("#inlinePublish").textContent = composer.publishLabel || "发布";
qs("#composeTitle").textContent = composer.dialogTitle || "新串聊";
qs("#composeText").placeholder = composer.textareaPlaceholder || "";
qs("#composeText").maxLength = Math.max(1, Number(composer.maxLength) || 500);
qs("#modalPublish").textContent = composer.publishLabel || "发布";
qs("#charCount").textContent = String(qs("#composeText").maxLength - qs("#composeText").value.length);
};
const autumnGrid = (label) => '<div class="image-grid" role="img" aria-label="' + escapeHTML(label) + '">' +
'<div class="main-image"><svg viewBox="0 0 400 520" preserveAspectRatio="xMidYMid slice"><defs><linearGradient id="threadAutumn1" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#a9c9de"/><stop offset="1" stop-color="#f2c096"/></linearGradient></defs><rect width="400" height="520" fill="url(#threadAutumn1)"/><circle cx="300" cy="105" r="48" fill="#fff0bf"/><path d="M0 350c100-50 210-20 400-60v230H0Z" fill="#6b7f63"/><path d="M210 520V205m0 42-92-88m92 120 108-110" stroke="#62493b" stroke-width="20" stroke-linecap="round"/><g fill="#d77b50"><circle cx="108" cy="148" r="70"/><circle cx="186" cy="115" r="82"/><circle cx="292" cy="138" r="68"/><circle cx="350" cy="205" r="54"/></g></svg></div>' +
'<div class="side-image"><svg viewBox="0 0 220 220"><rect width="220" height="220" fill="#e6b16d"/><circle cx="55" cy="60" r="38" fill="#c55c50"/><circle cx="130" cy="100" r="58" fill="#e37a4e"/><path d="M110 220V105" stroke="#553d35" stroke-width="16"/><path d="M0 190c80-30 150-10 220-40v70H0Z" fill="#736a4d"/></svg></div>' +
'<div class="side-image"><svg viewBox="0 0 220 220"><rect width="220" height="220" fill="#adc5ca"/><path d="M0 160 65 110l55 34 46-70 54 66v80H0Z" fill="#5b776e"/><circle cx="47" cy="55" r="28" fill="#ffe0a1"/><path d="M40 190c50-35 95-20 150-55" stroke="#eee0bd" stroke-width="12" fill="none" stroke-linecap="round"/></svg></div></div>';
document.head.insertAdjacentHTML("beforeend", '<style data-external-media>.thread-media{position:relative;margin-top:12px;overflow:hidden;border:1px solid var(--border);border-radius:14px;background:#000}.thread-media>img,.thread-media>video,.thread-media>iframe{width:100%;max-height:520px;display:block;border:0;object-fit:cover}.thread-media>iframe{height:360px}.thread-media>a.external-open{position:absolute;right:10px;bottom:10px;padding:7px 11px;border-radius:999px;color:#fff;background:rgba(0,0,0,.72);text-decoration:none;font-size:12px;font-weight:700}.thread-page>a{display:block;padding:13px 14px;color:var(--text);background:var(--surface);text-decoration:none}.thread-page>a strong,.thread-page>a span{display:block}.thread-page>a span{margin-top:4px;color:var(--muted);font-size:13px}</style>');
const mediaMarkup = (media) => {
if (!isObject(media)) return "";
if (media.type === "autumn-grid") return autumnGrid(media.label);
if (media.type === "image") {
const source = safeRemoteUrl(media.mediaUrl, "image");
if (!source) return "";
const pageUrl = safeRemoteUrl(media.pageUrl);
return '<div class="thread-media"><img src="' + escapeHTML(source) + '" alt="' + escapeHTML(media.alt || media.label || "串文图片") + '" loading="lazy" referrerpolicy="no-referrer"/>' + (pageUrl ? '<a class="external-open" href="' + escapeHTML(pageUrl) + '" target="_blank" rel="noopener noreferrer">打开来源</a>' : "") + "</div>";
}
if (media.type === "video") {
const source = safeRemoteUrl(media.mediaUrl, "video");
if (!source) return "";
const poster = safeRemoteUrl(media.posterUrl, "image");
const pageUrl = safeRemoteUrl(media.pageUrl);
return '<div class="thread-media"><video src="' + escapeHTML(source) + '"' + (poster ? ' poster="' + escapeHTML(poster) + '"' : "") + ' controls playsinline preload="metadata" aria-label="' + escapeHTML(media.alt || "串文视频") + '"></video>' + (pageUrl ? '<a class="external-open" href="' + escapeHTML(pageUrl) + '" target="_blank" rel="noopener noreferrer">打开来源</a>' : "") + "</div>";
}
if (media.type === "webpage") {
const pageUrl = safeRemoteUrl(media.pageUrl);
if (!pageUrl) return "";
const preview = safeRemoteUrl(media.mediaUrl, "image");
const body = media.embed === true ? '<iframe src="' + escapeHTML(pageUrl) + '" title="' + escapeHTML(media.title || "网页预览") + '" loading="lazy" referrerpolicy="no-referrer" sandbox="allow-forms allow-popups allow-scripts"></iframe>' : (preview ? '<img src="' + escapeHTML(preview) + '" alt="' + escapeHTML(media.alt || media.title || "网页预览图") + '" loading="lazy" referrerpolicy="no-referrer"/>' : "");
return '<div class="thread-media thread-page">' + body + '<a href="' + escapeHTML(pageUrl) + '" target="_blank" rel="noopener noreferrer"><strong>' + escapeHTML(media.title || pageUrl) + '</strong><span>' + escapeHTML(media.description || "打开网页") + "</span></a></div>";
}
return "";
};
const actionsMarkup = (metrics) => '<div class="post-actions">' +
'<button class="post-action like-action" aria-label="点赞">' + icon("i-heart") + metric(metrics.likes) + "</button>" +
'<button class="post-action reply-action" aria-label="回复">' + icon("i-comment") + metric(metrics.replies) + "</button>" +
'<button class="post-action repost-action" aria-label="转发">' + icon("i-repost") + metric(metrics.reposts) + "</button>" +
'<button class="post-action share" aria-label="分享">' + icon("i-share") + "</button></div>";
const quoteMarkup = (quote) => {
if (!isObject(quote)) return "";
return '<div class="quote-card"><div class="quote-head">' + avatar(quote, "avatar-xs") + "<strong>" +
escapeHTML(quote.name) + '</strong><span class="handle-time">' + escapeHTML(quote.handle) +
"</span></div><p>" + escapeHTML(quote.text) + "</p></div>";
};
const rowMarkup = (thread, reply, hasNext) => {
const person = reply ? reply.author : thread.author;
const metrics = reply ? reply.metrics : thread.metrics;
const time = reply ? "· " + reply.time : escapeHTML(person.handle) + " · " + escapeHTML(thread.time);
return '<div class="thread-row' + (hasNext ? " has-next" : "") + '"><div class="avatar-column">' +
avatar(person, reply ? "avatar-sm" : "avatar-lg") + '</div><div class="post-body"><header class="post-head"><strong>' +
escapeHTML(person.name) + "</strong>" + (!reply && person.verified ? '<svg class="verified"><use href="#i-verified"/></svg>' : "") +
'<span class="handle-time">' + time + '</span><span class="head-spacer"></span>' +
(!reply && thread.followable ? '<button class="follow-button" data-follow>关注</button>' : "") +
(!reply ? '<button class="more-button" aria-label="更多操作">' + icon("i-more") + "</button>" : "") +
'</header><p class="post-text">' + escapeHTML(reply ? reply.text : thread.text) + "</p>" +
(!reply ? mediaMarkup(thread.media) : "") +
(!reply ? quoteMarkup(thread.quote) : "") + actionsMarkup(metrics || {}) +
(!reply ? '<form class="reply-form"><label class="sr-only">回复内容</label><input maxlength="' +
Math.max(1, Number(pageData.composer.replyMaxLength) || 180) + '" placeholder="回复' + escapeHTML(person.name) +
'…" /><button class="reply-send" aria-label="发送回复">' + icon("i-send") + "</button></form>" : "") +
"</div></div>";
};
const threadMarkup = (thread) => {
const replies = Array.isArray(thread.replies) ? thread.replies : [];
const search = [thread.author && thread.author.name, thread.author && thread.author.handle, thread.text, thread.preview]
.concat(replies.map((reply) => reply.text)).join(" ");
return '<article class="card thread-card" data-thread-id="' + escapeHTML(thread.id) + '" data-search="' + escapeHTML(search) + '">' +
rowMarkup(thread, null, replies.length > 0) + replies.map((reply, index) => rowMarkup(thread, reply, index < replies.length - 1)).join("") +
'<div class="reply-preview"><span class="stacked-avatars">' +
replies.slice(0, 3).map((reply) => avatar(reply.author, "avatar-xs")).join("") +
'</span><span>' + escapeHTML(thread.preview) + '</span></div><div class="post-menu"><button>' +
icon("i-bookmark") + "收藏</button><button>" + icon("i-eye-off") + "减少此类内容</button></div></article>";
};
const renderThreads = () => {
const data = pageData.threads || {};
const items = Array.isArray(data.items) ? data.items : [];
qs("#threadList").innerHTML = items.map(threadMarkup).join("");
qs("#emptyTitle").textContent = data.emptyTitle || "";
qs("#emptyText").textContent = data.emptyText || "";
qs("#emptyState").style.display = items.length ? "none" : "block";
};
const renderRecommendations = () => {
const data = pageData.recommendations || {};
qs("#followTitle").textContent = data.followTitle || "";
qs("#suggestions").innerHTML = (Array.isArray(data.people) ? data.people : []).map((person) =>
'<div class="suggest-row">' + avatar(person, "avatar-sm") + '<span class="suggest-copy"><strong>' +
escapeHTML(person.name) + "</strong><span>" + escapeHTML(person.handle) +
'</span></span><button class="follow-button" data-follow>关注</button></div>'
).join("");
qs("#topicTitle").textContent = data.topicTitle || "";
qs("#topics").innerHTML = (Array.isArray(data.topics) ? data.topics : []).map((topic) =>
'<div class="topic"><span>' + escapeHTML(topic.meta) + "</span><strong>" + escapeHTML(topic.title) +
"</strong><small>" + escapeHTML(topic.count) + "</small></div>"
).join("");
};
const renderActivity = () => {
const data = pageData.activity || {};
qs("#activityTitle").textContent = data.title || "";
qs("#activityList").innerHTML = (Array.isArray(data.items) ? data.items : []).map((item) =>
'<div class="activity-row">' + avatar(item, "avatar-sm") + '<span class="activity-copy"><p><strong>' +
escapeHTML(item.name) + "</strong> " + escapeHTML(item.text) + "</p><time>" + escapeHTML(item.time) +
"</time></span>" + (item.followable ? '<button class="follow-button" data-follow>关注</button>' : "") + "</div>"
).join("");
};
const renderAll = () => {
renderSite();
renderNavigation();
renderComposer();
renderThreads();
renderRecommendations();
renderActivity();
bindDynamicInteractions();
};
const applySearch = () => {
const query = qs("#searchInput").value.trim().toLocaleLowerCase();
let visible = 0;
qsa(".thread-card").forEach((thread) => {
const match = !query || thread.dataset.search.toLocaleLowerCase().includes(query) || thread.textContent.toLocaleLowerCase().includes(query);
thread.hidden = !match;
if (match) visible += 1;
});
qs("#emptyState").style.display = visible ? "none" : "block";
};
const changeCount = (button, delta) => {
const count = qs(".action-count", button);
if (count) count.textContent = String(Math.max(0, (Number(count.textContent.replace(/,/g, "")) || 0) + delta));
};
const bindDynamicInteractions = () => {
qsa("[data-follow]").forEach((button) => button.addEventListener("click", () => {
const following = button.classList.toggle("following");
button.textContent = following ? "正在关注" : "关注";
showToast(following ? "已关注" : "已取消关注");
}));
qsa(".feed-tab").forEach((tab) => tab.addEventListener("click", () => {
qsa(".feed-tab").forEach((item) => item.classList.remove("active"));
tab.classList.add("active");
showToast("已切换到" + tab.textContent);
}));
qsa(".mobile-nav button").forEach((button) => button.addEventListener("click", () => {
qsa(".mobile-nav button").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
}));
qsa(".like-action").forEach((button) => button.addEventListener("click", () => {
const liked = button.classList.toggle("liked");
changeCount(button, liked ? 1 : -1);
button.setAttribute("aria-pressed", String(liked));
}));
qsa(".repost-action").forEach((button) => button.addEventListener("click", () => {
const reposted = button.classList.toggle("reposted");
changeCount(button, reposted ? 1 : -1);
showToast(reposted ? "已转发" : "已撤销转发");
}));
qsa(".reply-action").forEach((button) => button.addEventListener("click", () => {
const form = qs(".reply-form", button.closest(".post-body"));
if (!form) return;
form.classList.toggle("open");
if (form.classList.contains("open")) qs("input", form).focus();
}));
qsa(".reply-form").forEach((form) => form.addEventListener("submit", (event) => {
event.preventDefault();
const input = qs("input", form);
const value = input.value.trim();
if (!value) return;
const bubble = document.createElement("div");
bubble.className = "reply-bubble";
bubble.innerHTML = "<strong>" + escapeHTML(pageData.site.currentUser.name) + " · 刚刚</strong>" + escapeHTML(value);
form.before(bubble);
input.value = "";
const button = qs(".reply-action", form.closest(".post-body"));
if (button) changeCount(button, 1);
showToast("回复已发布");
}));
qsa(".share").forEach((button) => button.addEventListener("click", async () => {
try {
if (navigator.clipboard) await navigator.clipboard.writeText(location.href);
showToast("链接已复制");
} catch (_error) {
showToast("链接已准备");
}
}));
qsa(".thread-card").forEach((thread) => {
const menu = qs(".post-menu", thread);
const more = qs(".more-button", thread);
if (menu && more) more.addEventListener("click", (event) => {
event.stopPropagation();
qsa(".post-menu.open").forEach((other) => {
if (other !== menu) other.classList.remove("open");
});
menu.classList.toggle("open");
});
});
qsa("[data-open-composer]").forEach((button) => {
if (!button.dataset.composerBound) {
button.dataset.composerBound = "true";
button.addEventListener("click", openCompose);
}
});
qsa("[data-panel]").forEach((button) => {
if (!button.dataset.panelBound) {
button.dataset.panelBound = "true";
button.addEventListener("click", openPanel);
}
});
qs("#searchInput").value = qs("#searchInput").value || "";
applySearch();
};
const overlay = qs("#composeOverlay");
const composeText = qs("#composeText");
const modalPublish = qs("#modalPublish");
const charCount = qs("#charCount");
let modalOpener = null;
function openCompose(event) {
modalOpener = event.currentTarget;
overlay.classList.add("open");
overlay.setAttribute("aria-hidden", "false");
document.body.classList.add("modal-open");
window.setTimeout(() => composeText.focus(), 80);
}
const closeCompose = () => {
overlay.classList.remove("open");
overlay.setAttribute("aria-hidden", "true");
document.body.classList.remove("modal-open");
if (modalOpener) modalOpener.focus();
};
composeText.addEventListener("input", () => {
const left = composeText.maxLength - composeText.value.length;
charCount.textContent = String(left);
charCount.classList.toggle("warn", left <= 30);
modalPublish.disabled = !composeText.value.trim();
});
modalPublish.addEventListener("click", () => {
const value = composeText.value.trim();
if (!value) return;
const user = pageData.site.currentUser;
pageData.threads.items.unshift({
id: "new-" + Date.now(),
author: {name: user.name, handle: user.handle, initial: user.initial, colors: user.colors, verified: false},
time: "刚刚",
text: value,
media: null,
quote: null,
metrics: {likes: 0, replies: 0, reposts: 0},
followable: false,
replies: [],
preview: "成为第一个回复的人"
});
saveData();
renderThreads();
bindDynamicInteractions();
composeText.value = "";
charCount.textContent = String(composeText.maxLength);
modalPublish.disabled = true;
closeCompose();
qs(".thread-card").scrollIntoView({behavior: "smooth", block: "center"});
showToast("串聊已发布");
});
qs("#closeCompose").addEventListener("click", closeCompose);
overlay.addEventListener("click", (event) => {
if (event.target === overlay) closeCompose();
});
const closePanels = () => qsa(".panel.open").forEach((panel) => {
panel.classList.remove("open");
panel.setAttribute("aria-hidden", "true");
});
function openPanel(event) {
event.stopPropagation();
const panel = qs("#" + event.currentTarget.dataset.panel);
if (!panel) return;
const willOpen = !panel.classList.contains("open");
closePanels();
if (willOpen) {
panel.classList.add("open");
panel.setAttribute("aria-hidden", "false");
if (panel.id === "searchPanel") window.setTimeout(() => qs("input", panel).focus(), 80);
}
}
qsa("[data-close-panel]").forEach((button) => button.addEventListener("click", closePanels));
qs("#searchInput").addEventListener("input", applySearch);
const settingsOverlay = qs("#settingsOverlay");
const jsonEditor = qs("#jsonEditor");
const jsonStatus = qs("#jsonStatus");
let activeModule = MODULES[0];
let editorDraft = clone(pageData);
const validateEditor = () => {
try {
const parsed = JSON.parse(jsonEditor.value);
if (!isObject(parsed)) throw new Error("模块必须是 JSON 对象");
validateRemoteMediaTree(parsed);
jsonStatus.textContent = "JSON 有效";
jsonStatus.className = "json-status valid";
return parsed;
} catch (error) {
jsonStatus.textContent = "JSON 无效:" + error.message;
jsonStatus.className = "json-status invalid";
return null;
}
};
const storeActiveDraft = () => {
const parsed = validateEditor();
if (!parsed) return false;
editorDraft[activeModule] = parsed;
return true;
};
const selectModule = (name, force) => {
if (!force && !storeActiveDraft()) return;
activeModule = name;
qsa(".module-tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.module === name));
qs("#editorModuleTitle").textContent = MODULE_META[name][0];
jsonEditor.value = JSON.stringify(editorDraft[name], null, 2);
validateEditor();
};
const renderModuleTabs = () => {
qs("#moduleTabs").innerHTML = "<h3>页面模块</h3>" + MODULES.map((name) =>
'<button class="module-tab' + (name === activeModule ? " active" : "") + '" data-module="' + name + '"><strong>' +
MODULE_META[name][0] + "</strong><span>" + MODULE_META[name][1] + "</span></button>"
).join("");
qsa(".module-tab").forEach((tab) => tab.addEventListener("click", () => selectModule(tab.dataset.module, false)));
};
const openSettings = () => {
editorDraft = clone(pageData);
activeModule = MODULES[0];
renderModuleTabs();
selectModule(activeModule, true);
settingsOverlay.classList.add("open");
settingsOverlay.setAttribute("aria-hidden", "false");
document.body.classList.add("modal-open", "settings-open");
window.setTimeout(() => jsonEditor.focus(), 80);
};
const closeSettings = () => {
settingsOverlay.classList.remove("open");
settingsOverlay.setAttribute("aria-hidden", "true");
document.body.classList.remove("modal-open", "settings-open");
qs("#openSettings").focus();
};
qs("#openSettings").addEventListener("click", openSettings);
qs("#closeSettings").addEventListener("click", closeSettings);
settingsOverlay.addEventListener("click", (event) => {
if (event.target === settingsOverlay) closeSettings();
});
jsonEditor.addEventListener("input", validateEditor);
jsonEditor.addEventListener("keydown", (event) => {
if (event.key === "Tab") {
event.preventDefault();
const start = jsonEditor.selectionStart;
const end = jsonEditor.selectionEnd;
jsonEditor.setRangeText(" ", start, end, "end");
validateEditor();
}
});
qs("#applyJson").addEventListener("click", () => {
if (!storeActiveDraft() || !isCompleteData(editorDraft)) {
showToast("请修正 JSON 后再应用");
return;
}
pageData = clone(editorDraft);
saveData();
renderAll();
showToast("JSON 已应用到页面");
});
qs("#resetJson").addEventListener("click", () => {
pageData = clone(initialData);
editorDraft = clone(initialData);
removeStorage(storageKey);
removeStorage(themeKey);
themeInitialized = false;
renderAll();
renderModuleTabs();
selectModule(activeModule, true);
showToast("已恢复初始 JSON");
});
qs("#copyJson").addEventListener("click", async () => {
if (!storeActiveDraft()) return;
const text = JSON.stringify(editorDraft, null, 2);
try {
if (navigator.clipboard) await navigator.clipboard.writeText(text);
showToast("完整 JSON 已复制");
} catch (_error) {
jsonEditor.focus();
jsonEditor.select();
showToast("已选中当前模块,请手动复制");
}
});
qs("#downloadJson").addEventListener("click", () => {
if (!storeActiveDraft()) return;
const blob = new Blob([JSON.stringify(editorDraft, null, 2) + "\n"], {type: "application/json"});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "threads-page-data.json";
link.click();
window.setTimeout(() => URL.revokeObjectURL(link.href), 0);
showToast("JSON 已下载");
});
[qs("#themeToggle"), qs("#mobileTheme")].forEach((button) => button.addEventListener("click", toggleTheme));
document.addEventListener("click", (event) => {
if (!event.target.closest(".panel") && !event.target.closest("[data-panel]")) closePanels();
if (!event.target.closest(".post-menu") && !event.target.closest(".more-button")) {
qsa(".post-menu.open").forEach((menu) => menu.classList.remove("open"));
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closePanels();
if (overlay.classList.contains("open")) closeCompose();
if (settingsOverlay.classList.contains("open")) closeSettings();
}
});
qsa('a[href^="#"]').forEach((link) => link.addEventListener("click", (event) => {
const target = link.getAttribute("href");
if (target !== "#feed") {
event.preventDefault();
showToast("此页面正在准备中");
}
}));
renderAll();
function installJsonMediaUpload(){
const editor=document.getElementById("jsonEditor"),apply=document.getElementById("applyJson");if(!editor||!apply||document.getElementById("uploadJsonMedia"))return;
const actions=apply.parentElement,button=document.createElement("button"),input=document.createElement("input"),reference=actions.querySelector("#downloadJson,#copyJson,#validateJson,#resetJson");
button.type="button";button.id="uploadJsonMedia";button.className=reference?reference.className:apply.className;button.textContent="上传图片/视频";button.title="先把光标放进要替换的内容对象,再选择图片或视频";input.type="file";input.accept="image/*,video/*";input.hidden=true;actions.insertBefore(button,apply);actions.appendChild(input);
const report=(message)=>{const status=document.getElementById("settingsStatus")||document.getElementById("jsonError")||document.getElementById("jsonStatus");if(status)status.textContent=message};
const findTarget=(text,cursor)=>{const stack=[],matches=[];let quoted=false,escaped=false;for(let index=0;index<text.length;index+=1){const char=text[index];if(quoted){if(escaped)escaped=false;else if(char==="\\")escaped=true;else if(char==='"')quoted=false;continue}if(char==='"'){quoted=true;continue}if(char==="{")stack.push(index);else if(char==="}"){const start=stack.pop();if(start===undefined||cursor<start||cursor>index+1)continue;try{const value=JSON.parse(text.slice(start,index+1)),own=(key)=>Object.prototype.hasOwnProperty.call(value,key);if(value&&!Array.isArray(value)&&(own("media")||own("visual")||(own("id")&&(own("text")||own("title"))&&(own("author")||own("channel")||own("user")))))matches.push({start,end:index+1,value})}catch(_error){/* full validator handles malformed JSON */}}}return matches.sort((left,right)=>(left.end-left.start)-(right.end-right.start))[0]||null};
button.addEventListener("click",()=>{input.value="";report("请选择图片或视频;文件会写入光标所在内容对象的 media 字段。");input.click()});
input.addEventListener("change",()=>{const file=input.files&&input.files[0];if(!file)return;const type=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":"";if(!type){report("仅支持图片或视频文件。");return}const target=findTarget(editor.value,editor.selectionStart);if(!target){report("请先把光标放进带 visual/media 的内容对象,或带 id 的帖子/视频对象内。");return}const reader=new FileReader();reader.onerror=()=>report("文件读取失败,请重新选择。");reader.onload=()=>{const previous=target.value.media&&typeof target.value.media==="object"?target.value.media:{},media={type,mediaUrl:String(reader.result)};for(const key of ["alt","label","caption","pageUrl"])if(typeof previous[key]==="string")media[key]=previous[key];if(!media.alt)media.alt=type==="image"?"用户上传的图片":"用户上传的视频";target.value.media=media;const lineStart=editor.value.lastIndexOf("\n",target.start-1)+1,indent=(editor.value.slice(lineStart,target.start).match(/^\s*/)||[""])[0],replacement=JSON.stringify(target.value,null,2).split("\n").map((line,index)=>index?indent+line:line).join("\n");editor.value=editor.value.slice(0,target.start)+replacement+editor.value.slice(target.end);editor.dispatchEvent(new Event("input",{bubbles:true}));const mediaIndex=replacement.indexOf('"media"');editor.focus();editor.setSelectionRange(target.start+Math.max(mediaIndex,0),target.start+Math.max(mediaIndex,0));report(`已把 ${file.name} 写入 JSON,请点击“应用并渲染”。${file.size>2*1024*1024?" 文件较大,建议同时下载 JSON 保存。":""}`)};reader.readAsDataURL(file)});
}
installJsonMediaUpload();
function installInlineMediaReplace() {
if (document.getElementById("dfInlineMediaInput")) return;
const visualHostSelector = ".post-media,.video-visual,.thumbnail-visual,.short-thumb,.watch-visual,.thread-media,.image-grid,.visual,.lead-visual,.mini-visual,.compact-media,.card-art,.story-visual,.art,.video-art,.media-art,.player-stage,.thumbnail,.thumb,.list-thumb,.focus-thumb,.involve-bg,.hero-art,#heroArt,#adminArt";
const getRootData = () => {
if (typeof pageData !== "undefined") return pageData;
if (typeof data !== "undefined") return data;
return null;
};
const input = document.createElement("input");
input.id = "dfInlineMediaInput";
input.type = "file";
input.hidden = true;
document.body.appendChild(input);
const style = document.createElement("style");
style.dataset.dfInlineMediaReplace = "true";
style.textContent = ".df-inline-media-host{position:relative!important}.df-inline-media-button{position:absolute;z-index:40;top:10px;right:10px;width:40px;height:40px;padding:0;border:1px solid rgba(255,255,255,.55);border-radius:50%;display:grid;place-items:center;color:#fff;background:rgba(8,15,28,.78);box-shadow:0 8px 24px rgba(0,0,0,.28);backdrop-filter:blur(8px);cursor:pointer;opacity:0;transform:translateY(-4px);transition:opacity .16s,transform .16s,background .16s}.df-inline-media-button:hover{background:rgba(20,110,240,.92)}.df-inline-media-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.df-inline-media-host:hover>.df-inline-media-button,.df-inline-media-host:focus-within>.df-inline-media-button,.df-inline-media-button:focus-visible{opacity:1;transform:none}@media(hover:none){.df-inline-media-button{opacity:1;transform:none}}.df-inline-media-toast{position:fixed;z-index:2147483647;left:50%;bottom:28px;max-width:min(88vw,520px);padding:10px 14px;border-radius:10px;color:#fff;background:rgba(15,23,42,.94);box-shadow:0 12px 32px rgba(0,0,0,.28);font:600 13px/1.45 system-ui,sans-serif;transform:translateX(-50%)}";
document.head.appendChild(style);
const notify = (message) => {
if (typeof showToast === "function") { showToast(message); return; }
if (typeof toast === "function") { toast(message); return; }
let notice = document.getElementById("dfInlineMediaToast");
if (!notice) {
notice = document.createElement("div");
notice.id = "dfInlineMediaToast";
notice.className = "df-inline-media-toast";
document.body.appendChild(notice);
}
notice.textContent = message;
window.clearTimeout(notice._timer);
notice._timer = window.setTimeout(() => notice.remove(), 2600);
};
const sameSource = (value, source, absolute) => {
if (typeof value !== "string") return false;
if (value === source || value === absolute) return true;
try { return new URL(value, location.href).href === absolute; } catch (_error) { return false; }
};
const findMatches = (source, absolute) => {
const matches = [];
const stack = [];
const visit = (value) => {
if (Array.isArray(value)) { value.forEach(visit); return; }
if (!value || typeof value !== "object") return;
stack.push(value);
Object.entries(value).forEach(([key, item]) => {
if ((key === "mediaUrl" || key === "posterUrl") && sameSource(item, source, absolute)) {
const ancestors = stack.slice(0, -1).reverse();
const owner = ancestors.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && ("id" in candidate || "title" in candidate || "text" in candidate || "name" in candidate || "author" in candidate)) || ancestors[0] || value;
matches.push({ owner, previous: value, key });
}
if (item && typeof item === "object") visit(item);
});
stack.pop();
};
visit(getRootData());
return matches;
};
const visualOwners = () => {
const owners = [];
const seen = new Set();
const visit = (value) => {
if (Array.isArray(value)) { value.forEach(visit); return; }
if (!value || typeof value !== "object") return;
const hasVisual = Object.prototype.hasOwnProperty.call(value, "visual") || (value.media && typeof value.media === "object");
const hasIdentity = ["id", "title", "text", "leadTitle", "feature", "name", "author"].some((key) => typeof value[key] === "string" && value[key].trim());
if (hasVisual && hasIdentity && !seen.has(value)) { seen.add(value); owners.push(value); }
Object.values(value).forEach((item) => { if (item && typeof item === "object") visit(item); });
};
visit(getRootData());
return owners;
};
const domIdentifiers = (element) => {
const values = new Set();
let cursor = element;
for (let depth = 0; cursor && cursor !== document.body && depth < 8; depth += 1, cursor = cursor.parentElement) {
Object.values(cursor.dataset || {}).forEach((value) => { if (value) values.add(String(value)); });
}
return values;
};
const chooseVisualTarget = (element) => {
const identifiers = domIdentifiers(element);
const context = element.closest("article,button,[data-post-id],[data-video-id],[data-short-id],.post,.video-card,.news-card,.story-card,.lead-story,.hero") || element.parentElement || element;
const contextText = String(context.textContent || "").replace(/\s+/g, " ").trim();
const visualNode = element.closest("[data-visual]") || element.querySelector && element.querySelector("[data-visual]");
const visualValue = visualNode && visualNode.dataset ? visualNode.dataset.visual : "";
let best = null;
let bestScore = 0;
visualOwners().forEach((owner) => {
let score = 0;
Object.entries(owner).forEach(([key, value]) => {
if (/id$/i.test(key) && identifiers.has(String(value))) score += 100;
});
["title", "text", "leadTitle", "feature", "visualLabel", "label", "author", "name", "summary"].forEach((key) => {
const value = typeof owner[key] === "string" ? owner[key].trim() : "";
if (value.length >= 2 && contextText.includes(value)) score += Math.min(40, value.length);
});
if (visualValue && owner.visual === visualValue) score += 20;
if (score > bestScore) { best = owner; bestScore = score; }
});
if (!best || bestScore <= 0) return null;
return { owner: best, previous: best.media && typeof best.media === "object" ? best.media : { type: "image" }, key: "mediaUrl" };
};
const chooseTarget = (element) => {
const source = element.getAttribute("src") || "";
const absolute = element.currentSrc || element.src || source;
const matches = findMatches(source, absolute);
if (!matches.length) return null;
const identifiers = domIdentifiers(element);
return matches.find(({ owner }) => Object.entries(owner).some(([key, value]) => /id$/i.test(key) && identifiers.has(String(value)))) || matches[0];
};
const persist = () => {
const root = getRootData();
if (typeof saveData === "function") return saveData();
if (typeof writeStorage === "function" && typeof storageKey !== "undefined") return writeStorage(storageKey, JSON.stringify(root));
if (typeof writeStorage === "function" && typeof dataKey !== "undefined") return writeStorage(dataKey, JSON.stringify(root));
return false;
};
let active = null;
const attachButton = (host, target) => {
if (!host || !target || host.querySelector(":scope > .df-inline-media-button")) return;
host.classList.add("df-inline-media-host");
const button = document.createElement("button");
button.type = "button";
button.className = "df-inline-media-button";
button.setAttribute("aria-label", "替换图片或视频");
button.title = "上传图片或视频并替换";
button.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5"/><path d="M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/></svg>';
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
active = { ...target };
input.accept = "image/*,video/*";
input.value = "";
input.click();
});
host.appendChild(button);
};
const decorateBuiltInVisuals = () => {
document.querySelectorAll(visualHostSelector).forEach((host) => {
if (host.querySelector("img[src],video[src],iframe[src],:scope > .df-inline-media-button")) return;
if (!host.querySelector("svg") && !host.dataset.visual) return;
const target = chooseVisualTarget(host);
if (!target) return;
attachButton(host, target);
});
};
const decorate = () => {
decorateBuiltInVisuals();
document.querySelectorAll("img[src],video[src]").forEach((media) => {
if (media.dataset.dfInlineMediaReady === "true") return;
const target = chooseTarget(media);
if (!target) return;
const host = media.closest(visualHostSelector) || media.parentElement;
if (!host) return;
media.dataset.dfInlineMediaReady = "true";
attachButton(host, target);
});
};
input.addEventListener("change", () => {
const file = input.files && input.files[0];
if (!file || !active) return;
const uploadedType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : "";
if (!uploadedType) {
notify("请选择图片或视频文件。");
return;
}
const reader = new FileReader();
reader.onerror = () => notify("文件读取失败,请重新选择。");
reader.onload = () => {
const next = { ...(active.previous || {}) };
const result = String(reader.result);
next.type = uploadedType;
next.mediaUrl = result;
delete next.posterUrl;
if (!next.alt) next.alt = uploadedType === "video" ? "用户上传的视频" : "用户上传的图片";
active.owner.media = next;
const stored = persist();
renderAll();
notify(stored === false ? "媒体已替换;浏览器未能缓存,请下载 JSON 留存。" : "媒体已替换并写入页面 JSON。");
active = null;
};
reader.readAsDataURL(file);
});
let queued = false;
const queueDecorate = () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => { queued = false; decorate(); });
};
new MutationObserver(queueDecorate).observe(document.body, { childList: true, subtree: true });
decorate();
}
installInlineMediaReplace();
})();