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 (
{rows.map((n) => (
-
))}
);
}
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()}
>
{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)}
/>
)}
);
}