feat: translate system interface to English
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user