diff --git a/backend/app/api/public/random.py b/backend/app/api/public/random.py index 5d21bf6..26efafe 100644 --- a/backend/app/api/public/random.py +++ b/backend/app/api/public/random.py @@ -811,6 +811,10 @@ async def random_image( "bookmark_count": getattr(image, "bookmark_count", None), "view_count": getattr(image, "view_count", None), "comment_count": getattr(image, "comment_count", None), + "user": { + "id": str(image.user_id) if image.user_id is not None else None, + "name": image.user_name, + }, }, "urls": { "proxy": f"/i/{image.id}.{image.ext}", diff --git a/backend/app/api/public/wtf_page.py b/backend/app/api/public/wtf_page.py index 94abdc8..a3230cf 100644 --- a/backend/app/api/public/wtf_page.py +++ b/backend/app/api/public/wtf_page.py @@ -129,6 +129,38 @@ def _build_wtf_html(*, base_url: str) -> str: color: rgba(43, 29, 22, 0.90); }} .btn:hover {{ filter: brightness(0.98); }} + + .seg {{ + display: inline-flex; + align-items: center; + border: 1px solid var(--border); + background: rgba(43, 29, 22, 0.06); + border-radius: 999px; + padding: 2px; + gap: 2px; + }} + .segbtn {{ + cursor: pointer; + border: 0; + background: transparent; + border-radius: 999px; + padding: 7px 10px; + font-size: 12px; + color: rgba(43, 29, 22, 0.78); + user-select: none; + white-space: nowrap; + }} + .segbtn:hover {{ filter: brightness(0.98); }} + .segbtn.active {{ + background: var(--card-2); + color: rgba(43, 29, 22, 0.92); + box-shadow: 0 6px 18px rgba(40, 24, 16, 0.10); + }} + .segbtn:focus-visible {{ + outline: 2px solid rgba(162, 82, 44, 0.55); + outline-offset: 2px; + }} + .muted {{ color: var(--muted2); }} code {{ font-family: var(--mono); @@ -173,6 +205,24 @@ def _build_wtf_html(*, base_url: str) -> str: .item.pending img {{ aspect-ratio: var(--ar, 16 / 9); }} + .meta {{ + display: none; + padding: 8px 10px 10px; + border-top: 1px solid rgba(58, 38, 26, 0.10); + background: rgba(255, 250, 243, 0.92); + color: rgba(67, 51, 44, 0.78); + font-size: 12px; + line-height: 1.4; + gap: 6px; + flex-wrap: wrap; + }} + .meta a {{ + color: var(--link); + text-decoration: none; + font-family: var(--mono); + font-size: 11px; + }} + .meta a:hover {{ text-decoration: underline; }} .item img {{ width: 100%; height: auto; @@ -191,6 +241,119 @@ def _build_wtf_html(*, base_url: str) -> str: color: var(--muted2); font-size: 11px; }} + + body.view-masonry .wrap {{ + max-width: none; + padding-left: 0; + padding-right: 0; + }} + body.view-masonry .top {{ + margin: 0 16px; + }} + @media (max-width: 520px) {{ + body.view-masonry .top {{ margin: 0 12px; }} + }} + body.view-masonry .feed {{ + margin-top: 14px; + display: flex; + flex-direction: row; + gap: 0; + align-items: flex-start; + width: 100%; + }} + body.view-masonry .mcol {{ + flex: 1 1 0; + display: flex; + flex-direction: column; + gap: 0; + line-height: 0; + min-width: 0; + }} + body.view-masonry .item {{ + width: 100%; + break-inside: avoid; + border: none; + border-radius: 0; + box-shadow: none; + background: transparent; + contain-intrinsic-size: 620px; + }} + body.view-masonry .item.pending {{ + border: none; + border-radius: 0; + box-shadow: none; + }} + body.view-masonry .item img {{ + border-radius: 0; + transition: opacity 180ms ease; + transform: none; + }} + body.view-masonry .item.loaded img {{ + transform: none; + }} + + body.view-tiles .feed {{ + margin-top: 14px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + }} + @media (max-width: 1100px) {{ + body.view-tiles .feed {{ + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + }} + }} + @media (max-width: 760px) {{ + body.view-tiles .feed {{ + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + }} + }} + @media (max-width: 520px) {{ + body.view-tiles .feed {{ + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + }} + }} + body.view-tiles .item {{ + border-radius: 14px; + background: var(--card); + border: 1px solid var(--border); + box-shadow: 0 10px 30px rgba(40, 24, 16, 0.06); + transition: transform 120ms ease, box-shadow 180ms ease, border-color 180ms ease; + }} + body.view-tiles .item:hover {{ + transform: translateY(-1px); + border-color: rgba(162, 82, 44, 0.30); + box-shadow: 0 14px 36px rgba(40, 24, 16, 0.10); + }} + body.view-tiles .item:focus-within {{ + border-color: rgba(162, 82, 44, 0.42); + }} + body.view-tiles .item img {{ + aspect-ratio: var(--tile-ar, 16 / 9); + object-fit: cover; + transform: none; + }} + body.view-tiles .item.pending img {{ + aspect-ratio: var(--tile-ar, var(--ar, 16 / 9)); + }} + body.view-tiles .meta {{ + display: flex; + }} + body.view-tiles .meta a {{ + padding: 2px 6px; + border-radius: 8px; + }} + body.view-tiles .meta a:hover {{ + background: rgba(192, 112, 70, 0.14); + text-decoration: none; + }} + body.view-tiles .meta a:focus-visible {{ + outline: 2px solid rgba(162, 82, 44, 0.55); + outline-offset: 2px; + }} @@ -207,6 +370,11 @@ def _build_wtf_html(*, base_url: str) -> str:
+
+ + + +
@@ -220,18 +388,142 @@ def _build_wtf_html(*, base_url: str) -> str: const sentinel = document.getElementById("sentinel"); const info = document.getElementById("info"); const toggle = document.getElementById("toggle"); + const viewSeg = document.getElementById("viewSeg"); + const viewButtons = Array.from(viewSeg.querySelectorAll("button[data-view]")); - const baseParams = new URLSearchParams(window.location.search); + const qs = new URLSearchParams(window.location.search); + const VIEW_KEY = "wtf_view"; + + const allItems = []; + let masonryCols = []; + let masonryHeights = []; + let masonryCount = 0; + + function normalizeView(raw) {{ + const v = String(raw || "").trim().toLowerCase(); + if (v === "masonry" || v === "mason" || v === "standard") return "masonry"; + if (v === "tiles" || v === "tile" || v === "grid" || v === "small") return "tiles"; + return "single"; + }} + + function ratioToNumber(r) {{ + const s = String(r || "").replace(/\\s+/g, ""); + const m = s.match(/^(\\d+(?:\\.\\d+)?)\\/(\\d+(?:\\.\\d+)?)$/); + if (!m) return 0.75; + const w = Number(m[1]); + const h = Number(m[2]); + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return 0.75; + return h / w; + }} + + function desiredMasonryCount() {{ + const w = Math.max(0, document.documentElement.clientWidth || window.innerWidth || 0); + if (w <= 520) return 1; + if (w <= 980) return 2; + return 3; + }} + + function masonryColWidth() {{ + const w = Math.max(320, feed.clientWidth || document.documentElement.clientWidth || window.innerWidth || 1000); + return masonryCount > 0 ? (w / masonryCount) : w; + }} + + function masonryEstimateHeight(item) {{ + const ratio = Number(item && item.dataset ? item.dataset.ratio : NaN); + const r = Number.isFinite(ratio) && ratio > 0 ? ratio : 0.75; + return masonryColWidth() * r; + }} + + function rebuildLayout() {{ + try {{ feed.textContent = ""; }} catch (e) {{}} + masonryCols = []; + masonryHeights = []; + masonryCount = 0; + + if (viewMode === "masonry") {{ + masonryCount = desiredMasonryCount(); + masonryHeights = new Array(masonryCount).fill(0); + for (let i = 0; i < masonryCount; i++) {{ + const col = document.createElement("div"); + col.className = "mcol"; + masonryCols.push(col); + feed.appendChild(col); + }} + + for (const item of allItems) {{ + const h = masonryEstimateHeight(item); + let best = 0; + for (let i = 1; i < masonryHeights.length; i++) {{ + if (masonryHeights[i] < masonryHeights[best]) best = i; + }} + item.dataset.mcol = String(best); + item.dataset.estH = String(h); + masonryHeights[best] += h; + masonryCols[best].appendChild(item); + }} + return; + }} + + for (const item of allItems) {{ + try {{ + delete item.dataset.mcol; + delete item.dataset.estH; + }} catch (e) {{}} + feed.appendChild(item); + }} + }} + + function mountNewItem(item) {{ + allItems.push(item); + if (viewMode !== "masonry") {{ + feed.appendChild(item); + return; + }} + + if (!masonryCols.length || masonryCount !== desiredMasonryCount()) {{ + rebuildLayout(); + return; + }} + + const h = masonryEstimateHeight(item); + let best = 0; + for (let i = 1; i < masonryHeights.length; i++) {{ + if (masonryHeights[i] < masonryHeights[best]) best = i; + }} + item.dataset.mcol = String(best); + item.dataset.estH = String(h); + masonryHeights[best] += h; + masonryCols[best].appendChild(item); + }} + + function updateMasonryEstimate(item) {{ + if (viewMode !== "masonry") return; + if (!item || !item.dataset) return; + const idx = Number(item.dataset.mcol); + if (!Number.isInteger(idx) || idx < 0 || idx >= masonryHeights.length) return; + const oldH = Number(item.dataset.estH); + const prev = Number.isFinite(oldH) && oldH > 0 ? oldH : 0; + const next = masonryEstimateHeight(item); + masonryHeights[idx] = Math.max(0, masonryHeights[idx] - prev + next); + item.dataset.estH = String(next); + }} + + let storedView = ""; + try {{ storedView = localStorage.getItem(VIEW_KEY) || ""; }} catch (e) {{}} + + const baseParams = new URLSearchParams(qs); + baseParams.delete("view"); baseParams.delete("format"); baseParams.delete("redirect"); baseParams.delete("t"); if (!baseParams.has("adaptive")) baseParams.set("adaptive", "1"); - baseParams.set("redirect", "1"); - const baseQuery = baseParams.toString(); - info.textContent = baseQuery ? `当前过滤: ?${{baseQuery}}` : "当前过滤: (无)"; + const showParams = new URLSearchParams(baseParams); + const baseQuery = showParams.toString(); + info.textContent = baseQuery ? ("当前过滤: ?" + baseQuery) : "当前过滤: (无)"; let paused = false; + let viewMode = normalizeView(qs.get("view") || storedView || "single"); let seq = 0; let inflight = 0; let rendered = 0; @@ -242,24 +534,7 @@ def _build_wtf_html(*, base_url: str) -> str: return window.matchMedia && window.matchMedia("(max-width: 520px)").matches; }} - function cfg() {{ - const mobile = isMobile(); - const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection; - const effectiveType = conn && conn.effectiveType ? String(conn.effectiveType) : ""; - const slow = effectiveType.includes("2g") || effectiveType.includes("3g"); - - const base = mobile - ? {{ initial: 12, step: 8, maxInflight: 12 }} - : {{ initial: 18, step: 14, maxInflight: 22 }}; - - return {{ - initial: base.initial, - step: base.step, - maxInflight: slow ? Math.max(6, Math.floor(base.maxInflight * 0.6)) : base.maxInflight, - }}; - }} - - function pickSkeletonRatio() {{ + function pickRatioFromParams() {{ const o = String(baseParams.get("orientation") || "").trim().toLowerCase(); if (o === "square") return "1 / 1"; if (o === "portrait") return "2 / 3"; @@ -272,23 +547,149 @@ def _build_wtf_html(*, base_url: str) -> str: return isMobile() ? "3 / 4" : "16 / 9"; }} - function buildUrl() {{ + function cfg() {{ + const mobile = isMobile(); + const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection; + const effectiveType = conn && conn.effectiveType ? String(conn.effectiveType) : ""; + const slow = effectiveType.includes("2g") || effectiveType.includes("3g"); + + let initial = mobile ? 12 : 18; + let step = mobile ? 8 : 14; + let maxInflight = mobile ? 12 : 22; + + if (viewMode === "masonry") {{ + initial = mobile ? 14 : 24; + step = mobile ? 10 : 16; + maxInflight = mobile ? 14 : 22; + }} else if (viewMode === "tiles") {{ + initial = mobile ? 16 : 24; + step = mobile ? 10 : 14; + maxInflight = mobile ? 16 : 20; + }} + + if (slow) {{ + maxInflight = Math.max(6, Math.floor(maxInflight * 0.6)); + }} + + return {{ initial: initial, step: step, maxInflight: maxInflight }}; + }} + + function applyView(mode, persist) {{ + viewMode = normalizeView(mode); + document.body.classList.toggle("view-single", viewMode === "single"); + document.body.classList.toggle("view-masonry", viewMode === "masonry"); + document.body.classList.toggle("view-tiles", viewMode === "tiles"); + + for (const b of viewButtons) {{ + const on = String(b.dataset.view || "") === viewMode; + b.classList.toggle("active", on); + b.setAttribute("aria-selected", on ? "true" : "false"); + }} + + if (viewMode === "tiles") {{ + document.documentElement.style.setProperty("--tile-ar", pickRatioFromParams()); + }} else {{ + document.documentElement.style.removeProperty("--tile-ar"); + }} + + if (persist) {{ + try {{ localStorage.setItem(VIEW_KEY, viewMode); }} catch (e) {{}} + }} + + rebuildLayout(); + + const c = cfg(); + target = Math.max(target, c.initial); + ensure(); + updateSentinel(); + }} + + for (const b of viewButtons) {{ + b.addEventListener("click", () => {{ + applyView(String(b.dataset.view || "single"), true); + }}); + }} + applyView(viewMode, false); + + function buildRandomJsonUrl() {{ const p = new URLSearchParams(baseParams); + p.set("format", "simple_json"); p.set("t", String(Date.now()) + "_" + String(seq++)); return "/random?" + p.toString(); }} - function updateSentinel() {{ - const status = paused ? "已暂停" : "加载中"; - sentinel.textContent = `${{status}} · 已显示 ${{rendered}} 张 · in-flight ${{inflight}}`; + function buildProxyQuery() {{ + const qp = new URLSearchParams(); + const pc = String(baseParams.get("pixiv_cat") || "").trim(); + if (pc === "1") qp.set("pixiv_cat", "1"); + const mh = String(baseParams.get("pximg_mirror_host") || "").trim(); + if (mh) qp.set("pximg_mirror_host", mh); + const s = qp.toString(); + return s ? ("?" + s) : ""; }} - function appendItem(imgEl) {{ - const item = document.createElement("div"); - item.className = "item"; - item.appendChild(imgEl); - feed.appendChild(item); - requestAnimationFrame(() => item.classList.add("loaded")); + async function fetchRandomData() {{ + const url = buildRandomJsonUrl(); + const resp = await fetch(url, {{ cache: "no-store" }}); + if (resp.status === 404) {{ + const e = new Error("NO_MATCH"); + e.name = "NO_MATCH"; + throw e; + }} + if (!resp.ok) {{ + throw new Error("HTTP_" + String(resp.status)); + }} + const body = await resp.json(); + if (!body || body.ok !== true || !body.data || !body.data.urls || !body.data.image) {{ + throw new Error("BAD_BODY"); + }} + return body.data; + }} + + function updateSentinel() {{ + const status = paused ? "已暂停" : "加载中"; + sentinel.textContent = status + " · 已显示 " + String(rendered) + " 张 · in-flight " + String(inflight); + }} + + function setMeta(meta, data) {{ + try {{ + meta.textContent = ""; + const image = data && data.image ? data.image : null; + if (!image) return; + + const illustId = String(image.illust_id || "").trim(); + const user = image.user || null; + const userId = user && user.id ? String(user.id).trim() : ""; + const userName = user && user.name ? String(user.name).trim() : ""; + + const links = []; + if (userId) {{ + const a = document.createElement("a"); + a.href = "https://www.pixiv.net/users/" + userId; + a.target = "_blank"; + a.rel = "noreferrer noopener"; + a.textContent = "pixiv.net/users/" + userId; + if (userName) a.title = userName; + links.push(a); + }} + if (illustId) {{ + const a = document.createElement("a"); + a.href = "https://www.pixiv.net/artworks/" + illustId; + a.target = "_blank"; + a.rel = "noreferrer noopener"; + a.textContent = "pixiv.net/artworks/" + illustId; + links.push(a); + }} + + for (let i = 0; i < links.length; i++) {{ + if (i > 0) {{ + const sep = document.createElement("span"); + sep.textContent = " · "; + meta.appendChild(sep); + }} + meta.appendChild(links[i]); + }} + }} catch (e) {{}} }} function startOne() {{ @@ -302,22 +703,29 @@ def _build_wtf_html(*, base_url: str) -> str: const item = document.createElement("div"); item.className = "item pending"; - item.style.setProperty("--ar", pickSkeletonRatio()); + const fallbackAr = pickRatioFromParams(); + item.style.setProperty("--ar", fallbackAr); + item.dataset.ratio = String(ratioToNumber(fallbackAr)); - const img = new Image(); + const img = document.createElement("img"); img.decoding = "async"; + img.loading = "lazy"; img.referrerPolicy = "no-referrer"; img.alt = ""; + + const meta = document.createElement("div"); + meta.className = "meta"; + item.appendChild(img); - feed.appendChild(item); + item.appendChild(meta); + mountNewItem(item); let tries = 0; - const load = () => {{ - tries += 1; - img.src = buildUrl(); - }}; + let done = false; - img.onload = () => {{ + const finishOk = () => {{ + if (done) return; + done = true; inflight = Math.max(0, inflight - 1); rendered += 1; failStreak = 0; @@ -326,25 +734,97 @@ def _build_wtf_html(*, base_url: str) -> str: updateSentinel(); ensure(); }}; - img.onerror = () => {{ - if (tries < 3) {{ - setTimeout(load, 250 * tries); - return; - }} + + const finishFail = (reason) => {{ + if (done) return; + done = true; inflight = Math.max(0, inflight - 1); failStreak += 1; + try {{ + const idx = allItems.indexOf(item); + if (idx >= 0) allItems.splice(idx, 1); + }} catch (e) {{}} + try {{ + const colIdx = Number(item.dataset.mcol); + const est = Number(item.dataset.estH); + if (Number.isInteger(colIdx) && colIdx >= 0 && colIdx < masonryHeights.length && Number.isFinite(est) && est > 0) {{ + masonryHeights[colIdx] = Math.max(0, masonryHeights[colIdx] - est); + }} + }} catch (e) {{}} try {{ item.remove(); }} catch (e) {{}} updateSentinel(); + if (String(reason || "") === "NO_MATCH") {{ + paused = true; + toggle.textContent = "继续加载"; + sentinel.textContent = "没有匹配结果:请放宽过滤条件后重试。"; + return; + }} if (failStreak >= 8) {{ paused = true; toggle.textContent = "继续加载"; - sentinel.textContent = "连续失败较多:可能网络不稳定或当前过滤无匹配,请放宽条件后重试。"; + sentinel.textContent = "连续失败较多:可能网络不稳定,请稍后再试或放宽条件。"; return; }} ensure(); }}; - load(); + const attempt = async () => {{ + tries += 1; + try {{ + const data = await fetchRandomData(); + setMeta(meta, data); + try {{ + const iw = data && data.image ? Number(data.image.width) : NaN; + const ih = data && data.image ? Number(data.image.height) : NaN; + if (Number.isFinite(iw) && Number.isFinite(ih) && iw > 0 && ih > 0) {{ + img.setAttribute("width", String(iw)); + img.setAttribute("height", String(ih)); + item.style.setProperty("--ar", String(iw) + " / " + String(ih)); + item.dataset.ratio = String(ih / iw); + updateMasonryEstimate(item); + }} + }} catch (e) {{}} + const proxy = data && data.urls ? String(data.urls.proxy || "") : ""; + if (!proxy || proxy[0] !== "/") throw new Error("BAD_PROXY"); + img.src = proxy + buildProxyQuery(); + }} catch (e) {{ + if (done) return; + const name = e && e.name ? String(e.name) : ""; + const msg = e && e.message ? String(e.message) : ""; + if (name === "NO_MATCH" || msg === "NO_MATCH") {{ + finishFail("NO_MATCH"); + return; + }} + if (tries < 3) {{ + setTimeout(() => attempt(), 250 * tries); + return; + }} + finishFail(msg || name || "ERROR"); + }} + }}; + + img.onload = () => {{ + try {{ + if ((!img.hasAttribute("width") || !img.hasAttribute("height")) && img.naturalWidth > 0 && img.naturalHeight > 0) {{ + img.setAttribute("width", String(img.naturalWidth)); + img.setAttribute("height", String(img.naturalHeight)); + item.style.setProperty("--ar", String(img.naturalWidth) + " / " + String(img.naturalHeight)); + item.dataset.ratio = String(img.naturalHeight / img.naturalWidth); + updateMasonryEstimate(item); + }} + }} catch (e) {{}} + finishOk(); + }}; + img.onerror = () => {{ + if (done) return; + if (tries < 3) {{ + setTimeout(() => attempt(), 250 * tries); + return; + }} + finishFail("IMAGE_ERROR"); + }}; + + attempt(); }} function ensure() {{ @@ -402,6 +882,9 @@ def _build_wtf_html(*, base_url: str) -> str: window.addEventListener("resize", () => {{ const c = cfg(); target = Math.max(target, c.initial); + if (viewMode === "masonry" && masonryCount !== desiredMasonryCount()) {{ + rebuildLayout(); + }} ensure(); }});