538 lines
20 KiB
TypeScript
538 lines
20 KiB
TypeScript
import { useState } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { MarkdownText } from '../../lib/markdown-text';
|
||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||
|
||
interface Subscription {
|
||
consumer_user_id: string;
|
||
publisher_user_id: string;
|
||
folder: string;
|
||
mode: 'search' | 'inject';
|
||
enabled: number;
|
||
}
|
||
|
||
interface DiscoverRow {
|
||
owner_id: string;
|
||
folder: string;
|
||
file_name: string;
|
||
title: string | null;
|
||
visibility: string;
|
||
mode_hint: string | null;
|
||
updated_at: number;
|
||
}
|
||
|
||
interface InjectItem {
|
||
owner_id: string;
|
||
folder: string;
|
||
file_name: string;
|
||
size_kb: number;
|
||
}
|
||
|
||
interface InjectPreview {
|
||
items: InjectItem[];
|
||
total_kb: number;
|
||
budget_kb: number;
|
||
per_note_max_kb: number;
|
||
}
|
||
|
||
function NotesListExpanded({
|
||
ownerId,
|
||
folder,
|
||
onSelectNote,
|
||
}: {
|
||
ownerId: string;
|
||
folder: string;
|
||
onSelectNote: (fileName: string) => void;
|
||
}) {
|
||
const list = useQuery<{ rows: DiscoverRow[] }>({
|
||
queryKey: ['notes-folder-list', ownerId, folder],
|
||
queryFn: async () => {
|
||
const r = await fetch(
|
||
`/api/notes/discover?owner_id=${encodeURIComponent(ownerId)}&folder=${encodeURIComponent(folder)}&limit=200`,
|
||
{ credentials: 'include' },
|
||
);
|
||
if (!r.ok) throw new Error(`${r.status}`);
|
||
return r.json();
|
||
},
|
||
staleTime: 30_000,
|
||
});
|
||
|
||
if (list.isLoading) return <p className="text-2xs text-slate-400 pl-3 py-1">Loading…</p>;
|
||
if (list.isError) return <p className="text-2xs text-red-500 pl-3 py-1">Failed to load notes.</p>;
|
||
const rows = list.data?.rows ?? [];
|
||
if (rows.length === 0) return <p className="text-2xs text-slate-400 pl-3 py-1">(空)</p>;
|
||
|
||
return (
|
||
<ul className="pl-3 pt-1 pb-1 space-y-0.5">
|
||
{rows.map((n) => (
|
||
<li key={n.file_name}>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelectNote(n.file_name)}
|
||
className="w-full text-left flex items-center gap-2 px-2 py-1 rounded text-2xs hover:bg-surface-2/60 transition-colors"
|
||
title={`${n.owner_id}/${n.folder}/${n.file_name}`}
|
||
>
|
||
<span className="text-slate-400 flex-shrink-0">📄</span>
|
||
<span className="flex-1 min-w-0 truncate text-slate-700">
|
||
{n.title || <span className="font-mono text-slate-500">{n.file_name}</span>}
|
||
</span>
|
||
{n.mode_hint && (
|
||
<span className="text-slate-400 text-[10px] font-mono">{n.mode_hint}</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
function NoteContentModal({
|
||
ownerId,
|
||
folder,
|
||
fileName,
|
||
onClose,
|
||
}: {
|
||
ownerId: string;
|
||
folder: string;
|
||
fileName: string;
|
||
onClose: () => void;
|
||
}) {
|
||
const note = useQuery<{ fm: Record<string, unknown>; body: string; content: string }>({
|
||
queryKey: ['notes-cross-user-file', ownerId, folder, fileName],
|
||
queryFn: async () => {
|
||
const r = await fetch(
|
||
`/api/notes/file?owner_id=${encodeURIComponent(ownerId)}&folder=${encodeURIComponent(folder)}&file_name=${encodeURIComponent(fileName)}`,
|
||
{ credentials: 'include' },
|
||
);
|
||
if (!r.ok) throw new Error(`${r.status}`);
|
||
return r.json();
|
||
},
|
||
staleTime: 30_000,
|
||
});
|
||
|
||
const title = (note.data?.fm.title as string | undefined) || fileName;
|
||
const backdrop = useBackdropClose(onClose);
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
|
||
{...backdrop}
|
||
>
|
||
<div
|
||
className="bg-surface rounded-md shadow-lg w-full max-w-3xl max-h-[85vh] flex flex-col overflow-hidden"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<header className="flex items-center justify-between border-b border-hairline px-4 py-3 flex-shrink-0">
|
||
<div className="min-w-0">
|
||
<h3 className="text-[13px] font-semibold text-slate-900 truncate">{title}</h3>
|
||
<p className="text-2xs text-slate-500 font-mono truncate">
|
||
{ownerId}/{folder}/{fileName}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label="閉じる"
|
||
className="px-2 py-1 text-slate-500 hover:text-slate-800 rounded hover:bg-surface-2"
|
||
>
|
||
×
|
||
</button>
|
||
</header>
|
||
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
||
{note.isLoading && <p className="text-[13px] text-slate-400">Loading…</p>}
|
||
{note.isError && <p className="text-[13px] text-red-500">読み込みに失敗しました。</p>}
|
||
{note.data && note.data.body
|
||
? <MarkdownText text={note.data.body} />
|
||
: note.data && <p className="text-[13px] text-slate-400 italic">(本文なし)</p>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ModeSelect({
|
||
mode,
|
||
onChange,
|
||
}: {
|
||
mode: 'search' | 'inject';
|
||
onChange: (m: 'search' | 'inject') => void;
|
||
}) {
|
||
return (
|
||
<select
|
||
className="border border-hairline rounded text-2xs px-1 py-0.5 bg-canvas focus:outline-none focus:ring-1 focus:ring-accent"
|
||
value={mode}
|
||
onChange={(e) => onChange(e.target.value as 'search' | 'inject')}
|
||
>
|
||
<option value="search">search</option>
|
||
<option value="inject">inject</option>
|
||
</select>
|
||
);
|
||
}
|
||
|
||
export function SubscriptionsPanel({ currentUserId }: { currentUserId: string }) {
|
||
const qc = useQueryClient();
|
||
const [q, setQ] = useState('');
|
||
// Track which (owner, folder) rows are expanded to show the notes list inline.
|
||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||
const toggleExpanded = (key: string) =>
|
||
setExpanded((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) next.delete(key);
|
||
else next.add(key);
|
||
return next;
|
||
});
|
||
// Open-modal target for note content preview.
|
||
const [openNote, setOpenNote] = useState<{ ownerId: string; folder: string; fileName: string } | null>(null);
|
||
|
||
const subs = useQuery<{ rows: Subscription[] }>({
|
||
queryKey: ['notes-subscriptions'],
|
||
queryFn: async () => {
|
||
const r = await fetch('/api/notes/subscriptions', { credentials: 'include' });
|
||
if (!r.ok) throw new Error(`${r.status}`);
|
||
return r.json();
|
||
},
|
||
staleTime: 30_000,
|
||
});
|
||
|
||
const discover = useQuery<{ rows: DiscoverRow[] }>({
|
||
queryKey: ['notes-discover', q],
|
||
queryFn: async () => {
|
||
const r = await fetch(`/api/notes/discover?q=${encodeURIComponent(q)}`, {
|
||
credentials: 'include',
|
||
});
|
||
if (!r.ok) throw new Error(`${r.status}`);
|
||
return r.json();
|
||
},
|
||
staleTime: 15_000,
|
||
});
|
||
|
||
const preview = useQuery<InjectPreview>({
|
||
queryKey: ['notes-inject-preview'],
|
||
queryFn: async () => {
|
||
const r = await fetch('/api/notes/inject-preview', { credentials: 'include' });
|
||
if (!r.ok) throw new Error(`${r.status}`);
|
||
return r.json();
|
||
},
|
||
staleTime: 30_000,
|
||
});
|
||
|
||
const subscribe = useMutation({
|
||
mutationFn: async ({
|
||
publisher,
|
||
folder,
|
||
mode,
|
||
}: {
|
||
publisher: string;
|
||
folder: string;
|
||
mode: 'search' | 'inject';
|
||
}) => {
|
||
const r = await fetch('/api/notes/subscriptions', {
|
||
method: 'PUT',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ publisher_user_id: publisher, folder, mode, enabled: true }),
|
||
});
|
||
if (!r.ok) {
|
||
const j = await r.json().catch(() => ({ error: 'failed' }));
|
||
throw new Error((j as { error?: string }).error ?? 'failed');
|
||
}
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['notes-subscriptions'] });
|
||
qc.invalidateQueries({ queryKey: ['notes-inject-preview'] });
|
||
qc.invalidateQueries({ queryKey: ['notes-discover'] });
|
||
},
|
||
});
|
||
|
||
const unsubscribe = useMutation({
|
||
mutationFn: async ({ publisher, folder }: { publisher: string; folder: string }) => {
|
||
const r = await fetch(
|
||
`/api/notes/subscriptions?publisher_user_id=${encodeURIComponent(publisher)}&folder=${encodeURIComponent(folder)}`,
|
||
{ method: 'DELETE', credentials: 'include' },
|
||
);
|
||
if (!r.ok) throw new Error(`${r.status}`);
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['notes-subscriptions'] });
|
||
qc.invalidateQueries({ queryKey: ['notes-inject-preview'] });
|
||
},
|
||
});
|
||
|
||
// Group discover rows by (owner_id, folder)
|
||
const folderGroups = new Map<
|
||
string,
|
||
{ owner_id: string; folder: string; count: number; visibility: string }
|
||
>();
|
||
for (const row of discover.data?.rows ?? []) {
|
||
const key = `${row.owner_id}/${row.folder}`;
|
||
const existing = folderGroups.get(key);
|
||
if (existing) {
|
||
existing.count++;
|
||
} else {
|
||
folderGroups.set(key, {
|
||
owner_id: row.owner_id,
|
||
folder: row.folder,
|
||
count: 1,
|
||
visibility: row.visibility,
|
||
});
|
||
}
|
||
}
|
||
|
||
const myFolders = (subs.data?.rows ?? []).filter(
|
||
(s) => s.publisher_user_id === currentUserId,
|
||
);
|
||
const otherSubs = (subs.data?.rows ?? []).filter(
|
||
(s) => s.publisher_user_id !== currentUserId,
|
||
);
|
||
|
||
return (
|
||
<div className="h-full overflow-y-auto">
|
||
<div className="max-w-2xl mx-auto px-6 py-6 space-y-8">
|
||
|
||
{/* My Folders */}
|
||
<section>
|
||
<h3 className="text-[13px] font-semibold text-slate-800 mb-2">
|
||
My Folders{' '}
|
||
<span className="text-slate-400 font-normal">({myFolders.length})</span>
|
||
</h3>
|
||
{subs.isLoading && (
|
||
<p className="text-[13px] text-slate-400">Loading…</p>
|
||
)}
|
||
{subs.isError && (
|
||
<p className="text-[13px] text-red-500">Failed to load subscriptions.</p>
|
||
)}
|
||
{!subs.isLoading && !subs.isError && myFolders.length === 0 && (
|
||
<p className="text-[13px] text-slate-400">まだ folder がありません</p>
|
||
)}
|
||
<ul className="space-y-1">
|
||
{myFolders.map((s) => {
|
||
const key = `${s.publisher_user_id}/${s.folder}`;
|
||
const isOpen = expanded.has(key);
|
||
return (
|
||
<li
|
||
key={key}
|
||
className="rounded-md bg-surface-2/40 border border-hairline overflow-hidden"
|
||
>
|
||
<div className="flex items-center gap-2 px-3 py-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleExpanded(key)}
|
||
aria-label={isOpen ? '折りたたむ' : '展開'}
|
||
className="text-slate-500 hover:text-slate-800 text-2xs w-4"
|
||
>
|
||
{isOpen ? '▼' : '▶'}
|
||
</button>
|
||
<span className="flex-1 text-[13px] font-mono text-slate-700">{s.folder}</span>
|
||
<ModeSelect
|
||
mode={s.mode}
|
||
onChange={(m) =>
|
||
subscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder, mode: m })
|
||
}
|
||
/>
|
||
</div>
|
||
{isOpen && (
|
||
<NotesListExpanded
|
||
ownerId={s.publisher_user_id}
|
||
folder={s.folder}
|
||
onSelectNote={(fileName) =>
|
||
setOpenNote({ ownerId: s.publisher_user_id, folder: s.folder, fileName })
|
||
}
|
||
/>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</section>
|
||
|
||
{/* My Subscriptions */}
|
||
<section>
|
||
<h3 className="text-[13px] font-semibold text-slate-800 mb-2">
|
||
My Subscriptions{' '}
|
||
<span className="text-slate-400 font-normal">({otherSubs.length})</span>
|
||
</h3>
|
||
{!subs.isLoading && !subs.isError && otherSubs.length === 0 && (
|
||
<p className="text-[13px] text-slate-400">購読中の他ユーザー folder はありません</p>
|
||
)}
|
||
<ul className="space-y-1">
|
||
{otherSubs.map((s) => {
|
||
const key = `${s.publisher_user_id}/${s.folder}`;
|
||
const isOpen = expanded.has(key);
|
||
return (
|
||
<li
|
||
key={key}
|
||
className="rounded-md bg-surface-2/40 border border-hairline overflow-hidden"
|
||
>
|
||
<div className="flex items-center gap-2 px-3 py-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleExpanded(key)}
|
||
aria-label={isOpen ? '折りたたむ' : '展開'}
|
||
className="text-slate-500 hover:text-slate-800 text-2xs w-4"
|
||
>
|
||
{isOpen ? '▼' : '▶'}
|
||
</button>
|
||
<span className="flex-1 text-[13px] font-mono text-slate-700">
|
||
{s.publisher_user_id}/{s.folder}
|
||
</span>
|
||
<ModeSelect
|
||
mode={s.mode}
|
||
onChange={(m) =>
|
||
subscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder, mode: m })
|
||
}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="text-2xs text-red-600 hover:text-red-800 dark:hover:text-red-300 font-medium px-2 py-0.5 rounded hover:bg-red-50 dark:hover:bg-red-500/15 transition-colors"
|
||
onClick={() =>
|
||
unsubscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder })
|
||
}
|
||
disabled={unsubscribe.isPending}
|
||
>
|
||
Unsubscribe
|
||
</button>
|
||
</div>
|
||
{isOpen && (
|
||
<NotesListExpanded
|
||
ownerId={s.publisher_user_id}
|
||
folder={s.folder}
|
||
onSelectNote={(fileName) =>
|
||
setOpenNote({ ownerId: s.publisher_user_id, folder: s.folder, fileName })
|
||
}
|
||
/>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
{unsubscribe.isError && (
|
||
<p className="mt-1 text-2xs text-red-600">{(unsubscribe.error as Error).message}</p>
|
||
)}
|
||
</section>
|
||
|
||
{/* Discover */}
|
||
<section>
|
||
<h3 className="text-[13px] font-semibold text-slate-800 mb-2">Discover</h3>
|
||
<input
|
||
className="border border-hairline rounded px-2 py-1.5 mb-3 w-full text-[13px] bg-canvas focus:outline-none focus:ring-1 focus:ring-accent placeholder:text-slate-400"
|
||
placeholder="Search by title, tag, body…"
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
/>
|
||
{discover.isLoading && (
|
||
<p className="text-[13px] text-slate-400">Searching…</p>
|
||
)}
|
||
{discover.isError && (
|
||
<p className="text-[13px] text-red-500">Search failed.</p>
|
||
)}
|
||
{!discover.isLoading && !discover.isError && folderGroups.size === 0 && (
|
||
<p className="text-[13px] text-slate-400">
|
||
{q ? '結果なし' : '検索してフォルダーを探してください'}
|
||
</p>
|
||
)}
|
||
<ul className="space-y-2">
|
||
{Array.from(folderGroups.values()).map((g) => {
|
||
const alreadySubbed = (subs.data?.rows ?? []).some(
|
||
(s) => s.publisher_user_id === g.owner_id && s.folder === g.folder,
|
||
);
|
||
return (
|
||
<li
|
||
key={`${g.owner_id}/${g.folder}`}
|
||
className="border border-hairline rounded-md px-3 py-2 bg-surface-2/30"
|
||
>
|
||
<div className="flex items-center gap-2 mb-1.5">
|
||
<span className="flex-1 text-[13px] font-mono text-slate-700">
|
||
{g.owner_id}/{g.folder}
|
||
</span>
|
||
<span className="text-2xs text-slate-400">
|
||
{g.visibility} · {g.count} notes
|
||
</span>
|
||
</div>
|
||
{alreadySubbed ? (
|
||
<span className="text-2xs text-slate-400 italic">購読中</span>
|
||
) : (
|
||
<div className="flex gap-2">
|
||
<button
|
||
type="button"
|
||
className="text-2xs bg-surface-2 border border-hairline px-2 py-0.5 rounded hover:bg-slate-100 transition-colors disabled:opacity-50"
|
||
disabled={subscribe.isPending}
|
||
onClick={() =>
|
||
subscribe.mutate({ publisher: g.owner_id, folder: g.folder, mode: 'search' })
|
||
}
|
||
>
|
||
Subscribe (search)
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="text-2xs bg-surface-2 border border-hairline px-2 py-0.5 rounded hover:bg-slate-100 transition-colors disabled:opacity-50"
|
||
disabled={subscribe.isPending}
|
||
onClick={() =>
|
||
subscribe.mutate({ publisher: g.owner_id, folder: g.folder, mode: 'inject' })
|
||
}
|
||
>
|
||
Subscribe (inject)
|
||
</button>
|
||
</div>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
{subscribe.isError && (
|
||
<p className="mt-1 text-2xs text-red-600">{(subscribe.error as Error).message}</p>
|
||
)}
|
||
</section>
|
||
|
||
{/* Inject Preview */}
|
||
<section>
|
||
<h3 className="text-[13px] font-semibold text-slate-800 mb-1">Inject Preview</h3>
|
||
<p className="text-2xs text-slate-500 mb-2">
|
||
現在 inject モードで購読中のノートが LLM コンテキストに挿入されます。
|
||
</p>
|
||
{preview.isLoading && (
|
||
<p className="text-[13px] text-slate-400">Loading…</p>
|
||
)}
|
||
{preview.isError && (
|
||
<p className="text-[13px] text-red-500">Failed to load inject preview.</p>
|
||
)}
|
||
{!preview.isLoading && !preview.isError && (
|
||
<>
|
||
{(preview.data?.items ?? []).length === 0 ? (
|
||
<p className="text-[13px] text-slate-400">inject 対象のノートはありません</p>
|
||
) : (
|
||
<ul className="space-y-0.5 mb-2">
|
||
{(preview.data?.items ?? []).map((it) => (
|
||
<li
|
||
key={`${it.owner_id}/${it.folder}/${it.file_name}`}
|
||
className="flex items-center gap-2 text-[13px] font-mono text-slate-700"
|
||
>
|
||
<span className="flex-1">{it.owner_id}/{it.folder}/{it.file_name}</span>
|
||
<span className="text-slate-400 text-2xs">{it.size_kb} KB</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<div className="text-2xs text-slate-500 pt-1 border-t border-hairline">
|
||
Total:{' '}
|
||
<span className="font-semibold text-slate-700">{preview.data?.total_kb ?? 0} KB</span>
|
||
{' '}/{' '}
|
||
<span className="font-semibold text-slate-700">{preview.data?.budget_kb ?? 0} KB</span>
|
||
{' '}budget
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
|
||
</div>
|
||
{openNote && (
|
||
<NoteContentModal
|
||
ownerId={openNote.ownerId}
|
||
folder={openNote.folder}
|
||
fileName={openNote.fileName}
|
||
onClose={() => setOpenNote(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|