feat: migrate workspace to shadcn sidebar and chat components

This commit is contained in:
2026-09-28 17:29:08 +09:00
parent 2346848839
commit 96e545401e
61 changed files with 6727 additions and 1146 deletions
+2 -2
View File
@@ -162,7 +162,7 @@ it('preserves title and Twitter search options after a rejected save', async ()
fireEvent.change(screen.getByLabelText('表示順'), {
target: { value: 'Top' },
})
fireEvent.click(screen.getByLabelText('フォロー中のみ'))
fireEvent.click(screen.getByRole('checkbox', { name: 'フォロー中のみ' }))
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() => expect(save).toHaveBeenCalledOnce())
expect(screen.getByLabelText('カラム名')).toHaveValue('調査')
@@ -170,7 +170,7 @@ it('preserves title and Twitter search options after a rejected save', async ()
'from:owner WebMCP',
)
expect(screen.getByLabelText('表示順')).toHaveValue('Top')
expect(screen.getByLabelText('フォロー中のみ')).toBeChecked()
expect(screen.getByRole('checkbox', { name: 'フォロー中のみ' })).toBeChecked()
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
+22 -21
View File
@@ -1,4 +1,8 @@
import { useId, useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import type { Connection } from '#/features/connections/model'
import {
ColumnSourceEditor,
@@ -60,9 +64,9 @@ export function ColumnEditor({
}}
>
<div className="deck-editor-fields">
<label htmlFor={`${formId}-title`}>
<Label htmlFor={`${formId}-title`}>
カラム名
<input
<Input
id={`${formId}-title`}
name="title"
defaultValue={column?.title ?? ''}
@@ -70,10 +74,11 @@ export function ColumnEditor({
placeholder="例:発表元の投稿"
required
/>
</label>
<label htmlFor={`${formId}-profile`}>
</Label>
<Label htmlFor={`${formId}-profile`}>
接続プロファイル
<select
<NativeSelect
className="w-full"
id={`${formId}-profile`}
value={connectionId}
required
@@ -86,22 +91,22 @@ export function ColumnEditor({
setSource(rebindColumnSource(source, connection.platform))
}}
>
<option value="" disabled>
<NativeSelectOption value="" disabled>
選択してください
</option>
</NativeSelectOption>
{connectionId && !validConnection && (
<option value={connectionId} disabled>
<NativeSelectOption value={connectionId} disabled>
{connectionId}(利用不可)
</option>
</NativeSelectOption>
)}
{available.map((connection) => (
<option key={connection.id} value={connection.id}>
<NativeSelectOption key={connection.id} value={connection.id}>
{connection.displayName}
{connection.platform === 'mastodon' ? ' · Mastodon' : ''}
</option>
</NativeSelectOption>
))}
</select>
</label>
</NativeSelect>
</Label>
</div>
<ColumnSourceEditor
source={source}
@@ -117,16 +122,12 @@ export function ColumnEditor({
</p>
)}
<div className="deck-actions">
<button
type="submit"
className="deck-primary"
disabled={!validConnection}
>
<Button type="submit" disabled={!validConnection}>
{column ? '変更を保存' : '追加して検索'}
</button>
<button type="button" onClick={onCancel}>
</Button>
<Button variant="outline" type="button" onClick={onCancel}>
キャンセル
</button>
</Button>
</div>
</form>
)
+46 -43
View File
@@ -1,4 +1,13 @@
import { Icon } from '#/components/icon'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '#/components/ui/dropdown-menu'
import {
flattenResearchPages,
useResearchFeed,
@@ -53,8 +62,9 @@ export function ResearchColumn({
<div className="deck-column-title">
<PlatformIcon platform={column.source.platform} />
<h2 id={headingId}>{column.title}</h2>
<button
className="icon-button"
<Button
variant="ghost"
size="icon-sm"
aria-label="更新"
title="更新"
type="button"
@@ -62,55 +72,46 @@ export function ResearchColumn({
disabled={query.isFetching}
>
<Icon name="refresh" />
</button>
<details className="column-menu">
<summary
className="icon-button"
aria-label={`${column.title}の操作`}
title="カラムの操作"
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
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')
}}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>編集</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onMove(-1)}
disabled={first}
aria-label={`${column.title}を左へ`}
>
← 左へ移動
</button>
<button
type="button"
onClick={(event) => {
onMove(1)
event.currentTarget
.closest('details')
?.removeAttribute('open')
}}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onMove(1)}
disabled={last}
aria-label={`${column.title}を右へ`}
>
右へ移動 →
</button>
<button
type="button"
className="danger"
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={onRemove}
aria-label={`${column.title}を削除`}
>
カラムを削除
</button>
</div>
</details>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="deck-column-label">
<span
@@ -118,10 +119,10 @@ export function ResearchColumn({
>
{connectionLabel ?? '接続が見つかりません'}
</span>
<span className="deck-source-label">
<Badge variant="outline" className="deck-source-label">
<Icon name={column.source.kind} />
{sourceDescription.label}
</span>
</Badge>
</div>
<p className="deck-query">{sourceDescription.query}</p>
<p className="deck-column-status" role="status">
@@ -152,7 +153,8 @@ export function ResearchColumn({
<div className="deck-message" role="alert">
<p>{error.message}</p>
{error.retryable ? (
<button
<Button
variant="outline"
type="button"
disabled={query.isFetching}
onClick={() =>
@@ -162,7 +164,7 @@ export function ResearchColumn({
}
>
再試行
</button>
</Button>
) : null}
</div>
) : null}
@@ -171,13 +173,14 @@ export function ResearchColumn({
) : null}
{query.hasNextPage && !query.isFetchNextPageError ? (
<div className="deck-message">
<button
<Button
variant="outline"
type="button"
disabled={query.isFetching}
onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
>
続きを読み込む
</button>
</Button>
</div>
) : null}
{!query.isPending &&
+363 -304
View File
@@ -5,6 +5,16 @@ import { useRef, useState } from 'react'
import { AppShell } from '#/components/app-shell'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import { Button } from '#/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '#/components/ui/empty'
import { Input } from '#/components/ui/input'
import { ConnectionManager } from '#/features/connections/connection-manager'
import { loadConnections } from '#/features/connections/server-functions'
import { syncResearchDeck } from '#/features/research/research-deck-sync'
@@ -32,7 +42,9 @@ export function DeckPage() {
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
const [renaming, setRenaming] = useState(false)
const [managingConnections, setManagingConnections] = useState(false)
const [switcherEditing, setSwitcherEditing] = useState(false)
const [deckDialog, setDeckDialog] = useState<'create' | 'delete' | null>(null)
const [newDeckTitle, setNewDeckTitle] = useState('')
const switcherEditing = deckDialog !== null
const {
deck,
workspace,
@@ -83,6 +95,8 @@ export function DeckPage() {
})
function clearEditors() {
setDeckDialog(null)
setNewDeckTitle('')
setEditing(null)
setRenaming(false)
setRemoved(undefined)
@@ -102,312 +116,357 @@ export function DeckPage() {
return saved
}
const storageErrorNotice = storageError ? (
<p className="deck-error" role="alert">
{storageError}
<Button
variant="outline"
type="button"
disabled={saving}
onClick={() => {
clearEditors()
void refresh(true)
}}
>
最新のデッキを再読み込み
</Button>
</p>
) : null
return (
<AppShell
researchChat={
<ResearchPanel
onNavigateCitation={citationNavigation.navigate}
ready={ready}
contextDeck={{
id: deck.id,
title: deck.title,
columns: deck.columns,
}}
connections={profiles.data?.connections ?? []}
onOpenDeck={async (plan, activate = false) => {
await syncResearchDeck(plan, activate, researchDeckBindings, {
getWorkspace,
createTemporary,
save,
select,
})
if (activate) clearEditors()
}}
/>
}
sidebar={
<>
<DeckSwitcher
key={deck.id}
decks={workspace.decks}
activeId={deck.id}
ready={ready && !saving}
onEditingChange={setSwitcherEditing}
onSelect={(id) => {
select(id)
clearEditors()
}}
onCreate={async (title) => {
const saved = await create(title)
if (saved) clearEditors()
return saved
}}
onDelete={async () => {
const saved = await remove(deck.id)
if (saved) clearEditors()
return saved
}}
/>
<button
className="deck-primary sidebar-add"
aria-label="カラムを追加"
title="カラムを追加"
type="button"
disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS}
onClick={() => setEditing('new')}
>
<Icon name="plus" />
<span>カラムを追加</span>
</button>
<p className="sidebar-note">
{deck.persisted
? 'サーバーに保存・端末間で共有'
: '一時ビュー · このタブのみ'}
</p>
<button
type="button"
disabled={!ready}
onClick={() => setManagingConnections(true)}
>
接続アカウントを管理
</button>
</>
}
<ResearchPanel
onNavigateCitation={citationNavigation.navigate}
ready={ready}
contextDeck={{
id: deck.id,
title: deck.title,
columns: deck.columns,
}}
connections={profiles.data?.connections ?? []}
onOpenDeck={async (plan, activate = false) => {
await syncResearchDeck(plan, activate, researchDeckBindings, {
getWorkspace,
createTemporary,
save,
select,
})
if (activate) clearEditors()
}}
>
<div className="deck-heading">
<div>
<h1>{deck.title}</h1>
<span className="deck-count">
{deck.columns.length} / {MAX_COLUMNS} カラム
</span>
</div>
<div className="deck-actions">
{deck.persisted ? (
<button
type="button"
disabled={saving}
onClick={() => {
createTemporary(`${deck.title}のコピー`, deck.columns)
clearEditors()
}}
>
一時コピーを作成
</button>
) : (
<button
type="button"
disabled={!ready || saving}
onClick={() => void persist(deck.id)}
>
デッキとして保存
</button>
)}
<button
className="icon-button"
aria-label="名前を変更"
title="名前を変更"
type="button"
disabled={!ready || saving}
onClick={() => setRenaming(!renaming)}
>
<Icon name="edit" />
</button>
</div>
</div>
{saving ? (
<p className="deck-save-status" role="status">
保存しています…
</p>
) : null}
{legacyAvailable ? (
<p className="deck-save-status">
このブラウザに旧デッキがあります。
<button
type="button"
disabled={saving || !profiles.data}
onClick={() => void importLegacy()}
>
旧デッキを取り込む
</button>
</p>
) : null}
{!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}
{oauthResult === 'failed' ? (
<p className="deck-error" role="alert">
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
</p>
) : null}
{oauthResult === 'connected' ? (
<p className="deck-save-status" role="status">
Mastodonアカウントを接続しました。カラムの接続先に選べます。
</p>
) : null}
{profiles.data?.relayError ? (
<p className="deck-error" role="alert">
{profiles.data.relayError}
</p>
) : null}
{managingConnections ? (
<Dialog
title="接続アカウント"
onClose={() => setManagingConnections(false)}
>
<ConnectionManager
connections={profiles.data?.connections ?? []}
onChanged={() => profiles.refetch()}
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
/>
</Dialog>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
) : null}
{storageError ? (
<p className="deck-error" role="alert">
{storageError}
<button
type="button"
disabled={saving}
onClick={() => {
clearEditors()
void refresh(true)
}}
>
最新のデッキを再読み込み
</button>
</p>
) : null}
{renaming ? (
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
if (await save({ ...deck, title })) setRenaming(false)
}}
>
<label htmlFor="deck-title">
調査テーマ・デッキ名
<input
id="deck-title"
name="title"
defaultValue={deck.title}
maxLength={120}
required
{(renderResearchPanel) => (
<AppShell
researchChat={renderResearchPanel}
sidebar={
<>
<DeckSwitcher
key={deck.id}
errorNotice={storageErrorNotice}
decks={workspace.decks}
activeId={deck.id}
ready={ready && !saving}
dialog={deckDialog}
newTitle={newDeckTitle}
onNewTitleChange={setNewDeckTitle}
onDialogChange={(dialog) => {
setDeckDialog(dialog)
if (dialog !== 'create') setNewDeckTitle('')
}}
onSelect={(id) => {
select(id)
clearEditors()
}}
onCreate={async (title) => {
const saved = await create(title)
if (saved) clearEditors()
return saved
}}
onDelete={async () => {
const saved = await remove(deck.id)
if (saved) clearEditors()
return saved
}}
/>
</label>
<button type="submit">名前を保存</button>
</form>
</Dialog>
) : null}
{editing !== null ? (
<Dialog
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
onClose={() => setEditing(null)}
<Button
className="w-full"
aria-label="カラムを追加"
title="カラムを追加"
type="button"
disabled={
!ready || saving || deck.columns.length >= MAX_COLUMNS
}
onClick={() => setEditing('new')}
>
<Icon name="plus" />
<span>カラムを追加</span>
</Button>
<p className="sidebar-note">
{deck.persisted
? 'サーバーに保存・端末間で共有'
: '一時ビュー · このタブのみ'}
</p>
<Button
variant="outline"
type="button"
disabled={!ready}
onClick={() => setManagingConnections(true)}
>
接続アカウントを管理
</Button>
</>
}
>
<ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
connections={profiles.data?.connections ?? []}
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">
<span>「{removed.column.title}」を削除しました。</span>
<button
type="button"
disabled={deck.columns.length >= MAX_COLUMNS || editing === 'new'}
onClick={async () => {
const columns = [...deck.columns]
columns.splice(removed.index, 0, removed.column)
if (await save({ ...deck, columns })) setRemoved(undefined)
}}
>
元に戻す
</button>
</div>
) : null}
{ready && deck.columns.length === 0 && editing === null ? (
<div className="deck-empty">
<Icon name="columns" />
<h2>調べたいことから、デッキを作りましょう</h2>
<p>
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
</p>
<button
className="deck-primary"
type="button"
onClick={() => setEditing('new')}
>
手動でカラムを追加
</button>
</div>
) : null}
{ready && deck.columns.length > 0 ? (
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
{deck.columns.map((column, index) => (
<ResearchColumn
key={`${deck.id}:${column.id}`}
registry={registry}
column={column}
citation={
citationNavigation.target?.deckId === deck.id &&
citationNavigation.target.column.id === column.id &&
sameCitationSource(citationNavigation.target.column, column)
? citationNavigation.target
: undefined
}
connectionLabel={
profiles.data?.connections.find(
(connection) => connection.id === column.connectionId,
)?.displayName
}
first={index === 0}
last={index === deck.columns.length - 1}
onEdit={() => setEditing({ id: column.id })}
onMove={(direction) =>
save(moveColumn(deck, column.id, direction))
}
onRemove={async () => {
const saved = await save({
...deck,
columns: deck.columns.filter(
(current) => current.id !== column.id,
),
})
if (saved) setRemoved({ column, index })
if (typeof editing === 'object' && editing?.id === column.id)
setEditing(null)
}}
/>
))}
</section>
) : null}
</AppShell>
<div className="deck-heading">
<div>
<h1>{deck.title}</h1>
<span className="deck-count">
{deck.columns.length} / {MAX_COLUMNS} カラム
</span>
</div>
<div className="deck-actions">
{deck.persisted ? (
<Button
variant="outline"
type="button"
disabled={saving}
onClick={() => {
createTemporary(`${deck.title}のコピー`, deck.columns)
clearEditors()
}}
>
一時コピーを作成
</Button>
) : (
<Button
variant="outline"
type="button"
disabled={!ready || saving}
onClick={() => void persist(deck.id)}
>
デッキとして保存
</Button>
)}
<Button
variant="outline"
className="icon-button"
aria-label="名前を変更"
title="名前を変更"
type="button"
disabled={!ready || saving}
onClick={() => setRenaming(!renaming)}
>
<Icon name="edit" />
</Button>
</div>
</div>
{saving ? (
<p className="deck-save-status" role="status">
保存しています…
</p>
) : null}
{legacyAvailable ? (
<p className="deck-save-status">
このブラウザに旧デッキがあります。
<Button
variant="outline"
type="button"
disabled={saving || !profiles.data}
onClick={() => void importLegacy()}
>
旧デッキを取り込む
</Button>
</p>
) : null}
{!ready ? (
<p className="deck-save-status" role="status">
デッキを読み込んでいます…
</p>
) : null}
{profiles.isError ? (
<p role="alert" className="deck-error">
接続プロファイルを取得できませんでした。
<Button
variant="outline"
type="button"
onClick={() => void profiles.refetch()}
>
再試行
</Button>
</p>
) : null}
{oauthResult === 'failed' ? (
<p className="deck-error" role="alert">
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
</p>
) : null}
{oauthResult === 'connected' ? (
<p className="deck-save-status" role="status">
Mastodonアカウントを接続しました。カラムの接続先に選べます。
</p>
) : null}
{profiles.data?.relayError ? (
<p className="deck-error" role="alert">
{profiles.data.relayError}
</p>
) : null}
{managingConnections ? (
<Dialog
title="接続アカウント"
onClose={() => setManagingConnections(false)}
>
<ConnectionManager
connections={profiles.data?.connections ?? []}
onChanged={() => profiles.refetch()}
hasTemporaryDecks={workspace.decks.some(
(deck) => !deck.persisted,
)}
/>
</Dialog>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
) : null}
{!renaming && editing === null && !switcherEditing
? storageErrorNotice
: null}
{renaming ? (
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
if (await save({ ...deck, title })) setRenaming(false)
}}
>
<label htmlFor="deck-title">
調査テーマ・デッキ名
<Input
id="deck-title"
name="title"
defaultValue={deck.title}
maxLength={120}
required
/>
</label>
<Button variant="outline" type="submit">
名前を保存
</Button>
</form>
{storageErrorNotice}
</Dialog>
) : null}
{editing !== null ? (
<Dialog
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
onClose={() => setEditing(null)}
>
<ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
connections={profiles.data?.connections ?? []}
column={
editing === 'new'
? undefined
: deck.columns.find((column) => column.id === editing.id)
}
onSave={saveColumn}
onCancel={() => setEditing(null)}
/>
{storageErrorNotice}
</Dialog>
) : null}
{removed ? (
<div className="deck-undo" role="status">
<span>「{removed.column.title}」を削除しました。</span>
<Button
variant="outline"
type="button"
disabled={
deck.columns.length >= MAX_COLUMNS || editing === 'new'
}
onClick={async () => {
const columns = [...deck.columns]
columns.splice(removed.index, 0, removed.column)
if (await save({ ...deck, columns })) setRemoved(undefined)
}}
>
元に戻す
</Button>
</div>
) : null}
{ready && deck.columns.length === 0 && editing === null ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Icon name="columns" />
</EmptyMedia>
<EmptyTitle>
<h2>調べたいことから、デッキを作りましょう</h2>
</EmptyTitle>
<EmptyDescription>
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button type="button" onClick={() => setEditing('new')}>
手動でカラムを追加
</Button>
</EmptyContent>
</Empty>
) : null}
{ready && deck.columns.length > 0 ? (
<section
className="deck-board"
aria-label="調査カラム"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
tabIndex={0}
>
{deck.columns.map((column, index) => (
<ResearchColumn
key={`${deck.id}:${column.id}`}
registry={registry}
column={column}
citation={
citationNavigation.target?.deckId === deck.id &&
citationNavigation.target.column.id === column.id &&
sameCitationSource(citationNavigation.target.column, column)
? citationNavigation.target
: undefined
}
connectionLabel={
profiles.data?.connections.find(
(connection) => connection.id === column.connectionId,
)?.displayName
}
first={index === 0}
last={index === deck.columns.length - 1}
onEdit={() => setEditing({ id: column.id })}
onMove={(direction) =>
save(moveColumn(deck, column.id, direction))
}
onRemove={async () => {
const saved = await save({
...deck,
columns: deck.columns.filter(
(current) => current.id !== column.id,
),
})
if (saved) setRemoved({ column, index })
if (
typeof editing === 'object' &&
editing?.id === column.id
)
setEditing(null)
}}
/>
))}
</section>
) : null}
</AppShell>
)}
</ResearchPanel>
)
}
+60 -43
View File
@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import type { DeckView } from './model'
export function DeckSwitcher({
@@ -10,7 +14,11 @@ export function DeckSwitcher({
onSelect,
onCreate,
onDelete,
onEditingChange,
dialog,
onDialogChange,
newTitle,
onNewTitleChange,
errorNotice,
}: {
decks: DeckView[]
activeId: string
@@ -18,109 +26,118 @@ export function DeckSwitcher({
onSelect: (id: string) => void
onCreate: (title: string) => Promise<boolean>
onDelete: () => Promise<boolean>
onEditingChange: (editing: boolean) => void
dialog: 'create' | 'delete' | null
onDialogChange: (dialog: 'create' | 'delete' | null) => void
newTitle: string
onNewTitleChange: (title: string) => void
errorNotice?: ReactNode
}) {
const [creating, setCreating] = useState(false)
const [deleting, setDeleting] = useState(false)
useEffect(() => {
onEditingChange(creating || deleting)
return () => onEditingChange(false)
}, [creating, deleting, onEditingChange])
return (
<div className="deck-workspaces">
<div className="deck-actions">
<label htmlFor="deck-selection">デッキプロファイル</label>
<select
<Label htmlFor="deck-selection">デッキプロファイル</Label>
<NativeSelect
className="w-full"
id="deck-selection"
value={activeId}
disabled={!ready}
onChange={(event) => {
onSelect(event.target.value)
setCreating(false)
setDeleting(false)
onDialogChange(null)
}}
>
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
<NativeSelectOption key={deck.id} value={deck.id}>
{deck.title}
{deck.persisted ? '' : '(一時)'}
</option>
</NativeSelectOption>
))}
</select>
<button
</NativeSelect>
<Button
variant="outline"
className="workspace-create"
aria-label="デッキを作成"
title="デッキを作成"
type="button"
disabled={!ready}
onClick={() => {
setCreating(!creating)
setDeleting(false)
onDialogChange('create')
}}
>
<Icon name="plus" />
<span>デッキを作成</span>
</button>
<button
</Button>
<Button
variant="ghost"
className="workspace-delete"
aria-label="デッキを削除"
title="デッキを削除"
type="button"
disabled={!ready}
onClick={() => {
setDeleting(!deleting)
setCreating(false)
onDialogChange('delete')
}}
>
<Icon name="trash" />
<span>デッキを削除</span>
</button>
</Button>
</div>
{creating ? (
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
{dialog === 'create' ? (
<Dialog title="デッキを作成" onClose={() => onDialogChange(null)}>
<form
className="deck-rename"
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
const title = newTitle.trim()
if (!title) return
if (await onCreate(title)) setCreating(false)
if (await onCreate(title)) onDialogChange(null)
}}
>
<label htmlFor="new-deck-title">
<Label htmlFor="new-deck-title">
新しいデッキ名
<input
<Input
id="new-deck-title"
name="title"
value={newTitle}
onChange={(event) => onNewTitleChange(event.target.value)}
maxLength={120}
required
/>
</label>
<button type="submit">作成</button>
<button type="button" onClick={() => setCreating(false)}>
</Label>
<Button type="submit">作成</Button>
<Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
キャンセル
</button>
</Button>
</form>
{errorNotice}
</Dialog>
) : null}
{deleting ? (
<Dialog title="デッキを削除" onClose={() => setDeleting(false)}>
{dialog === 'delete' ? (
<Dialog title="デッキを削除" onClose={() => onDialogChange(null)}>
<div className="deck-undo">
<p>このデッキとカラムの設定を削除します。</p>
<button
<Button
variant="destructive"
type="button"
onClick={async () => {
if (await onDelete()) setDeleting(false)
if (await onDelete()) onDialogChange(null)
}}
>
削除する
</button>
<button type="button" onClick={() => setDeleting(false)}>
</Button>
<Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
キャンセル
</button>
</Button>
</div>
{errorNotice}
</Dialog>
) : null}
</div>
+14 -197
View File
@@ -1,47 +1,3 @@
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
.app-deck button {
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(--surface);
cursor: pointer;
white-space: nowrap;
}
.app-deck button:hover:not(:disabled) {
background: var(--color-paper-3);
}
.app-deck button:active:not(:disabled) {
color: var(--accent);
}
.app-deck button:disabled {
opacity: 0.45;
cursor: default;
}
.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: center;
@@ -61,7 +17,7 @@
}
.deck-heading p {
margin: var(--space-2xs) 0 0;
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-actions {
@@ -73,7 +29,7 @@
.deck-help,
.deck-save-status,
.deck-column-status {
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
line-height: 1.5;
}
@@ -130,7 +86,7 @@
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-column-label span {
@@ -149,7 +105,7 @@
.deck-query {
margin: var(--space-2xs) 0;
font-size: var(--text-xs);
color: var(--secondary);
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -166,47 +122,9 @@
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);
color: var(--muted-foreground);
font-size: var(--text-sm);
line-height: 1.6;
}
@@ -223,11 +141,6 @@
.research-post header > div {
min-width: 0;
}
.research-post header img {
flex-shrink: 0;
border-radius: var(--radius-sm);
object-fit: cover;
}
.research-post strong {
font-size: var(--text-sm);
}
@@ -250,11 +163,11 @@
justify-content: space-between;
gap: var(--space-xs);
margin-top: var(--space-sm);
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.research-post a {
color: var(--accent);
color: var(--primary);
text-underline-offset: 0.2em;
}
.research-media {
@@ -265,34 +178,6 @@
margin-block: var(--space-xs);
border-radius: var(--radius-sm);
}
.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;
@@ -312,30 +197,12 @@
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;
@@ -347,7 +214,7 @@
flex: 1 1 100%;
}
.deck-error {
color: var(--error);
color: var(--destructive);
font-size: var(--text-sm);
line-height: 1.6;
overflow-wrap: anywhere;
@@ -374,7 +241,7 @@
padding: var(--space-lg);
}
.deck-empty p {
color: var(--secondary);
color: var(--muted-foreground);
line-height: 1.7;
margin-bottom: var(--space-lg);
}
@@ -386,10 +253,10 @@
}
.deck-workspaces > .deck-actions > label {
width: 100%;
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-workspaces > .deck-actions > select {
.deck-workspaces > .deck-actions > [data-slot="native-select-wrapper"] {
flex: 1 1 100%;
}
.deck-workspaces > .deck-actions > button {
@@ -404,35 +271,6 @@
width: 100%;
}
@media (max-width: 767px) {
.deck-workspaces {
flex: 1;
}
.deck-workspaces > .deck-actions {
flex-wrap: nowrap;
gap: var(--space-2xs);
}
.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);
@@ -444,37 +282,19 @@
grid-auto-columns: min(328px, 100%);
}
}
@media (pointer: coarse) {
.app-deck .icon-button,
.column-menu summary {
min-width: 40px;
min-height: 40px;
}
}
.deck-count {
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.research-post header time {
display: block;
color: var(--secondary);
color: var(--muted-foreground);
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);
}
}
.research-post header > div {
.research-post header > div:not([data-slot="avatar"]) {
flex: 1;
}
.research-post header time {
@@ -490,6 +310,3 @@
width: 14px;
height: 14px;
}
.column-menu-panel .danger {
color: var(--error);
}
+38 -24
View File
@@ -1,6 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { type ReactElement, useId } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { loadMastodonLists } from '../platforms/mastodon-server-functions'
import type { MastodonSource } from '../platforms/mastodon-source'
@@ -32,9 +36,10 @@ export function MastodonSourceEditor({
const id = useId()
return (
<>
<label htmlFor={id}>
<Label htmlFor={id}>
カラムの種類
<select
<NativeSelect
className="w-full"
id={id}
value={source.kind}
onChange={(event) =>
@@ -45,12 +50,12 @@ export function MastodonSourceEditor({
)
}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
<option value="hashtag">ハッシュタグ</option>
</select>
</label>
<NativeSelectOption value="search">検索</NativeSelectOption>
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
<NativeSelectOption value="list">リスト</NativeSelectOption>
<NativeSelectOption value="hashtag">ハッシュタグ</NativeSelectOption>
</NativeSelect>
</Label>
<MastodonSourceFields
source={source}
connectionId={connectionId}
@@ -90,9 +95,9 @@ function MastodonSearchEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
<Label htmlFor={id}>
Mastodonの検索条件
<input
<Input
id={id}
value={source.query}
onChange={(event) =>
@@ -102,7 +107,7 @@ function MastodonSearchEditor({
placeholder="例:WebMCP"
required
/>
</label>
</Label>
<p className="deck-help">
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。
</p>
@@ -117,9 +122,9 @@ function MastodonUserEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
<Label htmlFor={id}>
ユーザー
<input
<Input
id={id}
value={source.target}
onChange={(event) =>
@@ -129,7 +134,7 @@ function MastodonUserEditor({
maxLength={256}
required
/>
</label>
</Label>
</div>
)
}
@@ -141,9 +146,9 @@ function MastodonHashtagEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
<Label htmlFor={id}>
ハッシュタグ
<input
<Input
id={id}
value={source.target}
onChange={(event) =>
@@ -153,7 +158,7 @@ function MastodonHashtagEditor({
maxLength={128}
required
/>
</label>
</Label>
</div>
)
}
@@ -168,9 +173,9 @@ function MastodonListEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
<Label htmlFor={id}>
リスト
<input
<Input
id={id}
value={source.target}
onChange={(event) =>
@@ -180,7 +185,7 @@ function MastodonListEditor({
maxLength={32}
required
/>
</label>
</Label>
{connectionId && (
<MastodonListChoices
connectionId={connectionId}
@@ -209,9 +214,13 @@ function MastodonListChoices({
return (
<p role="alert">
リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}>
<Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
再試行
</button>
</Button>
</p>
)
return (
@@ -220,9 +229,14 @@ function MastodonListChoices({
<p>このアカウントのリストはありません。</p>
) : (
lists.data.lists.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
<Button
variant="outline"
type="button"
key={list.id}
onClick={() => onSelect(list.id)}
>
{list.name}
</button>
</Button>
))
)}
</section>
+14 -12
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { Icon } from '#/components/icon'
import { PostText } from '#/components/post-text'
import { Avatar, AvatarFallback, AvatarImage } from '#/components/ui/avatar'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import type { ResearchPost } from '#/features/platforms/types'
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
@@ -49,20 +52,17 @@ export function ResearchPostCard({
className={`research-post${highlighted ? ' citation-highlight' : ''}`}
data-post-url={post.url}
>
{citationSnapshot ? <p className="handle">調査時に取得した投稿</p> : null}
{citationSnapshot ? (
<Badge variant="secondary">調査時に取得した投稿</Badge>
) : null}
{post.boostedBy ? (
<p className="handle">{post.boostedBy.name} がブースト</p>
) : null}
<header>
{post.author.avatarUrl ? (
<img
src={post.author.avatarUrl}
alt=""
width={36}
height={36}
loading="lazy"
/>
) : null}
<Avatar className="size-9 shrink-0">
<AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" />
<AvatarFallback>{post.author.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div>
<strong>{post.author.name}</strong>
<span className="handle">
@@ -78,13 +78,15 @@ export function ResearchPostCard({
{concealed ? (
<div>
<p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p>
<button
<Button
variant="outline"
size="sm"
type="button"
aria-expanded={revealed}
onClick={() => setRevealed(!revealed)}
>
{revealed ? '内容を隠す' : '内容を表示'}
</button>
</Button>
</div>
) : null}
{!concealed || revealed ? (
+47 -32
View File
@@ -1,4 +1,9 @@
import { type ReactElement, useId } from 'react'
import { Button } from '#/components/ui/button'
import { Checkbox } from '#/components/ui/checkbox'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { useListChoices } from '../posts/use-list-choices'
import type { DeckColumn } from './model'
@@ -33,20 +38,21 @@ export function TwitterSourceEditor({
const id = useId()
return (
<>
<label htmlFor={id}>
<Label htmlFor={id}>
カラムの種類
<select
<NativeSelect
className="w-full"
id={id}
value={source.kind}
onChange={(event) =>
onChange(defaultTwitterSource(event.target.value as Source['kind']))
}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
</select>
</label>
<NativeSelectOption value="search">検索</NativeSelectOption>
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
<NativeSelectOption value="list">リスト</NativeSelectOption>
</NativeSelect>
</Label>
<TwitterSourceFields
source={source}
connectionId={connectionId}
@@ -84,9 +90,9 @@ function TwitterSearchEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={`${id}-query`}>
<Label htmlFor={`${id}-query`}>
Twitterの検索条件
<input
<Input
id={`${id}-query`}
value={source.query}
onChange={(event) =>
@@ -96,10 +102,11 @@ function TwitterSearchEditor({
placeholder="例:WebMCP lang:ja"
required
/>
</label>
<label htmlFor={`${id}-product`}>
</Label>
<Label htmlFor={`${id}-product`}>
表示順
<select
<NativeSelect
className="w-full"
id={`${id}-product`}
value={source.product}
onChange={(event) =>
@@ -109,23 +116,22 @@ function TwitterSearchEditor({
})
}
>
<option value="Latest">最新</option>
<option value="Top">話題</option>
</select>
</label>
<NativeSelectOption value="Latest">最新</NativeSelectOption>
<NativeSelectOption value="Top">話題</NativeSelectOption>
</NativeSelect>
</Label>
<p className="deck-help">
from:、lang:、since: など、Twitterの検索構文を使えます。
</p>
<label className="deck-checkbox">
<input
type="checkbox"
<Label className="deck-checkbox">
<Checkbox
checked={source.following}
onChange={(event) =>
onChange({ ...source, following: event.target.checked })
onCheckedChange={(checked) =>
onChange({ ...source, following: checked })
}
/>
フォロー中のみ
</label>
</Label>
</div>
)
}
@@ -137,9 +143,9 @@ function TwitterUserEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
<Label htmlFor={id}>
ユーザー
<input
<Input
id={id}
value={source.target}
onChange={(event) =>
@@ -149,7 +155,7 @@ function TwitterUserEditor({
maxLength={256}
required
/>
</label>
</Label>
</div>
)
}
@@ -162,9 +168,9 @@ function TwitterListEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
<Label htmlFor={id}>
リスト
<input
<Input
id={id}
value={source.target}
onChange={(event) =>
@@ -174,7 +180,7 @@ function TwitterListEditor({
maxLength={256}
required
/>
</label>
</Label>
{connectionId && (
<TwitterListChoices
connectionId={connectionId}
@@ -198,9 +204,13 @@ function TwitterListChoices({
return (
<p role="alert">
リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}>
<Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
再試行
</button>
</Button>
</p>
)
return (
@@ -212,10 +222,15 @@ function TwitterListChoices({
<p>リストはありません。URLまたはIDでも指定できます。</p>
) : (
lists.data.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
<Button
variant="outline"
type="button"
key={list.id}
onClick={() => onSelect(list.id)}
>
{list.name}
{list.isPrivate ? ' · 非公開' : ''}
</button>
</Button>
))
)}
</section>