feat: translate system interface to English

This commit is contained in:
2026-09-28 18:42:16 +09:00
parent e870bdfcbb
commit 1cf32705a7
74 changed files with 1232 additions and 1065 deletions
+30 -30
View File
@@ -75,7 +75,7 @@ function show() {
/>
</QueryClientProvider>,
)
fireEvent.change(screen.getByLabelText('カラム名'), {
fireEvent.change(screen.getByLabelText('Column name'), {
target: { value: '調査' },
})
return onSave
@@ -83,16 +83,16 @@ function show() {
it('creates a Mastodon search without Twitter ordering or following fields', async () => {
const save = show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' },
})
expect(screen.queryByLabelText('表示順')).toBeNull()
expect(screen.queryByLabelText('フォロー中のみ')).toBeNull()
expect(screen.getByText(/結果が0件でも/)).toBeVisible()
fireEvent.change(screen.getByLabelText('Mastodonの検索条件'), {
expect(screen.queryByLabelText('Sort by')).toBeNull()
expect(screen.queryByLabelText('Following only')).toBeNull()
expect(screen.getByText(/Zero results/)).toBeVisible()
fireEvent.change(screen.getByLabelText('Mastodon search query'), {
target: { value: 'WebMCP' },
})
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() =>
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
@@ -105,16 +105,16 @@ it('creates a Mastodon search without Twitter ordering or following fields', asy
it('creates hashtags and resets unsupported source kind when changing to Twitter', async () => {
const save = show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' },
})
fireEvent.change(screen.getByLabelText('カラムの種類'), {
fireEvent.change(screen.getByLabelText('Column type'), {
target: { value: 'hashtag' },
})
fireEvent.change(screen.getByLabelText('ハッシュタグ'), {
fireEvent.change(screen.getByLabelText('Hashtag'), {
target: { value: 'WebMCP' },
})
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() =>
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
@@ -122,32 +122,32 @@ it('creates hashtags and resets unsupported source kind when changing to Twitter
}),
),
)
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'twitter' },
})
expect(screen.getByLabelText('カラムの種類')).toHaveValue('search')
expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue('')
expect(screen.queryByRole('option', { name: 'ハッシュタグ' })).toBeNull()
expect(screen.getByLabelText('Column type')).toHaveValue('search')
expect(screen.getByLabelText('Twitter search query')).toHaveValue('')
expect(screen.queryByRole('option', { name: 'Hashtag' })).toBeNull()
})
it('loads lists for each account and clears the selected list when the binding changes', async () => {
show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' },
})
fireEvent.change(screen.getByLabelText('カラムの種類'), {
fireEvent.change(screen.getByLabelText('Column type'), {
target: { value: 'list' },
})
fireEvent.click(await screen.findByRole('button', { name: 'mastodon-first' }))
expect(screen.getByLabelText('リスト')).toHaveValue('10')
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
expect(screen.getByLabelText('List')).toHaveValue('10')
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-second' },
})
expect(screen.getByLabelText('リスト')).toHaveValue('')
expect(screen.getByLabelText('List')).toHaveValue('')
fireEvent.click(
await screen.findByRole('button', { name: 'mastodon-second' }),
)
expect(screen.getByLabelText('リスト')).toHaveValue('20')
expect(screen.getByLabelText('List')).toHaveValue('20')
expect(lists).toHaveBeenCalledWith({
data: { connectionId: 'mastodon-second' },
})
@@ -156,22 +156,22 @@ it('loads lists for each account and clears the selected list when the binding c
it('preserves title and Twitter search options after a rejected save', async () => {
const save = show()
save.mockResolvedValue(false)
fireEvent.change(screen.getByLabelText('Twitterの検索条件'), {
fireEvent.change(screen.getByLabelText('Twitter search query'), {
target: { value: 'from:owner WebMCP' },
})
fireEvent.change(screen.getByLabelText('表示順'), {
fireEvent.change(screen.getByLabelText('Sort by'), {
target: { value: 'Top' },
})
fireEvent.click(screen.getByRole('checkbox', { name: 'フォロー中のみ' }))
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Following only' }))
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() => expect(save).toHaveBeenCalledOnce())
expect(screen.getByLabelText('カラム名')).toHaveValue('調査')
expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue(
expect(screen.getByLabelText('Column name')).toHaveValue('調査')
expect(screen.getByLabelText('Twitter search query')).toHaveValue(
'from:owner WebMCP',
)
expect(screen.getByLabelText('表示順')).toHaveValue('Top')
expect(screen.getByRole('checkbox', { name: 'フォロー中のみ' })).toBeChecked()
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
expect(screen.getByLabelText('Sort by')).toHaveValue('Top')
expect(screen.getByRole('checkbox', { name: 'Following only' })).toBeChecked()
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
})
+10 -10
View File
@@ -40,7 +40,7 @@ export function ColumnEditor({
return (
<form
className="deck-editor"
aria-label={column ? 'カラムを編集' : 'カラムを追加'}
aria-label={column ? 'Edit column' : 'Add column'}
onSubmit={async (event) => {
event.preventDefault()
setError(undefined)
@@ -55,7 +55,7 @@ export function ColumnEditor({
if (!parsed.success || !validConnection) {
setError(
parsed.success
? '接続プロファイルを選択してください。'
? 'Select a connection profile.'
: parsed.error.issues[0]?.message,
)
return
@@ -65,18 +65,18 @@ export function ColumnEditor({
>
<div className="deck-editor-fields">
<Label htmlFor={`${formId}-title`}>
カラム名
Column name
<Input
id={`${formId}-title`}
name="title"
defaultValue={column?.title ?? ''}
maxLength={80}
placeholder="例:発表元の投稿"
placeholder="e.g. Announcements"
required
/>
</Label>
<Label htmlFor={`${formId}-profile`}>
接続プロファイル
Connection profile
<NativeSelect
className="w-full"
id={`${formId}-profile`}
@@ -92,11 +92,11 @@ export function ColumnEditor({
}}
>
<NativeSelectOption value="" disabled>
選択してください
Select an option
</NativeSelectOption>
{connectionId && !validConnection && (
<NativeSelectOption value={connectionId} disabled>
{connectionId}(利用不可)
{connectionId} (unavailable)
</NativeSelectOption>
)}
{available.map((connection) => (
@@ -114,7 +114,7 @@ export function ColumnEditor({
onChange={setSource}
/>
<p className="deck-help">
このカラムは選択した接続プロファイルで取得します。
This column uses the selected connection profile.
</p>
{error && (
<p role="alert" className="deck-error">
@@ -123,10 +123,10 @@ export function ColumnEditor({
)}
<div className="deck-actions">
<Button type="submit" disabled={!validConnection}>
{column ? '変更を保存' : '追加して検索'}
{column ? 'Save changes' : 'Add and search'}
</Button>
<Button variant="outline" type="button" onClick={onCancel}>
キャンセル
Cancel
</Button>
</div>
</form>
+4 -4
View File
@@ -19,7 +19,7 @@ vi.mock('./research-post-card', () => ({
}) => (
<article>
{post.text}
{citationSnapshot ? '調査時に取得した投稿' : ''}
{citationSnapshot ? 'Post saved during research' : ''}
</article>
),
}))
@@ -71,11 +71,11 @@ it('temporarily renders missing evidence without inserting it into feed pages or
/>,
)
expect(screen.getByRole('article')).toHaveTextContent(
'Snapshot調査時に取得した投稿',
'SnapshotPost saved during research',
)
expect(page.posts).toHaveLength(0)
expect(page.nextCursor).toBe('original-cursor')
fireEvent.click(screen.getByRole('button', { name: '続きを読み込む' }))
fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(feed.next).toHaveBeenCalledWith({ cancelRefetch: false })
})
@@ -110,6 +110,6 @@ it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias
expect(screen.getAllByRole('article')).toHaveLength(1)
expect(screen.getByRole('article')).toHaveTextContent('Live')
expect(
screen.queryByText(/Snapshot|調査時に取得した投稿/),
screen.queryByText(/Snapshot|Post saved during research/),
).not.toBeInTheDocument()
})
+20 -22
View File
@@ -53,7 +53,7 @@ export function ResearchColumn({
const error =
query.error instanceof PostLoadError
? query.error.detail
: { message: '投稿を取得できませんでした。', retryable: true }
: { message: 'Unable to load posts.', retryable: true }
const headingId = `column-${column.id}`
const sourceDescription = describeSource(column.source)
return (
@@ -65,8 +65,8 @@ export function ResearchColumn({
<Button
variant="ghost"
size="icon-sm"
aria-label="更新"
title="更新"
aria-label="Refresh"
title="Refresh"
type="button"
onClick={() => void query.refetch()}
disabled={query.isFetching}
@@ -79,45 +79,45 @@ export function ResearchColumn({
<Button
variant="ghost"
size="icon-sm"
aria-label={`${column.title}の操作`}
title="カラムの操作"
aria-label={`Actions for ${column.title}`}
title="Column actions"
/>
}
>
<Icon name="more" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>編集</DropdownMenuItem>
<DropdownMenuItem onClick={onEdit}>Edit</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onMove(-1)}
disabled={first}
aria-label={`${column.title}を左へ`}
aria-label={`Move ${column.title} left`}
>
← 左へ移動
← Move left
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onMove(1)}
disabled={last}
aria-label={`${column.title}を右へ`}
aria-label={`Move ${column.title} right`}
>
右へ移動 →
Move right →
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={onRemove}
aria-label={`${column.title}を削除`}
aria-label={`Delete ${column.title}`}
>
カラムを削除
Delete column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="deck-column-label">
<span
title={`接続プロファイル: ${connectionLabel ?? '接続が見つかりません'}`}
title={`Connection profile: ${connectionLabel ?? 'Connection not found'}`}
>
{connectionLabel ?? '接続が見つかりません'}
{connectionLabel ?? 'Connection not found'}
</span>
<Badge variant="outline" className="deck-source-label">
<Icon name={column.source.kind} />
@@ -126,16 +126,14 @@ export function ResearchColumn({
</div>
<p className="deck-query">{sourceDescription.query}</p>
<p className="deck-column-status" role="status">
{query.isFetching
? '投稿を取得しています…'
: `${posts.length}件を読み込み済み`}
{query.isFetching ? 'Loading posts…' : `${posts.length} posts loaded`}
</p>
</header>
<section
className="deck-column-results"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Make the scrolling feed keyboard-accessible.
tabIndex={0}
aria-label={`${column.title}の投稿`}
aria-label={`Posts in ${column.title}`}
>
{visiblePosts.map((post) => (
<ResearchPostCard
@@ -163,13 +161,13 @@ export function ResearchColumn({
: query.refetch())
}
>
再試行
Retry
</Button>
) : null}
</div>
) : null}
{!query.isPending && !query.isError && visiblePosts.length === 0 ? (
<p className="deck-message">条件に一致する投稿はありません。</p>
<p className="deck-message">No posts match your filters.</p>
) : null}
{query.hasNextPage && !query.isFetchNextPageError ? (
<div className="deck-message">
@@ -179,7 +177,7 @@ export function ResearchColumn({
disabled={query.isFetching}
onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
>
続きを読み込む
Load more
</Button>
</div>
) : null}
@@ -187,7 +185,7 @@ export function ResearchColumn({
!query.isError &&
posts.length > 0 &&
!query.hasNextPage ? (
<p className="deck-message">これ以上の投稿はありません。</p>
<p className="deck-message">No more posts.</p>
) : null}
</section>
</section>
+34 -32
View File
@@ -126,7 +126,7 @@ export function DeckPage() {
void refresh(true)
}}
>
最新のデッキを再読み込み
Reload latest deck
</Button>
</p>
) : null
@@ -187,8 +187,8 @@ export function DeckPage() {
/>
<Button
className="w-full"
aria-label="カラムを追加"
title="カラムを追加"
aria-label="Add column"
title="Add column"
type="button"
disabled={
!ready || saving || deck.columns.length >= MAX_COLUMNS
@@ -196,12 +196,12 @@ export function DeckPage() {
onClick={() => setEditing('new')}
>
<Icon name="plus" />
<span>カラムを追加</span>
<span>Add column</span>
</Button>
<p className="sidebar-note">
{deck.persisted
? 'サーバーに保存・端末間で共有'
: '一時ビュー · このタブのみ'}
? 'Saved to server · Shared across devices'
: 'Temporary view · This tab only'}
</p>
</>
}
@@ -210,7 +210,7 @@ export function DeckPage() {
<div>
<h1>{deck.title}</h1>
<span className="deck-count">
{deck.columns.length} / {MAX_COLUMNS} カラム
{deck.columns.length} / {MAX_COLUMNS} columns
</span>
</div>
<div className="deck-actions">
@@ -220,11 +220,11 @@ export function DeckPage() {
type="button"
disabled={saving}
onClick={() => {
createTemporary(`${deck.title}のコピー`, deck.columns)
createTemporary(`${deck.title} copy`, deck.columns)
clearEditors()
}}
>
一時コピーを作成
Create temporary copy
</Button>
) : (
<Button
@@ -233,14 +233,14 @@ export function DeckPage() {
disabled={!ready || saving}
onClick={() => void persist(deck.id)}
>
デッキとして保存
Save as deck
</Button>
)}
<Button
variant="outline"
className="icon-button"
aria-label="名前を変更"
title="名前を変更"
aria-label="Rename"
title="Rename"
type="button"
disabled={!ready || saving}
onClick={() => setRenaming(!renaming)}
@@ -251,47 +251,48 @@ export function DeckPage() {
</div>
{saving ? (
<p className="deck-save-status" role="status">
保存しています…
Saving…
</p>
) : null}
{legacyAvailable ? (
<p className="deck-save-status">
このブラウザに旧デッキがあります。
Legacy decks are available in this browser.
<Button
variant="outline"
type="button"
disabled={saving || !profiles.data}
onClick={() => void importLegacy()}
>
旧デッキを取り込む
Import legacy decks
</Button>
</p>
) : null}
{!ready ? (
<p className="deck-save-status" role="status">
デッキを読み込んでいます…
Loading decks…
</p>
) : null}
{profiles.isError ? (
<p role="alert" className="deck-error">
接続プロファイルを取得できませんでした。
Unable to load connection profiles.
<Button
variant="outline"
type="button"
onClick={() => void profiles.refetch()}
>
再試行
Retry
</Button>
</p>
) : null}
{oauthResult === 'failed' ? (
<p className="deck-error" role="alert">
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
Could not complete Mastodon authorization. Try again from
Connected accounts.
</p>
) : null}
{oauthResult === 'connected' ? (
<p className="deck-save-status" role="status">
Mastodonアカウントを接続しました。カラムの接続先に選べます。
Mastodon account connected. You can now use it in a column.
</p>
) : null}
{profiles.data?.relayError ? (
@@ -300,13 +301,13 @@ export function DeckPage() {
</p>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
<p role="status">Loading connection profiles…</p>
) : null}
{!renaming && editing === null && !switcherEditing
? storageErrorNotice
: null}
{renaming ? (
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<Dialog title="Rename deck" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={async (event) => {
@@ -319,7 +320,7 @@ export function DeckPage() {
}}
>
<label htmlFor="deck-title">
調査テーマ・デッキ名
Research topic / deck name
<Input
id="deck-title"
name="title"
@@ -329,7 +330,7 @@ export function DeckPage() {
/>
</label>
<Button variant="outline" type="submit">
名前を保存
Save name
</Button>
</form>
{storageErrorNotice}
@@ -337,7 +338,7 @@ export function DeckPage() {
) : null}
{editing !== null ? (
<Dialog
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
title={editing === 'new' ? 'Add column' : 'Edit column'}
onClose={() => setEditing(null)}
>
<ColumnEditor
@@ -356,7 +357,7 @@ export function DeckPage() {
) : null}
{removed ? (
<div className="deck-undo" role="status">
<span>「{removed.column.title}」を削除しました。</span>
<span>Deleted “{removed.column.title}”.</span>
<Button
variant="outline"
type="button"
@@ -369,7 +370,7 @@ export function DeckPage() {
if (await save({ ...deck, columns })) setRemoved(undefined)
}}
>
元に戻す
Undo
</Button>
</div>
) : null}
@@ -380,16 +381,17 @@ export function DeckPage() {
<Icon name="columns" />
</EmptyMedia>
<EmptyTitle>
<h2>調べたいことから、デッキを作りましょう</h2>
<h2>Start a deck with a question</h2>
</EmptyTitle>
<EmptyDescription>
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
Tell the assistant what you want to research to create
relevant columns. You can also add search, user posts, and
list columns yourself.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button type="button" onClick={() => setEditing('new')}>
手動でカラムを追加
Add column manually
</Button>
</EmptyContent>
</Empty>
@@ -397,7 +399,7 @@ export function DeckPage() {
{ready && deck.columns.length > 0 ? (
<section
className="deck-board"
aria-label="調査カラム"
aria-label="Research columns"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
tabIndex={0}
>
+16 -16
View File
@@ -35,7 +35,7 @@ export function DeckSwitcher({
return (
<div className="deck-workspaces">
<div className="deck-actions">
<Label htmlFor="deck-selection">デッキプロファイル</Label>
<Label htmlFor="deck-selection">Deck profile</Label>
<NativeSelect
className="w-full"
id="deck-selection"
@@ -49,15 +49,15 @@ export function DeckSwitcher({
{decks.map((deck) => (
<NativeSelectOption key={deck.id} value={deck.id}>
{deck.title}
{deck.persisted ? '' : '(一時)'}
{deck.persisted ? '' : ' (temporary)'}
</NativeSelectOption>
))}
</NativeSelect>
<Button
variant="outline"
className="workspace-create"
aria-label="デッキを作成"
title="デッキを作成"
aria-label="Create deck"
title="Create deck"
type="button"
disabled={!ready}
onClick={() => {
@@ -65,13 +65,13 @@ export function DeckSwitcher({
}}
>
<Icon name="plus" />
<span>デッキを作成</span>
<span>Create deck</span>
</Button>
<Button
variant="ghost"
className="workspace-delete"
aria-label="デッキを削除"
title="デッキを削除"
aria-label="Delete deck"
title="Delete deck"
type="button"
disabled={!ready}
onClick={() => {
@@ -79,11 +79,11 @@ export function DeckSwitcher({
}}
>
<Icon name="trash" />
<span>デッキを削除</span>
<span>Delete deck</span>
</Button>
</div>
{dialog === 'create' ? (
<Dialog title="デッキを作成" onClose={() => onDialogChange(null)}>
<Dialog title="Create deck" onClose={() => onDialogChange(null)}>
<form
className="deck-rename"
onSubmit={async (event) => {
@@ -94,7 +94,7 @@ export function DeckSwitcher({
}}
>
<Label htmlFor="new-deck-title">
新しいデッキ名
New deck name
<Input
id="new-deck-title"
name="title"
@@ -104,22 +104,22 @@ export function DeckSwitcher({
required
/>
</Label>
<Button type="submit">作成</Button>
<Button type="submit">Create</Button>
<Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
キャンセル
Cancel
</Button>
</form>
{errorNotice}
</Dialog>
) : null}
{dialog === 'delete' ? (
<Dialog title="デッキを削除" onClose={() => onDialogChange(null)}>
<Dialog title="Delete deck" onClose={() => onDialogChange(null)}>
<div className="deck-undo">
<p>このデッキとカラムの設定を削除します。</p>
<p>Delete this deck and its column settings.</p>
<Button
variant="destructive"
type="button"
@@ -127,14 +127,14 @@ export function DeckSwitcher({
if (await onDelete()) onDialogChange(null)
}}
>
削除する
Delete
</Button>
<Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
キャンセル
Cancel
</Button>
</div>
{errorNotice}
+2 -2
View File
@@ -29,7 +29,7 @@ export function importLegacyWorkspace(
if (marker.payloadHash !== payloadHash)
throw new DeckPersistenceError(
'conflict',
'旧デッキはすでに取り込み済みです。別の内容で上書きできません。',
'Legacy decks have already been imported and cannot be overwritten.',
)
return listDecks(database)
}
@@ -56,7 +56,7 @@ export function importLegacyWorkspace(
if (!connection)
throw new DeckPersistenceError(
'connection-unavailable',
`旧プロファイル「${profileName}」に対応する接続がありません。接続一覧を更新してください。`,
`No connection matches the legacy profile “${profileName}”. Refresh your connections.`,
)
return { ...column, connectionId: connection.id }
}),
+2 -2
View File
@@ -77,7 +77,7 @@ it('blocks a different browser snapshot after the one-time import', () => {
database,
'https://relay.invalid',
),
).toThrow('旧デッキはすでに取り込み済みです')
).toThrow('Legacy decks have already been imported')
expect(listDecks(database)).toEqual(result)
})
it('rolls back all imported decks and marker when any profile is unavailable', () => {
@@ -92,7 +92,7 @@ it('rolls back all imported decks and marker when any profile is unavailable', (
database,
'https://relay.invalid',
),
).toThrow('対応する接続がありません')
).toThrow('No connection matches')
expect(listDecks(database)).toEqual([])
expect(database.select().from(legacyImports).all()).toEqual([])
})
+2 -2
View File
@@ -16,7 +16,7 @@ const legacyDeck = z
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'カラム ID が重複しています。',
'Column IDs must be unique.',
)
export const legacyWorkspaceSchema = z
.object({
@@ -28,5 +28,5 @@ export const legacyWorkspaceSchema = z
.refine(
(value) =>
new Set(value.decks.map((deck) => deck.id)).size === value.decks.length,
'デッキ ID が重複しています。',
'Deck IDs must be unique.',
)
+20 -19
View File
@@ -37,7 +37,7 @@ export function MastodonSourceEditor({
return (
<>
<Label htmlFor={id}>
カラムの種類
Column type
<NativeSelect
className="w-full"
id={id}
@@ -50,10 +50,10 @@ export function MastodonSourceEditor({
)
}
>
<NativeSelectOption value="search">検索</NativeSelectOption>
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
<NativeSelectOption value="list">リスト</NativeSelectOption>
<NativeSelectOption value="hashtag">ハッシュタグ</NativeSelectOption>
<NativeSelectOption value="search">Search</NativeSelectOption>
<NativeSelectOption value="user">User posts</NativeSelectOption>
<NativeSelectOption value="list">List</NativeSelectOption>
<NativeSelectOption value="hashtag">Hashtag</NativeSelectOption>
</NativeSelect>
</Label>
<MastodonSourceFields
@@ -96,7 +96,7 @@ function MastodonSearchEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
Mastodonの検索条件
Mastodon search query
<Input
id={id}
value={source.query}
@@ -104,12 +104,13 @@ function MastodonSearchEditor({
onChange({ ...source, query: event.target.value })
}
maxLength={512}
placeholder="例:WebMCP"
placeholder="e.g. WebMCP"
required
/>
</Label>
<p className="deck-help">
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。
Full-text search depends on server settings. Zero results do not
necessarily mean search is supported.
</p>
</div>
)
@@ -123,14 +124,14 @@ function MastodonUserEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
ユーザー
User
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="user@server またはアカウントID"
placeholder="user@server or account ID"
maxLength={256}
required
/>
@@ -147,14 +148,14 @@ function MastodonHashtagEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
ハッシュタグ
Hashtag
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="# を除いたタグ名"
placeholder="Tag without #"
maxLength={128}
required
/>
@@ -174,14 +175,14 @@ function MastodonListEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
リスト
List
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="このアカウントのリストID"
placeholder="List ID for this account"
maxLength={32}
required
/>
@@ -209,24 +210,24 @@ function MastodonListChoices({
queryFn: () => fetchLists({ data: { connectionId } }),
retry: false,
})
if (lists.isPending) return <p role="status">リストを取得しています…</p>
if (lists.isPending) return <p role="status">Loading lists…</p>
if (lists.isError)
return (
<p role="alert">
リストを取得できませんでした。
Unable to load lists.
<Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
再試行
Retry
</Button>
</p>
)
return (
<section className="deck-list-choices" aria-label="このアカウントのリスト">
<section className="deck-list-choices" aria-label="Lists for this account">
{lists.data.lists.length === 0 ? (
<p>このアカウントのリストはありません。</p>
<p>This account has no lists.</p>
) : (
lists.data.lists.map((list) => (
<Button
+4 -7
View File
@@ -24,9 +24,7 @@ const searchSourceSchema = z
ctx.addIssue({
code: 'custom',
message:
error instanceof Error
? error.message
: '検索条件を確認してください。',
error instanceof Error ? error.message : 'Check the search query.',
})
}
})
@@ -43,8 +41,7 @@ function targetSchema(normalize: (target: string) => string) {
} catch (error) {
ctx.addIssue({
code: 'custom',
message:
error instanceof Error ? error.message : '対象を確認してください。',
message: error instanceof Error ? error.message : 'Check the target.',
})
return z.NEVER
}
@@ -93,7 +90,7 @@ export const deckSchema = z
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'カラム ID が重複しています。',
'Column IDs must be unique.',
)
export type Deck = z.infer<typeof deckSchema>
@@ -102,7 +99,7 @@ export type DeckView = Deck & { persisted: boolean; revision?: number }
export type Workspace = { activeDeckId: string; decks: DeckView[] }
export const EMPTY_DECK: Deck = {
id: 'initial',
title: '新しい調査',
title: 'New research',
columns: [],
}
export const EMPTY_WORKSPACE: Workspace = {
+3 -3
View File
@@ -22,12 +22,12 @@ export class DeckPersistenceError extends Error {
const conflict = () =>
new DeckPersistenceError(
'conflict',
'別の端末で変更されました。最新のデッキを読み直してください。',
'This deck changed on another device. Reload the latest deck.',
)
const missing = () =>
new DeckPersistenceError(
'not-found',
'このデッキは削除されたか、見つかりません。',
'This deck was deleted or could not be found.',
)
export function loadDeck(
@@ -79,7 +79,7 @@ function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
) {
throw new DeckPersistenceError(
'connection-unavailable',
'カラムの接続アカウントが利用できません。接続を確認してください。',
'The account for this column is unavailable. Check your connections.',
)
}
}
+9 -9
View File
@@ -84,7 +84,7 @@ it('makes retried creation idempotent but rejects different content using the sa
expect(createDeck(deck, second)).toEqual(saved)
expect(() =>
createDeck({ ...deck, title: 'Different request' }, second),
).toThrow('別の端末で変更されました')
).toThrow('This deck changed on another device')
expect(listDecks(database)).toEqual([saved])
})
@@ -105,7 +105,7 @@ it('replaces title, ordered columns and bindings atomically, then rejects stale
createdAt: initial.createdAt,
})
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'別の端末で変更されました',
'This deck changed on another device',
)
expect(loadDeck(deck.id, second)).toEqual(saved)
})
@@ -119,7 +119,7 @@ it('clears all columns and prevents stale deletion from another device', () => {
expect(loadDeck(deck.id, second)?.columns).toEqual([])
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('別の端末で変更されました')
).toThrow('This deck changed on another device')
expect(loadDeck(deck.id, second)).toEqual(saved)
expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id)
expect(loadDeck(deck.id, database)).toBeNull()
@@ -130,11 +130,11 @@ it('cascades deleted deck columns and reports already-deleted decks', () => {
deleteDeck({ id: deck.id, expectedRevision: 1 }, database)
expect(database.select().from(deckColumns).all()).toEqual([])
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'このデッキは削除されたか',
'This deck was deleted',
)
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('このデッキは削除されたか')
).toThrow('This deck was deleted')
})
it.each([
@@ -152,7 +152,7 @@ it.each([
{ deck: { ...deck, title: 'Should not save' }, expectedRevision: 1 },
database,
),
).toThrow('カラムの接続アカウントが利用できません')
).toThrow('The account for this column is unavailable')
expect(loadDeck(deck.id, second)).toEqual(initial)
})
@@ -163,7 +163,7 @@ it('rejects a connection from another platform', () => {
.where(eq(connections.id, 'twitter-one'))
.run()
expect(() => createDeck(deck, database)).toThrow(
'カラムの接続アカウントが利用できません',
'The account for this column is unavailable',
)
expect(listDecks(database)).toEqual([])
})
@@ -171,7 +171,7 @@ it('rejects a connection from another platform', () => {
it('rejects unknown connections without creating rows', () => {
database.delete(connections).where(eq(connections.id, 'twitter-two')).run()
expect(() => createDeck(deck, database)).toThrow(
'カラムの接続アカウントが利用できません',
'The account for this column is unavailable',
)
expect(listDecks(database)).toEqual([])
})
@@ -186,7 +186,7 @@ it('rejects duplicate column IDs without changing the saved version', () => {
},
database,
),
).toThrow('カラム ID が重複しています')
).toThrow('Column IDs must be unique')
expect(loadDeck(deck.id, database)).toEqual(saved)
})
@@ -35,8 +35,8 @@ it('focuses and briefly highlights a cited card without revealing sensitive cont
inline: 'center',
behavior: 'instant',
})
expect(screen.getByText('調査時に取得した投稿')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '内容を表示' })).toHaveAttribute(
expect(screen.getByText('Post saved during research')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Show content' })).toHaveAttribute(
'aria-expanded',
'false',
)
@@ -44,7 +44,9 @@ it('focuses and briefly highlights a cited card without revealing sensitive cont
rerender(<ResearchPostCard post={post} citationRequest={1} />)
expect(scroll).toHaveBeenCalledTimes(2)
expect(article).toHaveFocus()
expect(screen.queryByText('調査時に取得した投稿')).not.toBeInTheDocument()
expect(
screen.queryByText('Post saved during research'),
).not.toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(2500)
})
@@ -76,12 +78,12 @@ it('waits for reveal before mounting CW content or sensitive media', () => {
],
}
render(<ResearchPostCard post={post} />)
expect(screen.getByText('Bob がブースト')).toBeInTheDocument()
expect(screen.getByText('Boosted by Bob')).toBeInTheDocument()
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
expect(screen.queryByText('content')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '内容を表示' }))
fireEvent.click(screen.getByRole('button', { name: 'Show content' }))
expect(screen.getByAltText('Hidden image')).toBeInTheDocument()
expect(screen.getByText('content').tagName).toBe('STRONG')
fireEvent.click(screen.getByRole('button', { name: '内容を隠す' }))
fireEvent.click(screen.getByRole('button', { name: 'Hide content' }))
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
})
+8 -8
View File
@@ -6,7 +6,7 @@ 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', {
const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
@@ -53,10 +53,10 @@ export function ResearchPostCard({
data-post-url={post.url}
>
{citationSnapshot ? (
<Badge variant="secondary">調査時に取得した投稿</Badge>
<Badge variant="secondary">Post saved during research</Badge>
) : null}
{post.boostedBy ? (
<p className="handle">{post.boostedBy.name} がブースト</p>
<p className="handle">Boosted by {post.boostedBy.name}</p>
) : null}
<header>
<Avatar className="size-9 shrink-0">
@@ -77,7 +77,7 @@ export function ResearchPostCard({
</header>
{concealed ? (
<div>
<p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p>
<p>{post.contentWarning || 'This post contains sensitive media'}</p>
<Button
variant="outline"
size="sm"
@@ -85,7 +85,7 @@ export function ResearchPostCard({
aria-expanded={revealed}
onClick={() => setRevealed(!revealed)}
>
{revealed ? '内容を隠す' : '内容を表示'}
{revealed ? 'Hide content' : 'Show content'}
</Button>
</div>
) : null}
@@ -118,7 +118,7 @@ export function ResearchPostCard({
target="_blank"
rel="noreferrer noopener"
>
動画を開く
Open video
</a>
),
)}
@@ -133,7 +133,7 @@ export function ResearchPostCard({
target="_blank"
rel="noreferrer noopener"
>
引用元を開く
Open quoted post
</a>
</blockquote>
) : null}
@@ -141,7 +141,7 @@ export function ResearchPostCard({
) : null}
<footer>
<a href={post.url} target="_blank" rel="noreferrer noopener">
元の投稿を開く <Icon name="arrow" />
Open original post <Icon name="arrow" />
</a>
</footer>
</article>
+2 -2
View File
@@ -24,14 +24,14 @@ async function run<T>(
ok: false,
error: {
code: 'invalid-input',
message: 'デッキの入力内容を確認してください。',
message: 'Check the deck details.',
},
}
return {
ok: false,
error: {
code: 'storage-unavailable',
message: 'デッキを保存・取得できませんでした。再試行してください。',
message: 'Unable to save or load the deck. Please try again.',
},
}
}
+7 -7
View File
@@ -10,25 +10,25 @@ export function describeSource(source: DeckColumn['source']): {
switch (source.kind) {
case 'search':
return {
label: `${source.product === 'Latest' ? '最新' : '話題'}${source.following ? ' · フォロー中' : ''}`,
label: `${source.product === 'Latest' ? 'Latest' : 'Top'}${source.following ? ' · Following' : ''}`,
query: source.query,
}
case 'user':
return { label: 'ユーザー投稿', query: source.target }
return { label: 'User posts', query: source.target }
case 'list':
return { label: 'リスト', query: source.target }
return { label: 'List', query: source.target }
}
break
case 'mastodon':
switch (source.kind) {
case 'search':
return { label: '全文検索', query: source.query }
return { label: 'Full-text search', query: source.query }
case 'user':
return { label: 'ユーザー投稿', query: source.target }
return { label: 'User posts', query: source.target }
case 'list':
return { label: 'リスト', query: source.target }
return { label: 'List', query: source.target }
case 'hashtag':
return { label: 'ハッシュタグ', query: `#${source.target}` }
return { label: 'Hashtag', query: `#${source.target}` }
}
break
}
+21 -24
View File
@@ -39,7 +39,7 @@ export function TwitterSourceEditor({
return (
<>
<Label htmlFor={id}>
カラムの種類
Column type
<NativeSelect
className="w-full"
id={id}
@@ -48,9 +48,9 @@ export function TwitterSourceEditor({
onChange(defaultTwitterSource(event.target.value as Source['kind']))
}
>
<NativeSelectOption value="search">検索</NativeSelectOption>
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
<NativeSelectOption value="list">リスト</NativeSelectOption>
<NativeSelectOption value="search">Search</NativeSelectOption>
<NativeSelectOption value="user">User posts</NativeSelectOption>
<NativeSelectOption value="list">List</NativeSelectOption>
</NativeSelect>
</Label>
<TwitterSourceFields
@@ -91,7 +91,7 @@ function TwitterSearchEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={`${id}-query`}>
Twitterの検索条件
Twitter search query
<Input
id={`${id}-query`}
value={source.query}
@@ -99,12 +99,12 @@ function TwitterSearchEditor({
onChange({ ...source, query: event.target.value })
}
maxLength={512}
placeholder="例:WebMCP lang:ja"
placeholder="e.g. WebMCP lang:en"
required
/>
</Label>
<Label htmlFor={`${id}-product`}>
表示順
Sort by
<NativeSelect
className="w-full"
id={`${id}-product`}
@@ -116,12 +116,12 @@ function TwitterSearchEditor({
})
}
>
<NativeSelectOption value="Latest">最新</NativeSelectOption>
<NativeSelectOption value="Top">話題</NativeSelectOption>
<NativeSelectOption value="Latest">Latest</NativeSelectOption>
<NativeSelectOption value="Top">Top</NativeSelectOption>
</NativeSelect>
</Label>
<p className="deck-help">
from:、lang:、since: など、Twitterの検索構文を使えます。
Use Twitter search operators such as from:, lang:, and since:.
</p>
<Label className="deck-checkbox">
<Checkbox
@@ -130,7 +130,7 @@ function TwitterSearchEditor({
onChange({ ...source, following: checked })
}
/>
フォロー中のみ
Following only
</Label>
</div>
)
@@ -144,14 +144,14 @@ function TwitterUserEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
ユーザー
User
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="@handle またはプロフィールURL"
placeholder="@handle or profile URL"
maxLength={256}
required
/>
@@ -169,14 +169,14 @@ function TwitterListEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
リスト
List
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="リストのURLまたはID"
placeholder="List URL or ID"
maxLength={256}
required
/>
@@ -199,27 +199,24 @@ function TwitterListChoices({
onSelect: (target: string) => void
}) {
const lists = useListChoices(connectionId)
if (lists.isPending) return <p role="status">リストを取得しています…</p>
if (lists.isPending) return <p role="status">Loading lists…</p>
if (lists.isError)
return (
<p role="alert">
リストを取得できませんでした。
Unable to load lists.
<Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
再試行
Retry
</Button>
</p>
)
return (
<section
className="deck-list-choices"
aria-label="このプロファイルのリスト"
>
<section className="deck-list-choices" aria-label="Lists for this profile">
{lists.data.length === 0 ? (
<p>リストはありません。URLまたはIDでも指定できます。</p>
<p>No lists found. You can enter a URL or ID instead.</p>
) : (
lists.data.map((list) => (
<Button
@@ -229,7 +226,7 @@ function TwitterListChoices({
onClick={() => onSelect(list.id)}
>
{list.name}
{list.isPrivate ? ' · 非公開' : ''}
{list.isPrivate ? ' · Private' : ''}
</Button>
))
)}
+1 -1
View File
@@ -194,7 +194,7 @@ it('leaves unreadable legacy data untouched', async () => {
localStorage.setItem(LEGACY_STORAGE_KEY, '{broken')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.storageError).toContain('読み込めませんでした')
expect(result.current.storageError).toContain('Unable to load')
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe('{broken')
})
+9 -11
View File
@@ -88,7 +88,7 @@ export function useDeck(editing = false) {
const id = newId()
if (preferred === view.id) preferred = id
setStorageError(
'保存済みの内容と異なる編集を、一時コピーとして残しました。',
'Your unsaved changes have been kept as a temporary copy.',
)
return [{ ...view, id }]
})
@@ -106,7 +106,7 @@ export function useDeck(editing = false) {
? preferred
: (all[0]?.id ?? '')
if (initialized.current && activeDeckId !== preferred)
setStorageError('表示中のデッキが別の端末で削除されました。')
setStorageError('This deck was deleted on another device.')
initialized.current = true
apply({ activeDeckId, decks: all })
},
@@ -138,9 +138,7 @@ export function useDeck(editing = false) {
requestGeneration !== generation.current
)
return false
setStorageError(
'保存済みデッキを読み込めませんでした。再試行してください。',
)
setStorageError('Unable to load saved decks. Please try again.')
return false
}
},
@@ -156,7 +154,7 @@ export function useDeck(editing = false) {
}
} catch {
setStorageError(
'旧デッキを読み込めませんでした。ブラウザの保存内容は変更していません。',
'Unable to load legacy decks. Your browser data has not been changed.',
)
}
void refresh()
@@ -176,7 +174,7 @@ export function useDeck(editing = false) {
accept: (value: T) => void,
) {
if (busy.current) {
setStorageError('保存処理が完了するまでお待ちください。')
setStorageError('Please wait until saving finishes.')
return false
}
busy.current = true
@@ -191,7 +189,7 @@ export function useDeck(editing = false) {
setStorageError(
error instanceof Error
? error.message
: '保存できませんでした。再試行してください。',
: 'Unable to save. Please try again.',
)
return false
} finally {
@@ -233,7 +231,7 @@ export function useDeck(editing = false) {
}
function select(id: string) {
if (!current.current.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。')
throw new Error('Deck not found.')
apply({ ...current.current, activeDeckId: id })
return true
}
@@ -247,7 +245,7 @@ export function useDeck(editing = false) {
}
async function persist(id: string) {
const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('デッキが見つかりません。')
if (!view) throw new Error('Deck not found.')
if (view.persisted) return true
return mutate(
async () => {
@@ -266,7 +264,7 @@ export function useDeck(editing = false) {
}
async function remove(id: string, expectedRevision?: number) {
const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('デッキが見つかりません。')
if (!view) throw new Error('Deck not found.')
const accept = () => {
const previous = current.current
const decks = previous.decks.filter((deck) => deck.id !== id)