diff --git a/README.md b/README.md index 42b9928..f178a09 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ deliberate post detail link, or manually open a valid - Infinite conversation loading with explicit continuation retry - Read-only cards for text, media, quotes, articles, and quiet engagement counts - Experimental WebMCP tools for search, loaded-post reading, and continuation +- Deck WebMCP tools to inspect or create/replace up to six columns at once +- Research deck with named Twitter search columns, independent pagination, + editing, ordering, and browser-local condition persistence It intentionally has no home feed, recommendations, trends, notifications, history, account directory, or write actions. @@ -28,6 +31,7 @@ they do not add related-post recommendations or an account-discovery surface. ## Routes - `/` shows the empty user input. +- `/deck` opens the research deck (up to six search columns). - `/:handle` shows a user timeline. - `/search` shows search results. Its typed query parameters preserve the query, author, period, language, content type, exclusions, ranking, and follows filter @@ -95,6 +99,22 @@ handwritten mock server. `test:live` is an explicit, opt-in smoke test that performs exactly one read-only Top search using the configured relay. +## Research deck + +Open **デッキ** in the navigation, name the investigation, and add a column +for each perspective. Each column has a title, native Twitter search query, +Top/Latest ordering, and an optional follows filter. Update or paginate a +column independently, edit its conditions, move it left/right, or undo a +removal. All columns use the currently selected relay profile. + +One deck's conditions are stored in this browser's localStorage. Reloading +fetches the first page again; collected posts are not archived or synced +between devices. Other open tabs are not synchronized. A WebMCP-capable agent +can use `get_deck` and `set_deck` on `/deck` to create or edit the whole deck. +Built-in AI planning, summaries, and the Mastodon/Bluesky/Threads/Nostr connectors +are not implemented yet. Only Twitter can currently be selected. See +[the platform boundary and next steps](docs/research-decks.md). + ## WebMCP prototype In a WebMCP-enabled browser, `search_posts` searches and updates the page, diff --git a/docs/research-decks.md b/docs/research-decks.md new file mode 100644 index 0000000..6d2b4d0 --- /dev/null +++ b/docs/research-decks.md @@ -0,0 +1,134 @@ +# Research decks + +## Product direction + +A research topic should become a TweetDeck-style workspace: columns represent +questions or perspectives, collect posts across social platforms, and support +AI summaries with references back to the evidence. The intended platforms are +Twitter, Mastodon, Bluesky, Threads, and Nostr. + +The first increment is a manually composed deck backed by Twitter. It makes +the deck definition, column lifecycle, and post normalization concrete before +adding AI or more connectors. + +## Implemented boundary + +- `src/features/decks/model.ts` validates a versioned deck definition: name, + ordered columns with stable IDs, and provider-specific search conditions. + At most six columns are loaded, bounding concurrent first-page requests. +- `src/features/platforms/types.ts` defines the display/evidence record without + importing Bird: stable key, platform, native identity, original URL, text, + author, optional publication time, media, and quoted post. +- `src/features/platforms/twitter.ts` maps existing reader posts into that + record. Keys use `twitter:` rather than the author's mutable handle. + Raw provider responses and credentials do not enter the common record. +- `ResearchColumn` uses the existing Twitter search hook and server functions. + Equal search conditions share the reader's query cache. Distinct conditions + have independent loading/error/pagination state. Profile changes invalidate + queries through the existing profile switcher. +- `ResearchPostCard` only consumes the common record. Provider-specific detail + routes remain in the existing Twitter reader. Original-post links, media, + and one level of quotes are shown in the deck; engagement metrics and + Twitter article previews are not normalized yet. + +The current source is a single Twitter search per column. Its `platform` +discriminator is the extension point for another real connector, not a claim +that five adapters already work. Twitter's raw syntax, Top/Latest, and follows +filter are not requirements imposed on the other platforms. Unsupported +source definitions fail validation instead of silently dropping conditions. + +The existing reader and its active-feed WebMCP tools remain separate from the +deck. Mounting multiple reader `PostFeed` components would register conflicting +active-feed tools; deck columns therefore use the search hook directly. +On `/deck`, `get_deck` and `set_deck` expose the definition once local storage +has loaded. They unregister when leaving the route. + +## Deck WebMCP tools + +`get_deck({})` returns the current definition (including column IDs) and any +storage error. `set_deck` replaces the complete ordered definition and closes +unsaved editor forms. Read before editing, keep IDs of retained columns, and +include every column you want to keep. Omit IDs for new columns. An empty array +clears the deck. The same deck schema validates the entire input before changes. + +```json +{ + "title": "WebMCPの反応", + "columns": [ + { "title": "日本語", "source": { "query": "WebMCP lang:ja" } }, + { "title": "海外の話題", "source": { "query": "WebMCP lang:en", "product": "Top" } } + ] +} +``` + +`source.platform` defaults to `twitter`, `product` to `Latest`, and `following` +to false. `set_deck` returns the applied definition and `persisted: true` before +post loading completes. It does not claim that searches succeeded. A storage +failure returns `isError: true` and explains that the in-memory change was +applied but will be lost on reload. Invalid input changes neither UI nor storage. + +Native WebMCP needs a supported browser and a secure context. Use the HTTPS +Tailscale Serve origin rather than an HTTP tailnet IP. For Vite, allow that exact +hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the dev process's +environment. HTTP and HTTPS origins have separate browser-local deck storage. + +## Persistence and fetching + +One deck is saved under `twitter-lite-research-deck` in localStorage. It contains +only conditions and names, not posts, summaries, credentials, or cursors. +SSR and the initial browser render show a loading state before reading it. +Invalid saved data is retained until the user explicitly saves an edit. Storage +read/write failures are surfaced in the UI. + +The selected relay profile is browser-wide, not pinned to a column. Reopening +a deck uses that current profile and fetches first pages. Pagination is manual; +there is no polling or background collection. Tabs do not synchronize deck +edits; the last edit written to localStorage wins. LocalStorage is an initial +single-browser workspace, not the eventual research archive. + +## Adding the second platform + +1. Add a real provider-specific source schema and a validated server-side + search operation. Keep authentication on the server. Extend the source + union and put dispatch at the feed boundary, outside the post card. +2. Implement normalization with stable provider identity. For Mastodon, do not + treat an instance-local numeric ID as globally unique; for Bluesky prefer + the canonical record identity; for Nostr use event identity. The human + original-post URL and the deduplication identity are separate fields. +3. Add a connection reference when per-column accounts, Mastodon instances, + or Nostr relay sets are introduced. Include it in query-cache identity. +4. Support multiple sources within a column only when a second connector can + exercise it. Each source needs its own continuation and error state. Do not + merge opaque upstream cursors into one cross-platform cursor or claim a + globally complete chronological feed from separately ranked searches. + +Search capability is connection-dependent. Mastodon documents that status +search depends on the instance's search backend and authentication: +[Mastodon search API](https://docs.joinmastodon.org/methods/search/). +Nostr's full-text search is an optional relay capability: +[NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md). +Validate actual connection capabilities when those connectors are added. +Threads authorization and Bluesky endpoint behavior must likewise be verified +when implementing those adapters, not inferred from Twitter's contract. + +## AI increment + +The next product slice should turn a topic into validated column definitions, +then let the user refine them. AI-generated conditions use the same schema as +manual edits. Summaries should reference a persisted collection snapshot +(post keys, original URLs, retrieval time, source/query and profile context), +so a later refresh does not change the evidence behind an earlier claim. +LocalStorage of conditions alone does not provide this evidence store. + +ACP or Codex app-server can connect the planner to the application, but they +are not part of the platform data model or this implementation. + +## Verification + +Unit tests cover normalization, source/definition validation, ordering and +storage errors. Playwright tests use the existing standalone mock relay through +the real server functions to verify independent pagination, retry, editing, +ordering, reload, deletion/undo, and invalid saved data. They also check viewport +widths 320/375/414/768 and run Axe on the populated deck. + +No live SNS search is required by these tests. diff --git a/docs/webmcp-prototype.md b/docs/webmcp-prototype.md index 9352ea9..98c3698 100644 --- a/docs/webmcp-prototype.md +++ b/docs/webmcp-prototype.md @@ -3,7 +3,9 @@ ## Scope and design Expose the existing intentional reader to agents through three React-owned -tools. Search remains available across routes. Feed tools are registered only +reader tools. The deck additionally exposes `get_deck` and `set_deck` on `/deck`; +see [deck tool contracts](research-decks.md#deck-webmcp-tools). +Search remains available across routes. Feed tools are registered only while a valid timeline, search, or conversation is open. Returning home or to an empty search/list page removes the feed tools. Unsupported browsers do not register tools or fetch posts automatically. diff --git a/e2e/integrations/deck.test.ts b/e2e/integrations/deck.test.ts new file mode 100644 index 0000000..8881872 --- /dev/null +++ b/e2e/integrations/deck.test.ts @@ -0,0 +1,193 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '../fixtures' + +async function addColumn( + page: Page, + title: string, + query: string, + top = false, +) { + await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() + const editor = page.getByRole('form', { name: 'カラムを追加', exact: true }) + await editor.getByLabel('カラム名', { exact: true }).fill(title) + await editor.getByLabel('Twitterの検索条件').fill(query) + if (top) await editor.getByLabel('表示順').selectOption('Top') + await editor.getByRole('button', { name: '追加して検索' }).click() + await expect( + page + .getByRole('region', { name: title, exact: true }) + .locator('article') + .first(), + ).toBeVisible() +} + +test.beforeEach(async ({ page }) => { + await page.goto('/deck') + await page.waitForLoadState('networkidle') +}) + +test('creates independent columns, edits, reorders, and restores saved conditions', async ({ + page, +}) => { + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + await page.getByRole('button', { name: '名前を変更' }).click() + await page.getByLabel('調査テーマ・デッキ名').fill('WebMCPの反応') + await page.getByRole('button', { name: '名前を保存' }).click() + await addColumn(page, '日本語の反応', 'WebMCP lang:ja') + await addColumn(page, '話題の投稿', 'WebMCP', true) + const first = page.getByRole('region', { name: '日本語の反応', exact: true }) + const second = page.getByRole('region', { name: '話題の投稿', exact: true }) + await first.getByRole('button', { name: '続きを読み込む' }).click() + await expect(first.locator('article')).toHaveCount(2) + await expect(second.locator('article')).toHaveCount(1) + await second.getByRole('button', { name: '話題の投稿を左へ' }).click() + await expect(page.locator('.deck-column h2')).toHaveText([ + '話題の投稿', + '日本語の反応', + ]) + await second.getByRole('button', { name: '編集', exact: true }).click() + await page + .getByRole('form', { name: 'カラムを編集' }) + .getByLabel('カラム名', { exact: true }) + .fill('公式の投稿') + await page.getByLabel('Twitterの検索条件').fill('from:OpenAI') + await page.getByRole('button', { name: '変更を保存' }).click() + await page.reload() + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'WebMCPの反応', + ) + await expect(page.locator('.deck-column h2')).toHaveText([ + '公式の投稿', + '日本語の反応', + ]) + const official = page.getByRole('region', { name: '公式の投稿', exact: true }) + await expect(official.locator('.deck-query')).toHaveText('from:OpenAI') + await expect(official.locator('article')).toHaveCount(1) + await official.getByRole('button', { name: '公式の投稿を削除' }).click() + await expect(page.locator('.deck-column')).toHaveCount(1) + await page.getByRole('button', { name: '元に戻す' }).click() + await expect(page.locator('.deck-column h2')).toHaveText([ + '公式の投稿', + '日本語の反応', + ]) + expect(errors).toEqual([]) +}) + +test('keeps other columns usable when pagination fails and retries only that column', async ({ + page, +}, testInfo) => { + await addColumn(page, '失敗する検索', `retry-deck-${testInfo.project.name}`) + await addColumn(page, '別の検索', 'independent-deck', true) + const failed = page.getByRole('region', { name: '失敗する検索', exact: true }) + const other = page.getByRole('region', { name: '別の検索', exact: true }) + await failed.getByRole('button', { name: '続きを読み込む' }).click() + await expect(failed.getByRole('alert')).toBeVisible() + await expect(failed.locator('article')).toHaveCount(1) + await other.getByRole('button', { name: '続きを読み込む' }).click() + await expect(other.locator('article')).toHaveCount(2) + await failed.getByRole('button', { name: '再試行' }).click() + await expect(failed.locator('article')).toHaveCount(2) + await expect(failed.getByRole('alert')).toHaveCount(0) +}) + +test('keeps columns within narrow viewports and accessible', async ({ + page, + a11y, +}, testInfo) => { + await addColumn(page, '日本語の反応', 'WebMCP lang:ja') + await addColumn(page, '海外の反応', 'WebMCP lang:en', true) + for (const width of [320, 375, 414, 768]) { + await page.setViewportSize({ width, height: 900 }) + expect( + await page.evaluate( + () => document.documentElement.scrollWidth > innerWidth, + ), + ).toBe(false) + } + await page.setViewportSize({ + width: testInfo.project.name === 'desktop' ? 1440 : 393, + height: 900, + }) + await expect( + page + .getByRole('region', { name: '日本語の反応', exact: true }) + .locator('article'), + ).toHaveCount(1) + expect((await a11y().analyze()).violations).toEqual([]) + await page.screenshot({ + path: testInfo.outputPath('deck.png'), + fullPage: true, + }) +}) + +test('does not overwrite an unreadable saved deck on reload', async ({ + page, +}) => { + await page.evaluate(() => + localStorage.setItem('twitter-lite-research-deck', '{broken'), + ) + await page.reload() + await expect(page.getByRole('alert')).toContainText('読み込めませんでした') + expect( + await page.evaluate(() => + localStorage.getItem('twitter-lite-research-deck'), + ), + ).toBe('{broken') +}) + +test('keeps the six-column limit when undo and add overlap', async ({ + page, +}) => { + await page.evaluate(() => + localStorage.setItem( + 'twitter-lite-research-deck', + JSON.stringify({ + version: 1, + title: '上限の確認', + columns: Array.from({ length: 6 }, (_, index) => ({ + id: `column-${index}`, + title: `観点${index}`, + source: { + platform: 'twitter', + query: `limit-${index}`, + product: 'Latest', + following: false, + }, + })), + }), + ), + ) + await page.reload() + await page.waitForLoadState('networkidle') + await expect( + page.getByRole('button', { name: 'カラムを追加', exact: true }), + ).toBeDisabled() + await page.getByRole('button', { name: '観点0を削除' }).click() + await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() + await expect(page.getByRole('button', { name: '元に戻す' })).toBeDisabled() + await page.getByLabel('カラム名', { exact: true }).fill('追加の観点') + await page.getByLabel('Twitterの検索条件').fill('new-limit') + await page.getByRole('button', { name: '追加して検索' }).click() + await expect(page.locator('.deck-column')).toHaveCount(6) + await expect(page.getByRole('button', { name: '元に戻す' })).toBeDisabled() + await page.reload() + await expect(page.locator('.deck-column')).toHaveCount(6) +}) + +test('creates columns without the secure-context-only randomUUID API', async ({ + page, +}) => { + await page.addInitScript(() => + Object.defineProperty(crypto, 'randomUUID', { value: undefined }), + ) + await page.reload() + await page.waitForLoadState('networkidle') + await addColumn(page, 'HTTP接続からの調査', 'http-deck') + await page.reload() + await expect( + page + .getByRole('region', { name: 'HTTP接続からの調査', exact: true }) + .locator('article'), + ).toHaveCount(1) +}) diff --git a/e2e/integrations/reader.test.ts b/e2e/integrations/reader.test.ts index 440bf47..cd22005 100644 --- a/e2e/integrations/reader.test.ts +++ b/e2e/integrations/reader.test.ts @@ -188,6 +188,7 @@ test('keeps keyboard focus intentional and discovery or mutation controls absent await searchPage.tabTo( searchPage.page.getByRole('combobox', { name: 'プロファイル' }), ) + await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'デッキ' })) await searchPage.tabTo(searchPage.page.getByRole('link', { name: '検索' })) await searchPage.tabTo( searchPage.page.getByRole('link', { name: 'ユーザー' }), diff --git a/e2e/integrations/reader.test.ts-snapshots/mist-thread-desktop-linux.png b/e2e/integrations/reader.test.ts-snapshots/mist-thread-desktop-linux.png index 12c918d..2e9fb54 100644 Binary files a/e2e/integrations/reader.test.ts-snapshots/mist-thread-desktop-linux.png and b/e2e/integrations/reader.test.ts-snapshots/mist-thread-desktop-linux.png differ diff --git a/e2e/integrations/reader.test.ts-snapshots/mist-thread-mobile-linux.png b/e2e/integrations/reader.test.ts-snapshots/mist-thread-mobile-linux.png index de2a180..bd2f60a 100644 Binary files a/e2e/integrations/reader.test.ts-snapshots/mist-thread-mobile-linux.png and b/e2e/integrations/reader.test.ts-snapshots/mist-thread-mobile-linux.png differ diff --git a/e2e/integrations/reader.test.ts-snapshots/mist-user-desktop-linux.png b/e2e/integrations/reader.test.ts-snapshots/mist-user-desktop-linux.png index 6e7ddb4..3166b9c 100644 Binary files a/e2e/integrations/reader.test.ts-snapshots/mist-user-desktop-linux.png and b/e2e/integrations/reader.test.ts-snapshots/mist-user-desktop-linux.png differ diff --git a/e2e/integrations/reader.test.ts-snapshots/mist-user-mobile-linux.png b/e2e/integrations/reader.test.ts-snapshots/mist-user-mobile-linux.png index 217f5a8..905e917 100644 Binary files a/e2e/integrations/reader.test.ts-snapshots/mist-user-mobile-linux.png and b/e2e/integrations/reader.test.ts-snapshots/mist-user-mobile-linux.png differ diff --git a/e2e/integrations/webmcp.test.ts b/e2e/integrations/webmcp.test.ts index 4293d6b..089028f 100644 --- a/e2e/integrations/webmcp.test.ts +++ b/e2e/integrations/webmcp.test.ts @@ -252,3 +252,105 @@ test('keeps the normal search form functional without native WebMCP', async ({ await browser.close() } }) + +test('creates a deck through native WebMCP, edits it, and restores its saved definition', async ({ + page, +}) => { + await page.goto('/deck') + await page.waitForLoadState('networkidle') + await expect + .poll(() => toolNames(page)) + .toEqual(['get_deck', 'search_posts', 'set_deck']) + const result = await executeTool(page, 'set_deck', { + title: 'WebMCPの反応', + columns: [ + { id: 'new', title: '日本語', source: { query: 'WebMCP lang:ja' } }, + { title: '話題', source: { query: 'WebMCP', product: 'Top' } }, + ], + }) + expect(result.isError).not.toBe(true) + await expect(page.locator('.deck-column h2')).toHaveText(['日本語', '話題']) + await expect(page.getByText('Latest · all page 1')).toBeVisible() + await expect(page.getByText('Top · all page 1')).toBeVisible() + const before = await executeTool(page, 'get_deck') + const { deck } = JSON.parse(before.content[0]?.text ?? '') + expect(deck.columns[0].id).toBe('new') + // Tool-provided IDs must not collide with the UI's new-column editing state. + await page + .getByRole('region', { name: '日本語', exact: true }) + .getByRole('button', { name: '編集', exact: true }) + .click() + await expect(page.getByLabel('Twitterの検索条件')).toHaveValue( + 'WebMCP lang:ja', + ) + const edited = await executeTool(page, 'set_deck', { + title: '更新した調査', + columns: [deck.columns[1]], + }) + expect(edited.isError).not.toBe(true) + await expect(page.getByRole('form', { name: 'カラムを編集' })).toHaveCount(0) + await expect(page.locator('.deck-column h2')).toHaveText(['話題']) + await page.reload() + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + '更新した調査', + ) + await expect.poll(() => toolNames(page)).toContain('get_deck') + const restored = await executeTool(page, 'get_deck') + expect(JSON.parse(restored.content[0]?.text ?? '').deck.columns[0].id).toBe( + deck.columns[1].id, + ) + await page.getByRole('link', { name: 'ユーザー', exact: true }).click() + await expect.poll(() => toolNames(page)).toEqual(['search_posts']) +}) + +test('rejects an invalid deck without changing the visible or saved columns', async ({ + page, +}) => { + await page.goto('/deck') + await page.waitForLoadState('networkidle') + await expect.poll(() => toolNames(page)).toContain('set_deck') + await executeTool(page, 'set_deck', { + title: '残す調査', + columns: [{ title: '残す観点', source: { query: 'AI' } }], + }) + const saved = await page.evaluate(() => + localStorage.getItem('twitter-lite-research-deck'), + ) + const column = { id: 'duplicate', title: '不正', source: { query: 'AI' } } + const result = await executeTool(page, 'set_deck', { + title: '変更しない', + columns: [column, column], + }) + expect(result.isError).toBe(true) + await expect(page.locator('.deck-column h2')).toHaveText(['残す観点']) + expect( + await page.evaluate(() => + localStorage.getItem('twitter-lite-research-deck'), + ), + ).toBe(saved) +}) + +test('reports a deck persistence failure instead of claiming it was saved', async ({ + page, +}) => { + await page.goto('/deck') + await page.waitForLoadState('networkidle') + await expect.poll(() => toolNames(page)).toContain('set_deck') + await page.evaluate(() => { + Storage.prototype.setItem = () => { + throw new Error('QuotaExceededError') + } + }) + const result = await executeTool(page, 'set_deck', { + title: '未保存の調査', + columns: [], + }) + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toContain( + 'applied in this tab but could not be saved', + ) + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + '未保存の調査', + ) + await expect(page.getByRole('alert')).toContainText('保存できません') +}) diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index e001633..8586d02 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -5,15 +5,21 @@ export function AppShell({ active, children, }: { - active?: 'user' | 'search' | 'list' + active?: 'user' | 'search' | 'list' | 'deck' children: React.ReactNode }) { return ( -
+

Twitter Lite

-

{linkedText(post.text)}

+

+ +

{post.media ? : null} {post.article ? (
diff --git a/src/features/posts/webmcp-tools.tsx b/src/features/posts/webmcp-tools.tsx index 79d3dd4..3a0b786 100644 --- a/src/features/posts/webmcp-tools.tsx +++ b/src/features/posts/webmcp-tools.tsx @@ -19,7 +19,7 @@ import { toolResult, } from './webmcp-contracts' -function useWebMCPSupported() { +export function useWebMCPSupported() { const [supported, setSupported] = useState(false) useEffect(() => { setSupported(Boolean(document.modelContext)) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 1351e09..136fa22 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SearchRouteImport } from './routes/search' +import { Route as DeckRouteImport } from './routes/deck' import { Route as HandleRouteImport } from './routes/$handle' import { Route as IndexRouteImport } from './routes/index' import { Route as IListsRouteImport } from './routes/i.lists' @@ -21,6 +22,11 @@ const SearchRoute = SearchRouteImport.update({ path: '/search', getParentRoute: () => rootRouteImport, } as any) +const DeckRoute = DeckRouteImport.update({ + id: '/deck', + path: '/deck', + getParentRoute: () => rootRouteImport, +} as any) const HandleRoute = HandleRouteImport.update({ id: '/$handle', path: '/$handle', @@ -50,6 +56,7 @@ const HandleStatusTweetIdRoute = HandleStatusTweetIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/$handle': typeof HandleRoute + '/deck': typeof DeckRoute '/search': typeof SearchRoute '/i/lists': typeof IListsRoute '/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute @@ -58,6 +65,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/$handle': typeof HandleRoute + '/deck': typeof DeckRoute '/search': typeof SearchRoute '/i/lists': typeof IListsRoute '/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute @@ -67,6 +75,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/$handle': typeof HandleRoute + '/deck': typeof DeckRoute '/search': typeof SearchRoute '/i/lists': typeof IListsRoute '/$handle_/status/$tweetId': typeof HandleStatusTweetIdRoute @@ -77,6 +86,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/$handle' + | '/deck' | '/search' | '/i/lists' | '/$handle/status/$tweetId' @@ -85,6 +95,7 @@ export interface FileRouteTypes { to: | '/' | '/$handle' + | '/deck' | '/search' | '/i/lists' | '/$handle/status/$tweetId' @@ -93,6 +104,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/$handle' + | '/deck' | '/search' | '/i/lists' | '/$handle_/status/$tweetId' @@ -102,6 +114,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute HandleRoute: typeof HandleRoute + DeckRoute: typeof DeckRoute SearchRoute: typeof SearchRoute IListsRoute: typeof IListsRoute HandleStatusTweetIdRoute: typeof HandleStatusTweetIdRoute @@ -117,6 +130,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SearchRouteImport parentRoute: typeof rootRouteImport } + '/deck': { + id: '/deck' + path: '/deck' + fullPath: '/deck' + preLoaderRoute: typeof DeckRouteImport + parentRoute: typeof rootRouteImport + } '/$handle': { id: '/$handle' path: '/$handle' @@ -158,6 +178,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, HandleRoute: HandleRoute, + DeckRoute: DeckRoute, SearchRoute: SearchRoute, IListsRoute: IListsRoute, HandleStatusTweetIdRoute: HandleStatusTweetIdRoute, diff --git a/src/routes/deck.tsx b/src/routes/deck.tsx new file mode 100644 index 0000000..472905a --- /dev/null +++ b/src/routes/deck.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +import { DeckPage } from '#/features/decks/deck-page' + +export const Route = createFileRoute('/deck')({ component: DeckPage }) diff --git a/src/styles.css b/src/styles.css index 9373e01..cd3bccf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -667,7 +667,7 @@ video.media { .tabs { display: grid; grid-column: 1 / -1; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; }