diff --git a/CHANGELOG.md b/CHANGELOG.md index f1db8bb..4c5fbbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,16 @@ 本專案遵循 [Semantic Versioning](https://semver.org/lang/zh-TW/);版本 `0.x` 表示功能仍可能調整。 -## [0.2.0] - Unreleased +## [0.3.0] - Unreleased + +### Added + +- 分頁與可依內容、標籤、來源、作者、日期及附件篩選的公開搜尋。 +- 標籤頁、來源頁、RSS 與 Atom feed,以及公開貼文 Open Graph metadata。 +- 安全 Markdown 渲染、GitHub Flavored Markdown 與程式碼高亮。 +- 收藏、稍後閱讀、閱讀紀錄、互動通知與通知已讀管理。 + +## [0.2.0] - 2026-07-19 ### Added diff --git a/README.md b/README.md index b78c898..3b52358 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ - 來源建立者可重新命名、停用、刪除或轉移所有權;共享成員可自行離開來源。 - 同步工作具去重、重試、觸發來源與歷史紀錄;管理員可集中檢視異常。 - 內建 SQLite 與附件備份腳本,以及可追蹤的 schema migration。 +- 可依內容、標籤、來源、作者、日期與附件篩選公開貼文,並支援分頁、標籤/來源頁、RSS 與 Atom。 +- 提供安全 Markdown、程式碼高亮、收藏、稍後閱讀、閱讀紀錄與互動通知。 ## 快速啟動(WSL/Docker) diff --git a/app/api/bookmarks/route.ts b/app/api/bookmarks/route.ts new file mode 100644 index 0000000..ea6b08d --- /dev/null +++ b/app/api/bookmarks/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { requireUser } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { externalUrl } from "@/lib/http"; + +export async function POST(req: Request) { + try { + const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const kind = String(form.get("kind")); + if (!postId || !["saved", "later"].includes(kind)) throw new Error("Invalid bookmark"); + const post = db.prepare("SELECT id FROM posts WHERE id=? AND visibility='PUBLIC' AND hidden=0").get(postId); if (!post) throw new Error("Post not found"); + const existing = db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, postId) as { kind: string } | undefined; + if (existing?.kind === kind) db.prepare("DELETE FROM bookmarks WHERE user_id=? AND post_id=?").run(user.id, postId); + else db.prepare("INSERT INTO bookmarks(user_id,post_id,kind) VALUES(?,?,?) ON CONFLICT(user_id,post_id) DO UPDATE SET kind=excluded.kind,created_at=CURRENT_TIMESTAMP").run(user.id, postId, kind); + return NextResponse.redirect(externalUrl(req, `/posts/${postId}`)); + } catch { return NextResponse.redirect(externalUrl(req, "/")); } +} diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts index 702bf45..ee2c45d 100644 --- a/app/api/comments/route.ts +++ b/app/api/comments/route.ts @@ -1,2 +1,17 @@ -import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http"; -export async function POST(req:Request){try{const user=await requireUser();const f=await req.formData();const postId=Number(f.get('postId'));const content=String(f.get('content')||'').trim();if(!postId||!content||content.length>5000)throw new Error('Invalid comment');db.prepare('INSERT INTO comments(post_id,author_id,content) VALUES(?,?,?)').run(postId,user.id,content);return NextResponse.redirect(externalUrl(req,`/posts/${postId}`));}catch{return NextResponse.redirect(externalUrl(req,'/'));}} +import { NextResponse } from "next/server"; +import { requireUser } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { externalUrl } from "@/lib/http"; +import { notify } from "@/lib/notifications"; + +export async function POST(req: Request) { + try { + const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const content = String(form.get("content") || "").trim(); + if (!postId || !content || content.length > 5000) throw new Error("Invalid comment"); + const post = db.prepare("SELECT author_id FROM posts WHERE id=? AND hidden=0").get(postId) as { author_id: number } | undefined; + if (!post) throw new Error("Post not found"); + db.prepare("INSERT INTO comments(post_id,author_id,content) VALUES(?,?,?)").run(postId, user.id, content); + notify(post.author_id, user.id, postId, "comment", `@${user.username} 留言了你的貼文`); + return NextResponse.redirect(externalUrl(req, `/posts/${postId}`)); + } catch { return NextResponse.redirect(externalUrl(req, "/")); } +} diff --git a/app/api/notifications/read/route.ts b/app/api/notifications/read/route.ts new file mode 100644 index 0000000..5f8bac5 --- /dev/null +++ b/app/api/notifications/read/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { requireUser } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { externalUrl } from "@/lib/http"; + +export async function POST(req: Request) { + try { + const user = await requireUser(); const form = await req.formData(); const id = Number(form.get("id")); + if (id) db.prepare("UPDATE notifications SET read_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?").run(id, user.id); + else db.prepare("UPDATE notifications SET read_at=CURRENT_TIMESTAMP WHERE user_id=? AND read_at IS NULL").run(user.id); + return NextResponse.redirect(externalUrl(req, "/notifications")); + } catch { return NextResponse.redirect(externalUrl(req, "/")); } +} diff --git a/app/api/reactions/route.ts b/app/api/reactions/route.ts index 7bee143..6e98d13 100644 --- a/app/api/reactions/route.ts +++ b/app/api/reactions/route.ts @@ -1,2 +1,19 @@ -import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http"; -const allowed=new Set(['👍','❤️','🎉','🤔']);export async function POST(req:Request){try{const user=await requireUser();const f=await req.formData();const postId=Number(f.get('postId'));const emoji=String(f.get('emoji'));if(!postId||!allowed.has(emoji))throw 0;const found=db.prepare('SELECT 1 FROM reactions WHERE post_id=? AND user_id=? AND emoji=?').get(postId,user.id,emoji);if(found)db.prepare('DELETE FROM reactions WHERE post_id=? AND user_id=? AND emoji=?').run(postId,user.id,emoji);else db.prepare('INSERT INTO reactions(post_id,user_id,emoji) VALUES(?,?,?)').run(postId,user.id,emoji);return NextResponse.redirect(externalUrl(req,`/posts/${postId}`));}catch{return NextResponse.redirect(externalUrl(req,'/'));}} +import { NextResponse } from "next/server"; +import { requireUser } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { externalUrl } from "@/lib/http"; +import { notify } from "@/lib/notifications"; + +const allowed = new Set(["👍", "❤️", "🎉", "🤔"]); +export async function POST(req: Request) { + try { + const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const emoji = String(form.get("emoji")); + if (!postId || !allowed.has(emoji)) throw new Error("Invalid reaction"); + const post = db.prepare("SELECT author_id FROM posts WHERE id=? AND hidden=0").get(postId) as { author_id: number } | undefined; + if (!post) throw new Error("Post not found"); + const found = db.prepare("SELECT 1 FROM reactions WHERE post_id=? AND user_id=? AND emoji=?").get(postId, user.id, emoji); + if (found) db.prepare("DELETE FROM reactions WHERE post_id=? AND user_id=? AND emoji=?").run(postId, user.id, emoji); + else { db.prepare("INSERT INTO reactions(post_id,user_id,emoji) VALUES(?,?,?)").run(postId, user.id, emoji); notify(post.author_id, user.id, postId, "reaction", `@${user.username} 對你的貼文給了 ${emoji}`); } + return NextResponse.redirect(externalUrl(req, `/posts/${postId}`)); + } catch { return NextResponse.redirect(externalUrl(req, "/")); } +} diff --git a/app/atom.xml/route.ts b/app/atom.xml/route.ts new file mode 100644 index 0000000..465a009 --- /dev/null +++ b/app/atom.xml/route.ts @@ -0,0 +1,8 @@ +import { db } from "@/lib/db"; + +const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, (char) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[char] || char)); +export async function GET() { + const origin = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:8088").replace(/\/$/, ""); const posts = db.prepare("SELECT p.id,p.content,p.created_at,u.username FROM posts p JOIN users u ON u.id=p.author_id WHERE p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 50").all() as { id: number; content: string; created_at: string; username: string }[]; const updated = posts[0] ? new Date(posts[0].created_at + "Z").toISOString() : new Date().toISOString(); + const entries = posts.map((post) => `${origin}/posts/${post.id}${escapeXml(`@${post.username} 的貼文`)}${new Date(post.created_at + "Z").toISOString()}${escapeXml(post.content)}`).join(""); + return new Response(`Mebbling${origin}${updated}${entries}`, { headers: { "Content-Type": "application/atom+xml; charset=utf-8", "Cache-Control": "public, max-age=300" } }); +} diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx new file mode 100644 index 0000000..6071cf4 --- /dev/null +++ b/app/components/markdown.tsx @@ -0,0 +1,8 @@ +import ReactMarkdown from "react-markdown"; +import rehypeHighlight from "rehype-highlight"; +import remarkGfm from "remark-gfm"; + +/** 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}
; +} diff --git a/app/components/post-card.tsx b/app/components/post-card.tsx new file mode 100644 index 0000000..a5dc08b --- /dev/null +++ b/app/components/post-card.tsx @@ -0,0 +1,10 @@ +import Link from "next/link"; +import { Attachments } from "./attachments"; +import { Markdown } from "./markdown"; + +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}
; +} diff --git a/app/layout.tsx b/app/layout.tsx index cd12fcf..9a54334 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,10 @@ import "./styles.css"; import Link from "next/link"; import { getSession } from "@/lib/auth"; -export const metadata = { title: "Mebbling", description: "Your Memos hub" }; +import { db } from "@/lib/db"; +export const metadata = { title: "Mebbling", description: "聚合朋友公開筆記的 Memos Hub", alternates: { types: { "application/rss+xml": [{ url: "/rss.xml", title: "Mebbling RSS" }], "application/atom+xml": [{ url: "/atom.xml", title: "Mebbling Atom" }] } }, openGraph: { title: "Mebbling", description: "聚合朋友公開筆記的 Memos Hub", type: "website" } }; export default async function RootLayout({ children }: { children: React.ReactNode }) { const user = await getSession(); - return
Mebbling
{children}
; + const unread = user ? Number((db.prepare("SELECT count(*) count FROM notifications WHERE user_id=? AND read_at IS NULL").get(user.id) as { count: number }).count) : 0; + return
Mebbling
{children}
; } diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx new file mode 100644 index 0000000..2d051bd --- /dev/null +++ b/app/notifications/page.tsx @@ -0,0 +1,10 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getSession } from "@/lib/auth"; +import { db } from "@/lib/db"; + +export default async function NotificationsPage() { + const user = await getSession(); if (!user) redirect("/login"); + const notifications = db.prepare("SELECT n.*,u.username AS actor_username FROM notifications n LEFT JOIN users u ON u.id=n.actor_id WHERE n.user_id=? ORDER BY n.created_at DESC LIMIT 100").all(user.id) as any[]; + return <>

通知

{notifications.length ? :

沒有通知。

}; +} diff --git a/app/page.tsx b/app/page.tsx index 1e4ec58..957905b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,11 +1,19 @@ -import Link from "next/link"; import { db } from "@/lib/db"; import { Attachments } from "./components/attachments"; +import Link from "next/link"; +import { db } from "@/lib/db"; +import { PostCard, type PublicPost } from "./components/post-card"; + export const dynamic = "force-dynamic"; -type Post = { id:number; 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 default async function Home({ searchParams }: { searchParams: Promise<{ q?: string; tag?: string }> }) { - const query = await searchParams; - const q = query.q?.trim() || ""; const tag = query.tag?.trim() || ""; - const where = ["p.visibility = 'PUBLIC'", "p.hidden = 0"]; const args: string[] = []; - if (q) { where.push("p.content LIKE ?"); args.push(`%${q}%`); } if (tag) { where.push("p.tags_json LIKE ?"); args.push(`%${JSON.stringify(tag).slice(1,-1)}%`); } - const posts = db.prepare(`SELECT p.*, u.username, s.name, s.base_url AS source_base_url, (SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count, (SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE ${where.join(" AND ")} ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100`).all(...args) as Post[]; - return <>

公開 Memos Hub

聚合朋友們公開分享的筆記。

發佈/連接來源
{posts.length ? posts.map(p=>
@{p.username}{p.name ? ` · ${p.name}` : ""}{new Date(p.created_at).toLocaleString("zh-TW")}
{p.content}
{JSON.parse(p.tags_json).map((t:string)=>#{t})}💬 {p.comment_count} 🙂 {p.reaction_count}
) :

尚無符合的公開貼文。

}; +const pageSize = 20; +type Query = { q?: string; tag?: string; source?: string; author?: string; from?: string; to?: string; attachments?: string; page?: string }; + +export default async function Home({ searchParams }: { searchParams: Promise }) { + const query = await searchParams; const q = query.q?.trim() || ""; const tag = query.tag?.trim() || ""; const author = query.author?.trim() || ""; const sourceId = Number(query.source) || 0; const from = query.from || ""; const to = query.to || ""; const attachments = query.attachments === "1"; const page = Math.max(1, Number(query.page) || 1); + const where = ["p.visibility='PUBLIC'", "p.hidden=0"]; const args: (string | number)[] = []; + if (q) { where.push("p.content LIKE ?"); args.push(`%${q}%`); } if (tag) { where.push("p.tags_json LIKE ?"); args.push(`%${JSON.stringify(tag).slice(1, -1)}%`); } if (author) { where.push("u.username LIKE ?"); args.push(`%${author}%`); } if (sourceId) { where.push("s.id=?"); args.push(sourceId); } if (from) { where.push("date(COALESCE(p.remote_created_at,p.created_at)) >= date(?)"); args.push(from); } if (to) { where.push("date(COALESCE(p.remote_created_at,p.created_at)) <= date(?)"); args.push(to); } if (attachments) where.push("p.attachments_json <> '[]'"); + const joins = " FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id "; const predicate = ` WHERE ${where.join(" AND ")}`; + const total = Number((db.prepare(`SELECT count(*) count${joins}${predicate}`).get(...args) as { count: number }).count); const pages = Math.max(1, Math.ceil(total / pageSize)); const safePage = Math.min(page, pages); + const posts = db.prepare(`SELECT p.*,u.username,s.name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count${joins}${predicate} ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT ? OFFSET ?`).all(...args, pageSize, (safePage - 1) * pageSize) as PublicPost[]; + const sources = db.prepare("SELECT id,name FROM sources WHERE is_enabled=1 ORDER BY name").all() as { id: number; name: string }[]; + const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) if (value && key !== "page") params.set(key, value); const pageHref = (target: number) => { const next = new URLSearchParams(params); next.set("page", String(target)); return `/?${next}`; }; + return <>

公開 Memos Hub

聚合朋友們公開分享的筆記。

發佈/連接來源

共 {total} 篇公開貼文

{posts.length ? posts.map((post) => ) :

尚無符合的公開貼文。

}{pages > 1 && }; } diff --git a/app/posts/[id]/page.tsx b/app/posts/[id]/page.tsx index b8ad024..6f05b06 100644 --- a/app/posts/[id]/page.tsx +++ b/app/posts/[id]/page.tsx @@ -1,3 +1,29 @@ -import { notFound, redirect } from "next/navigation"; import { db } from "@/lib/db"; import { getSession } from "@/lib/auth"; import { Attachments } from "@/app/components/attachments"; -export const dynamic="force-dynamic"; -export default async function PostPage({params}:{params:Promise<{id:string}>}){const {id:rawId}=await params;const id=Number(rawId);const post=db.prepare('SELECT p.*,u.username,s.name,s.base_url AS source_base_url FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?').get(id) as any;if(!post||post.hidden)notFound();const user=await getSession();if(post.visibility!=='PUBLIC'&&post.author_id!==user?.id)redirect('/');const comments=db.prepare('SELECT c.*,u.username FROM comments c JOIN users u ON u.id=c.author_id WHERE c.post_id=? AND c.hidden=0 ORDER BY c.created_at').all(id) as any[];const reactions=db.prepare('SELECT emoji,count(*) count FROM reactions WHERE post_id=? GROUP BY emoji').all(id) as any[];return

@{post.username} · {post.name||'Hub'} · {new Date(post.created_at).toLocaleString('zh-TW')}

{post.content}
{reactions.map((r:any)=>{r.emoji} {r.count})}{user&&['👍','❤️','🎉','🤔'].map(emoji=>
)}

留言

{user?