94368ec981
leo 試用 P0 後反饋的 4 個問題: bug #1: popup 編輯時沒有,更擠 - EditingArea 元件加 ⇱ 放大 button + popup overlay(共用 PostComposer 的 popup pattern) - 影響:所有 edit / reply 模式現在都有放大編輯 bug #2: Mira 思考中很久(即使沒 @mira) - root cause: onAiTriggered(pageName) 在 PostComposer 永遠 fire,會在每篇貼文觸發 thinking indicator UI - 修:移到 if (hasMiraMention) 分支內。沒 @mira → 不顯示 thinking - wiki_synthesis 仍每篇都跑(背景靜默)但不顯示 thinking,避免 leo 誤會 bug #3: Mira reply 不知道有 wiki,無狀態 - 加 fetchRelevantWikiContext: 撈所有 type=index-entry blocks,用 entity 名稱 keyword match against post content,取 top 3 整篇 markdown 餵進 prompt - prompt 加規則「如果 leo 提到的 entity 你已有 wiki 知識,請主動引用」 - 對應 leo「應該以擁有知識庫的專人來回覆」 bug #4: edit 時 @mira 沒反應 - PostEditor.save / BlockEditor.saveEdit / ReplyLine.save 三處都加 hasMiraMention 偵測 - 條件:新內容含 @mira 且舊版沒有 → 觸發(避免重複編輯重複 trigger) placeholder 加 hint「@mira 呼叫 Mira 回覆」。
1739 lines
60 KiB
TypeScript
1739 lines
60 KiB
TypeScript
'use client';
|
||
|
||
// Mira 河道 — 社群貼文式設計(FB / X 風格)
|
||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.3
|
||
|
||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||
import { MarkdownView } from '../_shared/markdown';
|
||
import '../mira.css';
|
||
|
||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||
const KBDB_BASE = 'https://kbdb.finally.click';
|
||
const KBDB_INGEST = 'https://kbdb-ingest.arcrun.dev';
|
||
const KBDB_PATCH = 'https://kbdb-patch-block.arcrun.dev';
|
||
const KBDB_CREATE = 'https://kbdb-create-block.arcrun.dev';
|
||
const CLAUDE_API = 'https://claude-api.arcrun.dev';
|
||
|
||
const PREVIEW_LINES = 3; // 卡片預覽顯示行數
|
||
const PREVIEW_CHARS = 200; // 卡片預覽顯示字數上限
|
||
|
||
type Document = {
|
||
page_name: string;
|
||
user_id: string;
|
||
block_count: number;
|
||
created_at: number | string;
|
||
updated_at: number | string;
|
||
};
|
||
|
||
type KBDBBlock = {
|
||
id: string;
|
||
content: string | null;
|
||
type: string;
|
||
source: string | null;
|
||
user_id: string | null;
|
||
parent_id: string | null;
|
||
page_name: string | null;
|
||
tags_json: string;
|
||
refs_json: string;
|
||
sort_order?: number;
|
||
created_at: number | string;
|
||
updated_at: number | string;
|
||
};
|
||
|
||
type Me = { email: string; api_key: string; display_name: string; avatar_url?: string };
|
||
|
||
const MIRA_POST_USER_ID = 'inkstone_mira_post';
|
||
|
||
export default function MiraPage() {
|
||
const [me, setMe] = useState<Me | null>(null);
|
||
const [docs, setDocs] = useState<Document[] | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
// page_name → "thinking" / null:哪些 doc 在等 AI 回覆
|
||
const [aiThinking, setAiThinking] = useState<Set<string>>(new Set());
|
||
|
||
const meRef = useRef<Me | null>(null);
|
||
meRef.current = me;
|
||
|
||
const reload = useCallback(async () => {
|
||
const m = meRef.current;
|
||
if (!m) return;
|
||
try {
|
||
const docsRes = await fetch(`${KBDB_BASE}/blocks/documents?limit=50&_t=${Date.now()}`, {
|
||
headers: { Authorization: `Bearer ${m.api_key}` },
|
||
cache: 'no-store',
|
||
});
|
||
if (docsRes.ok) {
|
||
const data = (await docsRes.json()) as { documents?: Document[] };
|
||
setDocs(data.documents ?? []);
|
||
}
|
||
} catch (e) {
|
||
console.error('[mira reload]', e);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' });
|
||
if (!meRes.ok) {
|
||
window.location.href = '/login?redirect=/mira/feed';
|
||
return;
|
||
}
|
||
const meData = (await meRes.json()) as Me;
|
||
if (cancelled) return;
|
||
setMe(meData);
|
||
|
||
const docsRes = await fetch(`${KBDB_BASE}/blocks/documents?limit=50`, {
|
||
headers: { Authorization: `Bearer ${meData.api_key}` },
|
||
});
|
||
if (!docsRes.ok) {
|
||
setError(`KBDB 讀取失敗:${docsRes.status}`);
|
||
return;
|
||
}
|
||
const data = (await docsRes.json()) as { documents?: Document[] };
|
||
if (cancelled) return;
|
||
setDocs(data.documents ?? []);
|
||
} catch (e) {
|
||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
return (
|
||
<div className="mira-app">
|
||
<div className="mira-content">
|
||
{me && (
|
||
<PostComposer
|
||
me={me}
|
||
onPosted={reload}
|
||
onAiTriggered={(pageName) => {
|
||
setAiThinking(prev => new Set(prev).add(pageName));
|
||
// 90 秒後 hard timeout(Mira 思考 + KBDB write 通常 10-45 秒,給寬限)
|
||
// 卡片內 useEffect 偵測 AI reply 真的進來會主動呼叫 onAiResponded 清掉
|
||
setTimeout(() => {
|
||
setAiThinking(prev => {
|
||
if (!prev.has(pageName)) return prev;
|
||
const n = new Set(prev);
|
||
n.delete(pageName);
|
||
return n;
|
||
});
|
||
}, 90000);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="mira-card">
|
||
<div className="mira-error">{error}</div>
|
||
</div>
|
||
)}
|
||
|
||
{!error && docs === null && <div className="empty-state">載入中…</div>}
|
||
|
||
{!error && docs !== null && docs.length === 0 && me && (
|
||
<EmptyRiver email={me.email} />
|
||
)}
|
||
|
||
{!error && docs && docs.length > 0 && me && (
|
||
<DocsList
|
||
docs={docs}
|
||
me={me}
|
||
aiThinking={aiThinking}
|
||
onAiResponded={(pageName) => {
|
||
setAiThinking(prev => {
|
||
if (!prev.has(pageName)) return prev;
|
||
const n = new Set(prev);
|
||
n.delete(pageName);
|
||
return n;
|
||
});
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── AI 回覆觸發器(fire-and-forget)──────────────────────
|
||
|
||
// P0 #5b:偵測文字含 @mira(mention) — 大小寫不限,前後可有標點
|
||
// 用法:leo 在貼文 / 留言寫 `@mira <主題>` → 觸發 mira reply
|
||
function hasMiraMention(text: string): boolean {
|
||
return /(^|[^\w])@mira\b/i.test(text);
|
||
}
|
||
|
||
// 撈相關 wiki context 給 mira reply 用(簡單 RAG)
|
||
// 策略:拿全部 index-entry blocks → 用 entity 名稱 keyword match against post content
|
||
// → 取 top 3 把整篇 markdown 餵進 prompt(每篇通常 < 1KB,3 篇還在 token 預算內)
|
||
async function fetchRelevantWikiContext(apiKey: string, postContent: string): Promise<string> {
|
||
try {
|
||
const res = await fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=200`, {
|
||
headers: { Authorization: `Bearer ${apiKey}` },
|
||
});
|
||
if (!res.ok) return '';
|
||
const data = await res.json() as { blocks?: Array<{ id: string; page_name: string | null; content: string }> };
|
||
const all = data.blocks ?? [];
|
||
if (all.length === 0) return '';
|
||
const lower = postContent.toLowerCase();
|
||
// 評分:page_name 剝 `index-` prefix = entity 名稱,看 postContent 是否含
|
||
const scored = all
|
||
.map(b => {
|
||
const entity = (b.page_name ?? '').replace(/^index-/, '').trim();
|
||
const score = entity && lower.includes(entity.toLowerCase()) ? entity.length : 0;
|
||
return { entity, content: b.content, score };
|
||
})
|
||
.filter(x => x.score > 0)
|
||
.sort((a, b) => b.score - a.score)
|
||
.slice(0, 3);
|
||
if (scored.length === 0) return '';
|
||
return scored.map(s => `### entity: ${s.entity}\n${s.content}`).join('\n\n');
|
||
} catch (e) {
|
||
console.warn('[fetchRelevantWikiContext] failed', e);
|
||
return '';
|
||
}
|
||
}
|
||
|
||
async function triggerWikiSynthesis(opts: { apiKey: string; rawBlockId: string }) {
|
||
// 觸發 arcrun wiki_synthesis workflow(arcrun-native public trigger endpoint)
|
||
// 不等結果(workflow 60-90s 含 2 次 claude_api pause/resume)
|
||
try {
|
||
const res = await fetch(`${API_BASE}/webhooks/named/wiki_synthesis/trigger`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Arcrun-API-Key': opts.apiKey,
|
||
},
|
||
body: JSON.stringify({ api_key: opts.apiKey, raw_block_id: opts.rawBlockId }),
|
||
});
|
||
if (!res.ok) {
|
||
console.warn('[wiki_synthesis trigger] non-ok:', res.status);
|
||
return;
|
||
}
|
||
const data = await res.json().catch(() => ({}));
|
||
console.log('[wiki_synthesis trigger] response:', data);
|
||
} catch (e) {
|
||
console.warn('[wiki_synthesis trigger] error:', e);
|
||
}
|
||
}
|
||
|
||
async function triggerAiReply(opts: {
|
||
apiKey: string;
|
||
postContent: string;
|
||
parentBlockId: string;
|
||
pageName: string;
|
||
}) {
|
||
// 抽 @mira 後面的 topic(同行第一段,到換行 / 句號 / 標點停)
|
||
const mentionMatch = opts.postContent.match(/@mira\s*([^\n。.!!??,,]*)/i);
|
||
const topic = mentionMatch?.[1]?.trim() || '';
|
||
const topicHint = topic
|
||
? `\nleo 用 \`@mira ${topic}\` 呼叫了你,所以這則對話的主題鎖定在「${topic}」。\n`
|
||
: '\nleo 用 \`@mira\` 呼叫了你,請針對訊息回覆。\n';
|
||
|
||
// RAG:撈既有 wiki index-entries,挑跟貼文 keyword match 的塞進 system context
|
||
// 對應 leo bug #3「Mira 應該以擁有知識庫的專人來回覆,不是無狀態」
|
||
const knowledgeContext = await fetchRelevantWikiContext(opts.apiKey, opts.postContent);
|
||
|
||
const prompt =
|
||
(knowledgeContext ? `## 你目前的 wiki 知識庫(跟本次訊息相關的 entity 摘要)\n\n${knowledgeContext}\n\n---\n\n` : '') +
|
||
`用戶 leo 在 mira 河道發了這則訊息:\n\n` +
|
||
`「${opts.postContent}」\n\n` +
|
||
topicHint +
|
||
`\n請以 Mira 副駕 AI 的身份留言回應。\n` +
|
||
`規則:\n` +
|
||
`- 繁體中文(台灣用語)\n` +
|
||
`- 簡短 1-3 段,務實,不客套\n` +
|
||
`- 可以發問、補充、提建議、或反問釐清\n` +
|
||
`- 不替老闆下決策,但可以給判斷依據\n` +
|
||
(knowledgeContext ? `- **如果 leo 提到的 entity 你已有 wiki 知識,請主動引用 / 連結(用「你之前寫過...」或「在《X》wiki 中你提到...」)**\n` : '');
|
||
|
||
try {
|
||
const aiRes = await fetch(CLAUDE_API, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ prompt, timeout_ms: 25000 }),
|
||
});
|
||
const aiData = (await aiRes.json()) as {
|
||
success?: boolean;
|
||
pending?: boolean;
|
||
data?: { text?: string };
|
||
error?: string;
|
||
};
|
||
if (!aiRes.ok || !aiData.success) {
|
||
console.error('[mira ai reply] claude_api failed:', aiData.error);
|
||
return;
|
||
}
|
||
if (aiData.pending) {
|
||
// daemon 切到 async,目前簡化版不處理 polling
|
||
console.warn('[mira ai reply] daemon went async, skipping');
|
||
return;
|
||
}
|
||
const text = aiData.data?.text;
|
||
if (!text) {
|
||
console.warn('[mira ai reply] no text in response');
|
||
return;
|
||
}
|
||
|
||
// 寫成 chat block (parent_id = 原貼文 block, source=ai-mira)
|
||
await fetch(KBDB_CREATE, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
api_key: opts.apiKey,
|
||
content: text,
|
||
type: 'chat',
|
||
parent_id: opts.parentBlockId,
|
||
user_id: MIRA_POST_USER_ID,
|
||
source: 'ai-mira',
|
||
page_name: opts.pageName,
|
||
}),
|
||
});
|
||
} catch (e) {
|
||
console.error('[mira ai reply] exception:', e);
|
||
}
|
||
}
|
||
|
||
// ─── 寫貼文 composer ──────────────────────────────────────
|
||
|
||
function PostComposer({
|
||
me,
|
||
onPosted,
|
||
onAiTriggered,
|
||
}: {
|
||
me: Me;
|
||
onPosted: () => Promise<void> | void;
|
||
onAiTriggered: (pageName: string) => void;
|
||
}) {
|
||
const [text, setText] = useState('');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [expanded, setExpanded] = useState(false);
|
||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||
const popupTaRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
const submit = useCallback(async () => {
|
||
const trimmed = text.trim();
|
||
if (!trimmed || submitting) return;
|
||
setSubmitting(true);
|
||
setErr(null);
|
||
try {
|
||
const stamp = new Date().toISOString();
|
||
const pageName = `post-${stamp}-${Math.random().toString(36).slice(2, 8)}`;
|
||
|
||
const res = await fetch(KBDB_CREATE, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
api_key: me.api_key,
|
||
content: trimmed,
|
||
type: 'note',
|
||
user_id: MIRA_POST_USER_ID,
|
||
source: 'km-writer-direct',
|
||
page_name: pageName,
|
||
}),
|
||
});
|
||
const data = (await res.json()) as { success?: boolean; error?: string; data?: { id?: string } };
|
||
if (!res.ok || !data.success || !data.data?.id) {
|
||
setErr(data.error || `寫入失敗:${res.status}`);
|
||
return;
|
||
}
|
||
const postBlockId = data.data.id;
|
||
setText('');
|
||
|
||
// P0 #5b:只有 @mira 時才觸發 Mira AI 回覆(撤回每篇 auto-reply)
|
||
// 對應 design.md §3.6.5「河道是 process 場 + Mira 是 KB 同步介面」
|
||
// 「Mira 思考中」indicator 也只在 @mira 時 show(避免每篇都顯示讓 leo 誤會)
|
||
if (hasMiraMention(trimmed)) {
|
||
void triggerAiReply({
|
||
apiKey: me.api_key,
|
||
postContent: trimmed,
|
||
parentBlockId: postBlockId,
|
||
pageName,
|
||
});
|
||
onAiTriggered(pageName);
|
||
}
|
||
// 7B.3h:fire-and-forget 觸發 wiki_synthesis(browser → cypher.arcrun.dev,arcrun-native)
|
||
// 跟 @mira 無關,每篇都跑 — 河道書寫永遠進 wiki KB 副本(不顯示 thinking indicator,背景靜默跑)
|
||
void triggerWikiSynthesis({ apiKey: me.api_key, rawBlockId: postBlockId });
|
||
|
||
// 給 D1 GROUP BY 查詢看到新資料的時間
|
||
await new Promise(r => setTimeout(r, 1500));
|
||
await onPosted();
|
||
} catch (e) {
|
||
setErr(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}, [text, submitting, me, onPosted, onAiTriggered]);
|
||
|
||
return (
|
||
<>
|
||
<div className="mira-card mira-composer-card">
|
||
<div className="mira-composer-row">
|
||
<Avatar me={me} />
|
||
<textarea
|
||
ref={taRef}
|
||
value={text}
|
||
onChange={e => setText(e.target.value)}
|
||
onKeyDown={e => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||
e.preventDefault();
|
||
submit();
|
||
return;
|
||
}
|
||
handleTabIndent(e, text, setText);
|
||
}}
|
||
placeholder="現在想分享什麼?(@mira 呼叫 Mira 回覆)"
|
||
rows={2}
|
||
disabled={submitting}
|
||
className="mira-composer-textarea"
|
||
/>
|
||
</div>
|
||
<div className="mira-composer-actions">
|
||
<MarkdownToolbar textareaRef={taRef} value={text} setValue={setText} compact />
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setExpanded(true);
|
||
setTimeout(() => popupTaRef.current?.focus(), 50);
|
||
}}
|
||
className="mira-btn-ghost mira-composer-expand"
|
||
title="放大編輯(⇱)"
|
||
aria-label="放大編輯"
|
||
>
|
||
⇱
|
||
</button>
|
||
{err && <span className="mira-msg-error">{err}</span>}
|
||
<span style={{ flex: 1 }} />
|
||
<span className="mira-kbd">⌘+Enter</span>
|
||
<button
|
||
type="button"
|
||
disabled={submitting || !text.trim()}
|
||
onClick={submit}
|
||
className={`mira-btn-primary${!text.trim() ? ' disabled' : ''}`}
|
||
>
|
||
{submitting ? '送出中⋯' : '貼文'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Popup 放大編輯(FB 覆蓋版面風格)— P0 #5c */}
|
||
{expanded && (
|
||
<div
|
||
className="mira-composer-popup-backdrop"
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) setExpanded(false);
|
||
}}
|
||
>
|
||
<div className="mira-composer-popup">
|
||
<header className="mira-composer-popup-header">
|
||
<Avatar me={me} size={32} />
|
||
<span className="mira-composer-popup-title">寫一篇</span>
|
||
<span style={{ flex: 1 }} />
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded(false)}
|
||
className="mira-btn-ghost"
|
||
title="收起"
|
||
aria-label="關閉"
|
||
>
|
||
✕
|
||
</button>
|
||
</header>
|
||
<textarea
|
||
ref={popupTaRef}
|
||
value={text}
|
||
onChange={e => setText(e.target.value)}
|
||
onKeyDown={e => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||
e.preventDefault();
|
||
submit().then(() => setExpanded(false));
|
||
return;
|
||
}
|
||
if (e.key === 'Escape') {
|
||
setExpanded(false);
|
||
return;
|
||
}
|
||
handleTabIndent(e, text, setText);
|
||
}}
|
||
placeholder="現在想分享什麼?(Esc 收起、⌘+Enter 發布)"
|
||
disabled={submitting}
|
||
className="mira-composer-popup-textarea"
|
||
/>
|
||
<div className="mira-composer-popup-actions">
|
||
<MarkdownToolbar textareaRef={popupTaRef} value={text} setValue={setText} />
|
||
{err && <span className="mira-msg-error">{err}</span>}
|
||
<span style={{ flex: 1 }} />
|
||
<span className="mira-kbd">⌘+Enter</span>
|
||
<button
|
||
type="button"
|
||
disabled={submitting || !text.trim()}
|
||
onClick={async () => {
|
||
await submit();
|
||
setExpanded(false);
|
||
}}
|
||
className={`mira-btn-primary${!text.trim() ? ' disabled' : ''}`}
|
||
>
|
||
{submitting ? '送出中⋯' : '貼文'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Avatar({ me, size = 40 }: { me: Me; size?: number }) {
|
||
if (me.avatar_url) {
|
||
return (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={me.avatar_url}
|
||
alt={me.display_name}
|
||
width={size}
|
||
height={size}
|
||
className="mira-avatar"
|
||
style={{ width: size, height: size }}
|
||
/>
|
||
);
|
||
}
|
||
const initial = (me.display_name || me.email || '?').slice(0, 1).toUpperCase();
|
||
return (
|
||
<div className="mira-avatar mira-avatar-fallback" style={{ width: size, height: size, fontSize: size * 0.4 }}>
|
||
{initial}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Mira 自有頭像(區別於 leo),用機器人 emoji + 紫色圓
|
||
// 對應 design.md §3.6.5 + tasks.md backlog #5a (P0)
|
||
function MiraAvatar({ size = 40 }: { size?: number }) {
|
||
return (
|
||
<div
|
||
className="mira-avatar mira-avatar-mira"
|
||
style={{ width: size, height: size, fontSize: size * 0.55 }}
|
||
title="Mira"
|
||
aria-label="Mira"
|
||
>
|
||
🤖
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 用 source 判斷該 post 是 leo 寫的還是 mira 生成的
|
||
// 已知 leo 來源 → leo 頭像;其他(ai-* / mira-* / test sources)→ mira 頭像
|
||
function isMiraSource(source: string | null | undefined): boolean {
|
||
if (!source) return false;
|
||
const s = source.toLowerCase();
|
||
// leo 寫入的所有 channels
|
||
if (s.startsWith('km-writer-direct')) return false;
|
||
if (s.startsWith('logseq')) return false;
|
||
if (s === 'mobile' || s === 'web' || s.startsWith('tg') || s === 'telegram') return false;
|
||
if (s === 'rss') return false;
|
||
// 其他(ai-* / mira* / test sources)視為 mira
|
||
return true;
|
||
}
|
||
|
||
// ─── ⋮ 選單 ──────────────────────────────────────────────
|
||
|
||
type MenuItem = { label: string; onClick: () => void; danger?: boolean };
|
||
|
||
function MoreMenu({ items }: { items: MenuItem[] }) {
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onClickOutside = (e: MouseEvent | TouchEvent) => {
|
||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||
};
|
||
document.addEventListener('mousedown', onClickOutside);
|
||
document.addEventListener('touchstart', onClickOutside);
|
||
return () => {
|
||
document.removeEventListener('mousedown', onClickOutside);
|
||
document.removeEventListener('touchstart', onClickOutside);
|
||
};
|
||
}, [open]);
|
||
|
||
return (
|
||
<div ref={ref} className="mira-menu-wrap">
|
||
<button
|
||
type="button"
|
||
className="mira-icon-btn mira-icon-btn-vertical"
|
||
aria-label="更多"
|
||
onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
|
||
>
|
||
⋮
|
||
</button>
|
||
{open && (
|
||
<div className="mira-menu" onClick={(e) => e.stopPropagation()} role="menu">
|
||
{items.map((item, i) => (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
className={`mira-menu-item${item.danger ? ' danger' : ''}`}
|
||
onClick={() => { item.onClick(); setOpen(false); }}
|
||
role="menuitem"
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 列表 ──────────────────────────────────────────────────
|
||
|
||
function EmptyRiver({ email }: { email: string }) {
|
||
return (
|
||
<div className="empty-state">
|
||
<div style={{ fontSize: 56, marginBottom: 14 }}>🌊</div>
|
||
<p style={{ color: 'var(--mira-text-2)', fontSize: 14, marginBottom: 6 }}>
|
||
河裡還沒有東西。
|
||
</p>
|
||
<p style={{ fontSize: 12, color: 'var(--mira-text-3)' }}>
|
||
登入身份:<span className="mira-en">{email}</span>
|
||
<br />
|
||
在上方寫第一則貼文。
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DocsList({
|
||
docs,
|
||
me,
|
||
aiThinking,
|
||
onAiResponded,
|
||
}: {
|
||
docs: Document[];
|
||
me: Me;
|
||
aiThinking: Set<string>;
|
||
onAiResponded: (pageName: string) => void;
|
||
}) {
|
||
return (
|
||
<>
|
||
{docs.map(d => (
|
||
<DocCard
|
||
key={`${d.page_name}::${d.user_id}`}
|
||
doc={d}
|
||
me={me}
|
||
aiThinking={aiThinking.has(d.page_name)}
|
||
onAiResponded={() => onAiResponded(d.page_name)}
|
||
/>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ─── 卡片(社群貼文風格)────────────────────────────────────
|
||
|
||
function DocCard({
|
||
doc,
|
||
me,
|
||
aiThinking,
|
||
onAiResponded,
|
||
}: {
|
||
doc: Document;
|
||
me: Me;
|
||
aiThinking: boolean;
|
||
onAiResponded: () => void;
|
||
}) {
|
||
const [blocks, setBlocks] = useState<KBDBBlock[] | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [expandedFull, setExpandedFull] = useState(false);
|
||
const [showReply, setShowReply] = useState(false);
|
||
const [showAllReplies, setShowAllReplies] = useState(false);
|
||
const [editingPost, setEditingPost] = useState(false);
|
||
const cardRef = useRef<HTMLElement>(null);
|
||
const fetchedRef = useRef(false);
|
||
|
||
// Lazy fetch:進入視窗才抓 sub-blocks(IntersectionObserver)
|
||
useEffect(() => {
|
||
if (fetchedRef.current) return;
|
||
const node = cardRef.current;
|
||
if (!node) return;
|
||
|
||
const trigger = async () => {
|
||
if (fetchedRef.current) return;
|
||
fetchedRef.current = true;
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch(
|
||
`${KBDB_BASE}/blocks?page_name=${encodeURIComponent(doc.page_name)}&limit=200`,
|
||
{ headers: { Authorization: `Bearer ${me.api_key}` } },
|
||
);
|
||
if (res.ok) {
|
||
const data = (await res.json()) as { blocks?: KBDBBlock[] };
|
||
setBlocks(data.blocks ?? []);
|
||
} else {
|
||
setBlocks([]);
|
||
}
|
||
} catch {
|
||
setBlocks([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
if (typeof IntersectionObserver === 'undefined') {
|
||
// 不支援 IntersectionObserver 的環境(極舊瀏覽器):直接 fetch
|
||
void trigger();
|
||
return;
|
||
}
|
||
|
||
const io = new IntersectionObserver((entries) => {
|
||
for (const entry of entries) {
|
||
if (entry.isIntersecting) {
|
||
void trigger();
|
||
io.disconnect();
|
||
break;
|
||
}
|
||
}
|
||
}, {
|
||
rootMargin: '200px 0px', // 提前 200px 觸發,不等卡片完全進入視窗
|
||
threshold: 0.01,
|
||
});
|
||
io.observe(node);
|
||
return () => io.disconnect();
|
||
}, [doc.page_name, me.api_key]);
|
||
|
||
// 強制重抓(外部呼叫 / aiThinking polling)
|
||
const refetch = useCallback(async (): Promise<KBDBBlock[] | null> => {
|
||
try {
|
||
const res = await fetch(
|
||
`${KBDB_BASE}/blocks?page_name=${encodeURIComponent(doc.page_name)}&limit=200&_t=${Date.now()}`,
|
||
{ headers: { Authorization: `Bearer ${me.api_key}` }, cache: 'no-store' },
|
||
);
|
||
if (res.ok) {
|
||
const data = (await res.json()) as { blocks?: KBDBBlock[] };
|
||
const fresh = data.blocks ?? [];
|
||
setBlocks(fresh);
|
||
return fresh;
|
||
}
|
||
} catch {}
|
||
return null;
|
||
}, [doc.page_name, me.api_key]);
|
||
|
||
// aiThinking 期間每 5 秒 refetch;偵測 AI reply 出現就清 thinking
|
||
useEffect(() => {
|
||
if (!aiThinking) return;
|
||
const id = setInterval(async () => {
|
||
const fresh = await refetch();
|
||
if (fresh && fresh.some(b => b.source?.startsWith('ai-'))) {
|
||
onAiResponded();
|
||
}
|
||
}, 5000);
|
||
return () => clearInterval(id);
|
||
}, [aiThinking, refetch, onAiResponded]);
|
||
|
||
// 主內容(type != chat 的 sorted blocks),合成單一文字流
|
||
// 留言(type=chat)拍平按時間排,不論 parent_id 都集中到卡片底部
|
||
const { mainContent, mainBlocksList, allReplies } = useMemo(() => {
|
||
if (!blocks) return {
|
||
mainContent: '',
|
||
mainBlocksList: [] as KBDBBlock[],
|
||
allReplies: [] as KBDBBlock[],
|
||
};
|
||
|
||
const sorted = [...blocks].sort((a, b) => {
|
||
const sa = a.sort_order ?? 0;
|
||
const sb = b.sort_order ?? 0;
|
||
if (sa !== sb) return sa - sb;
|
||
return toDate(a.created_at).getTime() - toDate(b.created_at).getTime();
|
||
});
|
||
|
||
const main = sorted.filter(b => b.type !== 'chat');
|
||
const chats = sorted.filter(b => b.type === 'chat');
|
||
const repliesSorted = [...chats].sort(
|
||
(a, b) => toDate(a.created_at).getTime() - toDate(b.created_at).getTime(),
|
||
);
|
||
|
||
const text = main.map(b => b.content || '').filter(t => t.trim()).join('\n\n');
|
||
return { mainContent: text, mainBlocksList: main, allReplies: repliesSorted };
|
||
}, [blocks]);
|
||
|
||
// 預覽截斷
|
||
const { previewText, isLong } = useMemo(() => {
|
||
const text = mainContent;
|
||
const lines = text.split('\n');
|
||
const tooManyLines = lines.length > PREVIEW_LINES;
|
||
const tooLong = text.length > PREVIEW_CHARS;
|
||
if (!tooManyLines && !tooLong) return { previewText: text, isLong: false };
|
||
let preview = lines.slice(0, PREVIEW_LINES).join('\n');
|
||
if (preview.length > PREVIEW_CHARS) preview = preview.slice(0, PREVIEW_CHARS);
|
||
return { previewText: preview, isLong: true };
|
||
}, [mainContent]);
|
||
|
||
const deletePost = useCallback(async () => {
|
||
if (!blocks || blocks.length === 0) return;
|
||
const ok = window.confirm(`確定刪除這篇貼文?(共 ${blocks.length} 個 block + 留言將一併刪除,無法復原)`);
|
||
if (!ok) return;
|
||
await Promise.all(
|
||
blocks.map(b =>
|
||
fetch(`${KBDB_BASE}/blocks/${b.id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${me.api_key}` },
|
||
}).catch(() => null),
|
||
),
|
||
);
|
||
window.location.reload();
|
||
}, [blocks, me.api_key]);
|
||
|
||
const cardMenu: MenuItem[] = [
|
||
...(isLong || expandedFull ? [{
|
||
label: expandedFull ? '收起' : '展開全文',
|
||
onClick: () => setExpandedFull(v => !v),
|
||
}] : []),
|
||
{ label: '編輯', onClick: () => setEditingPost(true) },
|
||
{ label: '留言', onClick: () => setShowReply(true) },
|
||
{ label: '複製連結', onClick: () => {
|
||
const url = `${window.location.origin}/mira/feed#page=${encodeURIComponent(doc.page_name)}`;
|
||
void navigator.clipboard?.writeText(url);
|
||
}},
|
||
{ label: '刪除', onClick: deletePost, danger: true },
|
||
];
|
||
|
||
const totalReplies = allReplies.length;
|
||
|
||
// 樂觀添加新 reply
|
||
const addReply = useCallback((b: KBDBBlock) => {
|
||
setBlocks(curr => (curr ? [...curr, b] : [b]));
|
||
}, []);
|
||
|
||
const updateBlock = useCallback((id: string, newContent: string) => {
|
||
setBlocks(curr => curr ? curr.map(b => b.id === id ? { ...b, content: newContent } : b) : null);
|
||
}, []);
|
||
|
||
const postSource = inferSource(blocks, doc.page_name);
|
||
const showMira = isMiraSource(postSource);
|
||
|
||
return (
|
||
<article className="mira-card mira-post" ref={cardRef}>
|
||
{/* 頭部:作者區塊(依 source 判斷頭像 + 名字) */}
|
||
<header className="mira-post-header">
|
||
{showMira ? <MiraAvatar size={40} /> : <Avatar me={me} size={40} />}
|
||
<div className="mira-post-author">
|
||
<div className="mira-post-name">{showMira ? 'Mira' : (me.display_name || me.email)}</div>
|
||
<div className="mira-post-time">
|
||
<SourceBadge source={postSource} />
|
||
<span>·</span>
|
||
<RelTime when={doc.updated_at} />
|
||
</div>
|
||
</div>
|
||
<MoreMenu items={cardMenu} />
|
||
</header>
|
||
|
||
{/* 內容主體 */}
|
||
<div className="mira-post-body">
|
||
{loading && <div className="mira-skel" />}
|
||
{!loading && !mainContent && !editingPost && (
|
||
<em style={{ color: 'var(--mira-text-3)' }}>(無內容)</em>
|
||
)}
|
||
{!loading && editingPost && (
|
||
<PostEditor
|
||
initial={mainContent}
|
||
mainBlocks={mainBlocksList}
|
||
apiKey={me.api_key}
|
||
onCancel={() => setEditingPost(false)}
|
||
onSaved={(newContent) => {
|
||
setEditingPost(false);
|
||
// 樂觀更新:第一個 block 改新內容、其餘清空
|
||
setBlocks(curr => {
|
||
if (!curr) return curr;
|
||
const main = curr.filter(b => b.type !== 'chat');
|
||
const chats = curr.filter(b => b.type === 'chat');
|
||
if (main.length === 0) return curr;
|
||
const updated: KBDBBlock[] = [
|
||
{ ...main[0], content: newContent },
|
||
...chats,
|
||
];
|
||
return updated;
|
||
});
|
||
}}
|
||
/>
|
||
)}
|
||
{!loading && !editingPost && mainContent && (
|
||
expandedFull ? (
|
||
<>
|
||
<FullContent blocks={mainBlocksList}
|
||
apiKey={me.api_key} docPageName={doc.page_name}
|
||
addReply={addReply} updateBlock={updateBlock} />
|
||
<button
|
||
type="button"
|
||
className="mira-show-more"
|
||
onClick={() => setExpandedFull(false)}
|
||
>
|
||
▴ 收起
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<PreviewContent text={previewText} />
|
||
{isLong && (
|
||
<button
|
||
type="button"
|
||
className="mira-show-more"
|
||
onClick={() => setExpandedFull(true)}
|
||
>
|
||
⋯查看更多
|
||
</button>
|
||
)}
|
||
</>
|
||
)
|
||
)}
|
||
</div>
|
||
|
||
{/* 互動列 */}
|
||
<footer className="mira-post-footer">
|
||
<button type="button" className="mira-action-btn" onClick={() => setShowReply(s => !s)}>
|
||
💬 留言{totalReplies > 0 && <span className="mira-action-count">{totalReplies}</span>}
|
||
</button>
|
||
</footer>
|
||
|
||
{/* 留言區(所有 chat 拍平按時間排,預設顯示後 2 則,點開看全部)*/}
|
||
{(allReplies.length > 0 || aiThinking) && (
|
||
<div className="mira-post-replies">
|
||
{!showAllReplies && allReplies.length > 2 && (
|
||
<button
|
||
type="button"
|
||
className="mira-show-replies"
|
||
onClick={() => setShowAllReplies(true)}
|
||
>
|
||
查看全部 {allReplies.length} 則留言
|
||
</button>
|
||
)}
|
||
{(showAllReplies ? allReplies : allReplies.slice(-2)).map(r => (
|
||
<ReplyLine
|
||
key={r.id}
|
||
reply={r}
|
||
apiKey={me.api_key}
|
||
docPageName={doc.page_name}
|
||
onUpdated={updateBlock}
|
||
onReplyAdded={addReply}
|
||
repliesByParent={new Map()}
|
||
depth={0}
|
||
/>
|
||
))}
|
||
{aiThinking && (
|
||
<div className="mira-reply-line is-ai mira-thinking">
|
||
<span className="mira-reply-icon">🤖</span>
|
||
<div className="mira-reply-content">
|
||
<span className="mira-thinking-dots">Mira 思考中</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{showAllReplies && allReplies.length > 2 && (
|
||
<button
|
||
type="button"
|
||
className="mira-show-replies"
|
||
onClick={() => setShowAllReplies(false)}
|
||
>
|
||
收起留言
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 留言 composer */}
|
||
{showReply && (
|
||
<div className="mira-reply-composer">
|
||
<Avatar me={me} size={32} />
|
||
<PageReplyComposer
|
||
apiKey={me.api_key}
|
||
docPageName={doc.page_name}
|
||
onSubmitted={(b) => { addReply(b); setShowReply(false); }}
|
||
onCancel={() => setShowReply(false)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
// 預覽內容(純文字,不顯示 sub-block 結構)
|
||
function PreviewContent({ text }: { text: string }) {
|
||
return <div className="mira-post-content"><MarkdownView text={text} /></div>;
|
||
}
|
||
|
||
// 整篇貼文編輯器:把所有 main blocks 合併成一段編輯
|
||
// 存回時:第一個 block 改成新內容,其餘 main blocks 刪除
|
||
function PostEditor({
|
||
initial,
|
||
mainBlocks,
|
||
apiKey,
|
||
onCancel,
|
||
onSaved,
|
||
}: {
|
||
initial: string;
|
||
mainBlocks: KBDBBlock[];
|
||
apiKey: string;
|
||
onCancel: () => void;
|
||
onSaved: (newContent: string) => void;
|
||
}) {
|
||
const [draft, setDraft] = useState(initial);
|
||
const [saving, setSaving] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
|
||
const save = async () => {
|
||
if (saving) return;
|
||
const trimmed = draft.trim();
|
||
if (!trimmed) {
|
||
setErr('內容不能空');
|
||
return;
|
||
}
|
||
if (trimmed === initial.trim()) {
|
||
onCancel();
|
||
return;
|
||
}
|
||
if (mainBlocks.length === 0) {
|
||
setErr('找不到可編輯的 block');
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
setErr(null);
|
||
try {
|
||
// 1. 改第一個 block 內容
|
||
const firstId = mainBlocks[0].id;
|
||
const patchRes = await fetch(KBDB_PATCH, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ api_key: apiKey, block_id: firstId, content: trimmed }),
|
||
});
|
||
const patchData = (await patchRes.json()) as { success?: boolean; error?: string };
|
||
if (!patchRes.ok || !patchData.success) {
|
||
setErr(patchData.error || `儲存失敗:${patchRes.status}`);
|
||
return;
|
||
}
|
||
|
||
// 2. 刪其餘 main blocks(如有)
|
||
if (mainBlocks.length > 1) {
|
||
await Promise.all(
|
||
mainBlocks.slice(1).map(b =>
|
||
fetch(`${KBDB_BASE}/blocks/${b.id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${apiKey}` },
|
||
}).catch(() => null),
|
||
),
|
||
);
|
||
}
|
||
|
||
onSaved(trimmed);
|
||
// P0 #5b bug #4: edit 後若加了 @mira(舊版沒)→ 觸發 mira reply
|
||
if (hasMiraMention(trimmed) && !hasMiraMention(initial)) {
|
||
const firstId = mainBlocks[0]?.id;
|
||
const pageName = mainBlocks[0]?.page_name ?? '';
|
||
if (firstId && pageName) {
|
||
void triggerAiReply({
|
||
apiKey, postContent: trimmed, parentBlockId: firstId, pageName,
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
setErr(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<EditingArea
|
||
draft={draft}
|
||
setDraft={setDraft}
|
||
onSubmit={save}
|
||
onCancel={onCancel}
|
||
saving={saving}
|
||
err={err}
|
||
submitLabel="儲存"
|
||
placeholder="編輯內容⋯(@mira 呼叫 Mira 回覆)"
|
||
/>
|
||
);
|
||
}
|
||
|
||
// 完整內容(每個 sub-block 一行,可編輯,可留言)
|
||
function FullContent({
|
||
blocks,
|
||
apiKey,
|
||
docPageName,
|
||
addReply,
|
||
updateBlock,
|
||
}: {
|
||
blocks: KBDBBlock[];
|
||
apiKey: string;
|
||
docPageName: string;
|
||
addReply: (b: KBDBBlock) => void;
|
||
updateBlock: (id: string, c: string) => void;
|
||
}) {
|
||
// 只顯示 main blocks(type != chat),所有 chat 全部集中到卡片底部 replies 區
|
||
return (
|
||
<div className="mira-post-content mira-post-content-full">
|
||
{blocks.slice(0, 100).map(b => (
|
||
<BlockLine
|
||
key={b.id}
|
||
block={b}
|
||
apiKey={apiKey}
|
||
docPageName={docPageName}
|
||
onUpdated={updateBlock}
|
||
onReplyAdded={addReply}
|
||
/>
|
||
))}
|
||
{blocks.length > 100 && (
|
||
<div style={{ color: 'var(--mira-text-2)', fontSize: 12, marginTop: 12 }}>
|
||
…還有 {blocks.length - 100} 個 block 未顯示
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BlockLine({
|
||
block,
|
||
apiKey,
|
||
docPageName,
|
||
onUpdated,
|
||
onReplyAdded,
|
||
}: {
|
||
block: KBDBBlock;
|
||
apiKey: string;
|
||
docPageName: string;
|
||
onUpdated: (id: string, c: string) => void;
|
||
onReplyAdded: (b: KBDBBlock) => void;
|
||
}) {
|
||
const [mode, setMode] = useState<'view' | 'edit' | 'reply'>('view');
|
||
const [draft, setDraft] = useState(block.content ?? '');
|
||
const [saving, setSaving] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
|
||
const startEdit = () => { setDraft(block.content ?? ''); setMode('edit'); setErr(null); };
|
||
const startReply = () => { setDraft(''); setMode('reply'); setErr(null); };
|
||
const cancel = () => { setMode('view'); setDraft(block.content ?? ''); setErr(null); };
|
||
|
||
const saveEdit = async () => {
|
||
if (saving) return;
|
||
if (draft === (block.content ?? '')) { setMode('view'); return; }
|
||
setSaving(true); setErr(null);
|
||
try {
|
||
const res = await fetch(KBDB_PATCH, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ api_key: apiKey, block_id: block.id, content: draft }),
|
||
});
|
||
const data = (await res.json()) as { success?: boolean; error?: string };
|
||
if (!res.ok || !data.success) { setErr(data.error || `儲存失敗:${res.status}`); return; }
|
||
onUpdated(block.id, draft);
|
||
// P0 #5b bug #4:edit 後若加了 @mira(之前沒)→ 觸發 mira reply
|
||
if (hasMiraMention(draft) && !hasMiraMention(block.content ?? '')) {
|
||
void triggerAiReply({
|
||
apiKey, postContent: draft, parentBlockId: block.id, pageName: docPageName,
|
||
});
|
||
}
|
||
setMode('view');
|
||
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
|
||
finally { setSaving(false); }
|
||
};
|
||
|
||
const submitReply = async () => {
|
||
if (saving) return;
|
||
const trimmed = draft.trim();
|
||
if (!trimmed) { cancel(); return; }
|
||
setSaving(true); setErr(null);
|
||
try {
|
||
const res = await fetch(KBDB_CREATE, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
api_key: apiKey,
|
||
content: trimmed, type: 'chat', parent_id: block.id,
|
||
user_id: MIRA_POST_USER_ID, source: 'mira-reply', page_name: docPageName,
|
||
}),
|
||
});
|
||
const data = (await res.json()) as { success?: boolean; data?: { id?: string }; error?: string };
|
||
if (!res.ok || !data.success || !data.data?.id) {
|
||
setErr(data.error || `留言失敗:${res.status}`); return;
|
||
}
|
||
onReplyAdded({
|
||
id: data.data.id, content: trimmed, type: 'chat', source: 'mira-reply',
|
||
user_id: MIRA_POST_USER_ID, parent_id: block.id, page_name: docPageName,
|
||
tags_json: '[]', refs_json: '[]', sort_order: 0,
|
||
created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
|
||
});
|
||
// P0 #5b:reply 內含 @mira → 觸發 mira 接龍(parent = 此 reply 形成 thread)
|
||
if (hasMiraMention(trimmed)) {
|
||
void triggerAiReply({
|
||
apiKey, postContent: trimmed, parentBlockId: data.data.id, pageName: docPageName,
|
||
});
|
||
}
|
||
setMode('view'); setDraft('');
|
||
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
|
||
finally { setSaving(false); }
|
||
};
|
||
|
||
const indent = block.parent_id ? 16 : 0;
|
||
const borderLeft = block.parent_id ? '2px solid var(--mira-line-soft)' : 'none';
|
||
|
||
const deleteBlock = async () => {
|
||
const ok = window.confirm('確定刪除這個 block?(不可復原)');
|
||
if (!ok) return;
|
||
await fetch(`${KBDB_BASE}/blocks/${block.id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${apiKey}` },
|
||
}).catch(() => null);
|
||
// 設為空內容讓父層感知(樂觀更新;refetch 才會真正消失)
|
||
onUpdated(block.id, '【已刪除】');
|
||
};
|
||
|
||
const menu: MenuItem[] = [
|
||
{ label: '編輯', onClick: startEdit },
|
||
{ label: '回覆', onClick: startReply },
|
||
{ label: '刪除', onClick: deleteBlock, danger: true },
|
||
];
|
||
|
||
return (
|
||
<div className="mira-block-line"
|
||
style={{ marginBottom: 8, paddingLeft: indent, borderLeft, position: 'relative', display: 'flex', alignItems: 'flex-start', gap: 8 }}
|
||
>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{mode === 'view' && (
|
||
block.content ? <MarkdownView text={block.content} /> :
|
||
<em style={{ color: 'var(--mira-text-3)' }}>(空)</em>
|
||
)}
|
||
{mode === 'edit' && (
|
||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={saveEdit} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" />
|
||
)}
|
||
{mode === 'reply' && (
|
||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆⋯⋯" />
|
||
)}
|
||
</div>
|
||
{mode === 'view' && <MoreMenu items={menu} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReplyLine({
|
||
reply,
|
||
apiKey,
|
||
docPageName,
|
||
onUpdated,
|
||
onReplyAdded,
|
||
repliesByParent,
|
||
depth,
|
||
}: {
|
||
reply: KBDBBlock;
|
||
apiKey: string;
|
||
docPageName: string;
|
||
onUpdated: (id: string, c: string) => void;
|
||
onReplyAdded: (b: KBDBBlock) => void;
|
||
repliesByParent: Map<string, KBDBBlock[]>;
|
||
depth: number;
|
||
}) {
|
||
const [mode, setMode] = useState<'view' | 'edit' | 'reply'>('view');
|
||
const [draft, setDraft] = useState(reply.content ?? '');
|
||
const [saving, setSaving] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const isAI = reply.source?.startsWith('ai-') ?? false;
|
||
|
||
const startEdit = () => { setDraft(reply.content ?? ''); setMode('edit'); setErr(null); };
|
||
const startReply = () => { setDraft(''); setMode('reply'); setErr(null); };
|
||
const cancel = () => { setMode('view'); setDraft(reply.content ?? ''); setErr(null); };
|
||
|
||
const save = async () => {
|
||
if (saving || draft === (reply.content ?? '')) { setMode('view'); return; }
|
||
setSaving(true); setErr(null);
|
||
try {
|
||
const res = await fetch(KBDB_PATCH, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ api_key: apiKey, block_id: reply.id, content: draft }),
|
||
});
|
||
const data = (await res.json()) as { success?: boolean; error?: string };
|
||
if (!res.ok || !data.success) { setErr(data.error || `儲存失敗:${res.status}`); return; }
|
||
onUpdated(reply.id, draft);
|
||
// P0 #5b bug #4: edit reply 加 @mira 也觸發
|
||
if (hasMiraMention(draft) && !hasMiraMention(reply.content ?? '')) {
|
||
void triggerAiReply({
|
||
apiKey, postContent: draft, parentBlockId: reply.id, pageName: docPageName,
|
||
});
|
||
}
|
||
setMode('view');
|
||
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
|
||
finally { setSaving(false); }
|
||
};
|
||
|
||
const submitReply = async () => {
|
||
if (saving) return;
|
||
const trimmed = draft.trim();
|
||
if (!trimmed) { cancel(); return; }
|
||
setSaving(true); setErr(null);
|
||
try {
|
||
const res = await fetch(KBDB_CREATE, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
api_key: apiKey,
|
||
content: trimmed, type: 'chat', parent_id: reply.id,
|
||
user_id: MIRA_POST_USER_ID, source: 'mira-reply', page_name: docPageName,
|
||
}),
|
||
});
|
||
const data = (await res.json()) as { success?: boolean; data?: { id?: string }; error?: string };
|
||
if (!res.ok || !data.success || !data.data?.id) {
|
||
setErr(data.error || `留言失敗:${res.status}`); return;
|
||
}
|
||
onReplyAdded({
|
||
id: data.data.id, content: trimmed, type: 'chat', source: 'mira-reply',
|
||
user_id: MIRA_POST_USER_ID, parent_id: reply.id, page_name: docPageName,
|
||
tags_json: '[]', refs_json: '[]', sort_order: 0,
|
||
created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
|
||
});
|
||
// P0 #5b:reply 內含 @mira → 觸發 mira 接龍(parent = 此 reply 形成 thread)
|
||
if (hasMiraMention(trimmed)) {
|
||
void triggerAiReply({
|
||
apiKey, postContent: trimmed, parentBlockId: data.data.id, pageName: docPageName,
|
||
});
|
||
}
|
||
setMode('view'); setDraft('');
|
||
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
|
||
finally { setSaving(false); }
|
||
};
|
||
|
||
const deleteReply = async () => {
|
||
const ok = window.confirm('確定刪除這則留言?(不可復原)');
|
||
if (!ok) return;
|
||
await fetch(`${KBDB_BASE}/blocks/${reply.id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${apiKey}` },
|
||
}).catch(() => null);
|
||
onUpdated(reply.id, '【已刪除】');
|
||
};
|
||
|
||
const menu: MenuItem[] = [
|
||
{ label: '回覆', onClick: startReply },
|
||
{ label: '編輯', onClick: startEdit },
|
||
{ label: '刪除', onClick: deleteReply, danger: true },
|
||
];
|
||
|
||
return (
|
||
<div className={`mira-reply-line${isAI ? ' is-ai' : ''}`}>
|
||
<span className="mira-reply-icon">{isAI ? '🤖' : '💬'}</span>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{mode === 'view' && (
|
||
<div className="mira-reply-content">
|
||
{reply.content ? <MarkdownView text={reply.content} /> :
|
||
<em style={{ color: 'var(--mira-text-3)' }}>(空)</em>}
|
||
</div>
|
||
)}
|
||
{mode === 'edit' && (
|
||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={save} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" />
|
||
)}
|
||
{mode === 'reply' && (
|
||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆這則留言⋯⋯" />
|
||
)}
|
||
{/* 巢狀留言 */}
|
||
{depth < 3 && (
|
||
<ReplyTree
|
||
replies={repliesByParent.get(reply.id) ?? []}
|
||
allReplies={repliesByParent}
|
||
apiKey={apiKey}
|
||
docPageName={docPageName}
|
||
onUpdated={onUpdated}
|
||
onReplyAdded={onReplyAdded}
|
||
depth={depth + 1}
|
||
/>
|
||
)}
|
||
</div>
|
||
{mode === 'view' && <MoreMenu items={menu} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReplyTree({
|
||
replies,
|
||
allReplies,
|
||
apiKey,
|
||
docPageName,
|
||
onUpdated,
|
||
onReplyAdded,
|
||
depth,
|
||
}: {
|
||
replies: KBDBBlock[];
|
||
allReplies: Map<string, KBDBBlock[]>;
|
||
apiKey: string;
|
||
docPageName: string;
|
||
onUpdated: (id: string, c: string) => void;
|
||
onReplyAdded: (b: KBDBBlock) => void;
|
||
depth: number;
|
||
}) {
|
||
if (replies.length === 0) return null;
|
||
const sorted = [...replies].sort((a, b) => toDate(a.created_at).getTime() - toDate(b.created_at).getTime());
|
||
return (
|
||
<div className="mira-reply-nested">
|
||
{sorted.map(r => (
|
||
<ReplyLine
|
||
key={r.id}
|
||
reply={r}
|
||
apiKey={apiKey}
|
||
docPageName={docPageName}
|
||
onUpdated={onUpdated}
|
||
onReplyAdded={onReplyAdded}
|
||
repliesByParent={allReplies}
|
||
depth={depth}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PageReplyComposer({
|
||
apiKey,
|
||
docPageName,
|
||
onSubmitted,
|
||
onCancel,
|
||
}: {
|
||
apiKey: string;
|
||
docPageName: string;
|
||
onSubmitted: (b: KBDBBlock) => void;
|
||
onCancel: () => void;
|
||
}) {
|
||
const [draft, setDraft] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
|
||
const submit = async () => {
|
||
if (saving) return;
|
||
const trimmed = draft.trim();
|
||
if (!trimmed) { onCancel(); return; }
|
||
setSaving(true); setErr(null);
|
||
try {
|
||
const res = await fetch(KBDB_CREATE, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
api_key: apiKey,
|
||
content: trimmed, type: 'chat',
|
||
user_id: MIRA_POST_USER_ID, source: 'mira-reply', page_name: docPageName,
|
||
}),
|
||
});
|
||
const data = (await res.json()) as { success?: boolean; data?: { id?: string }; error?: string };
|
||
if (!res.ok || !data.success || !data.data?.id) {
|
||
setErr(data.error || `留言失敗:${res.status}`); return;
|
||
}
|
||
onSubmitted({
|
||
id: data.data.id, content: trimmed, type: 'chat', source: 'mira-reply',
|
||
user_id: MIRA_POST_USER_ID, parent_id: null, page_name: docPageName,
|
||
tags_json: '[]', refs_json: '[]', sort_order: 0,
|
||
created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
|
||
});
|
||
setDraft('');
|
||
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
|
||
finally { setSaving(false); }
|
||
};
|
||
|
||
return (
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submit} onCancel={onCancel} saving={saving} err={err} submitLabel="留言" placeholder="寫留言⋯⋯" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EditingArea({
|
||
draft, setDraft, onSubmit, onCancel, saving, err, submitLabel, placeholder,
|
||
}: {
|
||
draft: string;
|
||
setDraft: (s: string) => void;
|
||
onSubmit: () => void;
|
||
onCancel: () => void;
|
||
saving: boolean;
|
||
err: string | null;
|
||
submitLabel: string;
|
||
placeholder?: string;
|
||
}) {
|
||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||
const popupTaRef = useRef<HTMLTextAreaElement>(null);
|
||
const [expanded, setExpanded] = useState(false);
|
||
return (
|
||
<>
|
||
<MarkdownToolbar textareaRef={taRef} value={draft} setValue={setDraft} />
|
||
<textarea
|
||
ref={taRef}
|
||
value={draft}
|
||
onChange={e => setDraft(e.target.value)}
|
||
onKeyDown={e => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); onSubmit(); return; }
|
||
if (e.key === 'Escape') { e.preventDefault(); onCancel(); return; }
|
||
handleTabIndent(e, draft, setDraft);
|
||
}}
|
||
autoFocus
|
||
rows={Math.max(2, draft.split('\n').length)}
|
||
disabled={saving}
|
||
placeholder={placeholder}
|
||
className="mira-edit-textarea"
|
||
/>
|
||
<div className="mira-edit-actions">
|
||
<button
|
||
type="button"
|
||
onClick={() => { setExpanded(true); setTimeout(() => popupTaRef.current?.focus(), 50); }}
|
||
className="mira-btn-ghost mira-composer-expand"
|
||
title="放大編輯"
|
||
aria-label="放大編輯"
|
||
>⇱</button>
|
||
{err && <span className="mira-msg-error">{err}</span>}
|
||
<span style={{ flex: 1 }} />
|
||
<span className="mira-kbd">⌘+Enter · Esc</span>
|
||
<button type="button" onClick={onCancel} disabled={saving} className="mira-btn-ghost">取消</button>
|
||
<button type="button" onClick={onSubmit} disabled={saving} className="mira-btn-primary">
|
||
{saving ? '處理中⋯' : submitLabel}
|
||
</button>
|
||
</div>
|
||
|
||
{/* P0 #5c: editing 也支援 popup 放大 */}
|
||
{expanded && (
|
||
<div
|
||
className="mira-composer-popup-backdrop"
|
||
onClick={(e) => { if (e.target === e.currentTarget) setExpanded(false); }}
|
||
>
|
||
<div className="mira-composer-popup">
|
||
<header className="mira-composer-popup-header">
|
||
<span className="mira-composer-popup-title">{submitLabel}</span>
|
||
<span style={{ flex: 1 }} />
|
||
<button type="button" onClick={() => setExpanded(false)} className="mira-btn-ghost" title="收起" aria-label="關閉">✕</button>
|
||
</header>
|
||
<textarea
|
||
ref={popupTaRef}
|
||
value={draft}
|
||
onChange={e => setDraft(e.target.value)}
|
||
onKeyDown={e => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); onSubmit(); setExpanded(false); return; }
|
||
if (e.key === 'Escape') { e.preventDefault(); setExpanded(false); return; }
|
||
handleTabIndent(e, draft, setDraft);
|
||
}}
|
||
placeholder={placeholder ?? '寫點什麼…(Esc 收起、⌘+Enter 送出)'}
|
||
disabled={saving}
|
||
className="mira-composer-popup-textarea"
|
||
/>
|
||
<div className="mira-composer-popup-actions">
|
||
<MarkdownToolbar textareaRef={popupTaRef} value={draft} setValue={setDraft} />
|
||
{err && <span className="mira-msg-error">{err}</span>}
|
||
<span style={{ flex: 1 }} />
|
||
<span className="mira-kbd">⌘+Enter</span>
|
||
<button
|
||
type="button"
|
||
disabled={saving}
|
||
onClick={() => { onSubmit(); setExpanded(false); }}
|
||
className="mira-btn-primary"
|
||
>
|
||
{saving ? '處理中⋯' : submitLabel}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Tab / Shift-Tab 處理:textarea 內按 Tab 縮排(不離開 textarea)
|
||
// - 無選取:游標位置插 2 空格(行內)/ 行首插 2 空格(行首)
|
||
// - 有選取:選取範圍內每行行首加/減 2 空格
|
||
// - Shift-Tab:減縮排
|
||
function handleTabIndent(
|
||
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||
value: string,
|
||
setValue: (v: string) => void,
|
||
): boolean {
|
||
if (e.key !== 'Tab') return false;
|
||
e.preventDefault();
|
||
const ta = e.currentTarget;
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
const indent = ' '; // 2 空格
|
||
const isShift = e.shiftKey;
|
||
|
||
// 找該 selection 涉及的所有行
|
||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||
const hasMultiLine = value.slice(start, end).includes('\n');
|
||
|
||
if (start === end && !isShift) {
|
||
// 無選取 + Tab:游標位置插 indent
|
||
const next = value.slice(0, start) + indent + value.slice(start);
|
||
setValue(next);
|
||
requestAnimationFrame(() => {
|
||
ta.selectionStart = ta.selectionEnd = start + indent.length;
|
||
});
|
||
return true;
|
||
}
|
||
|
||
// 有選取 或 Shift-Tab:對涉及的所有行操作
|
||
const beforeBlock = value.slice(0, lineStart);
|
||
const block = value.slice(lineStart, end);
|
||
const afterBlock = value.slice(end);
|
||
const lines = block.split('\n');
|
||
|
||
let delta = 0;
|
||
const newLines = lines.map((line, i) => {
|
||
if (isShift) {
|
||
// 減縮排:若行首是 indent 或 1 個 tab/space 開頭,去掉
|
||
if (line.startsWith(indent)) {
|
||
if (i === 0) delta -= indent.length;
|
||
else delta -= indent.length;
|
||
return line.slice(indent.length);
|
||
}
|
||
if (line.startsWith('\t')) {
|
||
if (i === 0) delta -= 1;
|
||
else delta -= 1;
|
||
return line.slice(1);
|
||
}
|
||
if (line.startsWith(' ')) {
|
||
if (i === 0) delta -= 1;
|
||
else delta -= 1;
|
||
return line.slice(1);
|
||
}
|
||
return line;
|
||
} else {
|
||
// 加縮排:行首加 indent
|
||
if (i === 0) delta += indent.length;
|
||
else delta += indent.length;
|
||
return indent + line;
|
||
}
|
||
});
|
||
|
||
const next = beforeBlock + newLines.join('\n') + afterBlock;
|
||
setValue(next);
|
||
requestAnimationFrame(() => {
|
||
if (hasMultiLine) {
|
||
ta.selectionStart = lineStart;
|
||
ta.selectionEnd = end + delta;
|
||
} else {
|
||
ta.selectionStart = ta.selectionEnd = end + delta;
|
||
}
|
||
});
|
||
return true;
|
||
}
|
||
|
||
// ─── Markdown toolbar(極簡,4 個按鈕)──────────────────────
|
||
|
||
function MarkdownToolbar({
|
||
textareaRef,
|
||
value,
|
||
setValue,
|
||
compact,
|
||
}: {
|
||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||
value: string;
|
||
setValue: (v: string) => void;
|
||
compact?: boolean;
|
||
}) {
|
||
const wrap = (before: string, after: string = before, placeholder = '') => {
|
||
const ta = textareaRef.current;
|
||
if (!ta) return;
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
const selected = value.slice(start, end);
|
||
const insert = selected || placeholder;
|
||
const next = value.slice(0, start) + before + insert + after + value.slice(end);
|
||
setValue(next);
|
||
requestAnimationFrame(() => {
|
||
ta.focus();
|
||
const pos = start + before.length;
|
||
ta.setSelectionRange(pos, pos + insert.length);
|
||
});
|
||
};
|
||
|
||
const linePrefix = (prefix: string) => {
|
||
const ta = textareaRef.current;
|
||
if (!ta) return;
|
||
const start = ta.selectionStart;
|
||
// 找該行行首
|
||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||
const next = value.slice(0, lineStart) + prefix + value.slice(lineStart);
|
||
setValue(next);
|
||
requestAnimationFrame(() => {
|
||
ta.focus();
|
||
ta.setSelectionRange(start + prefix.length, start + prefix.length);
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className={`mira-md-toolbar${compact ? ' compact' : ''}`}>
|
||
<button type="button" className="mira-md-btn" title="標題(# 開頭)" onClick={() => linePrefix('## ')}>
|
||
H
|
||
</button>
|
||
<button type="button" className="mira-md-btn" title="粗體(⌘+B)" onClick={() => wrap('**', '**', '粗體')}>
|
||
<strong>B</strong>
|
||
</button>
|
||
<button type="button" className="mira-md-btn" title="斜體(⌘+I)" onClick={() => wrap('*', '*', '斜體')}>
|
||
<em>I</em>
|
||
</button>
|
||
<button type="button" className="mira-md-btn" title="條列" onClick={() => linePrefix('- ')}>
|
||
•—
|
||
</button>
|
||
<button type="button" className="mira-md-btn" title="連結" onClick={() => wrap('[', '](url)', '文字')}>
|
||
🔗
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 視覺輔助:source / time / markdown ────────────────────
|
||
|
||
function inferSource(blocks: KBDBBlock[] | null, pageName?: string): string {
|
||
if (blocks && blocks.length > 0) {
|
||
const main = blocks.find(b => b.type !== 'chat' && b.source);
|
||
if (main?.source) return main.source;
|
||
}
|
||
// fallback:用 page_name 推測
|
||
if (pageName) {
|
||
if (pageName.startsWith('post-')) return 'km-writer-direct';
|
||
// Logseq daily journal 格式:YYYYMMDD(8 位數字)
|
||
if (/^\d{8}$/.test(pageName)) return 'logseq';
|
||
if (pageName.startsWith('km-writer')) return 'km-writer-direct';
|
||
if (pageName === 'MyDay' || pageName.includes('Mini me')) return 'logseq';
|
||
}
|
||
return 'logseq'; // 既有大部分資料來自 logseq sync
|
||
}
|
||
|
||
function SourceBadge({ source }: { source: string }) {
|
||
const s = source.toLowerCase();
|
||
let label = source;
|
||
let cls = 'logseq';
|
||
if (s.includes('logseq')) { label = 'Logseq'; cls = 'logseq'; }
|
||
else if (s.includes('km-writer') || s.includes('mobile') || s.includes('web')) { label = 'Web'; cls = 'mobile'; }
|
||
else if (s.includes('telegram') || s.includes('tg')) { label = 'Telegram'; cls = 'tg'; }
|
||
else if (s.includes('rss')) { label = 'RSS'; cls = 'rss'; }
|
||
else if (s.startsWith('ai-')) { label = 'AI'; cls = 'ai'; }
|
||
else if (s === 'unknown') { label = '?'; cls = ''; }
|
||
return (
|
||
<span className={`src-tag ${cls}`}>
|
||
<span className="src-dot" />
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function RelTime({ when }: { when: number | string }) {
|
||
const d = toDate(when);
|
||
const now = Date.now();
|
||
const diff = now - d.getTime();
|
||
let text: string;
|
||
if (diff < 60_000) text = '剛才';
|
||
else if (diff < 3600_000) text = `${Math.floor(diff / 60_000)} 分鐘前`;
|
||
else if (diff < 86400_000) text = `${Math.floor(diff / 3600_000)} 小時前`;
|
||
else if (diff < 7 * 86400_000) text = `${Math.floor(diff / 86400_000)} 天前`;
|
||
else text = d.toLocaleDateString('zh-TW', { year: 'numeric', month: 'numeric', day: 'numeric' });
|
||
return <span className="mira-rel-time" title={d.toLocaleString('zh-TW')}>{text}</span>;
|
||
}
|
||
|
||
// 用 react-markdown 渲染(GFM 支援:表格、刪除線、task list、autolink)
|
||
// + 預先 strip Logseq 專屬語法(屬性行、collapsed、id::、logseq.* 等)
|
||
// MarkdownView 與 stripLogseqMeta 已抽出至 _shared/markdown.tsx(給 wiki 頁面共用)
|
||
|
||
// ─── helpers ───────────────────────────────────────────────
|
||
|
||
function toDate(x: number | string): Date {
|
||
if (typeof x === 'number') return new Date(x * 1000);
|
||
const d = new Date(x);
|
||
return Number.isNaN(d.getTime()) ? new Date(0) : d;
|
||
}
|