maestro/ui/src/components/userfolder/SubscriptionsPanel.tsx
oss-sync 3848b5efd7
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (63a6e76)
2026-06-09 03:17:43 +00:00

538 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}