From da72094e07324b1e3a29fb8de0abf797ee3a482a Mon Sep 17 00:00:00 2001 From: yutakobayashidev Date: Tue, 14 Jul 2026 00:50:08 +0900 Subject: [PATCH] feat: add tweet detail page --- src/components/app-shell.tsx | 2 +- .../posts/components/post-card.test.tsx | 15 ++++ src/features/posts/components/post-card.tsx | 11 ++- .../posts/components/post-feed.test.tsx | 84 +++++++++++++++++++ src/features/posts/components/post-feed.tsx | 83 ++++++++++++------ src/routeTree.gen.ts | 24 +++++- src/routes/-feed-wiring.test.tsx | 27 ++++++ src/routes/status.$tweetId.tsx | 36 ++++++++ src/styles.css | 20 +++++ 9 files changed, 272 insertions(+), 30 deletions(-) create mode 100644 src/routes/status.$tweetId.tsx diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 8cffced..f057971 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -4,7 +4,7 @@ export function AppShell({ active, children, }: { - active: 'user' | 'search' + active?: 'user' | 'search' children: React.ReactNode }) { return ( diff --git a/src/features/posts/components/post-card.test.tsx b/src/features/posts/components/post-card.test.tsx index 245bc98..111e07a 100644 --- a/src/features/posts/components/post-card.test.tsx +++ b/src/features/posts/components/post-card.test.tsx @@ -80,6 +80,21 @@ describe('PostCard', () => { expect(screen.getByText('@yuta').closest('a')).toBeNull() }) + it('links deliberately to the internal detail page', () => { + render() + + expect( + screen.getByRole('link', { name: '詳細・スレッド' }), + ).toHaveAttribute('href', '/status/123') + }) + + it('marks the focal post without linking to its current page', () => { + render() + + expect(screen.queryByRole('link', { name: '詳細・スレッド' })).toBeNull() + expect(screen.getByRole('article')).toHaveClass('current-post') + }) + it('collapses a malformed optional timestamp without losing the post', () => { const { container } = render( , diff --git a/src/features/posts/components/post-card.tsx b/src/features/posts/components/post-card.tsx index f508129..e64f038 100644 --- a/src/features/posts/components/post-card.tsx +++ b/src/features/posts/components/post-card.tsx @@ -41,15 +41,19 @@ function formattedTime(createdAt?: string): string | undefined { export function PostCard({ post, quoted = false, + current = false, }: { post: Post quoted?: boolean + current?: boolean }) { const time = formattedTime(post.createdAt) const original = `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}` return ( -
+
{post.author.profileImageUrl ? ( + {!current ? ( + + 詳細・スレッド + + ) : null} 元の投稿を開く diff --git a/src/features/posts/components/post-feed.test.tsx b/src/features/posts/components/post-feed.test.tsx index a5b6a41..9ac5cbf 100644 --- a/src/features/posts/components/post-feed.test.tsx +++ b/src/features/posts/components/post-feed.test.tsx @@ -154,6 +154,90 @@ describe('PostFeed', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) + it('renders the focal post once above its deduplicated conversation', () => { + usePostFeed.mockReturnValue({ + ...queryResult(), + data: { + pages: [ + { + focalPost: post('2'), + conversationId: '1', + nextCursor: 'next', + tweets: [post('1'), post('2')], + }, + { + conversationId: '1', + tweets: [post('2'), post('3')], + }, + ], + pageParams: [undefined, { cursor: 'next', conversationId: '1' }], + }, + } as never) + + const { container } = render( + , + ) + + expect(screen.getByText('表示中の投稿')).toBeVisible() + expect(screen.getAllByText('post-2')).toHaveLength(1) + expect(screen.getAllByRole('article')).toHaveLength(3) + expect(container.querySelector('.current-post')).toBeInTheDocument() + }) + + it('keeps the focal post visible when there are no other posts', () => { + usePostFeed.mockReturnValue({ + ...queryResult(), + data: { + pages: [ + { + focalPost: post('2'), + conversationId: '1', + tweets: [post('2')], + }, + ], + pageParams: [undefined], + }, + } as never) + + render() + + expect(screen.getByText('post-2')).toBeVisible() + expect(screen.getByText('会話にほかの投稿はありません。')).toBeVisible() + }) + + it('keeps the focal post and conversation across a later-page error', () => { + usePostFeed.mockReturnValue({ + ...queryResult(), + data: { + pages: [ + { + focalPost: post('2'), + conversationId: '1', + nextCursor: 'next', + tweets: [post('1'), post('2')], + }, + ], + pageParams: [undefined], + }, + error: new PostLoadError({ + code: 'upstream', + message: 'X から投稿を取得できませんでした。', + retryable: true, + }), + hasNextPage: true, + isError: true, + isFetchNextPageError: true, + } as never) + + render() + + expect(screen.getByText('post-2')).toBeVisible() + expect(screen.getByText('post-1')).toBeVisible() + expect(screen.getByRole('alert')).toHaveTextContent( + '続きの投稿を取得できませんでした。', + ) + }) + it('retries an initial retryable error with refetch', () => { const refetch = vi.fn().mockResolvedValue(undefined) usePostFeed.mockReturnValue({ diff --git a/src/features/posts/components/post-feed.tsx b/src/features/posts/components/post-feed.tsx index be95e9d..04e80ae 100644 --- a/src/features/posts/components/post-feed.tsx +++ b/src/features/posts/components/post-feed.tsx @@ -1,12 +1,33 @@ import { useEffect, useRef } from 'react' -import { flattenPostPages } from '../page' +import { + flattenConversationPages, + flattenPostPages, + focalPostFromPages, +} from '../page' +import type { Post } from '../types' import { type FeedRequest, PostLoadError, usePostFeed } from '../use-post-feed' import { PostCard } from './post-card' +function FocalPost({ post }: { post: Post }) { + return ( +
+

+ 表示中の投稿 +

+ +
+ ) +} + export function PostFeed({ request }: { request: FeedRequest | undefined }) { const query = usePostFeed(request) const sentinel = useRef(null) - const posts = query.data ? flattenPostPages(query.data.pages) : [] + const pages = query.data?.pages ?? [] + const focalPost = + request?.kind === 'thread' ? focalPostFromPages(pages) : undefined + const posts = focalPost + ? flattenConversationPages(pages, focalPost.id) + : flattenPostPages(pages) useEffect(() => { if ( @@ -62,31 +83,43 @@ export function PostFeed({ request }: { request: FeedRequest | undefined }) { ) } if (posts.length === 0 && !query.hasNextPage) { - return

条件に一致する投稿はありません。

+ return ( + <> + {focalPost ? : null} +

+ {focalPost + ? '会話にほかの投稿はありません。' + : '条件に一致する投稿はありません。'} +

+ + ) } return ( -
- {posts.map((post) => ( - - ))} -
+ <> + {focalPost ? : null} +
+ {posts.map((post) => ( + + ))} +
+ ) } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index f0c1d42..c6f91ea 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as UserRouteImport } from './routes/user' import { Route as SearchRouteImport } from './routes/search' import { Route as IndexRouteImport } from './routes/index' +import { Route as StatusTweetIdRouteImport } from './routes/status.$tweetId' const UserRoute = UserRouteImport.update({ id: '/user', @@ -28,35 +29,44 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const StatusTweetIdRoute = StatusTweetIdRouteImport.update({ + id: '/status/$tweetId', + path: '/status/$tweetId', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/search': typeof SearchRoute '/user': typeof UserRoute + '/status/$tweetId': typeof StatusTweetIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/search': typeof SearchRoute '/user': typeof UserRoute + '/status/$tweetId': typeof StatusTweetIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/search': typeof SearchRoute '/user': typeof UserRoute + '/status/$tweetId': typeof StatusTweetIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/search' | '/user' + fullPaths: '/' | '/search' | '/user' | '/status/$tweetId' fileRoutesByTo: FileRoutesByTo - to: '/' | '/search' | '/user' - id: '__root__' | '/' | '/search' | '/user' + to: '/' | '/search' | '/user' | '/status/$tweetId' + id: '__root__' | '/' | '/search' | '/user' | '/status/$tweetId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute SearchRoute: typeof SearchRoute UserRoute: typeof UserRoute + StatusTweetIdRoute: typeof StatusTweetIdRoute } declare module '@tanstack/react-router' { @@ -82,6 +92,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/status/$tweetId': { + id: '/status/$tweetId' + path: '/status/$tweetId' + fullPath: '/status/$tweetId' + preLoaderRoute: typeof StatusTweetIdRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, SearchRoute: SearchRoute, UserRoute: UserRoute, + StatusTweetIdRoute: StatusTweetIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/-feed-wiring.test.tsx b/src/routes/-feed-wiring.test.tsx index a622d51..22322ae 100644 --- a/src/routes/-feed-wiring.test.tsx +++ b/src/routes/-feed-wiring.test.tsx @@ -88,4 +88,31 @@ describe('post feed route wiring', () => { }), ) }) + + it('turns a decimal status path into a deliberate thread request', async () => { + await renderRoute('/status/123') + + expect(await screen.findByRole('heading', { name: '会話' })).toBeVisible() + await waitFor(() => + expect(usePostFeed).toHaveBeenCalledWith({ + kind: 'thread', + tweetId: '123', + }), + ) + expect( + screen.queryByRole('link', { name: 'ユーザー', current: 'page' }), + ).toBeNull() + expect( + screen.queryByRole('link', { name: '検索', current: 'page' }), + ).toBeNull() + }) + + it('keeps an invalid status path local and idle', async () => { + await renderRoute('/status/not-a-tweet') + + expect(await screen.findByRole('alert')).toHaveTextContent( + '投稿 ID を確認してください。', + ) + expect(usePostFeed).toHaveBeenCalledWith(undefined) + }) }) diff --git a/src/routes/status.$tweetId.tsx b/src/routes/status.$tweetId.tsx new file mode 100644 index 0000000..7c3c0a2 --- /dev/null +++ b/src/routes/status.$tweetId.tsx @@ -0,0 +1,36 @@ +import { createFileRoute } from '@tanstack/react-router' +import { AppShell } from '#/components/app-shell' +import { PostFeed } from '#/features/posts/components/post-feed' +import { InputError, normalizeTweetId } from '#/features/posts/inputs' + +export const Route = createFileRoute('/status/$tweetId')({ + component: StatusRoute, +}) + +function StatusRoute() { + const { tweetId: rawTweetId } = Route.useParams() + let tweetId: string | undefined + let error: string | undefined + + try { + tweetId = normalizeTweetId(rawTweetId) + } catch (cause) { + error = + cause instanceof InputError + ? cause.message + : '投稿 ID を確認してください。' + } + + return ( + +

会話

+

選んだ投稿と、その会話だけを表示します。

+ {error ? ( +

+ {error} +

+ ) : null} + +
+ ) +} diff --git a/src/styles.css b/src/styles.css index 07a54c4..f8beb09 100644 --- a/src/styles.css +++ b/src/styles.css @@ -254,6 +254,20 @@ a { grid-column: 1 / -1; } +.focal-post { + display: grid; + gap: 0.5rem; + margin-top: 1.4rem; +} + +.focal-label { + margin: 0; + color: var(--accent); + font-size: 0.72rem; + font-weight: 750; + letter-spacing: 0.08em; +} + .feed { display: grid; gap: 0.85rem; @@ -268,6 +282,11 @@ a { background: var(--paper); } +.current-post { + border-color: var(--accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent); +} + .post-header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; @@ -361,6 +380,7 @@ video.media { .post-footer { display: flex; flex-wrap: wrap; + align-items: center; justify-content: space-between; gap: 0.75rem 1rem; margin-top: 1rem;