(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) => (
-
- ))}
-
- {query.isFetchingNextPage ? (
-
- 次の投稿を取得しています…
-
- ) : null}
- {query.isFetchNextPageError ? (
-
-
続きの投稿を取得できませんでした。
-
-
- ) : null}
- {!query.hasNextPage ? (
- これ以上の投稿はありません。
- ) : null}
-
+ <>
+ {focalPost ? : null}
+
+ {posts.map((post) => (
+
+ ))}
+
+ {query.isFetchingNextPage ? (
+
+ 次の投稿を取得しています…
+
+ ) : null}
+ {query.isFetchNextPageError ? (
+
+
続きの投稿を取得できませんでした。
+
+
+ ) : null}
+ {!query.hasNextPage ? (
+ これ以上の投稿はありません。
+ ) : null}
+
+ >
)
}
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;