Initial Mebbling hub implementation

This commit is contained in:
2026-07-19 00:29:30 +08:00
commit e6ebdb0576
41 changed files with 2571 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { createSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http";
export async function POST(req: Request) { const form = await req.formData(); const username=String(form.get("username")||""); const password=String(form.get("password")||""); const user=db.prepare("SELECT id,username,password_hash,role,disabled FROM users WHERE username=?").get(username) as any;
if (!user || user.disabled || !(await bcrypt.compare(password,user.password_hash))) return NextResponse.redirect(externalUrl(req,"/login?error=invalid")); await createSession({id:user.id,username:user.username,role:user.role}); return NextResponse.redirect(externalUrl(req,"/dashboard")); }
+1
View File
@@ -0,0 +1 @@
import { NextResponse } from "next/server"; import { clearSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http"; export async function POST(req:Request){await clearSession();return NextResponse.redirect(externalUrl(req,"/"));}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { createSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http";
export async function POST(req: Request) { const form = await req.formData(); const username = String(form.get("username") || "").trim(); const password = String(form.get("password") || "");
if (!/^[A-Za-z0-9_-]{3,32}$/.test(username) || password.length < 10) return NextResponse.redirect(externalUrl(req,"/register?error=invalid"));
try { const out = db.prepare("INSERT INTO users(username,password_hash) VALUES (?,?)").run(username, await bcrypt.hash(password, 12)); await createSession({ id: Number(out.lastInsertRowid), username, role: "user" }); return NextResponse.redirect(externalUrl(req,"/dashboard")); } catch { return NextResponse.redirect(externalUrl(req,"/register?error=taken")); }
}
+2
View File
@@ -0,0 +1,2 @@
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,'/'));}}
+2
View File
@@ -0,0 +1,2 @@
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http"; import { mkdir, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { extname, join } from "node:path";
export async function POST(req:Request){const json=req.headers.get("accept")?.includes("application/json");try{const user=await requireUser();const f=await req.formData();const content=String(f.get("content")||"").trim();const visibility=String(f.get("visibility")||"PUBLIC");const sourceId=Number(f.get("sourceId"));const tags=String(f.get("tags")||"").split(/\s*,\s*/).filter(Boolean).map(t=>t.replace(/^#/,""));if(!content||!['PRIVATE','PROTECTED','PUBLIC'].includes(visibility)||!sourceId)throw new Error("Invalid post");const source=db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=?").get(sourceId,user.id);if(!source)throw new Error("Source not found");const max=Number(process.env.UPLOAD_MAX_BYTES||10485760);const files=f.getAll('attachments').filter((x):x is File=>x instanceof File&&x.size>0);const attachments:any[]=[];await mkdir(join(process.cwd(),'public','uploads'),{recursive:true});for(const file of files){if(file.size>max)throw new Error(`${file.name} exceeds upload limit`);const id=randomUUID()+extname(file.name);await writeFile(join(process.cwd(),'public','uploads',id),Buffer.from(await file.arrayBuffer()));attachments.push({name:file.name,url:`/uploads/${id}`,type:file.type,size:file.size});}const out=db.prepare("INSERT INTO posts(source_id,author_id,content,visibility,tags_json,attachments_json,origin,sync_status) VALUES(?,?,?,?,?,?,'hub','queued')").run(sourceId,user.id,content,visibility,JSON.stringify(tags),JSON.stringify(attachments));db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json) VALUES(?, 'push', ?)").run(sourceId,JSON.stringify({postId:out.lastInsertRowid}));if(json)return NextResponse.json({id:Number(out.lastInsertRowid)},{status:201});return NextResponse.redirect(externalUrl(req,`/posts/${out.lastInsertRowid}`));}catch(e){const message=e instanceof Error?e.message:'post';if(json)return NextResponse.json({error:message},{status:400});return NextResponse.redirect(externalUrl(req,'/dashboard?error='+encodeURIComponent(message)));}}
+2
View File
@@ -0,0 +1,2 @@
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,'/'));}}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { createWebhookSecret, webhookSecretHash } from "@/lib/webhook";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId);
const source = db.prepare("SELECT id FROM sources WHERE id=? AND user_id=?").get(id, user.id);
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
const secret = createWebhookSecret();
db.prepare("UPDATE sources SET webhook_secret_hash=? WHERE id=?").run(webhookSecretHash(secret), id);
const publicOrigin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, "");
return NextResponse.json({ url: `${publicOrigin}/api/sync/webhook/${id}/${secret}` });
} catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); }
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { decrypt, encrypt } from "@/lib/crypto"; import { getMemosIdentity, verifyMemos } from "@/lib/memos"; 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 name=String(form.get("name")||"").trim(); const rawBaseUrl=String(form.get("baseUrl")||"").trim(); const token=String(form.get("token")||"").trim(); let baseUrl=""; try { const url=new URL(rawBaseUrl); if(!['http:','https:'].includes(url.protocol)) throw new Error(); baseUrl=`${url.origin}${url.pathname.replace(/\/+$/,"")}`; } catch { throw new Error("Invalid source URL"); } if(!name||token.length<20) throw new Error("Invalid source"); await verifyMemos(baseUrl,token); const identity=await getMemosIdentity(baseUrl,token);
const legacySources=db.prepare("SELECT id,token_encrypted FROM sources WHERE base_url=? AND remote_user IS NULL").all(baseUrl) as {id:number;token_encrypted:string}[];
for(const legacy of legacySources){try{const legacyIdentity=await getMemosIdentity(baseUrl,decrypt(legacy.token_encrypted));db.prepare("UPDATE sources SET remote_user=? WHERE id=? AND remote_user IS NULL").run(legacyIdentity.name,legacy.id);}catch{ /* Keep unavailable legacy sources unchanged. */ }}
const shared=db.prepare("SELECT id FROM sources WHERE base_url=? AND remote_user=?").get(baseUrl,identity.name) as {id:number}|undefined;
if(shared){db.prepare("INSERT OR IGNORE INTO source_members(source_id,user_id) VALUES(?,?)").run(shared.id,user.id);return NextResponse.redirect(externalUrl(req,"/dashboard?source=shared"));}
const out=db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,remote_user,sync_status) VALUES(?,?,?,?,?, 'queued')").run(user.id,name,baseUrl,encrypt(token),identity.name);const sourceId=Number(out.lastInsertRowid);db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,'owner')").run(sourceId,user.id);db.prepare("INSERT INTO sync_jobs(source_id,kind) VALUES(?, 'pull')").run(sourceId);return NextResponse.redirect(externalUrl(req,"/dashboard?source=connected"));
} catch(e){ return NextResponse.redirect(externalUrl(req,"/dashboard?error="+encodeURIComponent(e instanceof Error?e.message:"source"))); } }
+2
View File
@@ -0,0 +1,2 @@
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 sourceId=Number(f.get('sourceId'));const source=db.prepare('SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=?').get(sourceId,user.id);if(!source)throw 0;db.prepare("INSERT INTO sync_jobs(source_id,kind) VALUES(?, 'pull')").run(sourceId);return NextResponse.redirect(externalUrl(req,'/dashboard'));}catch{return NextResponse.redirect(externalUrl(req,'/'));}}
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { withinRateLimit } from "@/lib/rate-limit";
import { webhookSecretMatches } from "@/lib/webhook";
export async function POST(request: Request, { params }: { params: Promise<{ sourceId: string; secret: string }> }) {
const { sourceId, secret } = await params;
const id = Number(sourceId);
const source = db.prepare("SELECT id, webhook_secret_hash FROM sources WHERE id=?").get(id) as { id: number; webhook_secret_hash: string | null } | undefined;
if (!source || !webhookSecretMatches(secret, source.webhook_secret_hash)) return NextResponse.json({ error: "Not found" }, { status: 404 });
const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown";
if (!withinRateLimit(`webhook:${id}:${forwarded}`)) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
let payload: unknown = {};
try { payload = await request.json(); } catch { /* Memos payload is optional; a pull reconciles source state. */ }
db.prepare("UPDATE sources SET last_webhook_at=CURRENT_TIMESTAMP WHERE id=?").run(id);
db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json) VALUES(?, 'pull', ?)").run(id, JSON.stringify(payload));
return NextResponse.json({ ok: true });
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useEffect, useState } from "react";
type Attachment = { name?: string; filename?: string; url?: string; externalLink?: string; type?: string; size?: string | number };
function resourceUrl(attachment: Attachment, sourceBaseUrl?: string) {
if (attachment.url) return attachment.url;
if (attachment.externalLink) return attachment.externalLink;
if (!sourceBaseUrl || !attachment.name || !attachment.filename) return null;
const resourceName = attachment.name.split("/").map(encodeURIComponent).join("/");
return `${sourceBaseUrl.replace(/\/$/, "")}/file/${resourceName}/${encodeURIComponent(attachment.filename)}`;
}
export function Attachments({ json, sourceBaseUrl, compact = false }: { json: string; sourceBaseUrl?: string | null; compact?: boolean }) {
const [activeImage, setActiveImage] = useState<{ href: string; label: string } | null>(null);
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setActiveImage(null); };
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, []);
let attachments: Attachment[] = [];
try { attachments = JSON.parse(json); } catch { return null; }
const displayable = attachments.map((attachment) => ({ attachment, href: resourceUrl(attachment, sourceBaseUrl || undefined) })).filter((item): item is { attachment: Attachment; href: string } => Boolean(item.href));
if (!displayable.length) return null;
return <>
<section className={`attachments${compact ? " attachments-compact" : ""}`} aria-label="附件">
{displayable.map(({ attachment, href }) => {
const label = attachment.filename || attachment.name || "附件";
if (attachment.type?.startsWith("image/")) return <button type="button" className="attachment-image" onClick={() => setActiveImage({ href, label })} key={href} aria-label={`放大檢視:${label}`}><img src={href} alt={label} /></button>;
return <a className="attachment-file" href={href} target="_blank" rel="noreferrer" key={href}>📎 {label}</a>;
})}
</section>
{activeImage && <div className="image-lightbox" role="dialog" aria-modal="true" aria-label={activeImage.label} onClick={() => setActiveImage(null)}>
<button type="button" className="image-lightbox-close" onClick={() => setActiveImage(null)} aria-label="關閉圖片檢視">×</button>
<img src={activeImage.href} alt={activeImage.label} onClick={(event) => event.stopPropagation()} />
</div>}
</>;
}
+3
View File
@@ -0,0 +1,3 @@
import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; import { db } from "@/lib/db"; import { PublishForm } from "./publish-form"; import { WebhookControl } from "./webhook-control";
export const dynamic="force-dynamic";
export default async function Dashboard({searchParams}:{searchParams:Promise<{error?:string;source?:string}>}){const query=await searchParams;const user=await getSession();if(!user)redirect('/login');const sources=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 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 any[];return <><h1></h1>{query.error&&<p className="error">{query.error}</p>}{query.source==='shared'?<p> Memos </p>:query.source&&<p></p>}<section className="card"><h2> Memos</h2>{sources.length?<PublishForm sources={sources}/>:<p className="muted"> Memos </p>}</section><section className="card"><h2> Memos</h2><form action="/api/sources" method="post"><label><input name="name" required placeholder="我的 Memos"/></label><label>Memos <input name="baseUrl" type="url" required placeholder="https://memos.example.com"/></label><label>Personal Access Token<input name="token" type="password" required/></label><button></button></form><p className="muted">Token 使 Memos </p></section><section><h2></h2>{sources.map(s=><article className="card" key={s.id}><div className="space"><strong>{s.name}</strong><span className="tag">{s.sync_status}</span></div><p className="meta"> ID{s.id}<br/>{s.base_url}<br/>{s.last_synced_at||'尚未完成'}<br/>Webhook{s.webhook_secret_hash?(s.last_webhook_at?`最近收到:${new Date(s.last_webhook_at+'Z').toLocaleString('zh-TW')}`:'已建立 URL,尚未收到呼叫'):'尚未建立 URL'}{s.last_error&&<><br/><span className="error">{s.last_error}</span></>}</p>{s.owner_id===user.id?<WebhookControl sourceId={s.id} configured={Boolean(s.webhook_secret_hash)}/>:<p className="meta"> webhook</p>}<form action="/api/sync" method="post"><input type="hidden" name="sourceId" value={s.id}/><button></button></form></article>)}</section></>}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { FormEvent, useState } from "react";
type Source = { id: number; name: string };
export function PublishForm({ sources }: { sources: Source[] }) {
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
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 <form onSubmit={submit} encType="multipart/form-data">
<label>Markdown<textarea name="content" required /></label>
<label><input name="tags" placeholder="旅行, 想法" /></label>
<label><select name="visibility" defaultValue="PUBLIC"><option value="PUBLIC"></option><option value="PROTECTED"></option><option value="PRIVATE"></option></select></label>
<label><select name="sourceId" required>{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label>
<label> 10 MB<input name="attachments" type="file" multiple /></label>
{error && <p className="error">{error}</p>}
<button disabled={submitting}>{submitting ? "發佈中…" : "發佈並同步"}</button>
</form>;
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useState } from "react";
export function WebhookControl({ sourceId, configured }: { sourceId: number; configured: boolean }) {
const [url, setUrl] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false);
async function generate() {
setBusy(true); setError("");
try {
const response = await fetch(`/api/sources/${sourceId}/webhook`, { method: "POST", headers: { Accept: "application/json" } });
const body = await response.json(); if (!response.ok) throw new Error(body.error || "無法產生 webhook URL"); setUrl(body.url);
} catch (reason) { setError(reason instanceof Error ? reason.message : "無法產生 webhook URL"); }
finally { setBusy(false); }
}
async function copy() { if (url) await navigator.clipboard.writeText(url); }
return <div className="webhook-control"><p className="meta">Webhook{configured ? "已設定" : "尚未設定"}</p>
{url ? <><label className="sr-only" htmlFor={`webhook-${sourceId}`}>Webhook URL</label><input id={`webhook-${sourceId}`} readOnly value={url} onFocus={(event) => event.currentTarget.select()} /><div className="row"><button type="button" onClick={copy}> URL</button><button type="button" className="danger" onClick={generate} disabled={busy}></button></div><p className="meta"> Memos</p></> : <button type="button" onClick={generate} disabled={busy}>{busy ? "產生中…" : configured ? "重新產生 webhook URL" : "產生 webhook URL"}</button>}
{error && <p className="error">{error}</p>}
</div>;
}
+8
View File
@@ -0,0 +1,8 @@
import "./styles.css";
import Link from "next/link";
import { getSession } from "@/lib/auth";
export const metadata = { title: "Mebbling", description: "Your Memos hub" };
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><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>;
}
+1
View File
@@ -0,0 +1 @@
export default function Login(){return <><h1></h1><form action="/api/auth/login" method="post"><label><input name="username" required autoComplete="username"/></label><label><input name="password" type="password" required autoComplete="current-password"/></label><button></button></form></>}
+11
View File
@@ -0,0 +1,11 @@
import Link from "next/link"; import { db } from "@/lib/db"; import { Attachments } from "./components/attachments";
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>}</>;
}
+3
View File
@@ -0,0 +1,3 @@
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>}
+1
View File
@@ -0,0 +1 @@
export default function Register(){return <><h1></h1><form action="/api/auth/register" method="post"><label><input name="username" required minLength={3} pattern="[A-Za-z0-9_-]+"/></label><label><input name="password" type="password" required minLength={10}/></label><button></button></form><p className="muted"> Memos</p></>}
+1
View File
@@ -0,0 +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}