refactor: use X-style route paths

This commit is contained in:
2026-07-26 13:29:38 +09:00
parent 604c7f16e2
commit 766d13dfcc
19 changed files with 320 additions and 179 deletions
+11
View File
@@ -0,0 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { UserPage } from './index'
export const Route = createFileRoute('/$handle')({
component: UserRoute,
})
function UserRoute() {
const { handle } = Route.useParams()
return <UserPage handle={handle} />
}
@@ -1,24 +1,27 @@
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'
import {
InputError,
normalizeTweetId,
normalizeUserTarget,
} from '#/features/posts/inputs'
export const Route = createFileRoute('/status/$tweetId')({
export const Route = createFileRoute('/$handle_/status/$tweetId')({
component: StatusRoute,
})
function StatusRoute() {
const { tweetId: rawTweetId } = Route.useParams()
const { handle: rawHandle, tweetId: rawTweetId } = Route.useParams()
let tweetId: string | undefined
let error: string | undefined
try {
normalizeUserTarget(rawHandle)
tweetId = normalizeTweetId(rawTweetId)
} catch (cause) {
error =
cause instanceof InputError
? cause.message
: '投稿 ID を確認してください。'
cause instanceof InputError ? cause.message : 'URL を確認してください。'
}
return (
+35 -8
View File
@@ -72,7 +72,7 @@ describe('post feed route wiring', () => {
afterEach(() => vi.unstubAllGlobals())
it('keeps an empty user URL disabled', async () => {
await renderRoute('/user?target=')
await renderRoute('/')
expect(
await screen.findByRole('heading', { name: '誰の投稿を見ますか?' }),
@@ -81,7 +81,7 @@ describe('post feed route wiring', () => {
})
it('turns a manually entered user URL into a deliberate request', async () => {
await renderRoute('/user?target=yuta')
await renderRoute('/yuta')
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
@@ -91,8 +91,17 @@ describe('post feed route wiring', () => {
)
})
it('keeps an invalid user path local and idle', async () => {
await renderRoute('/not-a-handle')
expect(await screen.findByRole('alert')).toHaveTextContent(
'ハンドルは英数字とアンダースコアで入力してください。',
)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('keeps an empty list URL disabled', async () => {
await renderRoute('/list?target=')
await renderRoute('/i/lists')
expect(
await screen.findByRole('heading', { name: 'どのリストを読みますか?' }),
@@ -101,7 +110,7 @@ describe('post feed route wiring', () => {
})
it('turns a manually entered list URL into a deliberate request', async () => {
await renderRoute('/list?target=1234567890')
await renderRoute('/i/lists/1234567890')
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
@@ -114,8 +123,17 @@ describe('post feed route wiring', () => {
).toBeVisible()
})
it('keeps an invalid list path local and idle', async () => {
await renderRoute('/i/lists/not-a-list')
expect(await screen.findByRole('alert')).toHaveTextContent(
'リスト ID は数字で入力してください。',
)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('turns a selected authenticated list into a deliberate request', async () => {
await renderRoute('/list?target=')
await renderRoute('/i/lists')
fireEvent.click(
await screen.findByRole('link', { name: /My List.*10 メンバー/ }),
@@ -143,7 +161,7 @@ describe('post feed route wiring', () => {
})
it('turns a decimal status path into a deliberate thread request', async () => {
await renderRoute('/status/123')
await renderRoute('/yuta/status/123')
expect(await screen.findByRole('heading', { name: '会話' })).toBeVisible()
await waitFor(() =>
@@ -160,12 +178,21 @@ describe('post feed route wiring', () => {
).toBeNull()
})
it('keeps an invalid status path local and idle', async () => {
await renderRoute('/status/not-a-tweet')
it('keeps an invalid tweet path local and idle', async () => {
await renderRoute('/yuta/status/not-a-tweet')
expect(await screen.findByRole('alert')).toHaveTextContent(
'投稿 ID を確認してください。',
)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('keeps an invalid status handle local and idle', async () => {
await renderRoute('/not-a-handle/status/123')
expect(await screen.findByRole('alert')).toHaveTextContent(
'ハンドルは英数字とアンダースコアで入力してください。',
)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
})
+63
View File
@@ -0,0 +1,63 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell'
import { ListForm } from '#/features/posts/components/list-form'
import { ListPicker } from '#/features/posts/components/list-picker'
import { PostFeed } from '#/features/posts/components/post-feed'
import { InputError, normalizeListTarget } from '#/features/posts/inputs'
export const Route = createFileRoute('/i/lists')({
component: ListIndexRoute,
})
function ListIndexRoute() {
return <ListPage />
}
export function ListPage({ listId = '' }: { listId?: string }) {
const navigate = useNavigate()
let normalizedListId: string | undefined
let error: string | undefined
if (listId) {
try {
normalizedListId = normalizeListTarget(listId)
} catch (cause) {
error =
cause instanceof InputError
? cause.message
: 'リスト ID を確認してください。'
}
}
return (
<AppShell active="list">
<h1>どのリストを読みますか?</h1>
<p className="intro">
自分のリストを選ぶか、リストURLを入力してください。
</p>
<ListPicker selectedId={listId || undefined} />
<ListForm
initialTarget={listId}
key={listId}
onSubmit={(nextListId) =>
navigate({
to: '/i/lists/$listId',
params: { listId: nextListId },
})
}
/>
{error ? (
<p className="state" role="alert">
{error}
</p>
) : null}
<PostFeed
request={
normalizedListId
? { kind: 'list', target: normalizedListId }
: undefined
}
/>
</AppShell>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { ListPage } from './i.lists'
export const Route = createFileRoute('/i/lists_/$listId')({
component: ListRoute,
})
function ListRoute() {
const { listId } = Route.useParams()
return <ListPage listId={listId} />
}
+53 -4
View File
@@ -1,7 +1,56 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell'
import { PostFeed } from '#/features/posts/components/post-feed'
import { UserForm } from '#/features/posts/components/user-form'
import { InputError, normalizeUserTarget } from '#/features/posts/inputs'
export const Route = createFileRoute('/')({
beforeLoad: () => {
throw redirect({ to: '/user', search: { target: '' } })
},
component: UserIndexRoute,
})
function UserIndexRoute() {
return <UserPage />
}
export function UserPage({ handle = '' }: { handle?: string }) {
const navigate = useNavigate()
let normalizedHandle: string | undefined
let error: string | undefined
if (handle) {
try {
normalizedHandle = normalizeUserTarget(handle)
} catch (cause) {
error =
cause instanceof InputError
? cause.message
: 'ハンドルを確認してください。'
}
}
return (
<AppShell active="user">
<h1>誰の投稿を見ますか?</h1>
<p className="intro">履歴やおすすめは表示しません。</p>
<UserForm
initialTarget={handle}
key={handle}
onSubmit={(nextHandle) =>
navigate({ to: '/$handle', params: { handle: nextHandle } })
}
/>
{error ? (
<p className="state" role="alert">
{error}
</p>
) : null}
<PostFeed
request={
normalizedHandle
? { kind: 'user', target: normalizedHandle }
: undefined
}
/>
</AppShell>
)
}
-31
View File
@@ -1,31 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell'
import { ListForm } from '#/features/posts/components/list-form'
import { ListPicker } from '#/features/posts/components/list-picker'
import { PostFeed } from '#/features/posts/components/post-feed'
import { listRouteSearchSchema } from '#/features/posts/inputs'
export const Route = createFileRoute('/list')({
validateSearch: (search) => listRouteSearchSchema.parse(search),
component: ListRoute,
})
function ListRoute() {
const { target } = Route.useSearch()
const navigate = Route.useNavigate()
return (
<AppShell active="list">
<h1>どのリストを読みますか?</h1>
<p className="intro">
自分のリストを選ぶか、リストURLを入力してください。
</p>
<ListPicker selectedId={target || undefined} />
<ListForm
initialTarget={target}
key={target}
onSubmit={(nextTarget) => navigate({ search: { target: nextTarget } })}
/>
<PostFeed request={target ? { kind: 'list', target } : undefined} />
</AppShell>
)
}
-27
View File
@@ -1,27 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell'
import { PostFeed } from '#/features/posts/components/post-feed'
import { UserForm } from '#/features/posts/components/user-form'
import { userRouteSearchSchema } from '#/features/posts/inputs'
export const Route = createFileRoute('/user')({
validateSearch: (search) => userRouteSearchSchema.parse(search),
component: UserRoute,
})
function UserRoute() {
const { target } = Route.useSearch()
const navigate = Route.useNavigate()
return (
<AppShell active="user">
<h1>誰の投稿を見ますか?</h1>
<p className="intro">履歴やおすすめは表示しません。</p>
<UserForm
initialTarget={target}
key={target}
onSubmit={(nextTarget) => navigate({ search: { target: nextTarget } })}
/>
<PostFeed request={target ? { kind: 'user', target } : undefined} />
</AppShell>
)
}