From f851895366092388a600ce6531627df83497aa7a Mon Sep 17 00:00:00 2001 From: yutakobayashidev Date: Mon, 13 Jul 2026 11:24:10 +0900 Subject: [PATCH] feat: add intentional reader routes --- src/components/app-shell.tsx | 34 ++++ src/features/posts/components/forms.test.tsx | 57 ++++++ src/features/posts/components/search-form.tsx | 87 ++++++++ src/features/posts/components/user-form.tsx | 47 +++++ src/routeTree.gen.ts | 42 +++- src/routes/index.test.tsx | 12 -- src/routes/index.tsx | 18 +- src/routes/search.tsx | 27 +++ src/routes/user.tsx | 25 +++ src/styles.css | 192 +++++++++++++++++- 10 files changed, 507 insertions(+), 34 deletions(-) create mode 100644 src/components/app-shell.tsx create mode 100644 src/features/posts/components/forms.test.tsx create mode 100644 src/features/posts/components/search-form.tsx create mode 100644 src/features/posts/components/user-form.tsx delete mode 100644 src/routes/index.test.tsx create mode 100644 src/routes/search.tsx create mode 100644 src/routes/user.tsx diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx new file mode 100644 index 0000000..8cffced --- /dev/null +++ b/src/components/app-shell.tsx @@ -0,0 +1,34 @@ +import { Link } from '@tanstack/react-router' + +export function AppShell({ + active, + children, +}: { + active: 'user' | 'search' + children: React.ReactNode +}) { + return ( +
+
+

TWITTER LITE

+ +
+
{children}
+
+ ) +} diff --git a/src/features/posts/components/forms.test.tsx b/src/features/posts/components/forms.test.tsx new file mode 100644 index 0000000..0c68e0a --- /dev/null +++ b/src/features/posts/components/forms.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { SearchForm } from './search-form' +import { UserForm } from './user-form' + +describe('UserForm', () => { + it('submits only after the user enters a target', () => { + const onSubmit = vi.fn() + render() + + fireEvent.change(screen.getByLabelText('ハンドルまたはプロフィール URL'), { + target: { value: '@yuta' }, + }) + fireEvent.click(screen.getByRole('button', { name: '表示' })) + + expect(onSubmit).toHaveBeenCalledWith('yuta') + }) + + it('explains invalid input beside the field', () => { + const onSubmit = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: '表示' })) + + expect(screen.getByRole('alert')).toHaveTextContent( + 'ハンドルまたはプロフィール URL を入力してください。', + ) + expect(onSubmit).not.toHaveBeenCalled() + }) +}) + +describe('SearchForm', () => { + it('submits the selected ranking and follows filter', () => { + const onSubmit = vi.fn() + render( + , + ) + + fireEvent.change(screen.getByLabelText('検索語'), { + target: { value: 'AI lang:ja' }, + }) + fireEvent.click(screen.getByLabelText('人気順')) + fireEvent.click(screen.getByLabelText('フォロー中のみ')) + fireEvent.click(screen.getByRole('button', { name: '検索' })) + + expect(onSubmit).toHaveBeenCalledWith({ + q: 'AI lang:ja', + product: 'Top', + following: true, + }) + }) +}) diff --git a/src/features/posts/components/search-form.tsx b/src/features/posts/components/search-form.tsx new file mode 100644 index 0000000..0a58a50 --- /dev/null +++ b/src/features/posts/components/search-form.tsx @@ -0,0 +1,87 @@ +import type { SearchProduct } from '@yuta/bird' +import { type FormEvent, useState } from 'react' +import { buildSearchQuery } from '../inputs' + +type SearchValues = { + q: string + product: SearchProduct + following: boolean +} + +type Props = { + initialQuery: string + initialProduct: SearchProduct + initialFollowing: boolean + onSubmit: (values: SearchValues) => void +} + +export function SearchForm({ + initialQuery, + initialProduct, + initialFollowing, + onSubmit, +}: Props) { + const [q, setQuery] = useState(initialQuery) + const [product, setProduct] = useState(initialProduct) + const [following, setFollowing] = useState(initialFollowing) + const [error, setError] = useState('') + + const submit = (event: FormEvent) => { + event.preventDefault() + try { + const query = buildSearchQuery(q, false) + setError('') + onSubmit({ q: query, product, following }) + } catch (cause) { + setError( + cause instanceof Error ? cause.message : '入力内容を確認してください。', + ) + } + } + + return ( +
+ +
+ 並び順 + {(['Top', 'Latest'] as const).map((value) => ( + + ))} +
+ + +
+ ) +} diff --git a/src/features/posts/components/user-form.tsx b/src/features/posts/components/user-form.tsx new file mode 100644 index 0000000..e5c8608 --- /dev/null +++ b/src/features/posts/components/user-form.tsx @@ -0,0 +1,47 @@ +import { type FormEvent, useState } from 'react' +import { normalizeUserTarget } from '../inputs' + +type Props = { + initialTarget: string + onSubmit: (target: string) => void +} + +export function UserForm({ initialTarget, onSubmit }: Props) { + const [target, setTarget] = useState(initialTarget) + const [error, setError] = useState('') + const submit = (event: FormEvent) => { + event.preventDefault() + try { + const handle = normalizeUserTarget(target) + setError('') + onSubmit(handle) + } catch (cause) { + setError( + cause instanceof Error ? cause.message : '入力内容を確認してください。', + ) + } + } + + return ( +
+ + +
+ ) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index dceedff..f0c1d42 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,8 +9,20 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 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' +const UserRoute = UserRouteImport.update({ + id: '/user', + path: '/user', + getParentRoute: () => rootRouteImport, +} as any) +const SearchRoute = SearchRouteImport.update({ + id: '/search', + path: '/search', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -19,28 +31,50 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/search': typeof SearchRoute + '/user': typeof UserRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/search': typeof SearchRoute + '/user': typeof UserRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/search': typeof SearchRoute + '/user': typeof UserRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' + fullPaths: '/' | '/search' | '/user' fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' + to: '/' | '/search' | '/user' + id: '__root__' | '/' | '/search' | '/user' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + SearchRoute: typeof SearchRoute + UserRoute: typeof UserRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/user': { + id: '/user' + path: '/user' + fullPath: '/user' + preLoaderRoute: typeof UserRouteImport + parentRoute: typeof rootRouteImport + } + '/search': { + id: '/search' + path: '/search' + fullPath: '/search' + preLoaderRoute: typeof SearchRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -53,6 +87,8 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + SearchRoute: SearchRoute, + UserRoute: UserRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/index.test.tsx b/src/routes/index.test.tsx deleted file mode 100644 index 0ab01cb..0000000 --- a/src/routes/index.test.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import { Home } from './index' - -describe('Home', () => { - it('starts without ambient post content', () => { - render() - - expect(screen.getByText('目的を決めてから開く')).toBeInTheDocument() - expect(screen.queryByRole('article')).not.toBeInTheDocument() - }) -}) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 8cde3a5..ca7971e 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,13 +1,7 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, redirect } from '@tanstack/react-router' -export const Route = createFileRoute('/')({ component: Home }) - -export function Home() { - return ( -
-

TWITTER LITE

-

目的を決めてから開く

-

ユーザー名か検索語を入力するまで、投稿は表示しません。

-
- ) -} +export const Route = createFileRoute('/')({ + beforeLoad: () => { + throw redirect({ to: '/user', search: { target: '' } }) + }, +}) diff --git a/src/routes/search.tsx b/src/routes/search.tsx new file mode 100644 index 0000000..861f363 --- /dev/null +++ b/src/routes/search.tsx @@ -0,0 +1,27 @@ +import { createFileRoute } from '@tanstack/react-router' +import { AppShell } from '#/components/app-shell' +import { SearchForm } from '#/features/posts/components/search-form' +import { postSearchRouteSchema } from '#/features/posts/inputs' + +export const Route = createFileRoute('/search')({ + validateSearch: (search) => postSearchRouteSchema.parse(search), + component: SearchRoute, +}) + +function SearchRoute() { + const values = Route.useSearch() + const navigate = Route.useNavigate() + return ( + +

何を確認しますか?

+

入力した条件だけを検索します。

+ navigate({ search })} + /> +
+ ) +} diff --git a/src/routes/user.tsx b/src/routes/user.tsx new file mode 100644 index 0000000..3cf08f9 --- /dev/null +++ b/src/routes/user.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/react-router' +import { AppShell } from '#/components/app-shell' +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 ( + +

誰の投稿を見ますか?

+

履歴やおすすめは表示しません。

+ navigate({ search: { target: nextTarget } })} + /> +
+ ) +} diff --git a/src/styles.css b/src/styles.css index 3bf67db..c5aa430 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2,6 +2,7 @@ color: #1e3238; background: #dfe7e9; font-family: Inter, "Noto Sans JP", ui-sans-serif, system-ui, sans-serif; + color-scheme: light; } * { @@ -12,6 +13,7 @@ body { min-width: 320px; min-height: 100vh; margin: 0; + background: #dfe7e9; } button, @@ -19,17 +21,193 @@ input { font: inherit; } -.landing { - width: min(42rem, calc(100% - 2rem)); - margin: 15vh auto; - padding: 2rem; +.app { + width: min(52rem, calc(100% - 2rem)); + margin: clamp(2rem, 10vh, 6rem) auto; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0 0.25rem 0.75rem; +} + +.brand { + margin: 0; + color: #70858a; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.2em; +} + +.tabs { + display: grid; + grid-template-columns: repeat(2, minmax(5.5rem, 1fr)); + gap: 0.2rem; + padding: 0.2rem; + border: 1px solid #c1cfd2; + border-radius: 0.6rem; + background: #f8faf9; +} + +.tabs a { + display: flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + padding: 0.45rem 0.75rem; + border-radius: 0.4rem; + color: #70858a; + font-size: 0.82rem; + font-weight: 700; + text-decoration: none; +} + +.tabs a[aria-current="page"] { + color: #f8faf9; + background: #1e3238; +} + +.tabs a[aria-current="page"]::before { + width: 0.35rem; + height: 0.35rem; + border-radius: 50%; + background: currentColor; + content: ""; +} + +.tabs a:focus-visible, +button:focus-visible, +input:focus-visible { + outline: 3px solid #70858a; + outline-offset: 2px; +} + +.reader { + padding: clamp(1.5rem, 5vw, 3rem); border: 1px solid #c1cfd2; border-radius: 1rem; background: #f8faf9; } -.brand { +.reader h1 { + max-width: 18ch; + margin: 0; + font-size: clamp(1.65rem, 5vw, 2.35rem); + font-weight: 650; + letter-spacing: -0.04em; + line-height: 1.2; +} + +.intro { + margin: 0.75rem 0 2rem; color: #70858a; - font-size: 0.7rem; - letter-spacing: 0.2em; + line-height: 1.7; +} + +.intent-form { + display: grid; + gap: 1.25rem; + max-width: 36rem; +} + +.field { + display: grid; + gap: 0.5rem; + color: #1e3238; + font-size: 0.84rem; + font-weight: 700; +} + +.field input { + width: 100%; + min-height: 2.8rem; + padding: 0.65rem 0.8rem; + border: 1px solid #c1cfd2; + border-radius: 0.5rem; + color: #1e3238; + background: #f8faf9; +} + +.field input::placeholder { + color: #70858a; + font-weight: 400; +} + +.field input[aria-invalid="true"] { + border-color: #70858a; +} + +.field-error { + color: #1e3238; + font-size: 0.78rem; + font-weight: 600; +} + +.segmented { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1rem; + margin: 0; + padding: 0; + border: 0; +} + +.segmented legend { + width: 100%; + margin-bottom: 0.1rem; + font-size: 0.84rem; + font-weight: 700; +} + +.segmented label, +.check { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 0.86rem; +} + +.segmented input, +.check input { + accent-color: #1e3238; +} + +.intent-form button { + justify-self: start; + min-width: 7rem; + min-height: 2.7rem; + padding: 0.55rem 1rem; + border: 1px solid #1e3238; + border-radius: 0.5rem; + color: #f8faf9; + background: #1e3238; + font-weight: 700; + cursor: pointer; +} + +@media (max-width: 36rem) { + .app { + width: min(100% - 1rem, 52rem); + margin-block: 1rem; + } + + .app-header { + align-items: stretch; + flex-direction: column; + } + + .tabs { + width: 100%; + } + + .reader { + border-radius: 0.75rem; + } + + .intent-form button { + justify-self: stretch; + } }