feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white 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-white 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 font-medium px-2 py-0.5 rounded hover:bg-red-50 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-white 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user