196 lines
6.5 KiB
TypeScript
196 lines
6.5 KiB
TypeScript
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,
|
|
} from '#/features/platforms/use-research-feed'
|
|
import { PostLoadError } from '#/features/posts/use-post-feed'
|
|
import { citationUrlKey } from '#/features/research/citation-url'
|
|
import type { CitationTarget } from '#/features/research/use-citation-navigation'
|
|
import { PlatformIcon } from '../platforms/platform-icon'
|
|
import { type ColumnRegistry, useRegisterColumn } from './column-tools'
|
|
import type { DeckColumn } from './model'
|
|
import { ResearchPostCard } from './research-post-card'
|
|
import { describeSource } from './source-description'
|
|
|
|
export function ResearchColumn({
|
|
column,
|
|
connectionLabel,
|
|
onEdit,
|
|
onRemove,
|
|
onMove,
|
|
first,
|
|
last,
|
|
registry,
|
|
citation,
|
|
}: {
|
|
column: DeckColumn
|
|
connectionLabel?: string
|
|
onEdit: () => void
|
|
onRemove: () => void
|
|
onMove: (direction: -1 | 1) => void
|
|
first: boolean
|
|
last: boolean
|
|
registry: ColumnRegistry
|
|
citation?: CitationTarget
|
|
}) {
|
|
const query = useResearchFeed(column)
|
|
useRegisterColumn(registry, column, query)
|
|
const posts = flattenResearchPages(query.data?.pages ?? [])
|
|
const citedKey = citation ? citationUrlKey(citation.post.url) : null
|
|
const visiblePosts =
|
|
citation && !posts.some((post) => citationUrlKey(post.url) === citedKey)
|
|
? [citation.post, ...posts]
|
|
: posts
|
|
const error =
|
|
query.error instanceof PostLoadError
|
|
? query.error.detail
|
|
: { message: '投稿を取得できませんでした。', retryable: true }
|
|
const headingId = `column-${column.id}`
|
|
const sourceDescription = describeSource(column.source)
|
|
return (
|
|
<section className="deck-column" aria-labelledby={headingId}>
|
|
<header className="deck-column-header">
|
|
<div className="deck-column-title">
|
|
<PlatformIcon platform={column.source.platform} />
|
|
<h2 id={headingId}>{column.title}</h2>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="更新"
|
|
title="更新"
|
|
type="button"
|
|
onClick={() => void query.refetch()}
|
|
disabled={query.isFetching}
|
|
>
|
|
<Icon name="refresh" />
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label={`${column.title}の操作`}
|
|
title="カラムの操作"
|
|
/>
|
|
}
|
|
>
|
|
<Icon name="more" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={onEdit}>編集</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => onMove(-1)}
|
|
disabled={first}
|
|
aria-label={`${column.title}を左へ`}
|
|
>
|
|
← 左へ移動
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => onMove(1)}
|
|
disabled={last}
|
|
aria-label={`${column.title}を右へ`}
|
|
>
|
|
右へ移動 →
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={onRemove}
|
|
aria-label={`${column.title}を削除`}
|
|
>
|
|
カラムを削除
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
<div className="deck-column-label">
|
|
<span
|
|
title={`接続プロファイル: ${connectionLabel ?? '接続が見つかりません'}`}
|
|
>
|
|
{connectionLabel ?? '接続が見つかりません'}
|
|
</span>
|
|
<Badge variant="outline" className="deck-source-label">
|
|
<Icon name={column.source.kind} />
|
|
{sourceDescription.label}
|
|
</Badge>
|
|
</div>
|
|
<p className="deck-query">{sourceDescription.query}</p>
|
|
<p className="deck-column-status" role="status">
|
|
{query.isFetching
|
|
? '投稿を取得しています…'
|
|
: `${posts.length}件を読み込み済み`}
|
|
</p>
|
|
</header>
|
|
<section
|
|
className="deck-column-results"
|
|
// biome-ignore lint/a11y/noNoninteractiveTabindex: Make the scrolling feed keyboard-accessible.
|
|
tabIndex={0}
|
|
aria-label={`${column.title}の投稿`}
|
|
>
|
|
{visiblePosts.map((post) => (
|
|
<ResearchPostCard
|
|
key={post.key}
|
|
post={post}
|
|
citationRequest={
|
|
citation && citationUrlKey(post.url) === citedKey
|
|
? citation.request
|
|
: undefined
|
|
}
|
|
citationSnapshot={citation?.post === post && !posts.includes(post)}
|
|
/>
|
|
))}
|
|
{query.isError ? (
|
|
<div className="deck-message" role="alert">
|
|
<p>{error.message}</p>
|
|
{error.retryable ? (
|
|
<Button
|
|
variant="outline"
|
|
type="button"
|
|
disabled={query.isFetching}
|
|
onClick={() =>
|
|
void (query.isFetchNextPageError
|
|
? query.fetchNextPage({ cancelRefetch: false })
|
|
: query.refetch())
|
|
}
|
|
>
|
|
再試行
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{!query.isPending && !query.isError && visiblePosts.length === 0 ? (
|
|
<p className="deck-message">条件に一致する投稿はありません。</p>
|
|
) : null}
|
|
{query.hasNextPage && !query.isFetchNextPageError ? (
|
|
<div className="deck-message">
|
|
<Button
|
|
variant="outline"
|
|
type="button"
|
|
disabled={query.isFetching}
|
|
onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
|
|
>
|
|
続きを読み込む
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
{!query.isPending &&
|
|
!query.isError &&
|
|
posts.length > 0 &&
|
|
!query.hasNextPage ? (
|
|
<p className="deck-message">これ以上の投稿はありません。</p>
|
|
) : null}
|
|
</section>
|
|
</section>
|
|
)
|
|
}
|