diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c90d57..1e16cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 本專案遵循 [Semantic Versioning](https://semver.org/lang/zh-TW/);版本 `0.x` 表示功能仍可能調整。 +## [0.6.0] - Unreleased + +### Added + +- 文章顯示時隱藏已辨識的內文 hashtag,保留原始 Markdown 與文章底部標籤。 +- 具時間範圍篩選的公開標籤雲與標籤導覽入口。 +- 瀏覽器端自動儲存的發文草稿與 Markdown 預覽。 +- 來源附件保存資料結構:遠端連結、僅圖片快取或完整快取,並提供來源配額與失效快取清理。 +- 標籤別名/合併的資料結構,供後續管理介面使用。 + ## [0.5.0] - Unreleased ### Added diff --git a/README.md b/README.md index 4c6fa34..957ac19 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。 -目前開發版本:`v0.5.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。 +目前開發版本:`v0.6.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。 ## 功能 @@ -22,6 +22,8 @@ - 控制台可測試 Token/Memos 連線、顯示遠端名稱與頭像,並提示 webhook 長時間未收到事件的狀態。 - 同源請求保護、SQLite 共用登入/webhook 限流、附件白名單與可選掃毒服務。 - 管理員可審核檢舉、隱藏貼文、停權帳號與協助重設密碼;提供健康檢查與 JSON 結構化日誌。 +- 文章以底部標籤為主,避免內文 hashtag 重複;提供時間範圍標籤雲、草稿自動儲存與預覽。 +- 遠端附件預設直連;來源可選擇只快取圖片或完整快取,並受每來源配額限制。 ## 快速啟動(WSL/Docker) diff --git a/app/api/sources/[id]/manage/route.ts b/app/api/sources/[id]/manage/route.ts index 97faa23..46dff6e 100644 --- a/app/api/sources/[id]/manage/route.ts +++ b/app/api/sources/[id]/manage/route.ts @@ -28,6 +28,10 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str if ((from && !/^\d{4}-\d{2}-\d{2}$/.test(from)) || (to && !/^\d{4}-\d{2}-\d{2}$/.test(to)) || (from && to && from > to) || !["all", "images", "none"].includes(attachmentMode)) throw new Error("Invalid sync rules"); db.prepare("UPDATE sources SET sync_tags_json=?,sync_from=?,sync_to=?,sync_attachment_mode=? WHERE id=?").run(JSON.stringify(tags), from || null, to || null, attachmentMode, id); queuePull(id, "manual"); + } else if (action === "set-attachment-storage") { + if (!owner) throw new Error("Only the owner can change attachment storage"); const mode = String(form.get("mode") || "remote"); const quotaMiB = Number(form.get("quotaMiB") || 100); + if (!["remote", "images", "all"].includes(mode) || !Number.isFinite(quotaMiB) || quotaMiB < 10 || quotaMiB > 10_240) throw new Error("Invalid attachment storage settings"); + db.prepare("UPDATE sources SET attachment_storage_mode=?,attachment_cache_limit_bytes=?,attachment_cache_error=NULL WHERE id=?").run(mode, Math.round(quotaMiB * 1024 * 1024), id); queuePull(id, "manual"); } else if (action === "test-connection") { if (!owner) throw new Error("Only the owner can test the connection"); try { diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx index 6071cf4..36b61c9 100644 --- a/app/components/markdown.tsx +++ b/app/components/markdown.tsx @@ -1,8 +1,9 @@ import ReactMarkdown from "react-markdown"; import rehypeHighlight from "rehype-highlight"; import remarkGfm from "remark-gfm"; +import { withoutInlineTags } from "@/lib/tags"; /** Raw HTML is intentionally not enabled, so Memos content cannot inject script or markup. */ -export function Markdown({ content, compact = false }: { content: string; compact?: boolean }) { - return
{content}
; +export function Markdown({ content, tags = [], compact = false }: { content: string; tags?: string[]; compact?: boolean }) { + return
{withoutInlineTags(content, tags)}
; } diff --git a/app/components/post-card.tsx b/app/components/post-card.tsx index a5dc08b..f4150b8 100644 --- a/app/components/post-card.tsx +++ b/app/components/post-card.tsx @@ -1,10 +1,11 @@ import Link from "next/link"; import { Attachments } from "./attachments"; import { Markdown } from "./markdown"; +import { canonicalTags } from "@/lib/tags"; export type PublicPost = { id: number; source_id: number | null; content: string; tags_json: string; attachments_json: string; created_at: string; username: string; name: string | null; source_base_url: string | null; comment_count: number; reaction_count: number }; export function PostCard({ post }: { post: PublicPost }) { - let tags: string[] = []; try { tags = JSON.parse(post.tags_json); } catch { /* Ignore malformed legacy tags. */ } - return
@{post.username}{post.name ? ` · ${post.name}` : ""}{new Date(post.created_at).toLocaleString("zh-TW")}
{tags.map((tag) => #{tag})}{post.source_id && 來源}閱讀全文 · 💬 {post.comment_count} 🙂 {post.reaction_count}
; + let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch { /* Ignore malformed legacy tags. */ } + return
@{post.username}{post.name ? ` · ${post.name}` : ""}{new Date(post.created_at).toLocaleString("zh-TW")}
{tags.map((tag) => #{tag})}{post.source_id && 來源}閱讀全文 · 💬 {post.comment_count} 🙂 {post.reaction_count}
; } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 03151e9..77a6924 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -4,14 +4,14 @@ import { db } from "@/lib/db"; import { PublishForm } from "./publish-form"; import { WebhookControl } from "./webhook-control"; -type Source = { id: number; name: string; base_url: string; sync_status: string; last_synced_at: string | null; last_error: string | null; webhook_secret_hash: string | null; last_webhook_at: string | null; owner_id: number; is_enabled: number; disabled_at: string | null; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; remote_display_name: string | null; remote_avatar_url: string | null; last_connection_at: string | null; last_connection_error: string | null }; +type Source = { id: number; name: string; base_url: string; sync_status: string; last_synced_at: string | null; last_error: string | null; webhook_secret_hash: string | null; last_webhook_at: string | null; owner_id: number; is_enabled: number; disabled_at: string | null; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; attachment_storage_mode: "remote" | "images" | "all"; attachment_cache_limit_bytes: number; attachment_cache_error: string | null; remote_display_name: string | null; remote_avatar_url: string | null; last_connection_at: string | null; last_connection_error: string | null }; type Job = { id: number; kind: string; trigger: string | null; status: string; attempts: number; last_error: string | null; created_at: string; finished_at: string | null }; export const dynamic = "force-dynamic"; export default async function Dashboard({ searchParams }: { searchParams: Promise<{ error?: string; source?: string; sync?: string }> }) { const query = await searchParams; const user = await getSession(); if (!user) redirect("/login"); - const sourceRows = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.last_error,s.webhook_secret_hash,s.last_webhook_at,s.user_id AS owner_id,s.is_enabled,s.disabled_at,s.sync_tags_json,s.sync_from,s.sync_to,s.sync_attachment_mode,s.remote_display_name,s.remote_avatar_url,s.last_connection_at,s.last_connection_error FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE sm.user_id=? ORDER BY s.id DESC").all(user.id) as Source[]; + const sourceRows = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.last_error,s.webhook_secret_hash,s.last_webhook_at,s.user_id AS owner_id,s.is_enabled,s.disabled_at,s.sync_tags_json,s.sync_from,s.sync_to,s.sync_attachment_mode,s.attachment_storage_mode,s.attachment_cache_limit_bytes,s.attachment_cache_error,s.remote_display_name,s.remote_avatar_url,s.last_connection_at,s.last_connection_error FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE sm.user_id=? ORDER BY s.id DESC").all(user.id) as Source[]; const sources = sourceRows.map((source) => ({ ...source, syncTags: (() => { try { return JSON.parse(source.sync_tags_json) as string[]; } catch { return []; } })(), members: db.prepare("SELECT u.username,u.id,sm.role FROM source_members sm JOIN users u ON u.id=sm.user_id WHERE sm.source_id=? ORDER BY sm.role DESC,u.username").all(source.id) as { username: string; id: number; role: string }[], jobs: db.prepare("SELECT id,kind,trigger,status,attempts,last_error,created_at,finished_at FROM sync_jobs WHERE source_id=? ORDER BY id DESC LIMIT 5").all(source.id) as Job[] })); const publishSources = sources.filter((source) => source.is_enabled); return <> @@ -23,9 +23,10 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis

連接 Memos

Token 會使用伺服器金鑰加密保存。同一個 Memos 帳號與網址會自動共用來源,不會建立重複貼文。

已連接來源

{sources.map((source) =>
{source.name}{source.is_enabled ? source.sync_status : "disabled"}
-

來源 ID:{source.id}
{source.base_url}{source.remote_display_name && <>
Memos 帳號:{source.remote_avatar_url && } {source.remote_display_name}}
成員:{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}
上次同步:{source.last_synced_at || "尚未完成"}
連線:{source.last_connection_at ? `最近成功:${new Date(source.last_connection_at + "Z").toLocaleString("zh-TW")}` : "尚未測試"}
Webhook:{source.webhook_secret_hash ? (source.last_webhook_at ? (Date.now() - new Date(source.last_webhook_at + "Z").getTime() > 7 * 24 * 60 * 60 * 1000 ? `警示:超過 7 天未收到(最近:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")})` : `健康(最近收到:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")})`) : "已建立 URL,尚未收到呼叫") : "尚未建立 URL"}{!source.is_enabled && <>
已停用:{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}}{source.last_error && <>
同步:{source.last_error}}{source.last_connection_error && <>
連線:{source.last_connection_error}}

+

來源 ID:{source.id}
{source.base_url}{source.remote_display_name && <>
Memos 帳號:{source.remote_avatar_url && } {source.remote_display_name}}
成員:{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}
上次同步:{source.last_synced_at || "尚未完成"}
附件保存:{source.attachment_storage_mode === "remote" ? "遠端連結" : source.attachment_storage_mode === "images" ? "只快取圖片" : "完整備份"}(配額 {Math.round(source.attachment_cache_limit_bytes / 1024 / 1024)} MiB)
連線:{source.last_connection_at ? `最近成功:${new Date(source.last_connection_at + "Z").toLocaleString("zh-TW")}` : "尚未測試"}
Webhook:{source.webhook_secret_hash ? (source.last_webhook_at ? (Date.now() - new Date(source.last_webhook_at + "Z").getTime() > 7 * 24 * 60 * 60 * 1000 ? `警示:超過 7 天未收到(最近:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")})` : `健康(最近收到:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")})`) : "已建立 URL,尚未收到呼叫") : "尚未建立 URL"}{!source.is_enabled && <>
已停用:{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}}{source.last_error && <>
同步:{source.last_error}}{source.last_connection_error && <>
連線:{source.last_connection_error}}{source.attachment_cache_error && <>
附件快取:{source.attachment_cache_error}}

{source.owner_id === user.id ? <> +
來源管理
{source.members.length > 1 &&
}
:
}
diff --git a/app/dashboard/publish-form.tsx b/app/dashboard/publish-form.tsx index f0b013f..113840e 100644 --- a/app/dashboard/publish-form.tsx +++ b/app/dashboard/publish-form.tsx @@ -1,34 +1,15 @@ "use client"; -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useMemo, useState } from "react"; type Source = { id: number; name: string }; +const draftKey = "mebbling:publish-draft"; export function PublishForm({ sources }: { sources: Source[] }) { - const [error, setError] = useState(""); - const [submitting, setSubmitting] = useState(false); - - async function submit(event: FormEvent) { - event.preventDefault(); - setSubmitting(true); setError(""); - try { - const response = await fetch("/api/posts", { method: "POST", body: new FormData(event.currentTarget), headers: { Accept: "application/json" } }); - const result = await response.json(); - if (!response.ok) throw new Error(result.error || "發佈失敗"); - window.location.assign(`/posts/${result.id}`); - } catch (reason) { - setError(reason instanceof Error ? reason.message : "發佈失敗"); - setSubmitting(false); - } - } - - return
-