Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
- 推文来源与发布账号分离
- 仅显示当前用户可用的发布账号(原型内存权限)
- 发布账号包含黄小木、Roland.W、AI最严厉的父亲和阿川,账号切换同步名称、ID、认证状态和名称标签
- 正文编辑、使用原推、10 张城市与风景背景(4 张既有素材与 6 张 3:4 精选图)、上传/网络图片
- 正文编辑、使用原推、10 张城市与风景背景(4 张既有素材与 6 张 3:4 精选图)、网络图片,以及只保存在当前浏览器、可刷新保留和删除的个人上传图片
- 同步正文保留原始段落、空行、手动换行、连续空格与标点;回复、转推、点赞、书签在源数据存在时同步
- 左侧素材候选卡直接显示浏览、回复、转推、点赞和书签数据
- 五项候选数据固定保持单行;选中素材时自动带入原始数据,也可在右侧逐项修改当前卡片数据
Expand Down
35 changes: 31 additions & 4 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -513,25 +513,52 @@ 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 button {
.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-add {
display: block;
box-sizing: border-box;
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 {
.background-add {
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: 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 {
Expand Down
37 changes: 32 additions & 5 deletions app/material-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -37,7 +37,37 @@ async function responseJson<T>(url: string) {
return response.json() as Promise<T>;
}

function preloadLibraryImage(url: string, priority: "high" | "auto") {
if (typeof Image === "undefined") return Promise.resolve();
return new Promise<void>((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 {
Expand All @@ -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,
Expand Down
101 changes: 79 additions & 22 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -289,6 +290,7 @@ export default function Home() {
const [dragging, setDragging] = useState(false);
const [resizing, setResizing] = useState(false);
const backgroundInput = useRef<HTMLInputElement>(null);
const personalBackgroundUrls = useRef(new Map<string, string>());
const canvasAreaRef = useRef<HTMLDivElement>(null);
const posterRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLElement>(null);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -481,19 +508,36 @@ export default function Home() {
setPublishDraft((current) => replacePublishingTagLine(current, buildPublishingTags(selectedPost, account, nextOffset)));
};

const uploadBackground = (event: ChangeEvent<HTMLInputElement>) => {
const uploadBackground = async (event: ChangeEvent<HTMLInputElement>) => {
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 = () => {
Expand Down Expand Up @@ -1145,21 +1189,34 @@ export default function Home() {
<label>背景压暗<input type="range" min="0" max="70" value={dim} onChange={(event) => setDim(Number(event.target.value))} /></label>
<b>{dim}%</b>
</div>
<div className="background-heading"><strong>背景素材</strong><span>{backgrounds.length} 张</span></div>
<div className="background-heading"><strong>背景素材</strong><span>{backgrounds.length} 张 · {backgrounds.filter((item) => item.isPersonal).length} 张本机图片</span></div>
<div className="background-grid">
{backgrounds.map((item) => (
<button
key={item.id}
aria-label={item.name}
title={item.name}
className={item.id === background.id ? "background-active" : ""}
style={backgroundStyle(item)}
onClick={() => setBackgroundId(item.id)}
/>
<div className="background-tile" key={item.id}>
<button
aria-label={item.name}
title={item.name}
className={`background-swatch ${item.id === background.id ? "background-active" : ""}`}
style={backgroundStyle(item)}
onClick={() => setBackgroundId(item.id)}
/>
{item.isPersonal && (
<button
type="button"
className="background-remove"
aria-label={`删除本机图片 ${item.name}`}
title="删除本机图片"
onClick={() => void removePersonalBackground(item)}
>×</button>
)}
</div>
))}
<button className="background-add" onClick={() => backgroundInput.current?.click()} aria-label="上传背景">+</button>
<div className="background-tile background-add-tile">
<button className="background-add" onClick={() => backgroundInput.current?.click()} aria-label="上传背景">+</button>
</div>
</div>
<input ref={backgroundInput} hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={uploadBackground} />
<p className="background-privacy">上传图片仅保存在当前浏览器,不会上传到服务器或分享给其他用户。</p>
<div className="url-row">
<input value={backgroundUrl} onChange={(event) => setBackgroundUrl(event.target.value)} placeholder="粘贴网络图片地址" />
<button onClick={addNetworkBackground}>添加</button>
Expand Down
117 changes: 117 additions & 0 deletions app/personal-backgrounds.ts
Original file line number Diff line number Diff line change
@@ -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<T>(request: IDBRequest<T>) {
return new Promise<T>((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<void>((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<IDBDatabase>((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();
}
}
Loading