feat: complete v0.3 reading and discovery experience
This commit is contained in:
+10
-1
@@ -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
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
- 來源建立者可重新命名、停用、刪除或轉移所有權;共享成員可自行離開來源。
|
||||
- 同步工作具去重、重試、觸發來源與歷史紀錄;管理員可集中檢視異常。
|
||||
- 內建 SQLite 與附件備份腳本,以及可追蹤的 schema migration。
|
||||
- 可依內容、標籤、來源、作者、日期與附件篩選公開貼文,並支援分頁、標籤/來源頁、RSS 與 Atom。
|
||||
- 提供安全 Markdown、程式碼高亮、收藏、稍後閱讀、閱讀紀錄與互動通知。
|
||||
|
||||
## 快速啟動(WSL/Docker)
|
||||
|
||||
|
||||
@@ -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, "/")); }
|
||||
}
|
||||
@@ -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, "/")); }
|
||||
}
|
||||
|
||||
@@ -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, "/")); }
|
||||
}
|
||||
@@ -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, "/")); }
|
||||
}
|
||||
|
||||
@@ -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) => `<entry><id>${origin}/posts/${post.id}</id><title>${escapeXml(`@${post.username} 的貼文`)}</title><link href="${origin}/posts/${post.id}"/><updated>${new Date(post.created_at + "Z").toISOString()}</updated><content type="text">${escapeXml(post.content)}</content></entry>`).join("");
|
||||
return new Response(`<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Mebbling</title><id>${origin}</id><link href="${origin}/atom.xml" rel="self"/><updated>${updated}</updated>${entries}</feed>`, { headers: { "Content-Type": "application/atom+xml; charset=utf-8", "Cache-Control": "public, max-age=300" } });
|
||||
}
|
||||
@@ -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 <div className={`markdown${compact ? " markdown-compact" : ""}`}><ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>{content}</ReactMarkdown></div>;
|
||||
}
|
||||
@@ -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 <article className="card"><div className="space"><Link className="meta post-name-link" href={`/posts/${post.id}`}>@{post.username}{post.name ? ` · ${post.name}` : ""}</Link><span className="meta">{new Date(post.created_at).toLocaleString("zh-TW")}</span></div><Markdown content={post.content} compact /><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} compact /><div className="row">{tags.map((tag) => <Link className="tag" href={`/tags/${encodeURIComponent(tag)}`} key={tag}>#{tag}</Link>)}{post.source_id && <Link className="tag" href={`/sources/${post.source_id}`}>來源</Link>}<Link href={`/posts/${post.id}`}>閱讀全文 · 💬 {post.comment_count} 🙂 {post.reaction_count}</Link></div></article>;
|
||||
}
|
||||
+4
-2
@@ -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 <html lang="zh-Hant"><body><header><Link href="/" className="brand">Mebbling</Link><nav><Link href="/">探索</Link>{user ? <><Link href="/dashboard">控制台</Link><Link href="/account">帳號</Link>{user.role === "admin" && <Link href="/admin">管理</Link>}<form action="/api/auth/logout" method="post"><button>登出</button></form></> : <><Link href="/login">登入</Link><Link href="/register">註冊</Link></>}</nav></header><main>{children}</main></body></html>;
|
||||
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 <html lang="zh-Hant"><body><header><Link href="/" className="brand">Mebbling</Link><nav><Link href="/">探索</Link>{user ? <><Link href="/reading">閱讀清單</Link><Link href="/notifications">通知{unread ? ` (${unread})` : ""}</Link><Link href="/dashboard">控制台</Link><Link href="/account">帳號</Link>{user.role === "admin" && <Link href="/admin">管理</Link>}<form action="/api/auth/logout" method="post"><button>登出</button></form></> : <><Link href="/login">登入</Link><Link href="/register">註冊</Link></>}</nav></header><main>{children}</main></body></html>;
|
||||
}
|
||||
|
||||
@@ -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 <><div className="space"><h1>通知</h1><form action="/api/notifications/read" method="post"><button>全部標示已讀</button></form></div>{notifications.length ? <ul className="reading-list">{notifications.map((item) => <li className={item.read_at ? "" : "unread"} key={item.id}><Link href={`/posts/${item.post_id}`}>{item.message}</Link><br /><span className="meta">{new Date(item.created_at + "Z").toLocaleString("zh-TW")}</span>{!item.read_at && <form action="/api/notifications/read" method="post"><input type="hidden" name="id" value={item.id} /><button>標示已讀</button></form>}</li>)}</ul> : <p className="muted">沒有通知。</p>}</>;
|
||||
}
|
||||
+17
-9
@@ -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 <><section className="space"><div><h1>公開 Memos Hub</h1><p className="muted">聚合朋友們公開分享的筆記。</p></div><Link className="button" href="/dashboard">發佈/連接來源</Link></section><form className="row" method="get"><input name="q" defaultValue={q} placeholder="搜尋公開貼文"/><input name="tag" defaultValue={tag} placeholder="標籤"/><button>搜尋</button></form>{posts.length ? posts.map(p=><article className="card" key={p.id}><div className="space"><Link className="meta post-name-link" href={`/posts/${p.id}`}>@{p.username}{p.name ? ` · ${p.name}` : ""}</Link><span className="meta">{new Date(p.created_at).toLocaleString("zh-TW")}</span></div><pre>{p.content}</pre><Attachments json={p.attachments_json} sourceBaseUrl={p.source_base_url} compact/><div className="row">{JSON.parse(p.tags_json).map((t:string)=><span className="tag" key={t}>#{t}</span>)}<Link href={`/posts/${p.id}`}>💬 {p.comment_count} 🙂 {p.reaction_count}</Link></div></article>) : <p className="muted">尚無符合的公開貼文。</p>}</>;
|
||||
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<Query> }) {
|
||||
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 <><section className="space"><div><h1>公開 Memos Hub</h1><p className="muted">聚合朋友們公開分享的筆記。</p></div><Link className="button" href="/dashboard">發佈/連接來源</Link></section><form className="search-form" method="get"><input name="q" defaultValue={q} placeholder="搜尋公開貼文" /><input name="tag" defaultValue={tag} placeholder="標籤" /><input name="author" defaultValue={author} placeholder="作者" /><select name="source" defaultValue={sourceId || ""}><option value="">所有來源</option>{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}</select><label>從<input name="from" type="date" defaultValue={from} /></label><label>到<input name="to" type="date" defaultValue={to} /></label><label className="check"><input name="attachments" type="checkbox" value="1" defaultChecked={attachments} />只看附件</label><button>搜尋</button></form><p className="meta">共 {total} 篇公開貼文</p>{posts.length ? posts.map((post) => <PostCard post={post} key={post.id} />) : <p className="muted">尚無符合的公開貼文。</p>}{pages > 1 && <nav className="pagination" aria-label="貼文分頁">{safePage > 1 && <Link href={pageHref(safePage - 1)}>← 上一頁</Link>}<span>第 {safePage}/{pages} 頁</span>{safePage < pages && <Link href={pageHref(safePage + 1)}>下一頁 →</Link>}</nav>}</>;
|
||||
}
|
||||
|
||||
+29
-3
@@ -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 <article><p className="meta">@{post.username} · {post.name||'Hub'} · {new Date(post.created_at).toLocaleString('zh-TW')}</p><pre className="card">{post.content}</pre><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url}/><section className="row">{reactions.map((r:any)=><span className="tag" key={r.emoji}>{r.emoji} {r.count}</span>)}{user&&['👍','❤️','🎉','🤔'].map(emoji=><form action="/api/reactions" method="post" key={emoji}><input type="hidden" name="postId" value={id}/><input type="hidden" name="emoji" value={emoji}/><button>{emoji}</button></form>)}</section><section><h2>留言</h2>{user?<form action="/api/comments" method="post"><input type="hidden" name="postId" value={id}/><textarea name="content" required placeholder="在 Hub 留下留言"/><button>送出留言</button></form>:<p>請先登入以留言或表情回應。</p>}{comments.map(c=><div className="card" key={c.id}><strong>@{c.username}</strong><p>{c.content}</p><span className="meta">{new Date(c.created_at).toLocaleString('zh-TW')}</span></div>)}</section></article>}
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { db } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { Attachments } from "@/app/components/attachments";
|
||||
import { Markdown } from "@/app/components/markdown";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
const { id: rawId } = await params; const post = db.prepare("SELECT p.content,p.hidden,p.visibility,u.username,s.name 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(Number(rawId)) as { content: string; hidden: number; visibility: string; username: string; name: string | null } | undefined;
|
||||
if (!post || post.hidden || post.visibility !== "PUBLIC") return { title: "找不到貼文" };
|
||||
const description = post.content.replace(/\s+/g, " ").slice(0, 160);
|
||||
return { title: `@${post.username} 的貼文|Mebbling`, description, openGraph: { title: `@${post.username}${post.name ? ` · ${post.name}` : ""}|Mebbling`, description, type: "article" } };
|
||||
}
|
||||
|
||||
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("/");
|
||||
if (user && post.visibility === "PUBLIC") db.prepare("INSERT INTO reading_history(user_id,post_id) VALUES(?,?) ON CONFLICT(user_id,post_id) DO UPDATE SET last_read_at=CURRENT_TIMESTAMP").run(user.id, id);
|
||||
const bookmark = user ? db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, id) as { kind: string } | undefined : undefined;
|
||||
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 <article><p className="meta">@{post.username} · {post.name || "Hub"} · {new Date(post.created_at).toLocaleString("zh-TW")}</p><section className="card"><Markdown content={post.content} /></section><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} />
|
||||
<section className="row">{reactions.map((reaction: any) => <span className="tag" key={reaction.emoji}>{reaction.emoji} {reaction.count}</span>)}{user && <><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="saved" /><button>{bookmark?.kind === "saved" ? "取消收藏" : "收藏"}</button></form><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="later" /><button>{bookmark?.kind === "later" ? "取消稍後閱讀" : "稍後閱讀"}</button></form></>}{user && ["👍", "❤️", "🎉", "🤔"].map((emoji) => <form action="/api/reactions" method="post" key={emoji}><input type="hidden" name="postId" value={id} /><input type="hidden" name="emoji" value={emoji} /><button>{emoji}</button></form>)}</section>
|
||||
<section><h2>留言</h2>{user ? <form action="/api/comments" method="post"><input type="hidden" name="postId" value={id} /><textarea name="content" required placeholder="在 Hub 留下留言" /><button>送出留言</button></form> : <p>請先登入以留言或表情回應。</p>}{comments.map((comment) => <div className="card" key={comment.id}><strong>@{comment.username}</strong><p>{comment.content}</p><span className="meta">{new Date(comment.created_at).toLocaleString("zh-TW")}</span></div>)}</section>
|
||||
</article>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
type Item = { id: number; content: string; username: string; kind?: string; at: string };
|
||||
function PostList({ items, empty }: { items: Item[]; empty: string }) { return items.length ? <ul className="reading-list">{items.map((item) => <li key={`${item.kind}-${item.id}`}><Link href={`/posts/${item.id}`}>{item.content.slice(0, 120) || "(空白貼文)"}</Link><br /><span className="meta">@{item.username} · {item.kind === "later" ? "稍後閱讀" : item.kind === "saved" ? "收藏" : "最近閱讀"} · {new Date(item.at + "Z").toLocaleString("zh-TW")}</span></li>)}</ul> : <p className="muted">{empty}</p>; }
|
||||
export default async function ReadingPage() {
|
||||
const user = await getSession(); if (!user) redirect("/login");
|
||||
const saved = db.prepare("SELECT p.id,p.content,u.username,b.kind,b.created_at AS at FROM bookmarks b JOIN posts p ON p.id=b.post_id JOIN users u ON u.id=p.author_id WHERE b.user_id=? AND p.hidden=0 ORDER BY b.created_at DESC").all(user.id) as Item[];
|
||||
const history = db.prepare("SELECT p.id,p.content,u.username,h.last_read_at AS at FROM reading_history h JOIN posts p ON p.id=h.post_id JOIN users u ON u.id=p.author_id WHERE h.user_id=? AND p.hidden=0 ORDER BY h.last_read_at DESC LIMIT 50").all(user.id) as Item[];
|
||||
return <><h1>閱讀清單</h1><section className="card"><h2>收藏與稍後閱讀</h2><PostList items={saved} empty="尚未收藏任何貼文。" /></section><section className="card"><h2>最近閱讀</h2><PostList items={history} empty="尚無閱讀紀錄。" /></section></>;
|
||||
}
|
||||
@@ -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 items = posts.map((post) => `<item><title>${escapeXml(`@${post.username} 的貼文`)}</title><link>${origin}/posts/${post.id}</link><guid>${origin}/posts/${post.id}</guid><description>${escapeXml(post.content.slice(0, 500))}</description><pubDate>${new Date(post.created_at + "Z").toUTCString()}</pubDate></item>`).join("");
|
||||
return new Response(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>Mebbling</title><link>${origin}</link><description>公開 Memos Hub</description>${items}</channel></rss>`, { headers: { "Content-Type": "application/rss+xml; charset=utf-8", "Cache-Control": "public, max-age=300" } });
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/lib/db";
|
||||
import { PostCard, type PublicPost } from "@/app/components/post-card";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function SourcePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id,name,base_url FROM sources WHERE id=?").get(id) as { id: number; name: string; base_url: string } | undefined; if (!source) notFound();
|
||||
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 p.source_id=? AND p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(id) as PublicPost[];
|
||||
return <><p><Link href="/">← 探索</Link></p><h1>{source.name}</h1><p className="meta">{source.base_url} · {posts.length} 篇公開貼文</p>{posts.map((post) => <PostCard key={post.id} post={post} />)}</>;
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
:root{color-scheme:dark;font-family:ui-sans-serif,system-ui;background:#10131a;color:#edf1f8}*{box-sizing:border-box}body{margin:0}header{display:flex;justify-content:space-between;align-items:center;padding:1rem max(1.5rem,calc((100% - 1000px)/2));border-bottom:1px solid #293243;background:#151a23;position:sticky;top:0}main{width:min(900px,calc(100% - 2rem));margin:2rem auto}.brand{font-size:1.35rem;font-weight:700;color:#8ab4ff}nav{display:flex;gap:1rem;align-items:center}a{color:#bcd3ff;text-decoration:none}button,.button{background:#3778e5;color:#fff;border:0;border-radius:.5rem;padding:.55rem .8rem;cursor:pointer;font:inherit}button:hover,.button:hover{filter:brightness(1.1)}form{display:grid;gap:.8rem;max-width:580px}input,textarea,select{width:100%;padding:.65rem;border:1px solid #3a455a;border-radius:.45rem;background:#171d28;color:inherit}textarea{min-height:140px}.card{background:#171d28;border:1px solid #293243;border-radius:.75rem;padding:1rem;margin:.8rem 0}.muted{color:#aab4c5}.row{display:flex;gap:.7rem;align-items:center;flex-wrap:wrap}.space{display:flex;justify-content:space-between;gap:1rem}.error{color:#ff9d9d}.tag{background:#25314a;padding:.15rem .45rem;border-radius:.4rem;font-size:.85rem}pre{white-space:pre-wrap;font-family:inherit}.meta{font-size:.86rem;color:#aab4c5}.danger{background:#aa3746}.attachments{display:flex;flex-wrap:wrap;gap:.65rem;margin:.9rem 0}.attachment-image{display:block;max-width:min(100%,520px);padding:0;background:none;border:0;border-radius:.5rem;overflow:hidden}.attachment-image img{display:block;max-width:100%;max-height:520px;border-radius:.5rem;border:1px solid #3a455a}.attachment-image:hover img{border-color:#8ab4ff}.attachments-compact .attachment-image{max-width:220px}.attachments-compact .attachment-image img{max-height:220px;object-fit:cover}.attachment-file{padding:.45rem .65rem;border:1px solid #3a455a;border-radius:.45rem;background:#202838}.image-lightbox{position:fixed;z-index:100;inset:0;display:grid;place-items:center;padding:2rem;background:rgb(0 0 0 / .88);cursor:zoom-out}.image-lightbox img{display:block;max-width:100%;max-height:100%;object-fit:contain;cursor:default}.image-lightbox-close{position:absolute;top:1rem;right:1rem;width:2.5rem;height:2.5rem;padding:0;border-radius:50%;font-size:2rem;line-height:1;background:#25314a}
|
||||
:root{color-scheme:dark;font-family:ui-sans-serif,system-ui;background:#10131a;color:#edf1f8}*{box-sizing:border-box}body{margin:0}header{display:flex;justify-content:space-between;align-items:center;padding:1rem max(1.5rem,calc((100% - 1000px)/2));border-bottom:1px solid #293243;background:#151a23;position:sticky;top:0;z-index:10}main{width:min(900px,calc(100% - 2rem));margin:2rem auto}.brand{font-size:1.35rem;font-weight:700;color:#8ab4ff}nav{display:flex;gap:1rem;align-items:center;flex-wrap:wrap}a{color:#bcd3ff;text-decoration:none}button,.button{background:#3778e5;color:#fff;border:0;border-radius:.5rem;padding:.55rem .8rem;cursor:pointer;font:inherit}button:hover,.button:hover{filter:brightness(1.1)}form{display:grid;gap:.8rem;max-width:580px}input,textarea,select{width:100%;padding:.65rem;border:1px solid #3a455a;border-radius:.45rem;background:#171d28;color:inherit}textarea{min-height:140px}.card{background:#171d28;border:1px solid #293243;border-radius:.75rem;padding:1rem;margin:.8rem 0}.muted{color:#aab4c5}.row{display:flex;gap:.7rem;align-items:center;flex-wrap:wrap}.space{display:flex;justify-content:space-between;gap:1rem}.error{color:#ff9d9d}.tag{background:#25314a;padding:.15rem .45rem;border-radius:.4rem;font-size:.85rem}.meta{font-size:.86rem;color:#aab4c5}.danger{background:#aa3746}.attachments{display:flex;flex-wrap:wrap;gap:.65rem;margin:.9rem 0}.attachment-image{display:block;max-width:min(100%,520px);padding:0;background:none;border:0;border-radius:.5rem;overflow:hidden}.attachment-image img{display:block;max-width:100%;max-height:520px;border-radius:.5rem;border:1px solid #3a455a}.attachment-image:hover img{border-color:#8ab4ff}.attachments-compact .attachment-image{max-width:220px}.attachments-compact .attachment-image img{max-height:220px;object-fit:cover}.attachment-file{padding:.45rem .65rem;border:1px solid #3a455a;border-radius:.45rem;background:#202838}.image-lightbox{position:fixed;z-index:100;inset:0;display:grid;place-items:center;padding:2rem;background:rgb(0 0 0 / .88);cursor:zoom-out}.image-lightbox img{display:block;max-width:100%;max-height:100%;object-fit:contain;cursor:default}.image-lightbox-close{position:absolute;top:1rem;right:1rem;width:2.5rem;height:2.5rem;padding:0;border-radius:50%;font-size:2rem;line-height:1;background:#25314a}.markdown{line-height:1.7;overflow-wrap:anywhere}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:1rem;border-radius:.5rem;background:#0c1017}.markdown code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.markdown :not(pre)>code{padding:.1rem .3rem;border-radius:.25rem;background:#25314a}.markdown blockquote{margin-left:0;padding-left:1rem;border-left:3px solid #5278ba;color:#c1cad8}.markdown table{border-collapse:collapse;display:block;overflow:auto}.markdown th,.markdown td{padding:.4rem .6rem;border:1px solid #3a455a}.markdown-compact{max-height:18rem;overflow:hidden;mask-image:linear-gradient(#000 85%,transparent)}.search-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));max-width:none;margin:1rem 0}.search-form label{display:grid;gap:.3rem}.search-form .check{display:flex;align-items:center;gap:.4rem}.search-form .check input{width:auto}.pagination{display:flex;justify-content:center;gap:1rem;align-items:center;margin:2rem 0}.reading-list,.job-list{list-style:none;padding:0;display:grid;gap:.7rem}.reading-list li,.job-list li{padding:.8rem;border:1px solid #293243;border-radius:.5rem}.unread{border-left:3px solid #8ab4ff!important}@media (max-width:700px){header{align-items:flex-start;flex-direction:column}.search-form{grid-template-columns:1fr 1fr}.search-form button{grid-column:span 2}}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/lib/db";
|
||||
import { PostCard, type PublicPost } from "@/app/components/post-card";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) {
|
||||
const { tag: encoded } = await params; const tag = decodeURIComponent(encoded).trim(); if (!tag) notFound();
|
||||
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 p.visibility='PUBLIC' AND p.hidden=0 AND p.tags_json LIKE ? ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(`%${JSON.stringify(tag).slice(1, -1)}%`) as PublicPost[];
|
||||
return <><p><Link href="/">← 探索</Link></p><h1>#{tag}</h1><p className="muted">{posts.length} 篇公開貼文</p>{posts.map((post) => <PostCard key={post.id} post={post} />)}</>;
|
||||
}
|
||||
@@ -54,8 +54,26 @@ CREATE TABLE IF NOT EXISTS source_members (
|
||||
role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(source_id, user_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL DEFAULT 'saved', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(user_id, post_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reading_history (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
last_read_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(user_id, post_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL, post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL, message TEXT NOT NULL, read_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS posts_public_idx ON posts(visibility, hidden, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS sync_jobs_idx ON sync_jobs(status, run_after);
|
||||
CREATE INDEX IF NOT EXISTS notifications_user_idx ON notifications(user_id, read_at, created_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS source_remote_identity_unique ON sources(base_url, remote_user) WHERE remote_user IS NOT NULL;
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -77,6 +95,7 @@ applyColumnMigration(4, "sources", "disabled_at", "ALTER TABLE sources ADD COLUM
|
||||
applyColumnMigration(5, "sync_jobs", "trigger", "ALTER TABLE sync_jobs ADD COLUMN trigger TEXT NOT NULL DEFAULT 'manual'");
|
||||
applyColumnMigration(6, "sync_jobs", "started_at", "ALTER TABLE sync_jobs ADD COLUMN started_at TEXT");
|
||||
applyColumnMigration(7, "sync_jobs", "finished_at", "ALTER TABLE sync_jobs ADD COLUMN finished_at TEXT");
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(8)").run();
|
||||
|
||||
const admin = process.env.ADMIN_USERNAME;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export function notify(userId: number, actorId: number, postId: number, type: "comment" | "reaction", message: string) {
|
||||
if (userId === actorId) return;
|
||||
db.prepare("INSERT INTO notifications(user_id,actor_id,post_id,type,message) VALUES(?,?,?,?,?)").run(userId, actorId, postId, type, message);
|
||||
}
|
||||
Generated
+1586
-4
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mebbling",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -16,10 +16,14 @@
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"jose": "^6.2.3",
|
||||
"next": "^15.5.20",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tsx": "^4.23.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
+6
-1
@@ -14,12 +14,17 @@ after(() => { database?.close(); rmSync(databasePath, { force: true }); rmSync(`
|
||||
test("applies tracked migrations and deduplicates active pull jobs", async () => {
|
||||
const { db } = await import("../lib/db"); database = db;
|
||||
const { queuePull } = await import("../lib/sync");
|
||||
const { notify } = await import("../lib/notifications");
|
||||
const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[];
|
||||
assert.deepEqual(migrations.map((item) => item.version), [1, 2, 3, 4, 5, 6, 7]);
|
||||
assert.deepEqual(migrations.map((item) => item.version), [1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
const userId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('sync-test','hash')").run().lastInsertRowid);
|
||||
const sourceId = Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,is_enabled) VALUES(?,?,?,?,1)").run(userId, "Test", "https://example.test", "encrypted").lastInsertRowid);
|
||||
assert.equal(queuePull(sourceId, "manual"), true);
|
||||
assert.equal(queuePull(sourceId, "webhook", { event: "memo.updated" }), false);
|
||||
const jobs = db.prepare("SELECT kind,trigger,status FROM sync_jobs WHERE source_id=?").all(sourceId) as { kind: string; trigger: string; status: string }[];
|
||||
assert.deepEqual(jobs, [{ kind: "pull", trigger: "manual", status: "queued" }]);
|
||||
const actorId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('actor-test','hash')").run().lastInsertRowid);
|
||||
const postId = Number(db.prepare("INSERT INTO posts(author_id,content) VALUES(?,?)").run(userId, "Notification test").lastInsertRowid);
|
||||
notify(userId, actorId, postId, "comment", "commented"); notify(userId, userId, postId, "reaction", "ignored");
|
||||
assert.deepEqual(db.prepare("SELECT type,message FROM notifications WHERE user_id=?").all(userId), [{ type: "comment", message: "commented" }]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user