feat: unify research in profile-bound decks with TweetDeck-style UI
This commit is contained in:
@@ -1,17 +1,66 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useListChoices } from '#/features/posts/use-list-choices'
|
||||
import { columnSchema, type DeckColumn } from './model'
|
||||
|
||||
function ListChoices({
|
||||
profileName,
|
||||
onSelect,
|
||||
}: {
|
||||
profileName: string
|
||||
onSelect: (id: string) => void
|
||||
}) {
|
||||
const lists = useListChoices(profileName)
|
||||
if (lists.isPending) return <p role="status">リストを取得しています…</p>
|
||||
if (lists.isError)
|
||||
return (
|
||||
<p role="alert">
|
||||
リストを取得できませんでした。
|
||||
<button type="button" onClick={() => void lists.refetch()}>
|
||||
再試行
|
||||
</button>
|
||||
</p>
|
||||
)
|
||||
return (
|
||||
<section
|
||||
className="deck-list-choices"
|
||||
aria-label="このプロファイルのリスト"
|
||||
>
|
||||
{lists.data.length === 0 ? (
|
||||
<p>リストはありません。URLまたはIDでも指定できます。</p>
|
||||
) : (
|
||||
lists.data.map((list) => (
|
||||
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
|
||||
{list.name}
|
||||
{list.isPrivate ? ' · 非公開' : ''}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ColumnEditor({
|
||||
column,
|
||||
profiles,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
column?: DeckColumn
|
||||
profiles: string[]
|
||||
onSave: (column: DeckColumn) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const formId = useId()
|
||||
const [error, setError] = useState<string>()
|
||||
const [kind, setKind] = useState<DeckColumn['source']['kind']>(
|
||||
column?.source.kind ?? 'search',
|
||||
)
|
||||
const [profileName, setProfileName] = useState(
|
||||
column?.profileName ?? profiles[0] ?? '',
|
||||
)
|
||||
const [target, setTarget] = useState(
|
||||
column && column.source.kind !== 'search' ? column.source.target : '',
|
||||
)
|
||||
return (
|
||||
<form
|
||||
className="deck-editor"
|
||||
@@ -19,28 +68,34 @@ export function ColumnEditor({
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const data = new FormData(event.currentTarget)
|
||||
const source =
|
||||
kind === 'search'
|
||||
? {
|
||||
platform: 'twitter',
|
||||
kind,
|
||||
query: data.get('query'),
|
||||
product: data.get('product'),
|
||||
following: data.get('following') === 'on',
|
||||
}
|
||||
: { platform: 'twitter', kind, target }
|
||||
const parsed = columnSchema.safeParse({
|
||||
// getRandomValues also works on HTTP tailnet origins.
|
||||
id:
|
||||
column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
|
||||
title: data.get('title'),
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
query: data.get('query'),
|
||||
product: data.get('product'),
|
||||
following: data.get('following') === 'on',
|
||||
},
|
||||
profileName,
|
||||
source,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
if (!parsed.success || !profiles.includes(profileName)) {
|
||||
setError(
|
||||
'カラム名と検索条件を確認してください。検索語はフォロー条件を含め512文字以内です。',
|
||||
parsed.success
|
||||
? '接続プロファイルを選択してください。'
|
||||
: parsed.error.issues[0]?.message,
|
||||
)
|
||||
return
|
||||
}
|
||||
onSave(parsed.data)
|
||||
}}
|
||||
>
|
||||
<h2>{column ? 'カラムを編集' : '調べる観点を追加'}</h2>
|
||||
<div className="deck-editor-fields">
|
||||
<label htmlFor={`${formId}-title`}>
|
||||
カラム名
|
||||
@@ -49,52 +104,133 @@ export function ColumnEditor({
|
||||
name="title"
|
||||
defaultValue={column?.title ?? ''}
|
||||
maxLength={80}
|
||||
placeholder="例:日本語での反応"
|
||||
placeholder="例:発表元の投稿"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-query`}>
|
||||
Twitterの検索条件
|
||||
<input
|
||||
id={`${formId}-query`}
|
||||
name="query"
|
||||
defaultValue={column?.source.query ?? ''}
|
||||
maxLength={512}
|
||||
placeholder="例:WebMCP lang:ja"
|
||||
required
|
||||
aria-describedby={`${formId}-help`}
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-product`}>
|
||||
表示順
|
||||
<label htmlFor={`${formId}-profile`}>
|
||||
接続プロファイル
|
||||
<select
|
||||
id={`${formId}-product`}
|
||||
name="product"
|
||||
defaultValue={column?.source.product ?? 'Latest'}
|
||||
id={`${formId}-profile`}
|
||||
value={profileName}
|
||||
onChange={(event) => setProfileName(event.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="Latest">最新</option>
|
||||
<option value="Top">話題</option>
|
||||
<option value="" disabled>
|
||||
選択してください
|
||||
</option>
|
||||
{profileName && !profiles.includes(profileName) ? (
|
||||
<option value={profileName} disabled>
|
||||
{profileName}(利用不可)
|
||||
</option>
|
||||
) : null}
|
||||
{profiles.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-kind`}>
|
||||
カラムの種類
|
||||
<select
|
||||
id={`${formId}-kind`}
|
||||
value={kind}
|
||||
onChange={(event) => {
|
||||
setKind(event.target.value as typeof kind)
|
||||
setTarget('')
|
||||
}}
|
||||
>
|
||||
<option value="search">検索</option>
|
||||
<option value="user">ユーザー投稿</option>
|
||||
<option value="list">リスト</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="deck-help" id={`${formId}-help`}>
|
||||
from:、lang:、since: など、Twitterの検索構文を使えます。
|
||||
{kind === 'search' ? (
|
||||
<div className="deck-source-fields" key="search">
|
||||
<label htmlFor={`${formId}-query`}>
|
||||
Twitterの検索条件
|
||||
<input
|
||||
id={`${formId}-query`}
|
||||
name="query"
|
||||
defaultValue={
|
||||
column?.source.kind === 'search' ? column.source.query : ''
|
||||
}
|
||||
maxLength={512}
|
||||
placeholder="例:WebMCP lang:ja"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-product`}>
|
||||
表示順
|
||||
<select
|
||||
id={`${formId}-product`}
|
||||
name="product"
|
||||
defaultValue={
|
||||
column?.source.kind === 'search'
|
||||
? column.source.product
|
||||
: 'Latest'
|
||||
}
|
||||
>
|
||||
<option value="Latest">最新</option>
|
||||
<option value="Top">話題</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="deck-help">
|
||||
from:、lang:、since: など、Twitterの検索構文を使えます。
|
||||
</p>
|
||||
<label className="deck-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="following"
|
||||
defaultChecked={
|
||||
column?.source.kind === 'search' && column.source.following
|
||||
}
|
||||
/>
|
||||
フォロー中のみ
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="deck-source-fields" key={kind}>
|
||||
<label htmlFor={`${formId}-target`}>
|
||||
{kind === 'user' ? 'ユーザー' : 'リスト'}
|
||||
<input
|
||||
id={`${formId}-target`}
|
||||
value={target}
|
||||
onChange={(event) => setTarget(event.target.value)}
|
||||
placeholder={
|
||||
kind === 'user'
|
||||
? '@handle またはプロフィールURL'
|
||||
: 'リストのURLまたはID'
|
||||
}
|
||||
maxLength={256}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{kind === 'list' && profiles.includes(profileName) ? (
|
||||
<ListChoices
|
||||
key={profileName}
|
||||
profileName={profileName}
|
||||
onSelect={setTarget}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<p className="deck-help">
|
||||
このカラムは選択した接続プロファイルで取得します。
|
||||
</p>
|
||||
<label className="deck-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="following"
|
||||
defaultChecked={column?.source.following ?? false}
|
||||
/>
|
||||
フォロー中のみ
|
||||
</label>
|
||||
{error ? (
|
||||
<p role="alert" className="deck-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="deck-actions">
|
||||
<button type="submit" className="deck-primary">
|
||||
<button
|
||||
type="submit"
|
||||
className="deck-primary"
|
||||
disabled={!profiles.includes(profileName)}
|
||||
>
|
||||
{column ? '変更を保存' : '追加して検索'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel}>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { FeedRequest } from '#/features/posts/use-post-feed'
|
||||
import type { DeckColumn } from './model'
|
||||
|
||||
export function columnRequest(column: DeckColumn): FeedRequest {
|
||||
const { platform: _, ...source } = column.source
|
||||
return { ...source, profileName: column.profileName }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useWebMCP } from 'usewebmcp'
|
||||
import { z } from 'zod'
|
||||
import { mapTwitterPost } from '#/features/platforms/twitter'
|
||||
import { flattenPostPages } from '#/features/posts/page'
|
||||
import { PostLoadError, type usePostFeed } from '#/features/posts/use-post-feed'
|
||||
import type { DeckColumn } from './model'
|
||||
import { useWebMCPSupported } from './use-webmcp-supported'
|
||||
import { toolResult } from './webmcp-contracts'
|
||||
|
||||
type ColumnFeed = { column: DeckColumn; query: ReturnType<typeof usePostFeed> }
|
||||
export type ColumnRegistry = Map<string, { current: ColumnFeed }>
|
||||
|
||||
export function useRegisterColumn(
|
||||
registry: ColumnRegistry,
|
||||
column: DeckColumn,
|
||||
query: ReturnType<typeof usePostFeed>,
|
||||
) {
|
||||
const entry = useRef({ column, query })
|
||||
entry.current = { column, query }
|
||||
useEffect(() => {
|
||||
registry.set(column.id, entry)
|
||||
return () => {
|
||||
if (registry.get(column.id) === entry) registry.delete(column.id)
|
||||
}
|
||||
}, [registry, column.id])
|
||||
}
|
||||
const columnInput = z.object({ columnId: z.string().min(1) }).strict()
|
||||
const readInput = columnInput.extend({
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(50).default(20),
|
||||
})
|
||||
|
||||
function read(feed: ColumnFeed, offset = 0, limit = 20) {
|
||||
const posts = flattenPostPages(feed.query.data?.pages ?? [])
|
||||
const selected = posts.slice(offset, offset + limit)
|
||||
return {
|
||||
column: feed.column,
|
||||
status: feed.query.isPending
|
||||
? 'loading'
|
||||
: feed.query.isError
|
||||
? 'error'
|
||||
: 'ready',
|
||||
loading: feed.query.isFetching,
|
||||
error:
|
||||
feed.query.error instanceof PostLoadError
|
||||
? feed.query.error.detail
|
||||
: null,
|
||||
posts: selected.map(mapTwitterPost),
|
||||
loadedCount: posts.length,
|
||||
offset,
|
||||
nextOffset:
|
||||
offset + selected.length < posts.length ? offset + selected.length : null,
|
||||
hasMore: Boolean(feed.query.hasNextPage),
|
||||
}
|
||||
}
|
||||
|
||||
export function useColumnTools(registry: ColumnRegistry, ready: boolean) {
|
||||
const supported = useWebMCPSupported()
|
||||
const enabled = supported && ready
|
||||
function requireColumn(id: string) {
|
||||
const entry = registry.get(id)
|
||||
if (!entry)
|
||||
throw new Error(
|
||||
'Column is not loaded in the active deck. Select its deck and wait for it to render.',
|
||||
)
|
||||
return entry
|
||||
}
|
||||
useWebMCP({
|
||||
name: 'get_column_posts',
|
||||
description:
|
||||
'Read a slice of posts already loaded in a column of the active deck. Returns bound source/profile, loading/errors, original URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation. No network request.',
|
||||
inputSchema: readInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: true, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
const { columnId, offset, limit } = readInput.parse(input)
|
||||
return read(requireColumn(columnId).current, offset, limit)
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'load_more_column',
|
||||
description:
|
||||
'Load or retry one continuation page of a column in the active deck using its bound profile. Joins concurrent pagination. Returns newly appended posts and nextOffset if more loaded posts remain. Wait for the initial load before calling.',
|
||||
inputSchema: columnInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: false, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(async () => {
|
||||
const { columnId } = columnInput.parse(input)
|
||||
const entry = requireColumn(columnId)
|
||||
const { column, query } = entry.current
|
||||
const identity = JSON.stringify(column)
|
||||
if (query.isPending || (query.isFetching && !query.isFetchingNextPage))
|
||||
throw new Error('Wait for the column to finish loading.')
|
||||
if (query.isError && !query.isFetchNextPageError) throw query.error
|
||||
const offset = flattenPostPages(query.data?.pages ?? []).length
|
||||
if (!query.hasNextPage) return read(entry.current, offset)
|
||||
const result = await query.fetchNextPage({ cancelRefetch: false })
|
||||
if (
|
||||
registry.get(columnId) !== entry ||
|
||||
JSON.stringify(entry.current.column) !== identity
|
||||
)
|
||||
throw new Error(
|
||||
'The deck or column changed while loading. Read the current deck first.',
|
||||
)
|
||||
if (result.isError) throw result.error
|
||||
return read({ column, query: result }, offset)
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Icon } from '#/components/icon'
|
||||
import { mapTwitterPost } from '#/features/platforms/twitter'
|
||||
import { flattenPostPages } from '#/features/posts/page'
|
||||
import { PostLoadError, usePostFeed } from '#/features/posts/use-post-feed'
|
||||
import { columnRequest } from './column-request'
|
||||
import { type ColumnRegistry, useRegisterColumn } from './column-tools'
|
||||
import type { DeckColumn } from './model'
|
||||
import { ResearchPostCard } from './research-post-card'
|
||||
|
||||
@@ -11,6 +14,7 @@ export function ResearchColumn({
|
||||
onMove,
|
||||
first,
|
||||
last,
|
||||
registry,
|
||||
}: {
|
||||
column: DeckColumn
|
||||
onEdit: () => void
|
||||
@@ -18,13 +22,10 @@ export function ResearchColumn({
|
||||
onMove: (direction: -1 | 1) => void
|
||||
first: boolean
|
||||
last: boolean
|
||||
registry: ColumnRegistry
|
||||
}) {
|
||||
const query = usePostFeed({
|
||||
kind: 'search',
|
||||
query: column.source.query,
|
||||
product: column.source.product,
|
||||
following: column.source.following,
|
||||
})
|
||||
const query = usePostFeed(columnRequest(column))
|
||||
useRegisterColumn(registry, column, query)
|
||||
const posts = flattenPostPages(query.data?.pages ?? []).map(mapTwitterPost)
|
||||
const error =
|
||||
query.error instanceof PostLoadError
|
||||
@@ -34,50 +35,85 @@ export function ResearchColumn({
|
||||
return (
|
||||
<section className="deck-column" aria-labelledby={headingId}>
|
||||
<header className="deck-column-header">
|
||||
<div className="deck-column-label">
|
||||
<span>Twitter</span>
|
||||
<span>
|
||||
{column.source.product === 'Latest' ? '最新' : '話題'}
|
||||
{column.source.following ? ' · フォロー中' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<h2 id={headingId}>{column.title}</h2>
|
||||
<p className="deck-query">{column.source.query}</p>
|
||||
<div className="deck-actions">
|
||||
<button type="button" onClick={onEdit}>
|
||||
編集
|
||||
</button>
|
||||
<div className="deck-column-title">
|
||||
<Icon name={column.source.kind} />
|
||||
<h2 id={headingId}>{column.title}</h2>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="更新"
|
||||
title="更新"
|
||||
type="button"
|
||||
onClick={() => void query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
更新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(-1)}
|
||||
disabled={first}
|
||||
aria-label={`${column.title}を左へ`}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(1)}
|
||||
disabled={last}
|
||||
aria-label={`${column.title}を右へ`}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={`${column.title}を削除`}
|
||||
>
|
||||
削除
|
||||
<Icon name="refresh" />
|
||||
</button>
|
||||
<details className="column-menu">
|
||||
<summary
|
||||
className="icon-button"
|
||||
aria-label={`${column.title}の操作`}
|
||||
title="カラムの操作"
|
||||
>
|
||||
<Icon name="more" />
|
||||
</summary>
|
||||
<div className="column-menu-panel">
|
||||
<button type="button" onClick={onEdit}>
|
||||
編集
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onMove(-1)
|
||||
event.currentTarget
|
||||
.closest('details')
|
||||
?.removeAttribute('open')
|
||||
}}
|
||||
disabled={first}
|
||||
aria-label={`${column.title}を左へ`}
|
||||
>
|
||||
← 左へ移動
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onMove(1)
|
||||
event.currentTarget
|
||||
.closest('details')
|
||||
?.removeAttribute('open')
|
||||
}}
|
||||
disabled={last}
|
||||
aria-label={`${column.title}を右へ`}
|
||||
>
|
||||
右へ移動 →
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={onRemove}
|
||||
aria-label={`${column.title}を削除`}
|
||||
>
|
||||
カラムを削除
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div className="deck-column-label">
|
||||
<span title={`接続プロファイル: ${column.profileName}`}>
|
||||
{column.profileName}
|
||||
</span>
|
||||
<span>
|
||||
{column.source.kind === 'search'
|
||||
? `${column.source.product === 'Latest' ? '最新' : '話題'}${column.source.following ? ' · フォロー中' : ''}`
|
||||
: column.source.kind === 'user'
|
||||
? 'ユーザー投稿'
|
||||
: 'リスト'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="deck-query">
|
||||
{column.source.kind === 'search'
|
||||
? column.source.query
|
||||
: column.source.target}
|
||||
</p>
|
||||
<p className="deck-column-status" role="status">
|
||||
{query.isFetching
|
||||
? '投稿を取得しています…'
|
||||
|
||||
@@ -1,33 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import { useRef, useState } from 'react'
|
||||
import { AppShell } from '#/components/app-shell'
|
||||
import { Dialog } from '#/components/dialog'
|
||||
import { Icon } from '#/components/icon'
|
||||
import { loadProfiles } from '#/features/profiles/server-functions'
|
||||
import { ColumnEditor } from './column-editor'
|
||||
import { type ColumnRegistry, useColumnTools } from './column-tools'
|
||||
import { ResearchColumn } from './deck-column'
|
||||
import { DeckSwitcher } from './deck-switcher'
|
||||
import { type DeckColumn, MAX_COLUMNS, moveColumn } from './model'
|
||||
import { useDeck } from './use-deck'
|
||||
import { useDeckTools } from './webmcp-tools'
|
||||
import './decks.css'
|
||||
|
||||
export function DeckPage() {
|
||||
const { deck, ready, storageError, save } = useDeck()
|
||||
const registry = useRef<ColumnRegistry>(new Map()).current
|
||||
const {
|
||||
deck,
|
||||
workspace,
|
||||
ready,
|
||||
storageError,
|
||||
save,
|
||||
create,
|
||||
select,
|
||||
remove,
|
||||
getWorkspace,
|
||||
} = useDeck()
|
||||
const fetchProfiles = useServerFn(loadProfiles)
|
||||
const profiles = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => fetchProfiles(),
|
||||
retry: false,
|
||||
})
|
||||
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
useColumnTools(registry, ready)
|
||||
const [removed, setRemoved] = useState<{
|
||||
column: DeckColumn
|
||||
index: number
|
||||
}>()
|
||||
|
||||
useDeckTools({
|
||||
deck,
|
||||
ready,
|
||||
storageError,
|
||||
save,
|
||||
onApplied: () => {
|
||||
setEditing(null)
|
||||
setRenaming(false)
|
||||
setRemoved(undefined)
|
||||
},
|
||||
select,
|
||||
remove,
|
||||
getWorkspace,
|
||||
profiles: profiles.data?.profiles,
|
||||
onApplied: clearEditors,
|
||||
})
|
||||
|
||||
function clearEditors() {
|
||||
setEditing(null)
|
||||
setRenaming(false)
|
||||
setRemoved(undefined)
|
||||
}
|
||||
|
||||
function saveColumn(column: DeckColumn) {
|
||||
save({
|
||||
...deck,
|
||||
@@ -42,83 +72,148 @@ export function DeckPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell active="deck">
|
||||
<AppShell
|
||||
sidebar={
|
||||
<>
|
||||
<DeckSwitcher
|
||||
key={deck.id}
|
||||
decks={workspace.decks}
|
||||
activeId={deck.id}
|
||||
ready={ready}
|
||||
onSelect={(id) => {
|
||||
select(id)
|
||||
clearEditors()
|
||||
}}
|
||||
onCreate={(title) => {
|
||||
create(title)
|
||||
clearEditors()
|
||||
}}
|
||||
onDelete={() => {
|
||||
remove(deck.id)
|
||||
clearEditors()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="deck-primary sidebar-add"
|
||||
aria-label="カラムを追加"
|
||||
title="カラムを追加"
|
||||
type="button"
|
||||
disabled={!ready || deck.columns.length >= MAX_COLUMNS}
|
||||
onClick={() => setEditing('new')}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>カラムを追加</span>
|
||||
</button>
|
||||
<nav className="sidebar-columns" aria-label="カラム一覧">
|
||||
{deck.columns.map((column) => (
|
||||
<button
|
||||
key={column.id}
|
||||
type="button"
|
||||
title={column.title}
|
||||
onClick={() => {
|
||||
const element = document.getElementById(`column-${column.id}`)
|
||||
element?.scrollIntoView({ block: 'nearest', inline: 'start' })
|
||||
element
|
||||
?.closest('.deck-column')
|
||||
?.querySelector<HTMLElement>('.deck-column-results')
|
||||
?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<Icon name={column.source.kind} />
|
||||
<span>{column.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<p className="sidebar-note">このブラウザに保存</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="deck-heading">
|
||||
<div>
|
||||
<h1>{deck.title}</h1>
|
||||
<p className="deck-help">
|
||||
観点ごとに検索を並べて、投稿を読み比べる。
|
||||
</p>
|
||||
<span className="deck-count">
|
||||
{deck.columns.length} / {MAX_COLUMNS} カラム
|
||||
</span>
|
||||
</div>
|
||||
<div className="deck-actions">
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="名前を変更"
|
||||
title="名前を変更"
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => setRenaming(!renaming)}
|
||||
>
|
||||
名前を変更
|
||||
</button>
|
||||
<button
|
||||
className="deck-primary"
|
||||
type="button"
|
||||
disabled={
|
||||
!ready || deck.columns.length >= MAX_COLUMNS || editing !== null
|
||||
}
|
||||
onClick={() => setEditing('new')}
|
||||
>
|
||||
カラムを追加
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="deck-save-status">
|
||||
{!ready
|
||||
? 'デッキを読み込んでいます…'
|
||||
: storageError
|
||||
? 'デッキの保存状態を確認してください。'
|
||||
: `このブラウザにデッキの条件を保存 · ${deck.columns.length} / ${MAX_COLUMNS} カラム`}
|
||||
</p>
|
||||
{!ready ? (
|
||||
<p className="deck-save-status" role="status">
|
||||
デッキを読み込んでいます…
|
||||
</p>
|
||||
) : null}
|
||||
{profiles.isError ? (
|
||||
<p role="alert" className="deck-error">
|
||||
接続プロファイルを取得できませんでした。
|
||||
<button type="button" onClick={() => void profiles.refetch()}>
|
||||
再試行
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
{profiles.isPending ? (
|
||||
<p role="status">接続プロファイルを取得しています…</p>
|
||||
) : null}
|
||||
{storageError ? (
|
||||
<p className="deck-error" role="alert">
|
||||
{storageError}
|
||||
</p>
|
||||
) : null}
|
||||
{renaming ? (
|
||||
<form
|
||||
className="deck-rename"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const title = String(
|
||||
new FormData(event.currentTarget).get('title') ?? '',
|
||||
).trim()
|
||||
if (!title) return
|
||||
save({ ...deck, title })
|
||||
setRenaming(false)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="deck-title">
|
||||
調査テーマ・デッキ名
|
||||
<input
|
||||
id="deck-title"
|
||||
name="title"
|
||||
defaultValue={deck.title}
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">名前を保存</button>
|
||||
</form>
|
||||
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
|
||||
<form
|
||||
className="deck-rename"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const title = String(
|
||||
new FormData(event.currentTarget).get('title') ?? '',
|
||||
).trim()
|
||||
if (!title) return
|
||||
save({ ...deck, title })
|
||||
setRenaming(false)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="deck-title">
|
||||
調査テーマ・デッキ名
|
||||
<input
|
||||
id="deck-title"
|
||||
name="title"
|
||||
defaultValue={deck.title}
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">名前を保存</button>
|
||||
</form>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{editing !== null ? (
|
||||
<ColumnEditor
|
||||
key={editing === 'new' ? 'new' : editing.id}
|
||||
column={
|
||||
editing === 'new'
|
||||
? undefined
|
||||
: deck.columns.find((column) => column.id === editing.id)
|
||||
}
|
||||
onSave={saveColumn}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
<Dialog
|
||||
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
|
||||
onClose={() => setEditing(null)}
|
||||
>
|
||||
<ColumnEditor
|
||||
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}-${profiles.data?.profiles.join(',')}`}
|
||||
profiles={profiles.data?.profiles ?? []}
|
||||
column={
|
||||
editing === 'new'
|
||||
? undefined
|
||||
: deck.columns.find((column) => column.id === editing.id)
|
||||
}
|
||||
onSave={saveColumn}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{removed ? (
|
||||
<div className="deck-undo" role="status">
|
||||
@@ -139,9 +234,10 @@ export function DeckPage() {
|
||||
) : null}
|
||||
{ready && deck.columns.length === 0 && editing === null ? (
|
||||
<div className="deck-empty">
|
||||
<h2>最初の観点を追加しましょう。</h2>
|
||||
<Icon name="columns" />
|
||||
<h2>調査のカラムを並べましょう</h2>
|
||||
<p>
|
||||
「日本語での反応」「発表元の投稿」など、調べたい切り口に名前と検索条件を付けてください。
|
||||
検索、ユーザー投稿、リスト。読みたい情報を選んで、ここに追加できます。
|
||||
</p>
|
||||
<button
|
||||
className="deck-primary"
|
||||
@@ -157,7 +253,8 @@ export function DeckPage() {
|
||||
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
|
||||
{deck.columns.map((column, index) => (
|
||||
<ResearchColumn
|
||||
key={column.id}
|
||||
key={`${deck.id}:${column.id}`}
|
||||
registry={registry}
|
||||
column={column}
|
||||
first={index === 0}
|
||||
last={index === deck.columns.length - 1}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog } from '#/components/dialog'
|
||||
import { Icon } from '#/components/icon'
|
||||
import type { Deck } from './model'
|
||||
|
||||
export function DeckSwitcher({
|
||||
decks,
|
||||
activeId,
|
||||
ready,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
}: {
|
||||
decks: Deck[]
|
||||
activeId: string
|
||||
ready: boolean
|
||||
onSelect: (id: string) => void
|
||||
onCreate: (title: string) => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
return (
|
||||
<div className="deck-workspaces">
|
||||
<div className="deck-actions">
|
||||
<label htmlFor="deck-selection">デッキプロファイル</label>
|
||||
<select
|
||||
id="deck-selection"
|
||||
value={activeId}
|
||||
disabled={!ready}
|
||||
onChange={(event) => {
|
||||
onSelect(event.target.value)
|
||||
setCreating(false)
|
||||
setDeleting(false)
|
||||
}}
|
||||
>
|
||||
{decks.map((deck) => (
|
||||
<option key={deck.id} value={deck.id}>
|
||||
{deck.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="workspace-create"
|
||||
aria-label="デッキを作成"
|
||||
title="デッキを作成"
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
setCreating(!creating)
|
||||
setDeleting(false)
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>デッキを作成</span>
|
||||
</button>
|
||||
<button
|
||||
className="workspace-delete"
|
||||
aria-label="デッキを削除"
|
||||
title="デッキを削除"
|
||||
type="button"
|
||||
disabled={!ready || decks.length <= 1}
|
||||
onClick={() => {
|
||||
setDeleting(!deleting)
|
||||
setCreating(false)
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
<span>デッキを削除</span>
|
||||
</button>
|
||||
</div>
|
||||
{creating ? (
|
||||
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
|
||||
<form
|
||||
className="deck-rename"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const title = String(
|
||||
new FormData(event.currentTarget).get('title') ?? '',
|
||||
).trim()
|
||||
if (!title) return
|
||||
onCreate(title)
|
||||
setCreating(false)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="new-deck-title">
|
||||
新しいデッキ名
|
||||
<input
|
||||
id="new-deck-title"
|
||||
name="title"
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">作成</button>
|
||||
<button type="button" onClick={() => setCreating(false)}>
|
||||
キャンセル
|
||||
</button>
|
||||
</form>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{deleting ? (
|
||||
<Dialog title="デッキを削除" onClose={() => setDeleting(false)}>
|
||||
<div className="deck-undo">
|
||||
<p>このデッキとカラムの設定を削除します。</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
setDeleting(false)
|
||||
}}
|
||||
>
|
||||
削除する
|
||||
</button>
|
||||
<button type="button" onClick={() => setDeleting(false)}>
|
||||
キャンセル
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+381
-142
@@ -1,61 +1,68 @@
|
||||
/* Hallmark · existing Garden tokens · research deck: horizontal columns
|
||||
* User-requested application layout; inherit the reader's typography and color.
|
||||
* pre-emit critique: P4 H4 E4 S5 R5 V4
|
||||
*/
|
||||
.app-deck .reader {
|
||||
min-width: 0;
|
||||
}
|
||||
.app-deck button,
|
||||
.app-deck input,
|
||||
.app-deck select {
|
||||
font: inherit;
|
||||
}
|
||||
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
|
||||
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
|
||||
.app-deck button {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
min-height: 36px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color var(--dur-short) var(--ease-out);
|
||||
}
|
||||
.app-deck button:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
}
|
||||
.app-deck button:active:not(:disabled) {
|
||||
background: var(--color-paper-3);
|
||||
}
|
||||
.app-deck button:active:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.app-deck button:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.app-deck :focus-visible {
|
||||
outline: var(--rule-active) solid var(--color-focus);
|
||||
outline-offset: 3px;
|
||||
.app-deck .icon-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: var(--space-xs);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
.app-deck .deck-primary {
|
||||
color: var(--color-accent-ink);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 650;
|
||||
}
|
||||
.app-deck .deck-primary:hover:not(:disabled),
|
||||
.app-deck .deck-primary:active:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
color: var(--color-accent-ink);
|
||||
}
|
||||
.deck-heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
flex-shrink: 0;
|
||||
gap: var(--space-sm);
|
||||
min-height: 58px;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
.deck-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-heading h1 {
|
||||
overflow-wrap: anywhere;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-heading p {
|
||||
margin: var(--space-2xs) 0 0;
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-actions {
|
||||
display: flex;
|
||||
@@ -67,131 +74,82 @@
|
||||
.deck-save-status,
|
||||
.deck-column-status {
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.deck-save-status {
|
||||
margin: var(--space-md) 0;
|
||||
}
|
||||
.deck-editor {
|
||||
margin-block: var(--space-lg);
|
||||
padding-block: var(--space-lg);
|
||||
border-block: var(--rule-hairline) solid var(--border);
|
||||
}
|
||||
.deck-editor h2,
|
||||
.deck-empty h2 {
|
||||
font-size: var(--text-md);
|
||||
margin: 0 0 var(--space-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-editor-fields {
|
||||
display: grid;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.deck-editor label,
|
||||
.deck-rename label {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-editor input:not([type="checkbox"]),
|
||||
.deck-editor select,
|
||||
.deck-rename input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
.deck-editor .deck-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
.deck-checkbox input {
|
||||
accent-color: var(--accent);
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
.deck-rename {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-sm);
|
||||
margin-block: var(--space-md);
|
||||
}
|
||||
.deck-rename label {
|
||||
flex: 1 1 15rem;
|
||||
}
|
||||
.deck-error {
|
||||
color: var(--error);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.deck-undo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-block: var(--space-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-empty {
|
||||
max-width: 40rem;
|
||||
padding-block: var(--space-2xl);
|
||||
}
|
||||
.deck-empty p {
|
||||
line-height: 1.8;
|
||||
margin-bottom: var(--space-lg);
|
||||
margin: 0;
|
||||
padding: 0 var(--space-md) var(--space-xs);
|
||||
}
|
||||
.deck-board {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: min(100%, 23rem);
|
||||
gap: var(--space-md);
|
||||
grid-auto-columns: 328px;
|
||||
justify-content: start;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: var(--space-xs);
|
||||
overflow-x: auto;
|
||||
padding: var(--space-2xs) var(--space-2xs) var(--space-md);
|
||||
overflow-y: hidden;
|
||||
padding: 0 var(--space-xs) var(--space-xs);
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-color: var(--control-border) var(--canvas);
|
||||
}
|
||||
.deck-column {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: max(30rem, calc(100svh - 20rem));
|
||||
border: var(--rule-hairline) solid var(--border);
|
||||
background: var(--paper);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.deck-column-header {
|
||||
padding: var(--space-md);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-bottom: var(--rule-hairline) solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.deck-column-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.deck-column-title > svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
.deck-column h2 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-column-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-xs);
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-column h2 {
|
||||
margin: var(--space-sm) 0;
|
||||
font-size: var(--text-md);
|
||||
.deck-column-label span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-query {
|
||||
margin: 0 0 var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 4.5em;
|
||||
overflow-y: auto;
|
||||
margin: var(--space-2xs) 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-column-status {
|
||||
margin-bottom: 0;
|
||||
margin: var(--space-2xs) 0 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.deck-column-results {
|
||||
@@ -199,27 +157,70 @@
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
overscroll-behavior-y: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--control-border) var(--paper);
|
||||
}
|
||||
.column-menu {
|
||||
position: relative;
|
||||
}
|
||||
.column-menu summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.column-menu summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.column-menu summary:hover,
|
||||
.column-menu[open] summary {
|
||||
background: var(--color-paper-3);
|
||||
}
|
||||
.column-menu-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-width: 158px;
|
||||
padding: var(--space-2xs);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 var(--space-xs) var(--space-lg) var(--color-shadow);
|
||||
}
|
||||
.column-menu-panel button {
|
||||
justify-content: flex-start;
|
||||
border: 0;
|
||||
}
|
||||
.deck-message {
|
||||
padding: var(--space-md);
|
||||
color: var(--secondary);
|
||||
line-height: 1.7;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.research-post {
|
||||
padding: var(--space-md);
|
||||
padding: var(--space-sm);
|
||||
border-bottom: var(--rule-hairline) solid var(--border);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-post header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.research-post header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.research-post header img {
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
.research-post strong {
|
||||
font-size: var(--text-sm);
|
||||
@@ -227,20 +228,22 @@
|
||||
.research-post-text,
|
||||
.research-post blockquote p {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.8;
|
||||
line-height: 1.55;
|
||||
font-size: var(--text-sm);
|
||||
margin: var(--space-xs) 0;
|
||||
}
|
||||
.research-post blockquote {
|
||||
margin: var(--space-md) 0;
|
||||
padding-left: var(--space-md);
|
||||
border-left: var(--rule-hairline) solid var(--border);
|
||||
margin: var(--space-sm) 0;
|
||||
padding: var(--space-sm);
|
||||
border: var(--rule-hairline) solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.research-post footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-md);
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
@@ -253,22 +256,258 @@
|
||||
width: 100%;
|
||||
max-height: 20rem;
|
||||
object-fit: contain;
|
||||
margin-block: var(--space-sm);
|
||||
margin-block: var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@media (min-width: 52rem) {
|
||||
.app.app-deck {
|
||||
width: 100%;
|
||||
grid-template-columns: 11rem minmax(0, 1fr);
|
||||
.deck-dialog {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: var(--space-lg);
|
||||
overflow-y: auto;
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 var(--space-md) var(--space-xl) var(--color-shadow);
|
||||
}
|
||||
.deck-dialog::backdrop {
|
||||
background: var(--color-overlay);
|
||||
}
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.dialog-heading h2,
|
||||
.deck-editor h2,
|
||||
.deck-empty h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-editor {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.deck-editor-fields,
|
||||
.deck-source-fields {
|
||||
display: grid;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.deck-source-fields {
|
||||
margin-block: var(--space-md);
|
||||
}
|
||||
.deck-editor label,
|
||||
.deck-rename label {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-editor input:not([type="checkbox"]),
|
||||
.deck-editor select,
|
||||
.deck-rename input,
|
||||
.deck-workspaces select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: var(--space-xs);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
}
|
||||
.deck-editor .deck-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
.deck-checkbox input {
|
||||
accent-color: var(--accent);
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
.deck-rename {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.deck-rename label {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.deck-error {
|
||||
color: var(--error);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.reader > .deck-error {
|
||||
margin: 0;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
.deck-undo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.deck-undo p {
|
||||
margin: 0;
|
||||
}
|
||||
.deck-empty {
|
||||
margin: auto;
|
||||
max-width: 32rem;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
.deck-empty p {
|
||||
color: var(--secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.deck-workspaces {
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-workspaces > .deck-actions {
|
||||
align-items: stretch;
|
||||
}
|
||||
.deck-workspaces > .deck-actions > label {
|
||||
width: 100%;
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-workspaces > .deck-actions > select {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.deck-workspaces > .deck-actions > button {
|
||||
flex: 1;
|
||||
}
|
||||
.deck-list-choices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.app-deck .sidebar-add {
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.deck-workspaces {
|
||||
flex: 1;
|
||||
}
|
||||
.app-deck .reader {
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
.deck-workspaces > .deck-actions {
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
.deck-editor-fields {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(7rem, 0.5fr);
|
||||
.deck-workspaces > .deck-actions > label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
.deck-workspaces > .deck-actions > select {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
}
|
||||
.deck-workspaces > .deck-actions > button {
|
||||
flex: none;
|
||||
}
|
||||
.app-deck .sidebar-add {
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
.sidebar-add span {
|
||||
display: none;
|
||||
}
|
||||
.deck-heading {
|
||||
min-height: 52px;
|
||||
padding-inline: var(--space-sm);
|
||||
}
|
||||
.deck-heading .deck-actions {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.deck-board {
|
||||
grid-auto-columns: min(328px, 100%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-deck button {
|
||||
transition: none;
|
||||
@media (pointer: coarse) {
|
||||
.app-deck .icon-button,
|
||||
.column-menu summary {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
.deck-count {
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.research-post header time {
|
||||
display: block;
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-column-status {
|
||||
text-align: right;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.workspace-create span,
|
||||
.workspace-delete span {
|
||||
display: none;
|
||||
}
|
||||
.app-deck .workspace-create,
|
||||
.app-deck .workspace-delete {
|
||||
width: 36px;
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
}
|
||||
.sidebar-columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.app-deck .sidebar-columns button {
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
.sidebar-columns span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.research-post header > div {
|
||||
flex: 1;
|
||||
}
|
||||
.research-post header time {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.research-post footer a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
.research-post footer svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.column-menu-panel .danger {
|
||||
color: var(--error);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.sidebar-columns {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type Deck, deckSchema, moveColumn } from './model'
|
||||
import { type Deck, deckSchema, moveColumn, workspaceSchema } from './model'
|
||||
|
||||
const deck: Deck = {
|
||||
version: 1,
|
||||
id: 'research',
|
||||
title: '調査',
|
||||
columns: ['a', 'b'].map((id) => ({
|
||||
id,
|
||||
title: id,
|
||||
profileName: 'main',
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: 'AI lang:ja',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
describe('deck definitions', () => {
|
||||
it('preserves native Twitter search conditions when serialized', () => {
|
||||
it('preserves native search and the bound relay profile when serialized', () => {
|
||||
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck)
|
||||
})
|
||||
it('rejects duplicate column identities and unsupported platforms', () => {
|
||||
it('normalizes user and list targets and rejects invalid targets', () => {
|
||||
const column = deck.columns[0]
|
||||
expect(
|
||||
deckSchema.parse({
|
||||
...deck,
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: { platform: 'twitter', kind: 'user', target: '@alice' },
|
||||
},
|
||||
],
|
||||
}).columns[0]?.source,
|
||||
).toEqual({ platform: 'twitter', kind: 'user', target: 'alice' })
|
||||
expect(
|
||||
deckSchema.parse({
|
||||
...deck,
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'list',
|
||||
target: 'https://x.com/i/lists/123',
|
||||
},
|
||||
},
|
||||
],
|
||||
}).columns[0]?.source,
|
||||
).toEqual({ platform: 'twitter', kind: 'list', target: '123' })
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: { platform: 'twitter', kind: 'list', target: 'invalid' },
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
it('rejects duplicate identities and missing profiles', () => {
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
@@ -30,13 +71,25 @@ describe('deck definitions', () => {
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
columns: [
|
||||
{ ...deck.columns[0], source: { platform: 'nostr', query: 'AI' } },
|
||||
],
|
||||
columns: [{ ...deck.columns[0], profileName: '' }],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
workspaceSchema.safeParse({
|
||||
version: 2,
|
||||
activeDeckId: 'missing',
|
||||
decks: [deck],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
workspaceSchema.safeParse({
|
||||
version: 2,
|
||||
activeDeckId: deck.id,
|
||||
decks: [deck, deck],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
it('rejects queries that exceed the upstream limit after adding follows', () => {
|
||||
it('rejects queries exceeding the upstream limit after adding follows', () => {
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
@@ -45,6 +98,7 @@ describe('deck definitions', () => {
|
||||
...deck.columns[0],
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: 'a'.repeat(512),
|
||||
product: 'Latest',
|
||||
following: true,
|
||||
@@ -54,7 +108,7 @@ describe('deck definitions', () => {
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
it('moves a column without changing its query or the previous deck', () => {
|
||||
it('moves a column without changing its conditions or the previous deck', () => {
|
||||
expect(moveColumn(deck, 'b', -1).columns).toEqual([
|
||||
deck.columns[1],
|
||||
deck.columns[0],
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
import { buildSearchQuery } from '#/features/posts/inputs'
|
||||
import {
|
||||
buildSearchQuery,
|
||||
normalizeListTarget,
|
||||
normalizeUserTarget,
|
||||
} from '#/features/posts/inputs'
|
||||
|
||||
export const MAX_COLUMNS = 6
|
||||
|
||||
// Queries stay native to the provider. Add a discriminated-union member when
|
||||
// the next connector is implemented instead of inventing a shared search DSL.
|
||||
const columnSourceSchema = z
|
||||
const searchSourceSchema = z
|
||||
.object({
|
||||
platform: z.literal('twitter'),
|
||||
kind: z.literal('search'),
|
||||
query: z.string().trim().min(1).max(512),
|
||||
product: z.enum(['Latest', 'Top']),
|
||||
following: z.boolean(),
|
||||
@@ -27,17 +30,57 @@ const columnSourceSchema = z
|
||||
}
|
||||
})
|
||||
|
||||
function targetSchema(normalize: (target: string) => string) {
|
||||
return z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.transform((target, ctx) => {
|
||||
try {
|
||||
return normalize(target)
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message:
|
||||
error instanceof Error ? error.message : '対象を確認してください。',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const userSourceSchema = z
|
||||
.object({
|
||||
platform: z.literal('twitter'),
|
||||
kind: z.literal('user'),
|
||||
target: targetSchema(normalizeUserTarget),
|
||||
})
|
||||
.strict()
|
||||
const listSourceSchema = z
|
||||
.object({
|
||||
platform: z.literal('twitter'),
|
||||
kind: z.literal('list'),
|
||||
target: targetSchema(normalizeListTarget),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const columnSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(128),
|
||||
title: z.string().trim().min(1).max(80),
|
||||
source: columnSourceSchema,
|
||||
profileName: z.string().min(1),
|
||||
source: z.discriminatedUnion('kind', [
|
||||
searchSourceSchema,
|
||||
userSourceSchema,
|
||||
listSourceSchema,
|
||||
]),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const deckSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
id: z.string().min(1).max(128),
|
||||
title: z.string().trim().min(1).max(120),
|
||||
columns: z.array(columnSchema).max(MAX_COLUMNS),
|
||||
})
|
||||
@@ -49,10 +92,41 @@ export const deckSchema = z
|
||||
'カラム ID が重複しています。',
|
||||
)
|
||||
|
||||
export const workspaceSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
activeDeckId: deckSchema.shape.id,
|
||||
decks: z.array(deckSchema).min(1),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(workspace) =>
|
||||
new Set(workspace.decks.map((deck) => deck.id)).size ===
|
||||
workspace.decks.length,
|
||||
'デッキ ID が重複しています。',
|
||||
)
|
||||
.refine(
|
||||
(workspace) =>
|
||||
workspace.decks.some((deck) => deck.id === workspace.activeDeckId),
|
||||
'選択したデッキが見つかりません。',
|
||||
)
|
||||
|
||||
export type Deck = z.infer<typeof deckSchema>
|
||||
export type DeckColumn = z.infer<typeof columnSchema>
|
||||
|
||||
export const EMPTY_DECK: Deck = { version: 1, title: '新しい調査', columns: [] }
|
||||
export type Workspace = z.infer<typeof workspaceSchema>
|
||||
export const EMPTY_DECK: Deck = {
|
||||
id: 'initial',
|
||||
title: '新しい調査',
|
||||
columns: [],
|
||||
}
|
||||
export const EMPTY_WORKSPACE: Workspace = {
|
||||
version: 2,
|
||||
activeDeckId: EMPTY_DECK.id,
|
||||
decks: [EMPTY_DECK],
|
||||
}
|
||||
export function newId() {
|
||||
return crypto.getRandomValues(new Uint32Array(4)).join('-')
|
||||
}
|
||||
|
||||
export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck {
|
||||
const index = deck.columns.findIndex((column) => column.id === id)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Icon } from '#/components/icon'
|
||||
import { PostText } from '#/components/post-text'
|
||||
import type { ResearchPost } from '#/features/platforms/types'
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Tokyo',
|
||||
})
|
||||
|
||||
@@ -15,15 +18,22 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
|
||||
<img
|
||||
src={post.author.avatarUrl}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
width={36}
|
||||
height={36}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<strong>{post.author.name}</strong>
|
||||
<span className="handle">{post.author.handle}</span>
|
||||
<span className="handle">
|
||||
@{post.author.handle.replace(/^@/, '')}
|
||||
</span>
|
||||
</div>
|
||||
{post.createdAt ? (
|
||||
<time dateTime={post.createdAt} title={post.createdAt}>
|
||||
{dateFormatter.format(new Date(post.createdAt))}
|
||||
</time>
|
||||
) : null}
|
||||
</header>
|
||||
<p className="research-post-text">
|
||||
<PostText text={post.text} />
|
||||
@@ -64,13 +74,8 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
|
||||
</blockquote>
|
||||
) : null}
|
||||
<footer>
|
||||
{post.createdAt ? (
|
||||
<time dateTime={post.createdAt}>
|
||||
{dateFormatter.format(new Date(post.createdAt))}
|
||||
</time>
|
||||
) : null}
|
||||
<a href={post.url} target="_blank" rel="noreferrer noopener">
|
||||
元の投稿を開く
|
||||
元の投稿を開く <Icon name="arrow" />
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EMPTY_DECK } from './model'
|
||||
import { EMPTY_DECK, EMPTY_WORKSPACE } from './model'
|
||||
import { DECK_STORAGE_KEY, useDeck } from './use-deck'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('saved deck', () => {
|
||||
describe('saved decks', () => {
|
||||
it('restores saved conditions without overwriting them during mount', () => {
|
||||
const saved = { ...EMPTY_DECK, title: 'WebMCP調査' }
|
||||
const deck = { ...EMPTY_DECK, title: 'WebMCP調査' }
|
||||
const saved = { ...EMPTY_WORKSPACE, decks: [deck] }
|
||||
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(saved))
|
||||
const { result } = renderHook(useDeck)
|
||||
expect(result.current.ready).toBe(true)
|
||||
expect(result.current.deck).toEqual(saved)
|
||||
expect(result.current.deck).toEqual(deck)
|
||||
expect(JSON.parse(localStorage.getItem(DECK_STORAGE_KEY) ?? '')).toEqual(
|
||||
saved,
|
||||
)
|
||||
@@ -25,21 +25,42 @@ describe('saved deck', () => {
|
||||
expect(result.current.storageError).toContain('読み込めません')
|
||||
expect(localStorage.getItem(DECK_STORAGE_KEY)).toBe('{broken')
|
||||
})
|
||||
it('persists edits and restores them on remount', () => {
|
||||
const first = renderHook(useDeck)
|
||||
act(() =>
|
||||
first.result.current.save({ ...EMPTY_DECK, title: '保存した調査' }),
|
||||
)
|
||||
first.unmount()
|
||||
expect(renderHook(useDeck).result.current.deck.title).toBe('保存した調査')
|
||||
})
|
||||
it('reports failed writes instead of claiming that changes were saved', () => {
|
||||
it('reports failed writes while keeping changes usable in memory', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError')
|
||||
})
|
||||
const { result } = renderHook(useDeck)
|
||||
act(() => result.current.save({ ...EMPTY_DECK, title: '未保存' }))
|
||||
let persisted = true
|
||||
act(() => {
|
||||
persisted = result.current.save({ ...EMPTY_DECK, title: '未保存' })
|
||||
})
|
||||
expect(persisted).toBe(false)
|
||||
expect(result.current.deck.title).toBe('未保存')
|
||||
expect(result.current.storageError).toContain('保存できません')
|
||||
})
|
||||
it('keeps decks isolated through immediate create, edit, switch and reload', () => {
|
||||
const { result, unmount } = renderHook(useDeck)
|
||||
const firstId = result.current.deck.id
|
||||
let secondId = ''
|
||||
act(() => {
|
||||
result.current.create('第二の調査')
|
||||
secondId = result.current.getWorkspace().activeDeckId
|
||||
result.current.save({
|
||||
id: secondId,
|
||||
title: '変更した第二の調査',
|
||||
columns: [],
|
||||
})
|
||||
result.current.select(firstId)
|
||||
})
|
||||
expect(result.current.deck.title).toBe('新しい調査')
|
||||
expect(result.current.workspace.decks).toHaveLength(2)
|
||||
unmount()
|
||||
const restored = renderHook(useDeck)
|
||||
act(() => restored.result.current.select(secondId))
|
||||
expect(restored.result.current.deck.title).toBe('変更した第二の調査')
|
||||
act(() => restored.result.current.remove(secondId))
|
||||
expect(restored.result.current.deck.id).toBe(firstId)
|
||||
expect(() => restored.result.current.remove(firstId)).toThrow('最後')
|
||||
expect(() => restored.result.current.select('missing')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { type Deck, deckSchema, EMPTY_DECK } from './model'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
type Deck,
|
||||
deckSchema,
|
||||
EMPTY_DECK,
|
||||
EMPTY_WORKSPACE,
|
||||
newId,
|
||||
type Workspace,
|
||||
workspaceSchema,
|
||||
} from './model'
|
||||
|
||||
export const DECK_STORAGE_KEY = 'twitter-lite-research-deck'
|
||||
|
||||
export function useDeck() {
|
||||
const [deck, setDeck] = useState<Deck>(EMPTY_DECK)
|
||||
const [workspace, setWorkspace] = useState<Workspace>(EMPTY_WORKSPACE)
|
||||
const current = useRef(workspace)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [storageError, setStorageError] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(DECK_STORAGE_KEY)
|
||||
if (saved !== null) setDeck(deckSchema.parse(JSON.parse(saved)))
|
||||
if (saved !== null) {
|
||||
const restored = workspaceSchema.parse(JSON.parse(saved))
|
||||
current.current = restored
|
||||
setWorkspace(restored)
|
||||
}
|
||||
} catch {
|
||||
setStorageError(
|
||||
'保存したデッキを読み込めませんでした。編集内容を保存すると、このブラウザのデッキを置き換えます。',
|
||||
@@ -20,9 +33,10 @@ export function useDeck() {
|
||||
setReady(true)
|
||||
}, [])
|
||||
|
||||
function save(next: Deck) {
|
||||
const validated = deckSchema.parse(next)
|
||||
setDeck(validated)
|
||||
function persist(next: Workspace) {
|
||||
const validated = workspaceSchema.parse(next)
|
||||
current.current = validated
|
||||
setWorkspace(validated)
|
||||
try {
|
||||
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(validated))
|
||||
setStorageError(undefined)
|
||||
@@ -35,5 +49,56 @@ export function useDeck() {
|
||||
}
|
||||
}
|
||||
|
||||
return { deck, ready, storageError, save }
|
||||
function save(next: Deck) {
|
||||
const validated = deckSchema.parse(next)
|
||||
const previous = current.current
|
||||
const exists = previous.decks.some((deck) => deck.id === validated.id)
|
||||
return persist({
|
||||
...previous,
|
||||
activeDeckId: validated.id,
|
||||
decks: exists
|
||||
? previous.decks.map((deck) =>
|
||||
deck.id === validated.id ? validated : deck,
|
||||
)
|
||||
: [...previous.decks, validated],
|
||||
})
|
||||
}
|
||||
|
||||
function create(title: string) {
|
||||
return save({ id: newId(), title, columns: [] })
|
||||
}
|
||||
function select(id: string) {
|
||||
if (!current.current.decks.some((deck) => deck.id === id))
|
||||
throw new Error('デッキが見つかりません。')
|
||||
return persist({ ...current.current, activeDeckId: id })
|
||||
}
|
||||
function remove(id: string) {
|
||||
const previous = current.current
|
||||
if (!previous.decks.some((deck) => deck.id === id))
|
||||
throw new Error('デッキが見つかりません。')
|
||||
const decks = previous.decks.filter((deck) => deck.id !== id)
|
||||
const first = decks[0]
|
||||
if (!first) throw new Error('最後のデッキは削除できません。')
|
||||
return persist({
|
||||
...previous,
|
||||
decks,
|
||||
activeDeckId:
|
||||
previous.activeDeckId === id ? first.id : previous.activeDeckId,
|
||||
})
|
||||
}
|
||||
|
||||
const deck =
|
||||
workspace.decks.find((deck) => deck.id === workspace.activeDeckId) ??
|
||||
EMPTY_DECK
|
||||
return {
|
||||
deck,
|
||||
workspace,
|
||||
ready,
|
||||
storageError,
|
||||
save,
|
||||
create,
|
||||
select,
|
||||
remove,
|
||||
getWorkspace: () => current.current,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
export function useWebMCPSupported() {
|
||||
const [supported, setSupported] = useState(false)
|
||||
useEffect(() => {
|
||||
setSupported(Boolean(document.modelContext))
|
||||
}, [])
|
||||
return supported
|
||||
}
|
||||
@@ -1,60 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { prepareDeck } from './webmcp-contracts'
|
||||
|
||||
const column = {
|
||||
title: '観点',
|
||||
profileName: 'main',
|
||||
source: { kind: 'search', query: 'AI' },
|
||||
}
|
||||
describe('deck tool input', () => {
|
||||
it('fills Twitter defaults and assigns independent IDs to new columns', () => {
|
||||
const deck = prepareDeck({
|
||||
title: '調査',
|
||||
columns: [
|
||||
{ title: '日本語', source: { query: 'WebMCP lang:ja' } },
|
||||
{ title: '海外', source: { query: 'WebMCP lang:en' } },
|
||||
],
|
||||
})
|
||||
it('fills Twitter defaults and assigns independent deck and column IDs', () => {
|
||||
const deck = prepareDeck({ title: '調査', columns: [column, column] }, [
|
||||
'main',
|
||||
])
|
||||
expect(deck.columns[0]?.source).toEqual({
|
||||
platform: 'twitter',
|
||||
query: 'WebMCP lang:ja',
|
||||
kind: 'search',
|
||||
query: 'AI',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
})
|
||||
expect(new Set(deck.columns.map((column) => column.id)).size).toBe(2)
|
||||
})
|
||||
it('keeps existing IDs and rejects duplicates before applying a deck', () => {
|
||||
const column = { id: 'existing', title: '観点', source: { query: 'AI' } }
|
||||
expect(
|
||||
prepareDeck({ title: '調査', columns: [column] }).columns[0]?.id,
|
||||
).toBe('existing')
|
||||
new Set([deck.id, ...deck.columns.map((column) => column.id)]).size,
|
||||
).toBe(3)
|
||||
})
|
||||
it('keeps supplied identities and rejects duplicates', () => {
|
||||
const identified = { ...column, id: 'existing' }
|
||||
expect(
|
||||
prepareDeck(
|
||||
{ deckId: 'research', title: '調査', columns: [identified] },
|
||||
['main'],
|
||||
),
|
||||
).toMatchObject({ id: 'research', columns: [{ id: 'existing' }] })
|
||||
expect(() =>
|
||||
prepareDeck({ title: '調査', columns: [column, column] }),
|
||||
prepareDeck({ title: '調査', columns: [identified, identified] }, [
|
||||
'main',
|
||||
]),
|
||||
).toThrow()
|
||||
})
|
||||
it('rejects unavailable profiles before applying a definition', () => {
|
||||
expect(() =>
|
||||
prepareDeck({ title: '調査', columns: [column] }, ['work']),
|
||||
).toThrow('Unknown relay profile')
|
||||
})
|
||||
it('accepts user and list columns bound to separate profiles', () => {
|
||||
const deck = prepareDeck(
|
||||
{
|
||||
title: '調査',
|
||||
columns: [
|
||||
{ ...column, source: { kind: 'user', target: '@alice' } },
|
||||
{
|
||||
...column,
|
||||
profileName: 'work',
|
||||
source: { kind: 'list', target: '123' },
|
||||
},
|
||||
],
|
||||
},
|
||||
['main', 'work'],
|
||||
)
|
||||
expect(deck.columns.map((column) => column.profileName)).toEqual([
|
||||
'main',
|
||||
'work',
|
||||
])
|
||||
expect(deck.columns.map((column) => column.source.kind)).toEqual([
|
||||
'user',
|
||||
'list',
|
||||
])
|
||||
})
|
||||
it('rejects unsupported platforms, overlong queries and more than six columns', () => {
|
||||
expect(() =>
|
||||
prepareDeck({
|
||||
title: '調査',
|
||||
columns: [
|
||||
{ title: '観点', source: { platform: 'nostr', query: 'AI' } },
|
||||
],
|
||||
}),
|
||||
prepareDeck(
|
||||
{
|
||||
title: '調査',
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: { kind: 'search', platform: 'nostr', query: 'AI' },
|
||||
},
|
||||
],
|
||||
},
|
||||
['main'],
|
||||
),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
prepareDeck({
|
||||
title: '調査',
|
||||
columns: [
|
||||
{
|
||||
title: '観点',
|
||||
source: { query: 'a'.repeat(512), following: true },
|
||||
},
|
||||
],
|
||||
}),
|
||||
prepareDeck(
|
||||
{
|
||||
title: '調査',
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: {
|
||||
kind: 'search',
|
||||
query: 'a'.repeat(512),
|
||||
following: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
['main'],
|
||||
),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
prepareDeck({
|
||||
title: '調査',
|
||||
columns: Array.from({ length: 7 }, () => ({
|
||||
title: '観点',
|
||||
source: { query: 'AI' },
|
||||
})),
|
||||
}),
|
||||
prepareDeck(
|
||||
{ title: '調査', columns: Array.from({ length: 7 }, () => column) },
|
||||
['main'],
|
||||
),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,43 +1,102 @@
|
||||
import { z } from 'zod'
|
||||
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
|
||||
import { InputError } from '#/features/posts/inputs'
|
||||
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from './model'
|
||||
|
||||
const source = columnSchema.shape.source
|
||||
const toolColumn = columnSchema.omit({ id: true }).extend({
|
||||
const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
|
||||
id: columnSchema.shape.id
|
||||
.optional()
|
||||
.describe(
|
||||
'Keep an existing ID from get_deck when editing; omit for a new column.',
|
||||
'Keep an existing column ID when editing; omit for a new column.',
|
||||
),
|
||||
source: z
|
||||
.object({
|
||||
...source.shape,
|
||||
platform: source.shape.platform.default('twitter'),
|
||||
product: source.shape.product.default('Latest'),
|
||||
following: source.shape.following.default(false),
|
||||
})
|
||||
.strict(),
|
||||
source: z.discriminatedUnion('kind', [
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('twitter').default('twitter'),
|
||||
kind: z.literal('search'),
|
||||
query: z.string().trim().min(1).max(512),
|
||||
product: z.enum(['Latest', 'Top']).default('Latest'),
|
||||
following: z.boolean().default(false),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('twitter').default('twitter'),
|
||||
kind: z.literal('user'),
|
||||
target: z.string().min(1).max(256),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('twitter').default('twitter'),
|
||||
kind: z.literal('list'),
|
||||
target: z.string().min(1).max(256),
|
||||
})
|
||||
.strict(),
|
||||
]),
|
||||
})
|
||||
|
||||
export const emptyToolInput = z.object({}).strict()
|
||||
export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict()
|
||||
export const getDeckInput = z
|
||||
.object({ deckId: deckSchema.shape.id.optional() })
|
||||
.strict()
|
||||
export const setDeckInput = z
|
||||
.object({
|
||||
title: deckSchema.shape.title.describe('The research topic or deck title.'),
|
||||
deckId: deckSchema.shape.id
|
||||
.optional()
|
||||
.describe(
|
||||
'Omit to create a deck; supply an existing deck ID to replace and activate it.',
|
||||
),
|
||||
title: deckSchema.shape.title,
|
||||
columns: z
|
||||
.array(toolColumn)
|
||||
.max(MAX_COLUMNS)
|
||||
.describe(
|
||||
'Complete ordered column list. Omitted existing columns are removed. Empty list clears the deck.',
|
||||
'Complete ordered columns, each bound to a relay profile from list_decks. Omitted existing columns are removed.',
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export function prepareDeck(input: unknown) {
|
||||
export function prepareDeck(input: unknown, profiles: readonly string[]) {
|
||||
const parsed = setDeckInput.parse(input)
|
||||
return deckSchema.parse({
|
||||
version: 1,
|
||||
const deck = deckSchema.parse({
|
||||
id: parsed.deckId ?? newId(),
|
||||
title: parsed.title,
|
||||
columns: parsed.columns.map((column) => ({
|
||||
...column,
|
||||
id: column.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
|
||||
id: column.id ?? newId(),
|
||||
})),
|
||||
})
|
||||
for (const column of deck.columns) {
|
||||
if (!profiles.includes(column.profileName))
|
||||
throw new InputError(`Unknown relay profile: ${column.profileName}`)
|
||||
}
|
||||
return deck
|
||||
}
|
||||
|
||||
export async function toolResult(run: () => unknown | Promise<unknown>) {
|
||||
try {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: JSON.stringify(await run()) },
|
||||
] as const,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
code:
|
||||
error instanceof z.ZodError || error instanceof InputError
|
||||
? 'invalid-input'
|
||||
: 'tool-error',
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Tool execution failed.',
|
||||
retryable: false,
|
||||
}),
|
||||
},
|
||||
] as const,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,137 @@
|
||||
import { useRef } from 'react'
|
||||
import { useWebMCP } from 'usewebmcp'
|
||||
import { emptyToolInput, toolResult } from '#/features/posts/webmcp-contracts'
|
||||
import { useWebMCPSupported } from '#/features/posts/webmcp-tools'
|
||||
import type { Deck } from './model'
|
||||
import { prepareDeck, setDeckInput } from './webmcp-contracts'
|
||||
import type { Deck, Workspace } from './model'
|
||||
import { useWebMCPSupported } from './use-webmcp-supported'
|
||||
import {
|
||||
deckIdInput,
|
||||
emptyToolInput,
|
||||
getDeckInput,
|
||||
prepareDeck,
|
||||
setDeckInput,
|
||||
toolResult,
|
||||
} from './webmcp-contracts'
|
||||
|
||||
export function useDeckTools({
|
||||
deck,
|
||||
ready,
|
||||
storageError,
|
||||
save,
|
||||
select,
|
||||
remove,
|
||||
getWorkspace,
|
||||
profiles,
|
||||
onApplied,
|
||||
}: {
|
||||
deck: Deck
|
||||
ready: boolean
|
||||
storageError?: string
|
||||
save: (deck: Deck) => boolean
|
||||
select: (id: string) => boolean
|
||||
remove: (id: string) => boolean
|
||||
getWorkspace: () => Workspace
|
||||
profiles: string[] | undefined
|
||||
onApplied: () => void
|
||||
}) {
|
||||
const supported = useWebMCPSupported()
|
||||
const current = useRef({ deck, storageError })
|
||||
current.current = { deck, storageError }
|
||||
|
||||
const enabled = supported && ready
|
||||
function applied(persisted: boolean) {
|
||||
onApplied()
|
||||
if (!persisted)
|
||||
throw new Error(
|
||||
'The deck was applied in this tab but could not be saved. Reloading will lose these changes.',
|
||||
)
|
||||
}
|
||||
useWebMCP({
|
||||
name: 'get_deck',
|
||||
name: 'list_decks',
|
||||
description:
|
||||
'Read the current research deck: title and ordered columns with IDs and native Twitter search conditions. No post fetch. Use this before editing with set_deck. Available on /deck only.',
|
||||
'List saved decks and their columns, activeDeckId and available relay profile names. Conditions are stored in this browser only; posts are not included.',
|
||||
inputSchema: emptyToolInput,
|
||||
enabled: supported && ready,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: true, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
emptyToolInput.parse(input)
|
||||
const workspace = getWorkspace()
|
||||
return {
|
||||
deck: current.current.deck,
|
||||
storageError: current.current.storageError ?? null,
|
||||
decks: workspace.decks,
|
||||
activeDeckId: workspace.activeDeckId,
|
||||
profiles: profiles ?? null,
|
||||
storageError: storageError ?? null,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
useWebMCP({
|
||||
name: 'get_deck',
|
||||
description:
|
||||
'Read one saved deck with column IDs, bound relay profiles and source definitions. Omit deckId for the active deck. No post fetch.',
|
||||
inputSchema: getDeckInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: true, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
const { deckId } = getDeckInput.parse(input)
|
||||
const workspace = getWorkspace()
|
||||
const deck = workspace.decks.find(
|
||||
(deck) => deck.id === (deckId ?? workspace.activeDeckId),
|
||||
)
|
||||
if (!deck) throw new Error('Deck not found.')
|
||||
return { deck, storageError: storageError ?? null }
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'set_deck',
|
||||
description:
|
||||
'Create or replace the entire visible research deck and save its conditions in this browser. Supply a title and up to six ordered Twitter search columns. Keep existing column IDs to edit them; omitted columns are removed. Closes unsaved editor forms. Only Twitter is connected. Returns the applied definition immediately; posts load asynchronously and may fail independently. Available on /deck only.',
|
||||
'Create a saved deck when deckId is omitted, or replace an existing deck by ID, then activate it. Up to six ordered columns: search, user timeline or list, each with an explicit relay profile from list_decks. Omitted columns are removed. Closes unsaved forms. Posts load asynchronously and can fail independently.',
|
||||
inputSchema: setDeckInput,
|
||||
enabled: supported && ready,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: false, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
// Validate the whole definition before modifying either storage or UI.
|
||||
const next = prepareDeck(input)
|
||||
const persisted = save(next)
|
||||
onApplied()
|
||||
const error = persisted
|
||||
? undefined
|
||||
: 'The deck was applied in this tab but could not be saved. Reloading will lose these changes.'
|
||||
current.current = { deck: next, storageError: error }
|
||||
if (error) throw new Error(error)
|
||||
const parsed = setDeckInput.parse(input)
|
||||
if (
|
||||
parsed.deckId &&
|
||||
!getWorkspace().decks.some((deck) => deck.id === parsed.deckId)
|
||||
)
|
||||
throw new Error('Deck not found.')
|
||||
if (!profiles) throw new Error('Relay profiles are not available yet.')
|
||||
const next = prepareDeck(parsed, profiles)
|
||||
applied(save(next))
|
||||
return { deck: next, persisted: true, posts: 'loading-asynchronously' }
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'select_deck',
|
||||
description:
|
||||
'Activate a saved deck by ID and save the selection in this browser. Closes unsaved forms; its columns load asynchronously.',
|
||||
inputSchema: deckIdInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: false },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
const { deckId } = deckIdInput.parse(input)
|
||||
applied(select(deckId))
|
||||
const workspace = getWorkspace()
|
||||
return {
|
||||
decks: workspace.decks,
|
||||
activeDeckId: workspace.activeDeckId,
|
||||
persisted: true,
|
||||
}
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'delete_deck',
|
||||
description:
|
||||
'Permanently delete one saved deck and its column definitions from this browser. The last remaining deck cannot be deleted. Deleting the active deck selects the first remaining deck.',
|
||||
inputSchema: deckIdInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
const { deckId } = deckIdInput.parse(input)
|
||||
applied(remove(deckId))
|
||||
const workspace = getWorkspace()
|
||||
return {
|
||||
decks: workspace.decks,
|
||||
activeDeckId: workspace.activeDeckId,
|
||||
persisted: true,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user