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
+
+
+
+
+ )
+}
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 (
+
+ )
+}
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;
+ }
}