From eabbff159216e717893af20b057fbf1a6aa905b7 Mon Sep 17 00:00:00 2001 From: Gary Lau <261118068+GaryLauLGY@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:22:21 +0800 Subject: [PATCH 1/2] feat: keep personal backgrounds in the browser --- README.md | 2 +- app/globals.css | 28 ++++++++- app/material-repository.ts | 2 +- app/page.tsx | 97 ++++++++++++++++++++++------- app/personal-backgrounds.ts | 117 +++++++++++++++++++++++++++++++++++ tests/rendered-html.test.mjs | 11 +++- 6 files changed, 231 insertions(+), 26 deletions(-) create mode 100644 app/personal-backgrounds.ts diff --git a/README.md b/README.md index 17b1bbc..c1e81c1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - 推文来源与发布账号分离 - 仅显示当前用户可用的发布账号(原型内存权限) - 发布账号包含黄小木、Roland.W、AI最严厉的父亲和阿川,账号切换同步名称、ID、认证状态和名称标签 -- 正文编辑、使用原推、10 张城市与风景背景(4 张既有素材与 6 张 3:4 精选图)、上传/网络图片 +- 正文编辑、使用原推、10 张城市与风景背景(4 张既有素材与 6 张 3:4 精选图)、网络图片,以及只保存在当前浏览器、可刷新保留和删除的个人上传图片 - 同步正文保留原始段落、空行、手动换行、连续空格与标点;回复、转推、点赞、书签在源数据存在时同步 - 左侧素材候选卡直接显示浏览、回复、转推、点赞和书签数据 - 五项候选数据固定保持单行;选中素材时自动带入原始数据,也可在右侧逐项修改当前卡片数据 diff --git a/app/globals.css b/app/globals.css index 4abb433..c73b02d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -514,24 +514,48 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .background-heading { margin: 17px 0 9px; font-size: 11px; } .background-heading span { color: #929d96; font-size: 9px; } .background-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 7px; } -.background-grid button { +.background-tile { position: relative; aspect-ratio: 1; +} +.background-swatch, +.background-grid .background-add { + width: 100%; + height: 100%; border: 2px solid transparent; border-radius: 9px; background-size: cover; background-position: center; } -.background-grid button.background-active { +.background-swatch.background-active { border-color: var(--accent-strong); box-shadow: 0 0 0 2px #dfe4ff; } .background-grid .background-add { + aspect-ratio: 1; border: 1px dashed #b7bed3; background: #f8f9fd; color: #737e99; font-size: 18px; } +.background-remove { + position: absolute; + top: -5px; + right: -5px; + display: grid; + width: 19px; + height: 19px; + padding: 0; + place-items: center; + border: 2px solid #fff; + border-radius: 50%; + background: #30364a; + color: #fff; + font-size: 13px; + line-height: 1; + box-shadow: 0 2px 7px rgba(20, 25, 45, .22); +} +.background-privacy { margin: 8px 0 0; color: #7b867f; font-size: 9px; line-height: 1.5; } .url-row { display: flex; gap: 6px; margin-top: 8px; } .url-row input { min-width: 0; padding: 8px 9px; font-size: 10px; } .url-row button { diff --git a/app/material-repository.ts b/app/material-repository.ts index 47f60cd..d6c9ab6 100644 --- a/app/material-repository.ts +++ b/app/material-repository.ts @@ -13,7 +13,7 @@ export type Account = { export type PostMetrics = { replies: number; reposts: number; likes: number; bookmarks: number; views: number }; export type Post = { id: string; text: string; source: string; date: string; link: string; category: string; tags: string[]; note?: string; metrics: PostMetrics }; -export type Background = { id: string; name: string; kind: "gradient" | "image"; value: string; palette?: string[] }; +export type Background = { id: string; name: string; kind: "gradient" | "image"; value: string; palette?: string[]; isPersonal?: boolean }; export const legacyBootstrap = { accounts: existingAccounts as Account[], diff --git a/app/page.tsx b/app/page.tsx index 22138cb..64bdb7d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent, TouchEvent as ReactTouchEvent } from "react"; import { legacyBootstrap, loadLiveLibrary, type Account, type Background, type Post, type PostMetrics } from "./material-repository"; +import { deletePersonalBackground, listPersonalBackgrounds, savePersonalBackground, type PersonalBackgroundRecord } from "./personal-backgrounds"; type ActionIconName = "reply" | "repost" | "like" | "bookmark" | "share"; @@ -289,6 +290,7 @@ export default function Home() { const [dragging, setDragging] = useState(false); const [resizing, setResizing] = useState(false); const backgroundInput = useRef(null); + const personalBackgroundUrls = useRef(new Map()); const canvasAreaRef = useRef(null); const posterRef = useRef(null); const cardRef = useRef(null); @@ -334,13 +336,33 @@ export default function Home() { }, [author, category, posts, search]); const effectiveCardScale = Math.min(cardScale, maxCardScale); + const backgroundFromPersonalRecord = (record: PersonalBackgroundRecord): Background => { + let value = personalBackgroundUrls.current.get(record.id); + if (!value) { + value = URL.createObjectURL(record.blob); + personalBackgroundUrls.current.set(record.id, value); + } + return { id: record.id, name: record.name, kind: "image", value, isPersonal: true }; + }; + useEffect(() => { let cancelled = false; - loadLiveLibrary().then((library) => { + Promise.all([ + loadLiveLibrary(), + listPersonalBackgrounds().catch(() => []), + ]).then(([library, personalRecords]) => { if (cancelled) return; setPosts(library.posts); setAvailableAccounts(library.accounts); - setBackgrounds(library.backgrounds); + const personalBackgrounds = personalRecords.map(backgroundFromPersonalRecord); + const personalIds = new Set(personalBackgrounds.map((item) => item.id)); + for (const [id, url] of personalBackgroundUrls.current) { + if (!personalIds.has(id)) { + URL.revokeObjectURL(url); + personalBackgroundUrls.current.delete(id); + } + } + setBackgrounds([...library.backgrounds, ...personalBackgrounds]); const firstPost = library.posts[0]!; const firstAccount = accountForSource(firstPost.source, library.accounts) ?? library.accounts[0]!; setSelectedPostId(firstPost.id); @@ -359,6 +381,11 @@ export default function Home() { return () => { cancelled = true; }; }, [loadAttempt]); + useEffect(() => () => { + for (const url of personalBackgroundUrls.current.values()) URL.revokeObjectURL(url); + personalBackgroundUrls.current.clear(); + }, []); + useEffect(() => { if (tab !== "studio") return; const canvasArea = canvasAreaRef.current; @@ -481,19 +508,36 @@ export default function Home() { setPublishDraft((current) => replacePublishingTagLine(current, buildPublishingTags(selectedPost, account, nextOffset))); }; - const uploadBackground = (event: ChangeEvent) => { + const uploadBackground = async (event: ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; - const item: Background = { - id: `upload-${Date.now()}`, - name: file.name.replace(/\.[^.]+$/, ""), - kind: "image", - value: URL.createObjectURL(file), - }; - setBackgrounds((current) => [...current, item]); - setBackgroundId(item.id); - notify("背景已加入当前素材库"); + try { + const record = await savePersonalBackground(file); + const item = backgroundFromPersonalRecord(record); + setBackgrounds((current) => [...current, item]); + setBackgroundId(item.id); + notify("图片仅保存到当前浏览器"); + } catch (error) { + notify(error instanceof Error ? error.message : "图片保存失败"); + } + }; + + const removePersonalBackground = async (item: Background) => { + if (!window.confirm(`删除本机图片“${item.name}”?此操作无法撤销。`)) return; + try { + await deletePersonalBackground(item.id); + const objectUrl = personalBackgroundUrls.current.get(item.id); + if (objectUrl) URL.revokeObjectURL(objectUrl); + personalBackgroundUrls.current.delete(item.id); + setBackgrounds((current) => current.filter((candidate) => candidate.id !== item.id)); + setBackgroundId((current) => current === item.id + ? (backgrounds.find((candidate) => candidate.id !== item.id)?.id ?? builtinBackgrounds[0]!.id) + : current); + notify("本机图片已删除"); + } catch (error) { + notify(error instanceof Error ? error.message : "本机图片删除失败"); + } }; const addNetworkBackground = () => { @@ -1145,21 +1189,32 @@ export default function Home() { {dim}% -
背景素材{backgrounds.length} 张
+
背景素材{backgrounds.length} 张 · {backgrounds.filter((item) => item.isPersonal).length} 张本机图片
{backgrounds.map((item) => ( - + )} +
))} +

上传图片仅保存在当前浏览器,不会上传到服务器或分享给其他用户。

setBackgroundUrl(event.target.value)} placeholder="粘贴网络图片地址" /> diff --git a/app/personal-backgrounds.ts b/app/personal-backgrounds.ts new file mode 100644 index 0000000..e16f34b --- /dev/null +++ b/app/personal-backgrounds.ts @@ -0,0 +1,117 @@ +const DATABASE_NAME = "x-post-personal-media"; +const DATABASE_VERSION = 1; +const BACKGROUND_STORE = "backgrounds"; + +export const PERSONAL_BACKGROUND_MAX_BYTES = 20 * 1024 * 1024; + +const SUPPORTED_IMAGE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", +]); + +export type PersonalBackgroundRecord = { + id: string; + name: string; + blob: Blob; + createdAt: number; +}; + +function requestResult(request: IDBRequest) { + return new Promise((resolve, reject) => { + request.addEventListener("success", () => resolve(request.result), { once: true }); + request.addEventListener("error", () => reject(request.error ?? new Error("浏览器本地存储操作失败")), { once: true }); + }); +} + +function transactionComplete(transaction: IDBTransaction) { + return new Promise((resolve, reject) => { + transaction.addEventListener("complete", () => resolve(), { once: true }); + transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("浏览器本地存储操作已取消")), { once: true }); + transaction.addEventListener("error", () => reject(transaction.error ?? new Error("浏览器本地存储操作失败")), { once: true }); + }); +} + +function openDatabase() { + if (typeof indexedDB === "undefined") { + return Promise.reject(new Error("当前浏览器不支持本机图片库")); + } + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + if (!request.result.objectStoreNames.contains(BACKGROUND_STORE)) { + request.result.createObjectStore(BACKGROUND_STORE, { keyPath: "id" }); + } + }); + request.addEventListener("success", () => { + const database = request.result; + database.addEventListener("versionchange", () => database.close()); + resolve(database); + }, { once: true }); + request.addEventListener("error", () => reject(request.error ?? new Error("无法打开浏览器本机图片库")), { once: true }); + request.addEventListener("blocked", () => reject(new Error("浏览器本机图片库正在被其他页面占用")), { once: true }); + }); +} + +function personalBackgroundName(fileName: string) { + return fileName.replace(/\.[^.]+$/u, "").trim() || "我的图片"; +} + +export function validatePersonalBackground(file: File) { + if (!SUPPORTED_IMAGE_TYPES.has(file.type)) { + throw new Error("请选择 JPG、PNG 或 WebP 图片"); + } + if (file.size <= 0) { + throw new Error("图片文件为空"); + } + if (file.size > PERSONAL_BACKGROUND_MAX_BYTES) { + throw new Error("图片不能超过 20 MB"); + } +} + +export async function listPersonalBackgrounds() { + const database = await openDatabase(); + try { + const transaction = database.transaction(BACKGROUND_STORE, "readonly"); + const complete = transactionComplete(transaction); + const records = await requestResult(transaction.objectStore(BACKGROUND_STORE).getAll()) as PersonalBackgroundRecord[]; + await complete; + return records.sort((left, right) => left.createdAt - right.createdAt); + } finally { + database.close(); + } +} + +export async function savePersonalBackground(file: File) { + validatePersonalBackground(file); + const record: PersonalBackgroundRecord = { + id: `personal-${crypto.randomUUID()}`, + name: personalBackgroundName(file.name), + blob: file, + createdAt: Date.now(), + }; + const database = await openDatabase(); + try { + const transaction = database.transaction(BACKGROUND_STORE, "readwrite"); + const complete = transactionComplete(transaction); + transaction.objectStore(BACKGROUND_STORE).put(record); + await complete; + return record; + } finally { + database.close(); + } +} + +export async function deletePersonalBackground(id: string) { + if (!id.startsWith("personal-")) throw new Error("只能删除本机图片"); + const database = await openDatabase(); + try { + const transaction = database.transaction(BACKGROUND_STORE, "readwrite"); + const complete = transactionComplete(transaction); + transaction.objectStore(BACKGROUND_STORE).delete(id); + await complete; + } finally { + database.close(); + } +} diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs index d928964..1ac1505 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -27,7 +27,7 @@ test("server-renders the tweet card studio", async () => { }); test("removes starter-only preview assets and metadata", async () => { - const [page, layout, packageJson, materials, accounts, users, styles, syncScript] = await Promise.all([ + const [page, layout, packageJson, materials, accounts, users, styles, syncScript, personalBackgrounds] = await Promise.all([ readFile(new URL("../app/page.tsx", import.meta.url), "utf8"), readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"), readFile(new URL("../package.json", import.meta.url), "utf8"), @@ -36,6 +36,7 @@ test("removes starter-only preview assets and metadata", async () => { readFile(new URL("../app/data/existing-users.json", import.meta.url), "utf8"), readFile(new URL("../app/globals.css", import.meta.url), "utf8"), readFile(new URL("../scripts/build-existing-materials.mjs", import.meta.url), "utf8"), + readFile(new URL("../app/personal-backgrounds.ts", import.meta.url), "utf8"), ]); assert.match(page, /downloadPng/); assert.doesNotMatch(page, /本地原型|prototype-badge/); @@ -129,6 +130,14 @@ test("removes starter-only preview assets and metadata", async () => { assert.match(page, /backgrounds\/sunset-city-glass\.jpg/); assert.match(page, /backgrounds\/alpine-clouds\.jpg/); assert.match(page, /backgrounds\/alpine-clear-sky\.jpg/); + assert.match(page, /listPersonalBackgrounds/); + assert.match(page, /savePersonalBackground/); + assert.match(page, /deletePersonalBackground/); + assert.match(page, /上传图片仅保存在当前浏览器,不会上传到服务器或分享给其他用户/); + assert.match(page, /className="background-remove"/); + assert.match(personalBackgrounds, /indexedDB\.open/); + assert.match(personalBackgrounds, /PERSONAL_BACKGROUND_MAX_BYTES = 20 \* 1024 \* 1024/); + assert.doesNotMatch(personalBackgrounds, /fetch\(|XMLHttpRequest/); await access(new URL("../public/backgrounds/itadaki-city-night.jpg", import.meta.url)); await access(new URL("../public/backgrounds/sunny-city-blue-sky.jpg", import.meta.url)); await access(new URL("../public/backgrounds/sunny-city-clouds.jpg", import.meta.url)); From d69b8d34c2efb9ce110b2196aa57e09a9cf3f03f Mon Sep 17 00:00:00 2001 From: Gary Lau <261118068+GaryLauLGY@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:44:51 +0800 Subject: [PATCH 2/2] fix: stabilize background grid and preload avatars --- app/globals.css | 13 ++++++++----- app/material-repository.ts | 35 +++++++++++++++++++++++++++++++---- app/page.tsx | 4 +++- tests/rendered-html.test.mjs | 8 +++++++- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/app/globals.css b/app/globals.css index c73b02d..b6e0a4f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -513,13 +513,17 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .slider-row b { min-width: 34px; margin-bottom: 3px; text-align: right; font-size: 10px; } .background-heading { margin: 17px 0 9px; font-size: 11px; } .background-heading span { color: #929d96; font-size: 9px; } -.background-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 7px; } +.background-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); align-items: start; gap: 7px; } .background-tile { position: relative; + min-width: 0; aspect-ratio: 1; + line-height: 0; } .background-swatch, -.background-grid .background-add { +.background-add { + display: block; + box-sizing: border-box; width: 100%; height: 100%; border: 2px solid transparent; @@ -531,8 +535,7 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } border-color: var(--accent-strong); box-shadow: 0 0 0 2px #dfe4ff; } -.background-grid .background-add { - aspect-ratio: 1; +.background-add { border: 1px dashed #b7bed3; background: #f8f9fd; color: #737e99; @@ -555,7 +558,7 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } line-height: 1; box-shadow: 0 2px 7px rgba(20, 25, 45, .22); } -.background-privacy { margin: 8px 0 0; color: #7b867f; font-size: 9px; line-height: 1.5; } +.background-privacy { margin: 10px 0 0; color: #7b867f; font-size: 9px; line-height: 1.5; } .url-row { display: flex; gap: 6px; margin-top: 8px; } .url-row input { min-width: 0; padding: 8px 9px; font-size: 10px; } .url-row button { diff --git a/app/material-repository.ts b/app/material-repository.ts index d6c9ab6..ed9ea30 100644 --- a/app/material-repository.ts +++ b/app/material-repository.ts @@ -37,7 +37,37 @@ async function responseJson(url: string) { return response.json() as Promise; } +function preloadLibraryImage(url: string, priority: "high" | "auto") { + if (typeof Image === "undefined") return Promise.resolve(); + return new Promise((resolve) => { + const image = new Image(); + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(); + }; + const timeout = setTimeout(finish, 12000); + image.fetchPriority = priority; + image.addEventListener("load", finish, { once: true }); + image.addEventListener("error", finish, { once: true }); + image.src = url; + if (image.complete) finish(); + }); +} + export async function loadLiveLibrary() { + const accountsRequest = responseJson<{ data: Array<{ id: string; name: string; handle: string; verified: boolean; label?: string; accent?: string; avatarUrl?: string }> }>("/api/v1/publishing-accounts"); + const backgroundsRequest = responseJson<{ data: Array<{ id: string; title: string; url: string }> }>("/api/v1/backgrounds"); + const preloadedAccountsRequest = accountsRequest.then(async (response) => { + await Promise.all(response.data.map((item) => item.avatarUrl ? preloadLibraryImage(item.avatarUrl, "high") : Promise.resolve())); + return response; + }); + const preloadedBackgroundsRequest = backgroundsRequest.then(async (response) => { + await Promise.all(response.data.map((item) => preloadLibraryImage(item.url, "auto"))); + return response; + }); const materials: ApiMaterial[] = []; let cursor: string | null = null; do { @@ -48,10 +78,7 @@ export async function loadLiveLibrary() { cursor = page.page.nextCursor; } while (cursor); - const [accountsResponse, backgroundsResponse] = await Promise.all([ - responseJson<{ data: Array<{ id: string; name: string; handle: string; verified: boolean; label?: string; accent?: string; avatarUrl?: string }> }>("/api/v1/publishing-accounts"), - responseJson<{ data: Array<{ id: string; title: string; url: string }> }>("/api/v1/backgrounds"), - ]); + const [accountsResponse, backgroundsResponse] = await Promise.all([preloadedAccountsRequest, preloadedBackgroundsRequest]); const posts: Post[] = materials.map((item) => ({ id: item.id, diff --git a/app/page.tsx b/app/page.tsx index 64bdb7d..6072912 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1211,7 +1211,9 @@ export default function Home() { )}
))} - +
+ +

上传图片仅保存在当前浏览器,不会上传到服务器或分享给其他用户。

diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs index 1ac1505..244aa38 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -27,7 +27,7 @@ test("server-renders the tweet card studio", async () => { }); test("removes starter-only preview assets and metadata", async () => { - const [page, layout, packageJson, materials, accounts, users, styles, syncScript, personalBackgrounds] = await Promise.all([ + const [page, layout, packageJson, materials, accounts, users, styles, syncScript, personalBackgrounds, materialRepository] = await Promise.all([ readFile(new URL("../app/page.tsx", import.meta.url), "utf8"), readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"), readFile(new URL("../package.json", import.meta.url), "utf8"), @@ -37,6 +37,7 @@ test("removes starter-only preview assets and metadata", async () => { readFile(new URL("../app/globals.css", import.meta.url), "utf8"), readFile(new URL("../scripts/build-existing-materials.mjs", import.meta.url), "utf8"), readFile(new URL("../app/personal-backgrounds.ts", import.meta.url), "utf8"), + readFile(new URL("../app/material-repository.ts", import.meta.url), "utf8"), ]); assert.match(page, /downloadPng/); assert.doesNotMatch(page, /本地原型|prototype-badge/); @@ -135,9 +136,14 @@ test("removes starter-only preview assets and metadata", async () => { assert.match(page, /deletePersonalBackground/); assert.match(page, /上传图片仅保存在当前浏览器,不会上传到服务器或分享给其他用户/); assert.match(page, /className="background-remove"/); + assert.match(page, /className="background-tile background-add-tile"/); assert.match(personalBackgrounds, /indexedDB\.open/); assert.match(personalBackgrounds, /PERSONAL_BACKGROUND_MAX_BYTES = 20 \* 1024 \* 1024/); assert.doesNotMatch(personalBackgrounds, /fetch\(|XMLHttpRequest/); + assert.match(materialRepository, /function preloadLibraryImage/); + assert.match(materialRepository, /image\.fetchPriority = priority/); + assert.match(materialRepository, /preloadedAccountsRequest/); + assert.match(materialRepository, /preloadedBackgroundsRequest/); await access(new URL("../public/backgrounds/itadaki-city-night.jpg", import.meta.url)); await access(new URL("../public/backgrounds/sunny-city-blue-sky.jpg", import.meta.url)); await access(new URL("../public/backgrounds/sunny-city-clouds.jpg", import.meta.url));