feat: add research decks with WebMCP controls
@@ -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,
|
||||
|
||||
@@ -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:<id>` 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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: 'ユーザー' }),
|
||||
|
||||
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
@@ -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('保存できません')
|
||||
})
|
||||
|
||||
@@ -5,15 +5,21 @@ export function AppShell({
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
active?: 'user' | 'search' | 'list'
|
||||
active?: 'user' | 'search' | 'list' | 'deck'
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<main className="app">
|
||||
<main className={active === 'deck' ? 'app app-deck' : 'app'}>
|
||||
<header className="app-header">
|
||||
<p className="brand">Twitter Lite</p>
|
||||
<ProfileSwitcher />
|
||||
<nav aria-label="閲覧方法" className="tabs">
|
||||
<Link
|
||||
aria-current={active === 'deck' ? 'page' : undefined}
|
||||
to="/deck"
|
||||
>
|
||||
デッキ
|
||||
</Link>
|
||||
<Link
|
||||
aria-current={active === 'search' ? 'page' : undefined}
|
||||
search={{ q: '', product: 'Latest', following: false }}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const URL = /(https?:\/\/[^\s]+)/g
|
||||
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
''': "'",
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
}
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
|
||||
if (!decimal && !hexadecimal) {
|
||||
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity
|
||||
}
|
||||
|
||||
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
|
||||
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity
|
||||
})
|
||||
}
|
||||
|
||||
function linkedText(text: string): ReactNode[] {
|
||||
const decoded = decodeHtmlEntities(text)
|
||||
const parts: ReactNode[] = []
|
||||
let cursor = 0
|
||||
for (const match of decoded.matchAll(URL)) {
|
||||
const href = match[0]
|
||||
const start = match.index
|
||||
if (start > cursor) parts.push(decoded.slice(cursor, start))
|
||||
parts.push(
|
||||
<a
|
||||
href={href}
|
||||
key={`link-${start}`}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{href}
|
||||
</a>,
|
||||
)
|
||||
cursor = start + href.length
|
||||
}
|
||||
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
|
||||
return parts
|
||||
}
|
||||
|
||||
export function PostText({ text }: { text: string }) {
|
||||
return <>{linkedText(text)}</>
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { columnSchema, type DeckColumn } from './model'
|
||||
|
||||
export function ColumnEditor({
|
||||
column,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
column?: DeckColumn
|
||||
onSave: (column: DeckColumn) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const formId = useId()
|
||||
const [error, setError] = useState<string>()
|
||||
return (
|
||||
<form
|
||||
className="deck-editor"
|
||||
aria-label={column ? 'カラムを編集' : 'カラムを追加'}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const data = new FormData(event.currentTarget)
|
||||
const parsed = columnSchema.safeParse({
|
||||
// getRandomValues also works on HTTP tailnet origins.
|
||||
id:
|
||||
column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
|
||||
title: data.get('title'),
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
query: data.get('query'),
|
||||
product: data.get('product'),
|
||||
following: data.get('following') === 'on',
|
||||
},
|
||||
})
|
||||
if (!parsed.success) {
|
||||
setError(
|
||||
'カラム名と検索条件を確認してください。検索語はフォロー条件を含め512文字以内です。',
|
||||
)
|
||||
return
|
||||
}
|
||||
onSave(parsed.data)
|
||||
}}
|
||||
>
|
||||
<h2>{column ? 'カラムを編集' : '調べる観点を追加'}</h2>
|
||||
<div className="deck-editor-fields">
|
||||
<label htmlFor={`${formId}-title`}>
|
||||
カラム名
|
||||
<input
|
||||
id={`${formId}-title`}
|
||||
name="title"
|
||||
defaultValue={column?.title ?? ''}
|
||||
maxLength={80}
|
||||
placeholder="例:日本語での反応"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-query`}>
|
||||
Twitterの検索条件
|
||||
<input
|
||||
id={`${formId}-query`}
|
||||
name="query"
|
||||
defaultValue={column?.source.query ?? ''}
|
||||
maxLength={512}
|
||||
placeholder="例:WebMCP lang:ja"
|
||||
required
|
||||
aria-describedby={`${formId}-help`}
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-product`}>
|
||||
表示順
|
||||
<select
|
||||
id={`${formId}-product`}
|
||||
name="product"
|
||||
defaultValue={column?.source.product ?? 'Latest'}
|
||||
>
|
||||
<option value="Latest">最新</option>
|
||||
<option value="Top">話題</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="deck-help" id={`${formId}-help`}>
|
||||
from:、lang:、since: など、Twitterの検索構文を使えます。
|
||||
</p>
|
||||
<label className="deck-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="following"
|
||||
defaultChecked={column?.source.following ?? false}
|
||||
/>
|
||||
フォロー中のみ
|
||||
</label>
|
||||
{error ? (
|
||||
<p role="alert" className="deck-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="deck-actions">
|
||||
<button type="submit" className="deck-primary">
|
||||
{column ? '変更を保存' : '追加して検索'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel}>
|
||||
キャンセル
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { mapTwitterPost } from '#/features/platforms/twitter'
|
||||
import { flattenPostPages } from '#/features/posts/page'
|
||||
import { PostLoadError, usePostFeed } from '#/features/posts/use-post-feed'
|
||||
import type { DeckColumn } from './model'
|
||||
import { ResearchPostCard } from './research-post-card'
|
||||
|
||||
export function ResearchColumn({
|
||||
column,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onMove,
|
||||
first,
|
||||
last,
|
||||
}: {
|
||||
column: DeckColumn
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
onMove: (direction: -1 | 1) => void
|
||||
first: boolean
|
||||
last: boolean
|
||||
}) {
|
||||
const query = usePostFeed({
|
||||
kind: 'search',
|
||||
query: column.source.query,
|
||||
product: column.source.product,
|
||||
following: column.source.following,
|
||||
})
|
||||
const posts = flattenPostPages(query.data?.pages ?? []).map(mapTwitterPost)
|
||||
const error =
|
||||
query.error instanceof PostLoadError
|
||||
? query.error.detail
|
||||
: { message: '投稿を取得できませんでした。', retryable: true }
|
||||
const headingId = `column-${column.id}`
|
||||
return (
|
||||
<section className="deck-column" aria-labelledby={headingId}>
|
||||
<header className="deck-column-header">
|
||||
<div className="deck-column-label">
|
||||
<span>Twitter</span>
|
||||
<span>
|
||||
{column.source.product === 'Latest' ? '最新' : '話題'}
|
||||
{column.source.following ? ' · フォロー中' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<h2 id={headingId}>{column.title}</h2>
|
||||
<p className="deck-query">{column.source.query}</p>
|
||||
<div className="deck-actions">
|
||||
<button type="button" onClick={onEdit}>
|
||||
編集
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
更新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(-1)}
|
||||
disabled={first}
|
||||
aria-label={`${column.title}を左へ`}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(1)}
|
||||
disabled={last}
|
||||
aria-label={`${column.title}を右へ`}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={`${column.title}を削除`}
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
<p className="deck-column-status" role="status">
|
||||
{query.isFetching
|
||||
? '投稿を取得しています…'
|
||||
: `${posts.length}件を読み込み済み`}
|
||||
</p>
|
||||
</header>
|
||||
<section
|
||||
className="deck-column-results"
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: Make the scrolling feed keyboard-accessible.
|
||||
tabIndex={0}
|
||||
aria-label={`${column.title}の投稿`}
|
||||
>
|
||||
{posts.map((post) => (
|
||||
<ResearchPostCard key={post.key} post={post} />
|
||||
))}
|
||||
{query.isError ? (
|
||||
<div className="deck-message" role="alert">
|
||||
<p>{error.message}</p>
|
||||
{error.retryable ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={query.isFetching}
|
||||
onClick={() =>
|
||||
void (query.isFetchNextPageError
|
||||
? query.fetchNextPage({ cancelRefetch: false })
|
||||
: query.refetch())
|
||||
}
|
||||
>
|
||||
再試行
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!query.isPending && !query.isError && posts.length === 0 ? (
|
||||
<p className="deck-message">条件に一致する投稿はありません。</p>
|
||||
) : null}
|
||||
{query.hasNextPage && !query.isFetchNextPageError ? (
|
||||
<div className="deck-message">
|
||||
<button
|
||||
type="button"
|
||||
disabled={query.isFetching}
|
||||
onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
|
||||
>
|
||||
続きを読み込む
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{!query.isPending &&
|
||||
!query.isError &&
|
||||
posts.length > 0 &&
|
||||
!query.hasNextPage ? (
|
||||
<p className="deck-message">これ以上の投稿はありません。</p>
|
||||
) : null}
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState } from 'react'
|
||||
import { AppShell } from '#/components/app-shell'
|
||||
import { ColumnEditor } from './column-editor'
|
||||
import { ResearchColumn } from './deck-column'
|
||||
import { type DeckColumn, MAX_COLUMNS, moveColumn } from './model'
|
||||
import { useDeck } from './use-deck'
|
||||
import { useDeckTools } from './webmcp-tools'
|
||||
import './decks.css'
|
||||
|
||||
export function DeckPage() {
|
||||
const { deck, ready, storageError, save } = useDeck()
|
||||
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [removed, setRemoved] = useState<{
|
||||
column: DeckColumn
|
||||
index: number
|
||||
}>()
|
||||
|
||||
useDeckTools({
|
||||
deck,
|
||||
ready,
|
||||
storageError,
|
||||
save,
|
||||
onApplied: () => {
|
||||
setEditing(null)
|
||||
setRenaming(false)
|
||||
setRemoved(undefined)
|
||||
},
|
||||
})
|
||||
|
||||
function saveColumn(column: DeckColumn) {
|
||||
save({
|
||||
...deck,
|
||||
columns:
|
||||
editing === 'new'
|
||||
? [...deck.columns, column]
|
||||
: deck.columns.map((current) =>
|
||||
current.id === column.id ? column : current,
|
||||
),
|
||||
})
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell active="deck">
|
||||
<div className="deck-heading">
|
||||
<div>
|
||||
<h1>{deck.title}</h1>
|
||||
<p className="deck-help">
|
||||
観点ごとに検索を並べて、投稿を読み比べる。
|
||||
</p>
|
||||
</div>
|
||||
<div className="deck-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => setRenaming(!renaming)}
|
||||
>
|
||||
名前を変更
|
||||
</button>
|
||||
<button
|
||||
className="deck-primary"
|
||||
type="button"
|
||||
disabled={
|
||||
!ready || deck.columns.length >= MAX_COLUMNS || editing !== null
|
||||
}
|
||||
onClick={() => setEditing('new')}
|
||||
>
|
||||
カラムを追加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="deck-save-status">
|
||||
{!ready
|
||||
? 'デッキを読み込んでいます…'
|
||||
: storageError
|
||||
? 'デッキの保存状態を確認してください。'
|
||||
: `このブラウザにデッキの条件を保存 · ${deck.columns.length} / ${MAX_COLUMNS} カラム`}
|
||||
</p>
|
||||
{storageError ? (
|
||||
<p className="deck-error" role="alert">
|
||||
{storageError}
|
||||
</p>
|
||||
) : null}
|
||||
{renaming ? (
|
||||
<form
|
||||
className="deck-rename"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const title = String(
|
||||
new FormData(event.currentTarget).get('title') ?? '',
|
||||
).trim()
|
||||
if (!title) return
|
||||
save({ ...deck, title })
|
||||
setRenaming(false)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="deck-title">
|
||||
調査テーマ・デッキ名
|
||||
<input
|
||||
id="deck-title"
|
||||
name="title"
|
||||
defaultValue={deck.title}
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">名前を保存</button>
|
||||
</form>
|
||||
) : null}
|
||||
{editing !== null ? (
|
||||
<ColumnEditor
|
||||
key={editing === 'new' ? 'new' : editing.id}
|
||||
column={
|
||||
editing === 'new'
|
||||
? undefined
|
||||
: deck.columns.find((column) => column.id === editing.id)
|
||||
}
|
||||
onSave={saveColumn}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
) : null}
|
||||
{removed ? (
|
||||
<div className="deck-undo" role="status">
|
||||
<span>「{removed.column.title}」を削除しました。</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deck.columns.length >= MAX_COLUMNS || editing === 'new'}
|
||||
onClick={() => {
|
||||
const columns = [...deck.columns]
|
||||
columns.splice(removed.index, 0, removed.column)
|
||||
save({ ...deck, columns })
|
||||
setRemoved(undefined)
|
||||
}}
|
||||
>
|
||||
元に戻す
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{ready && deck.columns.length === 0 && editing === null ? (
|
||||
<div className="deck-empty">
|
||||
<h2>最初の観点を追加しましょう。</h2>
|
||||
<p>
|
||||
「日本語での反応」「発表元の投稿」など、調べたい切り口に名前と検索条件を付けてください。
|
||||
</p>
|
||||
<button
|
||||
className="deck-primary"
|
||||
type="button"
|
||||
onClick={() => setEditing('new')}
|
||||
>
|
||||
最初のカラムを作る
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{ready && deck.columns.length > 0 ? (
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
|
||||
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
|
||||
{deck.columns.map((column, index) => (
|
||||
<ResearchColumn
|
||||
key={column.id}
|
||||
column={column}
|
||||
first={index === 0}
|
||||
last={index === deck.columns.length - 1}
|
||||
onEdit={() => setEditing({ id: column.id })}
|
||||
onMove={(direction) =>
|
||||
save(moveColumn(deck, column.id, direction))
|
||||
}
|
||||
onRemove={() => {
|
||||
setRemoved({ column, index })
|
||||
save({
|
||||
...deck,
|
||||
columns: deck.columns.filter(
|
||||
(current) => current.id !== column.id,
|
||||
),
|
||||
})
|
||||
if (typeof editing === 'object' && editing?.id === column.id)
|
||||
setEditing(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/* Hallmark · existing Garden tokens · research deck: horizontal columns
|
||||
* User-requested application layout; inherit the reader's typography and color.
|
||||
* pre-emit critique: P4 H4 E4 S5 R5 V4
|
||||
*/
|
||||
.app-deck .reader {
|
||||
min-width: 0;
|
||||
}
|
||||
.app-deck button,
|
||||
.app-deck input,
|
||||
.app-deck select {
|
||||
font: inherit;
|
||||
}
|
||||
.app-deck button {
|
||||
min-height: 44px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color var(--dur-short) var(--ease-out);
|
||||
}
|
||||
.app-deck button:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
}
|
||||
.app-deck button:active:not(:disabled) {
|
||||
background: var(--color-paper-3);
|
||||
}
|
||||
.app-deck button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.app-deck :focus-visible {
|
||||
outline: var(--rule-active) solid var(--color-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.app-deck .deck-primary {
|
||||
color: var(--color-accent-ink);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.app-deck .deck-primary:hover:not(:disabled),
|
||||
.app-deck .deck-primary:active:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.deck-heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.deck-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-heading h1 {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.deck-help,
|
||||
.deck-save-status,
|
||||
.deck-column-status {
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.deck-save-status {
|
||||
margin: var(--space-md) 0;
|
||||
}
|
||||
.deck-editor {
|
||||
margin-block: var(--space-lg);
|
||||
padding-block: var(--space-lg);
|
||||
border-block: var(--rule-hairline) solid var(--border);
|
||||
}
|
||||
.deck-editor h2,
|
||||
.deck-empty h2 {
|
||||
font-size: var(--text-md);
|
||||
margin: 0 0 var(--space-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-editor-fields {
|
||||
display: grid;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.deck-editor label,
|
||||
.deck-rename label {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-editor input:not([type="checkbox"]),
|
||||
.deck-editor select,
|
||||
.deck-rename input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
.deck-editor .deck-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
.deck-checkbox input {
|
||||
accent-color: var(--accent);
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
.deck-rename {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-sm);
|
||||
margin-block: var(--space-md);
|
||||
}
|
||||
.deck-rename label {
|
||||
flex: 1 1 15rem;
|
||||
}
|
||||
.deck-error {
|
||||
color: var(--error);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.deck-undo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-block: var(--space-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-empty {
|
||||
max-width: 40rem;
|
||||
padding-block: var(--space-2xl);
|
||||
}
|
||||
.deck-empty p {
|
||||
line-height: 1.8;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.deck-board {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: min(100%, 23rem);
|
||||
gap: var(--space-md);
|
||||
overflow-x: auto;
|
||||
padding: var(--space-2xs) var(--space-2xs) var(--space-md);
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
.deck-column {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: max(30rem, calc(100svh - 20rem));
|
||||
border: var(--rule-hairline) solid var(--border);
|
||||
background: var(--paper);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.deck-column-header {
|
||||
padding: var(--space-md);
|
||||
border-bottom: var(--rule-hairline) solid var(--border);
|
||||
}
|
||||
.deck-column-label {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-xs);
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-column h2 {
|
||||
margin: var(--space-sm) 0;
|
||||
font-size: var(--text-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-query {
|
||||
margin: 0 0 var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 4.5em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.deck-column-status {
|
||||
margin-bottom: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.deck-column-results {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
.deck-message {
|
||||
padding: var(--space-md);
|
||||
color: var(--secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.research-post {
|
||||
padding: var(--space-md);
|
||||
border-bottom: var(--rule-hairline) solid var(--border);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-post header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.research-post header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.research-post header img {
|
||||
border-radius: 50%;
|
||||
}
|
||||
.research-post strong {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.research-post-text,
|
||||
.research-post blockquote p {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.8;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.research-post blockquote {
|
||||
margin: var(--space-md) 0;
|
||||
padding-left: var(--space-md);
|
||||
border-left: var(--rule-hairline) solid var(--border);
|
||||
}
|
||||
.research-post footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-md);
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.research-post a {
|
||||
color: var(--accent);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
.research-media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 20rem;
|
||||
object-fit: contain;
|
||||
margin-block: var(--space-sm);
|
||||
}
|
||||
@media (min-width: 52rem) {
|
||||
.app.app-deck {
|
||||
width: 100%;
|
||||
grid-template-columns: 11rem minmax(0, 1fr);
|
||||
}
|
||||
.app-deck .reader {
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
}
|
||||
.deck-editor-fields {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(7rem, 0.5fr);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-deck button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type Deck, deckSchema, moveColumn } from './model'
|
||||
|
||||
const deck: Deck = {
|
||||
version: 1,
|
||||
title: '調査',
|
||||
columns: ['a', 'b'].map((id) => ({
|
||||
id,
|
||||
title: id,
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
query: 'AI lang:ja',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
describe('deck definitions', () => {
|
||||
it('preserves native Twitter search conditions when serialized', () => {
|
||||
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck)
|
||||
})
|
||||
it('rejects duplicate column identities and unsupported platforms', () => {
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
columns: [deck.columns[0], deck.columns[0]],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
columns: [
|
||||
{ ...deck.columns[0], source: { platform: 'nostr', query: 'AI' } },
|
||||
],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
it('rejects queries that exceed the upstream limit after adding follows', () => {
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
columns: [
|
||||
{
|
||||
...deck.columns[0],
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
query: 'a'.repeat(512),
|
||||
product: 'Latest',
|
||||
following: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
it('moves a column without changing its query or the previous deck', () => {
|
||||
expect(moveColumn(deck, 'b', -1).columns).toEqual([
|
||||
deck.columns[1],
|
||||
deck.columns[0],
|
||||
])
|
||||
expect(deck.columns.map((column) => column.id)).toEqual(['a', 'b'])
|
||||
expect(moveColumn(deck, 'a', -1)).toBe(deck)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod'
|
||||
import { buildSearchQuery } from '#/features/posts/inputs'
|
||||
|
||||
export const MAX_COLUMNS = 6
|
||||
|
||||
// Queries stay native to the provider. Add a discriminated-union member when
|
||||
// the next connector is implemented instead of inventing a shared search DSL.
|
||||
const columnSourceSchema = z
|
||||
.object({
|
||||
platform: z.literal('twitter'),
|
||||
query: z.string().trim().min(1).max(512),
|
||||
product: z.enum(['Latest', 'Top']),
|
||||
following: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((source, ctx) => {
|
||||
try {
|
||||
buildSearchQuery(source.query, source.following)
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '検索条件を確認してください。',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const columnSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(128),
|
||||
title: z.string().trim().min(1).max(80),
|
||||
source: columnSourceSchema,
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const deckSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
title: z.string().trim().min(1).max(120),
|
||||
columns: z.array(columnSchema).max(MAX_COLUMNS),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(deck) =>
|
||||
new Set(deck.columns.map((column) => column.id)).size ===
|
||||
deck.columns.length,
|
||||
'カラム ID が重複しています。',
|
||||
)
|
||||
|
||||
export type Deck = z.infer<typeof deckSchema>
|
||||
export type DeckColumn = z.infer<typeof columnSchema>
|
||||
|
||||
export const EMPTY_DECK: Deck = { version: 1, title: '新しい調査', columns: [] }
|
||||
|
||||
export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck {
|
||||
const index = deck.columns.findIndex((column) => column.id === id)
|
||||
const target = index + direction
|
||||
if (index < 0 || target < 0 || target >= deck.columns.length) return deck
|
||||
const columns = [...deck.columns]
|
||||
const [column] = columns.splice(index, 1)
|
||||
if (column) columns.splice(target, 0, column)
|
||||
return { ...deck, columns }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { PostText } from '#/components/post-text'
|
||||
import type { ResearchPost } from '#/features/platforms/types'
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'Asia/Tokyo',
|
||||
})
|
||||
|
||||
export function ResearchPostCard({ post }: { post: ResearchPost }) {
|
||||
return (
|
||||
<article className="research-post">
|
||||
<header>
|
||||
{post.author.avatarUrl ? (
|
||||
<img
|
||||
src={post.author.avatarUrl}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<strong>{post.author.name}</strong>
|
||||
<span className="handle">{post.author.handle}</span>
|
||||
</div>
|
||||
</header>
|
||||
<p className="research-post-text">
|
||||
<PostText text={post.text} />
|
||||
</p>
|
||||
{post.media?.map((media) =>
|
||||
media.type === 'photo' ? (
|
||||
<img
|
||||
className="research-media"
|
||||
key={media.url}
|
||||
src={media.url}
|
||||
alt={media.alt ?? ''}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
key={media.url}
|
||||
href={media.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
動画を開く
|
||||
</a>
|
||||
),
|
||||
)}
|
||||
{post.quotedPost ? (
|
||||
<blockquote>
|
||||
<strong>{post.quotedPost.author.name}</strong>
|
||||
<p>
|
||||
<PostText text={post.quotedPost.text} />
|
||||
</p>
|
||||
<a
|
||||
href={post.quotedPost.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
引用元を開く
|
||||
</a>
|
||||
</blockquote>
|
||||
) : null}
|
||||
<footer>
|
||||
{post.createdAt ? (
|
||||
<time dateTime={post.createdAt}>
|
||||
{dateFormatter.format(new Date(post.createdAt))}
|
||||
</time>
|
||||
) : null}
|
||||
<a href={post.url} target="_blank" rel="noreferrer noopener">
|
||||
元の投稿を開く
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EMPTY_DECK } from './model'
|
||||
import { DECK_STORAGE_KEY, useDeck } from './use-deck'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('saved deck', () => {
|
||||
it('restores saved conditions without overwriting them during mount', () => {
|
||||
const saved = { ...EMPTY_DECK, title: 'WebMCP調査' }
|
||||
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(saved))
|
||||
const { result } = renderHook(useDeck)
|
||||
expect(result.current.ready).toBe(true)
|
||||
expect(result.current.deck).toEqual(saved)
|
||||
expect(JSON.parse(localStorage.getItem(DECK_STORAGE_KEY) ?? '')).toEqual(
|
||||
saved,
|
||||
)
|
||||
})
|
||||
it('keeps invalid saved data untouched and explains the failure', () => {
|
||||
localStorage.setItem(DECK_STORAGE_KEY, '{broken')
|
||||
const { result } = renderHook(useDeck)
|
||||
expect(result.current.storageError).toContain('読み込めません')
|
||||
expect(localStorage.getItem(DECK_STORAGE_KEY)).toBe('{broken')
|
||||
})
|
||||
it('persists edits and restores them on remount', () => {
|
||||
const first = renderHook(useDeck)
|
||||
act(() =>
|
||||
first.result.current.save({ ...EMPTY_DECK, title: '保存した調査' }),
|
||||
)
|
||||
first.unmount()
|
||||
expect(renderHook(useDeck).result.current.deck.title).toBe('保存した調査')
|
||||
})
|
||||
it('reports failed writes instead of claiming that changes were saved', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError')
|
||||
})
|
||||
const { result } = renderHook(useDeck)
|
||||
act(() => result.current.save({ ...EMPTY_DECK, title: '未保存' }))
|
||||
expect(result.current.deck.title).toBe('未保存')
|
||||
expect(result.current.storageError).toContain('保存できません')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { type Deck, deckSchema, EMPTY_DECK } from './model'
|
||||
|
||||
export const DECK_STORAGE_KEY = 'twitter-lite-research-deck'
|
||||
|
||||
export function useDeck() {
|
||||
const [deck, setDeck] = useState<Deck>(EMPTY_DECK)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [storageError, setStorageError] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(DECK_STORAGE_KEY)
|
||||
if (saved !== null) setDeck(deckSchema.parse(JSON.parse(saved)))
|
||||
} catch {
|
||||
setStorageError(
|
||||
'保存したデッキを読み込めませんでした。編集内容を保存すると、このブラウザのデッキを置き換えます。',
|
||||
)
|
||||
}
|
||||
setReady(true)
|
||||
}, [])
|
||||
|
||||
function save(next: Deck) {
|
||||
const validated = deckSchema.parse(next)
|
||||
setDeck(validated)
|
||||
try {
|
||||
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(validated))
|
||||
setStorageError(undefined)
|
||||
return true
|
||||
} catch {
|
||||
setStorageError(
|
||||
'このブラウザに保存できません。現在の変更は、ページを閉じると失われます。',
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return { deck, ready, storageError, save }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { prepareDeck } from './webmcp-contracts'
|
||||
|
||||
describe('deck tool input', () => {
|
||||
it('fills Twitter defaults and assigns independent IDs to new columns', () => {
|
||||
const deck = prepareDeck({
|
||||
title: '調査',
|
||||
columns: [
|
||||
{ title: '日本語', source: { query: 'WebMCP lang:ja' } },
|
||||
{ title: '海外', source: { query: 'WebMCP lang:en' } },
|
||||
],
|
||||
})
|
||||
expect(deck.columns[0]?.source).toEqual({
|
||||
platform: 'twitter',
|
||||
query: 'WebMCP lang:ja',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
})
|
||||
expect(new Set(deck.columns.map((column) => column.id)).size).toBe(2)
|
||||
})
|
||||
it('keeps existing IDs and rejects duplicates before applying a deck', () => {
|
||||
const column = { id: 'existing', title: '観点', source: { query: 'AI' } }
|
||||
expect(
|
||||
prepareDeck({ title: '調査', columns: [column] }).columns[0]?.id,
|
||||
).toBe('existing')
|
||||
expect(() =>
|
||||
prepareDeck({ title: '調査', columns: [column, column] }),
|
||||
).toThrow()
|
||||
})
|
||||
it('rejects unsupported platforms, overlong queries and more than six columns', () => {
|
||||
expect(() =>
|
||||
prepareDeck({
|
||||
title: '調査',
|
||||
columns: [
|
||||
{ title: '観点', source: { platform: 'nostr', query: 'AI' } },
|
||||
],
|
||||
}),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
prepareDeck({
|
||||
title: '調査',
|
||||
columns: [
|
||||
{
|
||||
title: '観点',
|
||||
source: { query: 'a'.repeat(512), following: true },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
prepareDeck({
|
||||
title: '調査',
|
||||
columns: Array.from({ length: 7 }, () => ({
|
||||
title: '観点',
|
||||
source: { query: 'AI' },
|
||||
})),
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod'
|
||||
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
|
||||
|
||||
const source = columnSchema.shape.source
|
||||
const toolColumn = columnSchema.omit({ id: true }).extend({
|
||||
id: columnSchema.shape.id
|
||||
.optional()
|
||||
.describe(
|
||||
'Keep an existing ID from get_deck when editing; omit for a new column.',
|
||||
),
|
||||
source: z
|
||||
.object({
|
||||
...source.shape,
|
||||
platform: source.shape.platform.default('twitter'),
|
||||
product: source.shape.product.default('Latest'),
|
||||
following: source.shape.following.default(false),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
|
||||
export const setDeckInput = z
|
||||
.object({
|
||||
title: deckSchema.shape.title.describe('The research topic or deck title.'),
|
||||
columns: z
|
||||
.array(toolColumn)
|
||||
.max(MAX_COLUMNS)
|
||||
.describe(
|
||||
'Complete ordered column list. Omitted existing columns are removed. Empty list clears the deck.',
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export function prepareDeck(input: unknown) {
|
||||
const parsed = setDeckInput.parse(input)
|
||||
return deckSchema.parse({
|
||||
version: 1,
|
||||
title: parsed.title,
|
||||
columns: parsed.columns.map((column) => ({
|
||||
...column,
|
||||
id: column.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useRef } from 'react'
|
||||
import { useWebMCP } from 'usewebmcp'
|
||||
import { emptyToolInput, toolResult } from '#/features/posts/webmcp-contracts'
|
||||
import { useWebMCPSupported } from '#/features/posts/webmcp-tools'
|
||||
import type { Deck } from './model'
|
||||
import { prepareDeck, setDeckInput } from './webmcp-contracts'
|
||||
|
||||
export function useDeckTools({
|
||||
deck,
|
||||
ready,
|
||||
storageError,
|
||||
save,
|
||||
onApplied,
|
||||
}: {
|
||||
deck: Deck
|
||||
ready: boolean
|
||||
storageError?: string
|
||||
save: (deck: Deck) => boolean
|
||||
onApplied: () => void
|
||||
}) {
|
||||
const supported = useWebMCPSupported()
|
||||
const current = useRef({ deck, storageError })
|
||||
current.current = { deck, storageError }
|
||||
|
||||
useWebMCP({
|
||||
name: 'get_deck',
|
||||
description:
|
||||
'Read the current research deck: title and ordered columns with IDs and native Twitter search conditions. No post fetch. Use this before editing with set_deck. Available on /deck only.',
|
||||
inputSchema: emptyToolInput,
|
||||
enabled: supported && ready,
|
||||
annotations: { readOnlyHint: true, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
emptyToolInput.parse(input)
|
||||
return {
|
||||
deck: current.current.deck,
|
||||
storageError: current.current.storageError ?? null,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
useWebMCP({
|
||||
name: 'set_deck',
|
||||
description:
|
||||
'Create or replace the entire visible research deck and save its conditions in this browser. Supply a title and up to six ordered Twitter search columns. Keep existing column IDs to edit them; omitted columns are removed. Closes unsaved editor forms. Only Twitter is connected. Returns the applied definition immediately; posts load asynchronously and may fail independently. Available on /deck only.',
|
||||
inputSchema: setDeckInput,
|
||||
enabled: supported && ready,
|
||||
annotations: { readOnlyHint: false, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
// Validate the whole definition before modifying either storage or UI.
|
||||
const next = prepareDeck(input)
|
||||
const persisted = save(next)
|
||||
onApplied()
|
||||
const error = persisted
|
||||
? undefined
|
||||
: 'The deck was applied in this tab but could not be saved. Reloading will lose these changes.'
|
||||
current.current = { deck: next, storageError: error }
|
||||
if (error) throw new Error(error)
|
||||
return { deck: next, persisted: true, posts: 'loading-asynchronously' }
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Post } from '../posts/types'
|
||||
import { mapTwitterPost } from './twitter'
|
||||
|
||||
const post: Post = {
|
||||
id: '123',
|
||||
text: 'A & B https://content.invalid/story',
|
||||
author: { name: 'Researcher', username: 'researcher' },
|
||||
}
|
||||
|
||||
describe('mapTwitterPost', () => {
|
||||
it('preserves the native identity and original text with explicit provenance', () => {
|
||||
expect(mapTwitterPost(post)).toEqual({
|
||||
key: 'twitter:123',
|
||||
platform: 'twitter',
|
||||
nativeId: '123',
|
||||
url: 'https://x.com/researcher/status/123',
|
||||
text: post.text,
|
||||
author: { name: 'Researcher', handle: 'researcher' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps identity stable when the author handle changes', () => {
|
||||
const renamed = mapTwitterPost({
|
||||
...post,
|
||||
author: { ...post.author, username: 'new_name' },
|
||||
})
|
||||
expect(renamed.key).toBe(mapTwitterPost(post).key)
|
||||
expect(renamed.url).toBe('https://x.com/new_name/status/123')
|
||||
})
|
||||
|
||||
it('normalizes a valid timestamp and preserves the avatar', () => {
|
||||
expect(
|
||||
mapTwitterPost({
|
||||
...post,
|
||||
createdAt: '2026-09-24T09:00:00+09:00',
|
||||
author: {
|
||||
...post.author,
|
||||
profileImageUrl: 'https://media.invalid/avatar.jpg',
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
createdAt: '2026-09-24T00:00:00.000Z',
|
||||
author: { avatarUrl: 'https://media.invalid/avatar.jpg' },
|
||||
})
|
||||
})
|
||||
|
||||
it('omits invalid dates without dropping the post', () => {
|
||||
const result = mapTwitterPost({ ...post, createdAt: 'not-a-date' })
|
||||
expect(result).not.toHaveProperty('createdAt')
|
||||
expect(result.nativeId).toBe('123')
|
||||
})
|
||||
|
||||
it('maps playable media and uses an image when the video URL is missing', () => {
|
||||
const result = mapTwitterPost({
|
||||
...post,
|
||||
media: [
|
||||
{ type: 'photo', url: 'https://media.invalid/photo.jpg' },
|
||||
{
|
||||
type: 'video',
|
||||
url: 'https://media.invalid/poster.jpg',
|
||||
videoUrl: 'https://media.invalid/video.mp4',
|
||||
},
|
||||
{
|
||||
type: 'animated_gif',
|
||||
url: 'https://media.invalid/gif.jpg',
|
||||
videoUrl: 'https://media.invalid/gif.mp4',
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
url: 'https://media.invalid/missing.jpg',
|
||||
previewUrl: 'https://media.invalid/preview.jpg',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(result.media).toEqual([
|
||||
{ type: 'photo', url: 'https://media.invalid/photo.jpg' },
|
||||
{
|
||||
type: 'video',
|
||||
url: 'https://media.invalid/video.mp4',
|
||||
previewUrl: 'https://media.invalid/poster.jpg',
|
||||
},
|
||||
{
|
||||
type: 'gif',
|
||||
url: 'https://media.invalid/gif.mp4',
|
||||
previewUrl: 'https://media.invalid/gif.jpg',
|
||||
},
|
||||
{ type: 'photo', url: 'https://media.invalid/preview.jpg' },
|
||||
])
|
||||
})
|
||||
|
||||
it('includes one quote with its own provenance and bounds nested quotes', () => {
|
||||
const result = mapTwitterPost({
|
||||
...post,
|
||||
quotedTweet: { ...post, id: '122', quotedTweet: { ...post, id: '121' } },
|
||||
})
|
||||
expect(result.quotedPost).toMatchObject({
|
||||
key: 'twitter:122',
|
||||
nativeId: '122',
|
||||
url: 'https://x.com/researcher/status/122',
|
||||
})
|
||||
expect(result.quotedPost).not.toHaveProperty('quotedPost')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Post } from '../posts/types'
|
||||
import type { ResearchPost } from './types'
|
||||
|
||||
function normalizePost(post: Post, includeQuote: boolean): ResearchPost {
|
||||
const timestamp = post.createdAt ? Date.parse(post.createdAt) : Number.NaN
|
||||
return {
|
||||
key: `twitter:${post.id}`,
|
||||
platform: 'twitter',
|
||||
nativeId: post.id,
|
||||
url: `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`,
|
||||
text: post.text,
|
||||
author: {
|
||||
name: post.author.name,
|
||||
handle: post.author.username,
|
||||
...(post.author.profileImageUrl
|
||||
? { avatarUrl: post.author.profileImageUrl }
|
||||
: {}),
|
||||
},
|
||||
...(Number.isFinite(timestamp)
|
||||
? { createdAt: new Date(timestamp).toISOString() }
|
||||
: {}),
|
||||
...(post.media?.length
|
||||
? {
|
||||
media: post.media.map((item) =>
|
||||
item.type === 'photo' || !item.videoUrl
|
||||
? {
|
||||
type: 'photo' as const,
|
||||
url: item.previewUrl ?? item.url,
|
||||
}
|
||||
: {
|
||||
type:
|
||||
item.type === 'animated_gif'
|
||||
? ('gif' as const)
|
||||
: ('video' as const),
|
||||
url: item.videoUrl,
|
||||
previewUrl: item.previewUrl ?? item.url,
|
||||
},
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(includeQuote && post.quotedTweet
|
||||
? { quotedPost: normalizePost(post.quotedTweet, false) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapTwitterPost(post: Post): ResearchPost {
|
||||
return normalizePost(post, true)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
type Platform = 'twitter' | 'mastodon' | 'bluesky' | 'threads' | 'nostr'
|
||||
|
||||
export type ResearchPost = {
|
||||
key: string
|
||||
platform: Platform
|
||||
nativeId: string
|
||||
url: string
|
||||
text: string
|
||||
author: {
|
||||
name: string
|
||||
handle: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
createdAt?: string
|
||||
media?: {
|
||||
type: 'photo' | 'video' | 'gif'
|
||||
url: string
|
||||
previewUrl?: string
|
||||
alt?: string
|
||||
}[]
|
||||
quotedPost?: ResearchPost
|
||||
}
|
||||
@@ -1,57 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { PostText } from '#/components/post-text'
|
||||
import type { Post } from '../types'
|
||||
import { PostMedia } from './post-media'
|
||||
|
||||
const URL = /(https?:\/\/[^\s]+)/g
|
||||
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
''': "'",
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
}
|
||||
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'Asia/Tokyo',
|
||||
})
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
|
||||
if (!decimal && !hexadecimal) {
|
||||
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity
|
||||
}
|
||||
|
||||
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
|
||||
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity
|
||||
})
|
||||
}
|
||||
|
||||
function linkedText(text: string): ReactNode[] {
|
||||
const decoded = decodeHtmlEntities(text)
|
||||
const parts: ReactNode[] = []
|
||||
let cursor = 0
|
||||
for (const match of decoded.matchAll(URL)) {
|
||||
const href = match[0]
|
||||
const start = match.index
|
||||
if (start > cursor) parts.push(decoded.slice(cursor, start))
|
||||
parts.push(
|
||||
<a
|
||||
href={href}
|
||||
key={`link-${start}`}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{href}
|
||||
</a>,
|
||||
)
|
||||
cursor = start + href.length
|
||||
}
|
||||
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
|
||||
return parts
|
||||
}
|
||||
|
||||
function formattedTime(createdAt?: string): string | undefined {
|
||||
if (!createdAt) return undefined
|
||||
const date = new Date(createdAt)
|
||||
@@ -93,7 +49,9 @@ export function PostCard({
|
||||
<time dateTime={post.createdAt}>{time}</time>
|
||||
) : null}
|
||||
</header>
|
||||
<p className="post-text">{linkedText(post.text)}</p>
|
||||
<p className="post-text">
|
||||
<PostText text={post.text} />
|
||||
</p>
|
||||
{post.media ? <PostMedia media={post.media} /> : null}
|
||||
{post.article ? (
|
||||
<section className="article-card">
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
toolResult,
|
||||
} from './webmcp-contracts'
|
||||
|
||||
function useWebMCPSupported() {
|
||||
export function useWebMCPSupported() {
|
||||
const [supported, setSupported] = useState(false)
|
||||
useEffect(() => {
|
||||
setSupported(Boolean(document.modelContext))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { DeckPage } from '#/features/decks/deck-page'
|
||||
|
||||
export const Route = createFileRoute('/deck')({ component: DeckPage })
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||