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

Loading…

; if (list.isError) return

Failed to load notes.

; const rows = list.data?.rows ?? []; if (rows.length === 0) return

(空)

; return ( ); } function NoteContentModal({ ownerId, folder, fileName, onClose, }: { ownerId: string; folder: string; fileName: string; onClose: () => void; }) { const note = useQuery<{ fm: Record; 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 (
e.stopPropagation()} >

{title}

{ownerId}/{folder}/{fileName}

{note.isLoading &&

Loading…

} {note.isError &&

読み込みに失敗しました。

} {note.data && note.data.body ? : note.data &&

(本文なし)

}
); } function ModeSelect({ mode, onChange, }: { mode: 'search' | 'inject'; onChange: (m: 'search' | 'inject') => void; }) { return ( ); } 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>(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({ 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 (
{/* My Folders */}

My Folders{' '} ({myFolders.length})

{subs.isLoading && (

Loading…

)} {subs.isError && (

Failed to load subscriptions.

)} {!subs.isLoading && !subs.isError && myFolders.length === 0 && (

まだ folder がありません

)}
    {myFolders.map((s) => { const key = `${s.publisher_user_id}/${s.folder}`; const isOpen = expanded.has(key); return (
  • {s.folder} subscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder, mode: m }) } />
    {isOpen && ( setOpenNote({ ownerId: s.publisher_user_id, folder: s.folder, fileName }) } /> )}
  • ); })}
{/* My Subscriptions */}

My Subscriptions{' '} ({otherSubs.length})

{!subs.isLoading && !subs.isError && otherSubs.length === 0 && (

購読中の他ユーザー folder はありません

)}
    {otherSubs.map((s) => { const key = `${s.publisher_user_id}/${s.folder}`; const isOpen = expanded.has(key); return (
  • {s.publisher_user_id}/{s.folder} subscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder, mode: m }) } />
    {isOpen && ( setOpenNote({ ownerId: s.publisher_user_id, folder: s.folder, fileName }) } /> )}
  • ); })}
{unsubscribe.isError && (

{(unsubscribe.error as Error).message}

)}
{/* Discover */}

Discover

setQ(e.target.value)} /> {discover.isLoading && (

Searching…

)} {discover.isError && (

Search failed.

)} {!discover.isLoading && !discover.isError && folderGroups.size === 0 && (

{q ? '結果なし' : '検索してフォルダーを探してください'}

)}
    {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 (
  • {g.owner_id}/{g.folder} {g.visibility} · {g.count} notes
    {alreadySubbed ? ( 購読中 ) : (
    )}
  • ); })}
{subscribe.isError && (

{(subscribe.error as Error).message}

)}
{/* Inject Preview */}

Inject Preview

現在 inject モードで購読中のノートが LLM コンテキストに挿入されます。

{preview.isLoading && (

Loading…

)} {preview.isError && (

Failed to load inject preview.

)} {!preview.isLoading && !preview.isError && ( <> {(preview.data?.items ?? []).length === 0 ? (

inject 対象のノートはありません

) : (
    {(preview.data?.items ?? []).map((it) => (
  • {it.owner_id}/{it.folder}/{it.file_name} {it.size_kb} KB
  • ))}
)}
Total:{' '} {preview.data?.total_kb ?? 0} KB {' '}/{' '} {preview.data?.budget_kb ?? 0} KB {' '}budget
)}
{openNote && ( setOpenNote(null)} /> )}
); }