feat: migrate workspace to shadcn sidebar and chat components
This commit is contained in:
@@ -1,4 +1,12 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-09-28",
|
||||||
|
"macrostructure": "sidebar-09 Workbench",
|
||||||
|
"theme": "shadcn base-nova neutral",
|
||||||
|
"scope": "app",
|
||||||
|
"enrichment": "none",
|
||||||
|
"brief": "Base UI icon rail and contextual sidebar with shadcn chat primitives and independent deck columns"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"date": "2026-09-24",
|
"date": "2026-09-24",
|
||||||
"macrostructure": "Workbench",
|
"macrostructure": "Workbench",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"categories": { "correctness": "off" },
|
||||||
|
"jsPlugins": ["@shadcn/lint"],
|
||||||
|
"rules": {
|
||||||
|
"shadcn/no-raw-colors": "error"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["src/routeTree.gen.ts"]
|
||||||
|
}
|
||||||
@@ -66,6 +66,9 @@ profiles; Mastodon credentials never go to the browser.
|
|||||||
nix develop -c pnpm dev
|
nix develop -c pnpm dev
|
||||||
nix develop -c pnpm dev:tailscale
|
nix develop -c pnpm dev:tailscale
|
||||||
nix develop -c pnpm generate:e2e-openapi
|
nix develop -c pnpm generate:e2e-openapi
|
||||||
|
nix develop -c pnpm lint
|
||||||
|
nix develop -c pnpm lint:ui
|
||||||
|
nix develop -c pnpm typecheck
|
||||||
nix develop -c pnpm knip
|
nix develop -c pnpm knip
|
||||||
nix develop -c pnpm test
|
nix develop -c pnpm test
|
||||||
nix develop -c pnpm test:e2e
|
nix develop -c pnpm test:e2e
|
||||||
@@ -163,11 +166,31 @@ and `TWITTER_LITE_LIVE_QUERY` overrides the search text.
|
|||||||
|
|
||||||
## Deck workspace
|
## Deck workspace
|
||||||
|
|
||||||
The interface uses a full-height, dark TweetDeck-style workbench: a deck sidebar,
|
The interface uses shadcn/ui's Base UI components and Tailwind CSS v4, with a
|
||||||
compact column headers, and independently scrolling post columns. On narrow
|
collapsible sidebar based on
|
||||||
screens, the sidebar becomes a compact top bar. Add/edit forms open in native
|
[sidebar-09](https://github.com/shadcn-ui/ui/tree/98a1fe67b439324ddc857f47fbdce056600a4329/apps/v4/registry/bases/base/blocks/sidebar-09).
|
||||||
dialogs; Escape closes them and returns focus. Column menus contain editing,
|
The workspace retains compact column headers and independently scrolling post
|
||||||
ordering, and deletion; refresh stays available directly in each header.
|
columns. On narrow screens, the sidebar opens as a sheet. Add/edit forms use
|
||||||
|
shadcn dialogs with Escape handling and focus restoration. Column menus contain
|
||||||
|
editing, ordering, and deletion; refresh stays available directly in each header.
|
||||||
|
|
||||||
|
Shared UI components live in `src/components/ui`; `components.json` configures
|
||||||
|
the registry and theme entrypoint. Chat uses MessageScroller, Message, Bubble,
|
||||||
|
Marker and InputGroup while retaining the existing Codex transport and saved
|
||||||
|
conversation model. The icon rail switches between chat and deck management;
|
||||||
|
closing the mobile sheet or crossing the desktop breakpoint preserves chat
|
||||||
|
drafts, selected accounts and live deck updates. Theme colors are declared in
|
||||||
|
`src/ui.css`.
|
||||||
|
|
||||||
|
`pnpm lint` runs Biome and the UI checks. `pnpm lint:ui` runs
|
||||||
|
[@shadcn/lint](https://github.com/shadcn-ui/lint) through Oxlint. The
|
||||||
|
`shadcn/no-raw-colors` rule in `.oxlintrc.json` requires theme colors in JSX and
|
||||||
|
class helpers, including shared UI components. Layout values remain available
|
||||||
|
for the sidebar and deck. Biome continues to handle formatting and general
|
||||||
|
code checks. Knip checks application usage while retaining the public exports
|
||||||
|
of registry components for composition. Additional design rules can be configured
|
||||||
|
in `.oxlintrc.json`;
|
||||||
|
see the [available rules](https://github.com/shadcn-ui/lint#rules).
|
||||||
|
|
||||||
Create a deck for an investigation, then add columns for each perspective.
|
Create a deck for an investigation, then add columns for each perspective.
|
||||||
Choose a connected account and one of its supported sources. Account
|
Choose a connected account and one of its supported sources. Account
|
||||||
|
|||||||
+19
-3
@@ -8,9 +8,25 @@
|
|||||||
"!!**/test-results"
|
"!!**/test-results"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": { "enabled": true, "indentStyle": "space" },
|
"formatter": {
|
||||||
"linter": { "enabled": true, "rules": { "recommended": true } },
|
"enabled": true,
|
||||||
|
"indentStyle": "space"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": { "quoteStyle": "single", "semicolons": "asNeeded" }
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"semicolons": "asNeeded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/ui.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "#/components",
|
||||||
|
"utils": "#/lib/utils",
|
||||||
|
"ui": "#/components/ui",
|
||||||
|
"lib": "#/lib",
|
||||||
|
"hooks": "#/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# shadcn workspace migration
|
||||||
|
|
||||||
|
## Target
|
||||||
|
|
||||||
|
Replace the custom workspace UI with the Base UI version of shadcn sidebar-09
|
||||||
|
at commit 98a1fe67b439324ddc857f47fbdce056600a4329. Preserve its icon rail,
|
||||||
|
contextual second sidebar and SidebarInset. Adapt the rail to research chat and
|
||||||
|
deck management, keeping the research panel mounted so SSE state remains live.
|
||||||
|
On small screens, use the sidebar Sheet and keep all actions reachable.
|
||||||
|
|
||||||
|
Use official MessageScroller, Message, Bubble and Marker for the existing Codex
|
||||||
|
conversation. Keep transport, history, retry, stop, account selection, Markdown
|
||||||
|
and citation navigation. Use shadcn primitives for forms, dialogs, menus and
|
||||||
|
post metadata throughout the app. Use Tailwind v4 and shared shadcn semantic
|
||||||
|
tokens; remove legacy control styles that override primitives.
|
||||||
|
|
||||||
|
Install @shadcn/lint with an executable design-token policy alongside Biome.
|
||||||
|
Update README and Nix dependency hash as needed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Typecheck, Biome, design lint, unit suite, production build, unused-code check.
|
||||||
|
- Browser integration suite: decks, source editing, connections, conversation,
|
||||||
|
citation navigation, menus and keyboard focus.
|
||||||
|
- Render desktop plus 320/375/414/768 px: rail collapse, mobile Sheet, independent
|
||||||
|
column scrolling, chat composer visibility, no document horizontal overflow.
|
||||||
|
- Inspect screenshots and run Axe on relevant states.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Completed on 2026-09-28.
|
||||||
|
|
||||||
|
- SidebarProvider, nested icon/context Sidebars and SidebarInset now implement
|
||||||
|
the referenced sidebar-09 structure; the mobile Sheet keeps its content mounted.
|
||||||
|
- Research state and deck-dialog drafts live above the responsive shell. Closing
|
||||||
|
or resizing the sidebar preserves drafts, selected accounts and retry identity.
|
||||||
|
- MessageScroller, Message, Bubble/BubbleContent, Marker/MarkerContent, InputGroup
|
||||||
|
and Collapsible replace the chat UI. Markdown list markers and headings remain
|
||||||
|
visible after Tailwind's reset; citation highlighting uses theme tokens.
|
||||||
|
- Forms, dialogs, dropdown menus, avatars, badges and empty states use Base UI
|
||||||
|
shadcn components. No backend transport, storage or access contracts changed.
|
||||||
|
- @shadcn/lint runs in pnpm lint and rejects raw palette colors. Nix dependency
|
||||||
|
hash and README/deck docs are updated.
|
||||||
|
|
||||||
|
Verification: 336 unit tests passed (one explicit live-relay test skipped), all
|
||||||
|
56 desktop/mobile E2E tests passed. After the final isolated chat styling fixes,
|
||||||
|
all 10 research E2E tests and 22 related unit tests passed again. Typecheck,
|
||||||
|
Biome/design lint, Knip, production build and the Nix package build passed.
|
||||||
|
Browser checks covered 320/375/414/768px, rendered chat and post screenshots,
|
||||||
|
Axe, dialog focus restoration, breakpoint draft preservation and closed-sidebar
|
||||||
|
SSE deck updates. Automated browser tests use mock relay/research data.
|
||||||
|
A subsequent dev-server smoke check through the Tailscale HTTPS URL confirmed
|
||||||
|
the real relay displayed 14 posts across six columns without UI alerts.
|
||||||
|
No production deployment was performed.
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
|
|
||||||
Both `/` and `/deck` render the deck-only application. A dark sidebar switches
|
Both `/` and `/deck` render the research workspace. A collapsible shadcn/ui
|
||||||
decks, adds columns, and jumps to a column. Up to six ordered columns scroll
|
sidebar based on `sidebar-09` switches between research chat and deck management.
|
||||||
independently; only the active deck mounts and fetches its columns. On mobile,
|
Deck management switches decks, adds columns, and manages connected accounts. Up to six ordered columns scroll independently; only the active deck
|
||||||
the sidebar becomes a compact top bar. Native dialogs handle creation and
|
mounts and fetches its columns. On mobile, the sidebar opens as a sheet.
|
||||||
editing, with Escape and focus restoration. Column menus offer editing,
|
shadcn Base UI dialogs handle creation and editing, with Escape and focus
|
||||||
ordering, and deletion; refresh and pagination are manual.
|
restoration. Column menus offer editing, ordering, and deletion; refresh and
|
||||||
|
pagination are manual. The adjacent chat uses MessageScroller, Message, Bubble,
|
||||||
|
Marker and InputGroup with the existing research backend.
|
||||||
|
|
||||||
Twitter and Mastodon can appear together, with different accounts in each
|
Twitter and Mastodon can appear together, with different accounts in each
|
||||||
column. Original-post links open their source site. Separate reader, search,
|
column. Original-post links open their source site. Separate reader, search,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { expect, test } from '../fixtures'
|
import { expect, test } from '../fixtures'
|
||||||
|
import { showDeckManagement } from '../workspace'
|
||||||
|
|
||||||
test('opens account management after hydration without inheriting real OAuth configuration', async ({
|
test('opens account management after hydration without inheriting real OAuth configuration', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
await showDeckManagement(page)
|
||||||
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
|
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
|
||||||
await expect(manage).toBeEnabled()
|
await expect(manage).toBeEnabled()
|
||||||
await manage.click()
|
await manage.click()
|
||||||
@@ -28,6 +30,7 @@ test('rejects an unbound OAuth callback without contacting an instance', async (
|
|||||||
await expect(page.getByRole('alert')).toContainText(
|
await expect(page.getByRole('alert')).toContainText(
|
||||||
'Mastodonの認可を完了できませんでした。',
|
'Mastodonの認可を完了できませんでした。',
|
||||||
)
|
)
|
||||||
|
await showDeckManagement(page)
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('button', { name: '接続アカウントを管理' }),
|
page.getByRole('button', { name: '接続アカウントを管理' }),
|
||||||
).toBeEnabled()
|
).toBeEnabled()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Page } from '@playwright/test'
|
import type { Page } from '@playwright/test'
|
||||||
import { expect, test } from '../fixtures'
|
import { expect, test } from '../fixtures'
|
||||||
|
import { closeMobileSidebar, showDeckManagement } from '../workspace'
|
||||||
|
|
||||||
async function addColumn(
|
async function addColumn(
|
||||||
page: Page,
|
page: Page,
|
||||||
@@ -8,6 +9,7 @@ async function addColumn(
|
|||||||
profile = 'e2e',
|
profile = 'e2e',
|
||||||
kind = 'search',
|
kind = 'search',
|
||||||
) {
|
) {
|
||||||
|
await showDeckManagement(page)
|
||||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||||
const editor = page.getByRole('form', { name: 'カラムを追加', exact: true })
|
const editor = page.getByRole('form', { name: 'カラムを追加', exact: true })
|
||||||
await editor.getByLabel('カラム名', { exact: true }).fill(title)
|
await editor.getByLabel('カラム名', { exact: true }).fill(title)
|
||||||
@@ -28,6 +30,7 @@ async function addColumn(
|
|||||||
)
|
)
|
||||||
.fill(query)
|
.fill(query)
|
||||||
await editor.getByRole('button', { name: '追加して検索' }).click()
|
await editor.getByRole('button', { name: '追加して検索' }).click()
|
||||||
|
await closeMobileSidebar(page)
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.getByRole('region', { name: title, exact: true })
|
.getByRole('region', { name: title, exact: true })
|
||||||
@@ -44,7 +47,9 @@ test.beforeEach(async ({ page }) => {
|
|||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'デッキとして保存', exact: true })
|
.getByRole('button', { name: 'デッキとして保存', exact: true })
|
||||||
.click()
|
.click()
|
||||||
await expect(page.getByText('サーバーに保存・端末間で共有')).toHaveCount(1)
|
await showDeckManagement(page)
|
||||||
|
await expect(page.getByText('サーバーに保存・端末間で共有')).toBeVisible()
|
||||||
|
await closeMobileSidebar(page)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('binds identical searches to independent profiles through paging, editing and reload', async ({
|
test('binds identical searches to independent profiles through paging, editing and reload', async ({
|
||||||
@@ -67,8 +72,8 @@ test('binds identical searches to independent profiles through paging, editing a
|
|||||||
await expect(
|
await expect(
|
||||||
second.getByText('alternate · Latest · all page 2'),
|
second.getByText('alternate · Latest · all page 2'),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
await second.locator('summary[aria-label="別の接続の操作"]').click()
|
await second.getByRole('button', { name: '別の接続の操作' }).click()
|
||||||
await second.getByRole('button', { name: '編集', exact: true }).click()
|
await page.getByRole('menuitem', { name: '編集', exact: true }).click()
|
||||||
await page
|
await page
|
||||||
.getByRole('combobox', { name: '接続プロファイル', exact: true })
|
.getByRole('combobox', { name: '接続プロファイル', exact: true })
|
||||||
.selectOption({ label: 'e2e' })
|
.selectOption({ label: 'e2e' })
|
||||||
@@ -93,25 +98,32 @@ test('creates, switches, renames and deletes persisted deck profiles', async ({
|
|||||||
await page.getByLabel('調査テーマ・デッキ名').fill('最初の調査')
|
await page.getByLabel('調査テーマ・デッキ名').fill('最初の調査')
|
||||||
await page.getByRole('button', { name: '名前を保存' }).click()
|
await page.getByRole('button', { name: '名前を保存' }).click()
|
||||||
await addColumn(page, '最初の観点', 'first-deck')
|
await addColumn(page, '最初の観点', 'first-deck')
|
||||||
|
await showDeckManagement(page)
|
||||||
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
|
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
|
||||||
await page.getByLabel('新しいデッキ名').fill('別の調査')
|
await page.getByLabel('新しいデッキ名').fill('別の調査')
|
||||||
await page.getByRole('button', { name: '作成', exact: true }).click()
|
await page.getByRole('button', { name: '作成', exact: true }).click()
|
||||||
await expect(page.locator('.deck-column')).toHaveCount(0)
|
await expect(page.locator('.deck-column')).toHaveCount(0)
|
||||||
await addColumn(page, '別の観点', 'second-deck', 'alternate')
|
await addColumn(page, '別の観点', 'second-deck', 'alternate')
|
||||||
|
await showDeckManagement(page)
|
||||||
await page
|
await page
|
||||||
.getByLabel('デッキプロファイル', { exact: true })
|
.getByLabel('デッキプロファイル', { exact: true })
|
||||||
.selectOption({ label: '最初の調査' })
|
.selectOption({ label: '最初の調査' })
|
||||||
|
await closeMobileSidebar(page)
|
||||||
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
|
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
|
||||||
|
await showDeckManagement(page)
|
||||||
await page
|
await page
|
||||||
.getByLabel('デッキプロファイル', { exact: true })
|
.getByLabel('デッキプロファイル', { exact: true })
|
||||||
.selectOption({ label: '別の調査' })
|
.selectOption({ label: '別の調査' })
|
||||||
|
await closeMobileSidebar(page)
|
||||||
await expect(page.locator('.deck-column h2')).toHaveText(['別の観点'])
|
await expect(page.locator('.deck-column h2')).toHaveText(['別の観点'])
|
||||||
|
await showDeckManagement(page)
|
||||||
await page.getByRole('button', { name: 'デッキを削除', exact: true }).click()
|
await page.getByRole('button', { name: 'デッキを削除', exact: true }).click()
|
||||||
await page.getByRole('button', { name: '削除する', exact: true }).click()
|
await page.getByRole('button', { name: '削除する', exact: true }).click()
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
|
||||||
|
await showDeckManagement(page)
|
||||||
await expect(
|
await expect(
|
||||||
page.getByLabel('デッキプロファイル', { exact: true }).locator('option'),
|
page.getByLabel('デッキプロファイル', { exact: true }).locator('option'),
|
||||||
).toHaveCount(1)
|
).toHaveCount(1)
|
||||||
@@ -128,14 +140,14 @@ test('reads user and list timelines within columns and reorders, deletes and res
|
|||||||
await expect(list.getByText('list page 1')).toBeVisible()
|
await expect(list.getByText('list page 1')).toBeVisible()
|
||||||
await list.getByRole('button', { name: '続きを読み込む' }).click()
|
await list.getByRole('button', { name: '続きを読み込む' }).click()
|
||||||
await expect(list.getByText('list page 2')).toBeVisible()
|
await expect(list.getByText('list page 2')).toBeVisible()
|
||||||
await list.locator('summary[aria-label="リストの投稿の操作"]').click()
|
await list.getByRole('button', { name: 'リストの投稿の操作' }).click()
|
||||||
await list.getByRole('button', { name: 'リストの投稿を左へ' }).click()
|
await page.getByRole('menuitem', { name: 'リストの投稿を左へ' }).click()
|
||||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||||
'リストの投稿',
|
'リストの投稿',
|
||||||
'アカウント',
|
'アカウント',
|
||||||
])
|
])
|
||||||
await list.locator('summary[aria-label="リストの投稿の操作"]').click()
|
await list.getByRole('button', { name: 'リストの投稿の操作' }).click()
|
||||||
await list.getByRole('button', { name: 'リストの投稿を削除' }).click()
|
await page.getByRole('menuitem', { name: 'リストの投稿を削除' }).click()
|
||||||
await page.getByRole('button', { name: '元に戻す' }).click()
|
await page.getByRole('button', { name: '元に戻す' }).click()
|
||||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||||
'リストの投稿',
|
'リストの投稿',
|
||||||
@@ -200,6 +212,7 @@ test('keeps the deck and editor accessible within narrow viewports', async ({
|
|||||||
path: testInfo.outputPath('deck-populated.png'),
|
path: testInfo.outputPath('deck-populated.png'),
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
})
|
})
|
||||||
|
await showDeckManagement(page)
|
||||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||||
expect((await a11y().analyze()).violations).toEqual([])
|
expect((await a11y().analyze()).violations).toEqual([])
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
@@ -211,12 +224,17 @@ test('keeps the deck and editor accessible within narrow viewports', async ({
|
|||||||
test('closes the column editor with Escape and restores focus without applying edits', async ({
|
test('closes the column editor with Escape and restores focus without applying edits', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
await showDeckManagement(page)
|
||||||
const opener = page.getByRole('button', { name: 'カラムを追加', exact: true })
|
const opener = page.getByRole('button', { name: 'カラムを追加', exact: true })
|
||||||
await opener.click()
|
await opener.click()
|
||||||
await expect(page.getByRole('dialog')).toBeVisible()
|
await expect(
|
||||||
|
page.getByRole('dialog', { name: 'カラムを追加', exact: true }),
|
||||||
|
).toBeVisible()
|
||||||
await page.getByLabel('カラム名', { exact: true }).fill('キャンセルする観点')
|
await page.getByLabel('カラム名', { exact: true }).fill('キャンセルする観点')
|
||||||
await page.keyboard.press('Escape')
|
await page.keyboard.press('Escape')
|
||||||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
await expect(
|
||||||
|
page.getByRole('dialog', { name: 'カラムを追加', exact: true }),
|
||||||
|
).toHaveCount(0)
|
||||||
await expect(opener).toBeFocused()
|
await expect(opener).toBeFocused()
|
||||||
await expect(page.locator('.deck-column')).toHaveCount(0)
|
await expect(page.locator('.deck-column')).toHaveCount(0)
|
||||||
await opener.click()
|
await opener.click()
|
||||||
@@ -239,6 +257,7 @@ test('does not overwrite unreadable storage on reload', async ({ page }) => {
|
|||||||
test('selects a list from the column profile catalog and refreshes it on profile change', async ({
|
test('selects a list from the column profile catalog and refreshes it on profile change', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
await showDeckManagement(page)
|
||||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||||
await page.getByLabel('カラム名', { exact: true }).fill('購読リスト')
|
await page.getByLabel('カラム名', { exact: true }).fill('購読リスト')
|
||||||
await page
|
await page
|
||||||
@@ -263,6 +282,7 @@ test('selects a list from the column profile catalog and refreshes it on profile
|
|||||||
'1234567890',
|
'1234567890',
|
||||||
)
|
)
|
||||||
await page.getByRole('button', { name: '追加して検索' }).click()
|
await page.getByRole('button', { name: '追加して検索' }).click()
|
||||||
|
await closeMobileSidebar(page)
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.getByRole('region', { name: '購読リスト', exact: true })
|
.getByRole('region', { name: '購読リスト', exact: true })
|
||||||
@@ -347,11 +367,15 @@ test('imports old browser decks explicitly and avoids duplicate imports after re
|
|||||||
legacy,
|
legacy,
|
||||||
)
|
)
|
||||||
await page.reload()
|
await page.reload()
|
||||||
|
await showDeckManagement(page)
|
||||||
const selector = page.getByLabel('デッキプロファイル', { exact: true })
|
const selector = page.getByLabel('デッキプロファイル', { exact: true })
|
||||||
await expect(selector.locator('option')).toHaveCount(1)
|
await expect(selector.locator('option')).toHaveCount(1)
|
||||||
|
await closeMobileSidebar(page)
|
||||||
await page.getByRole('button', { name: '旧デッキを取り込む' }).click()
|
await page.getByRole('button', { name: '旧デッキを取り込む' }).click()
|
||||||
|
await showDeckManagement(page)
|
||||||
await expect(selector.locator('option')).toHaveCount(2)
|
await expect(selector.locator('option')).toHaveCount(2)
|
||||||
await selector.selectOption({ label: '以前の調査' })
|
await selector.selectOption({ label: '以前の調査' })
|
||||||
|
await closeMobileSidebar(page)
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('Latest · all page 1', { exact: true }),
|
page.getByText('Latest · all page 1', { exact: true }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
@@ -368,5 +392,29 @@ test('imports old browser decks explicitly and avoids duplicate imports after re
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole('button', { name: '旧デッキを取り込む' }),
|
page.getByRole('button', { name: '旧デッキを取り込む' }),
|
||||||
).toHaveCount(0)
|
).toHaveCount(0)
|
||||||
|
await showDeckManagement(page)
|
||||||
await expect(selector.locator('option')).toHaveCount(2)
|
await expect(selector.locator('option')).toHaveCount(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('preserves a new deck dialog and its draft across the mobile breakpoint', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await showDeckManagement(page)
|
||||||
|
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'デッキを作成', exact: true })
|
||||||
|
await dialog.getByLabel('新しいデッキ名').fill('画面を回転しても残る調査')
|
||||||
|
for (const width of [1024, 375, 1024]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 })
|
||||||
|
await expect(dialog).toHaveCount(1)
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog.getByLabel('新しいデッキ名')).toHaveValue(
|
||||||
|
'画面を回転しても残る調査',
|
||||||
|
)
|
||||||
|
await expect(page.locator('#new-deck-title')).toHaveCount(1)
|
||||||
|
}
|
||||||
|
await dialog.getByRole('button', { name: '作成', exact: true }).click()
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
|
||||||
|
'画面を回転しても残る調査',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { DeckColumn } from '../../src/features/decks/model'
|
import type { DeckColumn } from '../../src/features/decks/model'
|
||||||
import type { ResearchRun } from '../../src/features/research/model'
|
import type { ResearchRun } from '../../src/features/research/model'
|
||||||
import { expect, test } from '../fixtures'
|
import { expect, test } from '../fixtures'
|
||||||
|
import {
|
||||||
|
closeMobileSidebar,
|
||||||
|
showDeckManagement,
|
||||||
|
showResearch,
|
||||||
|
} from '../workspace'
|
||||||
|
|
||||||
test('pushes the current research snapshot on each SSE connection', async ({
|
test('pushes the current research snapshot on each SSE connection', async ({
|
||||||
page,
|
page,
|
||||||
@@ -37,6 +42,7 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
await showDeckManagement(page)
|
||||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||||
const accounts = page.getByRole('combobox', {
|
const accounts = page.getByRole('combobox', {
|
||||||
name: '接続プロファイル',
|
name: '接続プロファイル',
|
||||||
@@ -45,9 +51,11 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
|
|||||||
await accounts.selectOption({ label: 'e2e' })
|
await accounts.selectOption({ label: 'e2e' })
|
||||||
const connectionId = await accounts.inputValue()
|
const connectionId = await accounts.inputValue()
|
||||||
await page.keyboard.press('Escape')
|
await page.keyboard.press('Escape')
|
||||||
|
await closeMobileSidebar(page)
|
||||||
const run: ResearchRun = {
|
const run: ResearchRun = {
|
||||||
id: '36ad8cc7-318c-46ae-94fa-28d30336f027',
|
id: '36ad8cc7-318c-46ae-94fa-28d30336f027',
|
||||||
threadId: 'conversation-test',
|
threadId: 'conversation-test',
|
||||||
|
connectionIds: [connectionId],
|
||||||
topic: '既存の観点を使って調査して',
|
topic: '既存の観点を使って調査して',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
@@ -89,10 +97,14 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.reload()
|
await page.reload()
|
||||||
|
await showResearch(page)
|
||||||
const conversation = page.getByRole('log', { name: '調査の会話' })
|
const conversation = page.getByRole('log', { name: '調査の会話' })
|
||||||
await expect(conversation).toContainText('既存の観点を使って調査して')
|
await expect(conversation).toContainText('既存の観点を使って調査して')
|
||||||
await expect(conversation).toContainText('list_decks: 完了')
|
await expect(conversation).toContainText('list_decks: 完了')
|
||||||
await expect(conversation).toContainText('既存の検索条件を再利用しています。')
|
await expect(conversation).toContainText('既存の検索条件を再利用しています。')
|
||||||
|
await expect(
|
||||||
|
conversation.locator('[data-slot="bubble-content"]'),
|
||||||
|
).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
|
||||||
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
|
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
|
||||||
run.deckVersion = 2
|
run.deckVersion = 2
|
||||||
run.status = 'complete'
|
run.status = 'complete'
|
||||||
@@ -114,19 +126,79 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
await page.reload()
|
await page.reload()
|
||||||
|
await showResearch(page)
|
||||||
await expect(conversation).toContainText('既存の観点を使って調査して')
|
await expect(conversation).toContainText('既存の観点を使って調査して')
|
||||||
await expect(conversation).toContainText('比較する観点を追加しました。')
|
await expect(conversation).toContainText('比較する観点を追加しました。')
|
||||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||||
'最初の観点',
|
'最初の観点',
|
||||||
'比較の観点',
|
'比較の観点',
|
||||||
])
|
])
|
||||||
|
await closeMobileSidebar(page)
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('button', { name: 'デッキとして保存', exact: true }),
|
page.getByRole('button', { name: 'デッキとして保存', exact: true }),
|
||||||
).toBeEnabled()
|
).toBeEnabled()
|
||||||
|
await showResearch(page)
|
||||||
|
const accountPicker = page.getByRole('button', {
|
||||||
|
name: '接続アカウント · 1件',
|
||||||
|
})
|
||||||
|
await accountPicker.click()
|
||||||
|
await expect(accountPicker).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
await expect(
|
||||||
|
page.getByRole('checkbox', { name: 'e2e · Twitter' }),
|
||||||
|
).toBeChecked()
|
||||||
|
await accountPicker.click()
|
||||||
|
await expect(accountPicker).toHaveAttribute('aria-expanded', 'false')
|
||||||
await page.getByLabel('メッセージ', { exact: true }).fill('この観点を詳しく')
|
await page.getByLabel('メッセージ', { exact: true }).fill('この観点を詳しく')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('button', { name: '送信', exact: true }),
|
page.getByRole('button', { name: '送信', exact: true }),
|
||||||
).toBeEnabled()
|
).toBeEnabled()
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'サイドバーを閉じる', exact: true })
|
||||||
|
.click()
|
||||||
|
run.deckVersion = 3
|
||||||
|
if (run.deck) run.deck.title = '閉じている間に更新された調査'
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
|
||||||
|
'閉じている間に更新された調査',
|
||||||
|
{ timeout: 15000 },
|
||||||
|
)
|
||||||
|
await showResearch(page)
|
||||||
|
await expect(page.getByLabel('メッセージ', { exact: true })).toHaveValue(
|
||||||
|
'この観点を詳しく',
|
||||||
|
)
|
||||||
|
await page.setViewportSize({ width: 600, height: 900 })
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
|
||||||
|
).toHaveCount(1)
|
||||||
|
await showResearch(page)
|
||||||
|
await expect(page.getByLabel('メッセージ', { exact: true })).toHaveValue(
|
||||||
|
'この観点を詳しく',
|
||||||
|
)
|
||||||
|
await page.getByRole('button', { name: '接続アカウント · 1件' }).click()
|
||||||
|
await page.getByRole('checkbox', { name: 'e2e · Twitter' }).uncheck()
|
||||||
|
await page.setViewportSize({ width: 1100, height: 900 })
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
|
||||||
|
).toHaveCount(0)
|
||||||
|
await showResearch(page)
|
||||||
|
await expect(page.getByLabel('メッセージ', { exact: true })).toHaveValue(
|
||||||
|
'この観点を詳しく',
|
||||||
|
)
|
||||||
|
await page.getByRole('button', { name: '接続アカウント · 0件' }).click()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('checkbox', { name: 'e2e · Twitter' }),
|
||||||
|
).not.toBeChecked()
|
||||||
|
await page.setViewportSize({ width: 600, height: 900 })
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
|
||||||
|
).toHaveCount(1)
|
||||||
|
await showResearch(page)
|
||||||
|
await expect(page.getByLabel('メッセージ', { exact: true })).toHaveValue(
|
||||||
|
'この観点を詳しく',
|
||||||
|
)
|
||||||
|
await page.getByRole('button', { name: '接続アカウント · 0件' }).click()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('checkbox', { name: 'e2e · Twitter' }),
|
||||||
|
).not.toBeChecked()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('rejects a different tailnet owner at the SSE endpoint', async ({
|
test('rejects a different tailnet owner at the SSE endpoint', async ({
|
||||||
@@ -144,6 +216,7 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
|
|||||||
}) => {
|
}) => {
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
await showDeckManagement(page)
|
||||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||||
const accounts = page.getByRole('combobox', {
|
const accounts = page.getByRole('combobox', {
|
||||||
name: '接続プロファイル',
|
name: '接続プロファイル',
|
||||||
@@ -152,6 +225,7 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
|
|||||||
await accounts.selectOption({ label: 'e2e' })
|
await accounts.selectOption({ label: 'e2e' })
|
||||||
const connectionId = await accounts.inputValue()
|
const connectionId = await accounts.inputValue()
|
||||||
await page.keyboard.press('Escape')
|
await page.keyboard.press('Escape')
|
||||||
|
await closeMobileSidebar(page)
|
||||||
const source: DeckColumn = {
|
const source: DeckColumn = {
|
||||||
id: 'citation-column-2',
|
id: 'citation-column-2',
|
||||||
title: '引用の観点',
|
title: '引用の観点',
|
||||||
@@ -213,8 +287,14 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.reload()
|
await page.reload()
|
||||||
|
await showResearch(page)
|
||||||
const chat = page.getByRole('log', { name: '調査の会話' })
|
const chat = page.getByRole('log', { name: '調査の会話' })
|
||||||
await expect(chat.getByRole('heading', { name: '調査結果' })).toBeVisible()
|
await expect(chat.getByRole('heading', { name: '調査結果' })).toBeVisible()
|
||||||
|
await expect(chat.getByRole('heading', { name: '調査結果' })).toHaveCSS(
|
||||||
|
'font-weight',
|
||||||
|
'600',
|
||||||
|
)
|
||||||
|
await expect(chat.locator('ul')).toHaveCSS('list-style-type', 'disc')
|
||||||
await expect(
|
await expect(
|
||||||
chat.locator('strong').filter({ hasText: '重要な発見' }),
|
chat.locator('strong').filter({ hasText: '重要な発見' }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
@@ -246,6 +326,7 @@ test('explains unconfigured Codex and prevents starting research without affecti
|
|||||||
const heading = page.getByRole('heading', { level: 1 })
|
const heading = page.getByRole('heading', { level: 1 })
|
||||||
await expect(heading).toBeVisible()
|
await expect(heading).toBeVisible()
|
||||||
const originalTitle = await heading.textContent()
|
const originalTitle = await heading.textContent()
|
||||||
|
await showResearch(page)
|
||||||
const panel = page.getByRole('complementary', { name: '調査チャット' })
|
const panel = page.getByRole('complementary', { name: '調査チャット' })
|
||||||
await expect(panel).toBeVisible()
|
await expect(panel).toBeVisible()
|
||||||
await expect(
|
await expect(
|
||||||
@@ -257,10 +338,14 @@ test('explains unconfigured Codex and prevents starting research without affecti
|
|||||||
await expect(panel.getByLabel('メッセージ', { exact: true })).toBeDisabled()
|
await expect(panel.getByLabel('メッセージ', { exact: true })).toBeDisabled()
|
||||||
const accessibility = await a11y().analyze()
|
const accessibility = await a11y().analyze()
|
||||||
expect(accessibility.violations).toEqual([])
|
expect(accessibility.violations).toEqual([])
|
||||||
|
await closeMobileSidebar(page)
|
||||||
await expect(heading).toHaveText(originalTitle ?? '')
|
await expect(heading).toHaveText(originalTitle ?? '')
|
||||||
|
await showDeckManagement(page)
|
||||||
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
|
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
|
||||||
await page.getByLabel('新しいデッキ名').fill('手動の調査')
|
await page.getByLabel('新しいデッキ名').fill('手動の調査')
|
||||||
await page.getByRole('button', { name: '作成', exact: true }).click()
|
await page.getByRole('button', { name: '作成', exact: true }).click()
|
||||||
|
await closeMobileSidebar(page)
|
||||||
await expect(heading).toHaveText('手動の調査')
|
await expect(heading).toHaveText('手動の調査')
|
||||||
|
await showResearch(page)
|
||||||
await expect(panel).toBeVisible()
|
await expect(panel).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -117,12 +117,9 @@ test('creates temporary research, edits it, persists explicitly and reopens it o
|
|||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
await page
|
await page
|
||||||
.getByRole('region', { name: '日本語', exact: true })
|
.getByRole('region', { name: '日本語', exact: true })
|
||||||
.locator('summary')
|
.getByRole('button', { name: '日本語の操作' })
|
||||||
.click()
|
|
||||||
await page
|
|
||||||
.getByRole('region', { name: '日本語', exact: true })
|
|
||||||
.getByRole('button', { name: '編集', exact: true })
|
|
||||||
.click()
|
.click()
|
||||||
|
await page.getByRole('menuitem', { name: '編集', exact: true }).click()
|
||||||
const replaced = await executeTool(page, 'set_deck', {
|
const replaced = await executeTool(page, 'set_deck', {
|
||||||
deckId: deck.id,
|
deckId: deck.id,
|
||||||
title: '更新した調査',
|
title: '更新した調査',
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { expect, test } from '../fixtures'
|
||||||
|
import { closeMobileSidebar, showResearch } from '../workspace'
|
||||||
|
|
||||||
|
test('keeps the sidebar, composer and deck reachable at compact widths', async ({
|
||||||
|
page,
|
||||||
|
a11y,
|
||||||
|
}, testInfo) => {
|
||||||
|
await page.goto('/')
|
||||||
|
for (const width of [320, 375, 414, 768]) {
|
||||||
|
await page.setViewportSize({ width, height: 850 })
|
||||||
|
await showResearch(page)
|
||||||
|
const chat = page.getByRole('complementary', { name: '調査チャット' })
|
||||||
|
await expect(chat).toBeVisible()
|
||||||
|
const composer = chat.getByRole('textbox', {
|
||||||
|
name: 'メッセージ',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
await expect(composer).toBeVisible()
|
||||||
|
const bounds = await composer.boundingBox()
|
||||||
|
if (!bounds) throw new Error('Composer must have a visible bounding box')
|
||||||
|
expect(bounds.x).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(bounds.x + bounds.width).toBeLessThanOrEqual(width)
|
||||||
|
expect(bounds.y + bounds.height).toBeLessThanOrEqual(850)
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollWidth),
|
||||||
|
).toBe(width)
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath(`workspace-${width}.png`),
|
||||||
|
})
|
||||||
|
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
|
||||||
|
await closeMobileSidebar(page)
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('complementary', { name: '調査チャット' }),
|
||||||
|
).toBeHidden()
|
||||||
|
await page.getByRole('button', { name: '調査チャット', exact: true }).click()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('complementary', { name: '調査チャット' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
export async function showDeckManagement(page: Page) {
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
|
||||||
|
).toBeEnabled()
|
||||||
|
const navigation = page.getByRole('button', {
|
||||||
|
name: 'デッキ管理',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
if (!(await navigation.isVisible())) {
|
||||||
|
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
|
||||||
|
}
|
||||||
|
await navigation.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showResearch(page: Page) {
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
|
||||||
|
).toBeEnabled()
|
||||||
|
const navigation = page.getByRole('button', {
|
||||||
|
name: '調査チャット',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
if (!(await navigation.isVisible())) {
|
||||||
|
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
|
||||||
|
}
|
||||||
|
await navigation.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeMobileSidebar(page: Page) {
|
||||||
|
const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]')
|
||||||
|
if (await sheet.isVisible()) {
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'サイドバーを閉じる', exact: true })
|
||||||
|
.click()
|
||||||
|
await expect(sheet).toBeHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
pnpm = pkgs.pnpm_11;
|
pnpm = pkgs.pnpm_11;
|
||||||
fetcherVersion = 4;
|
fetcherVersion = 4;
|
||||||
hash = "sha256-dV9yHImzYzkwE5ZK75zWLkpJ5+V0Rvqg8/zC0zRw76I=";
|
hash = "sha256-+DqNt+58TrI0W8SU//JNdYYJtCdKNIG7gfWRxno36QQ=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
|
|||||||
+19
-2
@@ -12,7 +12,12 @@
|
|||||||
],
|
],
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"e2e/generated/**"
|
"e2e/generated/**"
|
||||||
|
],
|
||||||
|
"ignoreIssues": {
|
||||||
|
"src/components/ui/**": [
|
||||||
|
"exports"
|
||||||
]
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"#/*": "./src/*"
|
"#/*": "./src/*"
|
||||||
@@ -29,28 +34,36 @@
|
|||||||
"build": "vite build && node scripts/build-tools.mjs",
|
"build": "vite build && node scripts/build-tools.mjs",
|
||||||
"start": "HOST=127.0.0.1 node .output/server/index.mjs",
|
"start": "HOST=127.0.0.1 node .output/server/index.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "biome check .",
|
"lint": "biome check . && pnpm lint:ui",
|
||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"format": "biome check --write .",
|
"format": "biome check --write .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts"
|
"test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts",
|
||||||
|
"lint:ui": "oxlint src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@base-ui/react": "1.8.0",
|
||||||
|
"@shadcn/react": "^0.3.1",
|
||||||
"@tanstack/react-query": "5.101.2",
|
"@tanstack/react-query": "5.101.2",
|
||||||
"@tanstack/react-router": "1.170.17",
|
"@tanstack/react-router": "1.170.17",
|
||||||
"@tanstack/react-router-ssr-query": "1.167.1",
|
"@tanstack/react-router-ssr-query": "1.167.1",
|
||||||
"@tanstack/react-start": "1.168.27",
|
"@tanstack/react-start": "1.168.27",
|
||||||
"@yuta/bird": "0.10.1",
|
"@yuta/bird": "0.10.1",
|
||||||
"better-sqlite3": "13.0.3",
|
"better-sqlite3": "13.0.3",
|
||||||
|
"class-variance-authority": "0.7.1",
|
||||||
|
"cn": "^0.4.0",
|
||||||
"drizzle-orm": "0.45.3",
|
"drizzle-orm": "0.45.3",
|
||||||
|
"lucide-react": "1.48.0",
|
||||||
"nitro": "3.0.260610-beta",
|
"nitro": "3.0.260610-beta",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"remark-gfm": "4.0.1",
|
"remark-gfm": "4.0.1",
|
||||||
"sanitize-html": "2.17.7",
|
"sanitize-html": "2.17.7",
|
||||||
|
"shadcn": "4.21.0",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"usewebmcp": "5.1.0",
|
"usewebmcp": "5.1.0",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
@@ -58,6 +71,8 @@
|
|||||||
"@axe-core/playwright": "4.11.1",
|
"@axe-core/playwright": "4.11.1",
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@playwright/test": "1.61.1",
|
"@playwright/test": "1.61.1",
|
||||||
|
"@shadcn/lint": "^0.2.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@testing-library/dom": "10.4.1",
|
"@testing-library/dom": "10.4.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
"@testing-library/react": "16.3.2",
|
"@testing-library/react": "16.3.2",
|
||||||
@@ -71,6 +86,8 @@
|
|||||||
"jsdom": "29.1.1",
|
"jsdom": "29.1.1",
|
||||||
"knip": "6.27.0",
|
"knip": "6.27.0",
|
||||||
"orval": "8.24.0",
|
"orval": "8.24.0",
|
||||||
|
"oxlint": "^1.85.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
"tsx": "4.23.12",
|
"tsx": "4.23.12",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vite": "8.1.4",
|
"vite": "8.1.4",
|
||||||
|
|||||||
Generated
+2655
-4
File diff suppressed because it is too large
Load Diff
+143
-15
@@ -1,21 +1,149 @@
|
|||||||
export function AppShell({
|
import { Columns3, MessageSquare, PanelLeftClose, Search } from 'lucide-react'
|
||||||
sidebar,
|
import { type CSSProperties, type ReactNode, useState } from 'react'
|
||||||
researchChat,
|
import {
|
||||||
children,
|
Sidebar,
|
||||||
}: {
|
SidebarContent,
|
||||||
sidebar: React.ReactNode
|
SidebarFooter,
|
||||||
researchChat: React.ReactNode
|
SidebarGroup,
|
||||||
children: React.ReactNode
|
SidebarGroupContent,
|
||||||
}) {
|
SidebarHeader,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
} from '#/components/ui/sidebar'
|
||||||
|
import { TooltipProvider } from '#/components/ui/tooltip'
|
||||||
|
|
||||||
|
type ShellProps = {
|
||||||
|
sidebar: ReactNode
|
||||||
|
researchChat: (closeMobileSidebar: () => void) => ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell(props: ShellProps) {
|
||||||
return (
|
return (
|
||||||
<main className="app app-deck">
|
<TooltipProvider delay={800}>
|
||||||
{researchChat}
|
<SidebarProvider
|
||||||
<section className="reader">
|
className="workspace h-dvh min-h-0 overflow-hidden"
|
||||||
<section className="deck-toolbar" aria-label="デッキ管理">
|
style={{ '--sidebar-width': '25rem' } as CSSProperties}
|
||||||
{sidebar}
|
>
|
||||||
|
<Workspace {...props} />
|
||||||
|
</SidebarProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Workspace({ sidebar, researchChat, children }: ShellProps) {
|
||||||
|
const [panel, setPanel] = useState<'chat' | 'decks'>('chat')
|
||||||
|
const { setOpen, setOpenMobile, isMobile } = useSidebar()
|
||||||
|
const navigation = [
|
||||||
|
{ id: 'chat', title: '調査チャット', icon: MessageSquare },
|
||||||
|
{ id: 'decks', title: 'デッキ管理', icon: Columns3 },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sidebar
|
||||||
|
collapsible="icon"
|
||||||
|
className="overflow-hidden *:data-[sidebar=sidebar]:flex-row"
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
collapsible="none"
|
||||||
|
className="w-[calc(var(--sidebar-width-icon)+1px)]! shrink-0 border-r"
|
||||||
|
>
|
||||||
|
<SidebarHeader>
|
||||||
|
<div
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||||
|
role="img"
|
||||||
|
aria-label="Twitter Lite"
|
||||||
|
>
|
||||||
|
<Search className="size-4" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.id}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
aria-label={item.title}
|
||||||
|
tooltip={{ children: item.title, hidden: false }}
|
||||||
|
isActive={panel === item.id}
|
||||||
|
onClick={() => {
|
||||||
|
setPanel(item.id)
|
||||||
|
if (isMobile) setOpenMobile(true)
|
||||||
|
else setOpen(true)
|
||||||
|
}}
|
||||||
|
className="px-2"
|
||||||
|
>
|
||||||
|
<item.icon />
|
||||||
|
<span className="sr-only">{item.title}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
aria-label="サイドバーを閉じる"
|
||||||
|
tooltip="サイドバーを閉じる"
|
||||||
|
onClick={() =>
|
||||||
|
isMobile ? setOpenMobile(false) : setOpen(false)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PanelLeftClose />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
<Sidebar
|
||||||
|
collapsible="none"
|
||||||
|
className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
panel === 'chat' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{researchChat(() => setOpenMobile(false))}
|
||||||
|
</div>
|
||||||
|
<section
|
||||||
|
aria-label="デッキ管理"
|
||||||
|
className={
|
||||||
|
panel === 'decks'
|
||||||
|
? 'flex min-h-0 flex-1 flex-col overflow-y-auto p-4'
|
||||||
|
: 'hidden'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h2 className="mb-4 text-base font-semibold">デッキ</h2>
|
||||||
|
<div className="flex flex-col gap-4">{sidebar}</div>
|
||||||
</section>
|
</section>
|
||||||
|
</Sidebar>
|
||||||
|
</Sidebar>
|
||||||
|
<SidebarInset className="min-w-0 overflow-hidden">
|
||||||
|
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
|
||||||
|
<SidebarTrigger aria-label="サイドバーを切り替え" />
|
||||||
|
<span className="text-sm font-medium">Twitter Lite</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
調査ワークスペース
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<section
|
||||||
|
className="reader flex min-h-0 min-w-0 flex-1 flex-col"
|
||||||
|
aria-label="デッキ"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</SidebarInset>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-31
@@ -1,5 +1,12 @@
|
|||||||
import { useEffect, useId, useRef } from 'react'
|
|
||||||
import { Icon } from './icon'
|
import { Icon } from './icon'
|
||||||
|
import { Button } from './ui/button'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
Dialog as DialogRoot,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog'
|
||||||
|
|
||||||
export function Dialog({
|
export function Dialog({
|
||||||
title,
|
title,
|
||||||
@@ -10,42 +17,35 @@ export function Dialog({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const dialog = useRef<HTMLDialogElement>(null)
|
|
||||||
const headingId = useId()
|
|
||||||
useEffect(() => {
|
|
||||||
const element = dialog.current
|
|
||||||
const opener =
|
|
||||||
document.activeElement instanceof HTMLElement
|
|
||||||
? document.activeElement
|
|
||||||
: null
|
|
||||||
element?.showModal()
|
|
||||||
return () => {
|
|
||||||
element?.close()
|
|
||||||
if (opener?.isConnected) opener.focus({ preventScroll: true })
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
return (
|
return (
|
||||||
<dialog
|
<DialogRoot
|
||||||
ref={dialog}
|
open
|
||||||
className="deck-dialog"
|
onOpenChange={(open) => {
|
||||||
aria-labelledby={headingId}
|
if (!open) onClose()
|
||||||
onCancel={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
onClose()
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="dialog-heading">
|
<DialogContent
|
||||||
<h2 id={headingId}>{title}</h2>
|
showCloseButton={false}
|
||||||
<button
|
className="max-h-[calc(100dvh-2rem)] overflow-y-auto"
|
||||||
type="button"
|
aria-describedby={undefined}
|
||||||
className="icon-button"
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogClose
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="absolute right-2 top-2"
|
||||||
aria-label="閉じる"
|
aria-label="閉じる"
|
||||||
onClick={onClose}
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</button>
|
</DialogClose>
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
</dialog>
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: AvatarPrimitive.Root.Props & {
|
||||||
|
size?: 'default' | 'sm' | 'lg'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn(
|
||||||
|
'aspect-square size-full rounded-full object-cover',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AvatarPrimitive.Fallback.Props) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
|
||||||
|
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
|
||||||
|
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
|
||||||
|
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarBadge,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
AvatarImage,
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { mergeProps } from '@base-ui/react/merge-props'
|
||||||
|
import { useRender } from '@base-ui/react/use-render'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
|
||||||
|
outline:
|
||||||
|
'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||||
|
ghost:
|
||||||
|
'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'span',
|
||||||
|
props: mergeProps<'span'>(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: 'badge',
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { mergeProps } from '@base-ui/react/merge-props'
|
||||||
|
import { useRender } from '@base-ui/react/use-render'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="bubble-group"
|
||||||
|
className={cn('flex min-w-0 flex-col gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bubbleVariants = cva(
|
||||||
|
'group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
|
||||||
|
muted:
|
||||||
|
'*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
|
||||||
|
tinted:
|
||||||
|
'*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
|
||||||
|
outline:
|
||||||
|
'*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30',
|
||||||
|
ghost:
|
||||||
|
'border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50',
|
||||||
|
destructive:
|
||||||
|
'*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function Bubble({
|
||||||
|
variant = 'default',
|
||||||
|
align = 'start',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> &
|
||||||
|
VariantProps<typeof bubbleVariants> & {
|
||||||
|
align?: 'start' | 'end'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="bubble"
|
||||||
|
data-variant={variant}
|
||||||
|
data-align={align}
|
||||||
|
className={cn(bubbleVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BubbleContent({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<'div'>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'div',
|
||||||
|
props: mergeProps<'div'>(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
'w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50',
|
||||||
|
className,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: 'bubble-content',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const bubbleReactionsVariants = cva(
|
||||||
|
'absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: 'top-0 -translate-y-3/4',
|
||||||
|
bottom: 'bottom-0 translate-y-3/4',
|
||||||
|
},
|
||||||
|
align: {
|
||||||
|
start: 'left-3',
|
||||||
|
end: 'right-3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'end',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function BubbleReactions({
|
||||||
|
side = 'bottom',
|
||||||
|
align = 'end',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
align?: 'start' | 'end'
|
||||||
|
side?: 'top' | 'bottom'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="bubble-reactions"
|
||||||
|
data-align={align}
|
||||||
|
data-side={side}
|
||||||
|
className={cn(bubbleReactionsVariants({ side, align }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Bubble, BubbleContent, BubbleGroup, BubbleReactions }
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Button as ButtonPrimitive } from '@base-ui/react/button'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
outline:
|
||||||
|
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||||
|
ghost:
|
||||||
|
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
icon: 'size-8',
|
||||||
|
'icon-xs':
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
'icon-sm':
|
||||||
|
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||||
|
'icon-lg': 'size-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
import { CheckIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible'
|
||||||
|
|
||||||
|
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleContent, CollapsibleTrigger }
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
import { XIcon } from 'lucide-react'
|
||||||
|
import type * as React from 'react'
|
||||||
|
import { Button } from '#/components/ui/button.tsx'
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 isolate z-50 bg-overlay duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn('flex flex-col gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn('text-base leading-none font-medium', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Menu as MenuPrimitive } from '@base-ui/react/menu'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
import { CheckIcon, ChevronRightIcon } from 'lucide-react'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = 'start',
|
||||||
|
alignOffset = 0,
|
||||||
|
side = 'bottom',
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
MenuPrimitive.Positioner.Props,
|
||||||
|
'align' | 'alignOffset' | 'side' | 'sideOffset'
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn(
|
||||||
|
'z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: 'default' | 'destructive'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = 'start',
|
||||||
|
alignOffset = -3,
|
||||||
|
side = 'right',
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
'w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon />
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon />
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
|
||||||
|
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty"
|
||||||
|
className={cn(
|
||||||
|
'flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-header"
|
||||||
|
className={cn('flex max-w-sm flex-col items-center gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMediaVariants = cva(
|
||||||
|
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-transparent',
|
||||||
|
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function EmptyMedia({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-icon"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(emptyMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-title"
|
||||||
|
className={cn('text-sm font-medium tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-description"
|
||||||
|
className={cn(
|
||||||
|
'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-content"
|
||||||
|
className={cn(
|
||||||
|
'flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { Button } from '#/components/ui/button.tsx'
|
||||||
|
import { Input } from '#/components/ui/input.tsx'
|
||||||
|
import { Textarea } from '#/components/ui/textarea.tsx'
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<'fieldset'>) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="input-group"
|
||||||
|
className={cn(
|
||||||
|
'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
'inline-start':
|
||||||
|
'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]',
|
||||||
|
'inline-end':
|
||||||
|
'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]',
|
||||||
|
'block-start':
|
||||||
|
'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
|
||||||
|
'block-end':
|
||||||
|
'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: 'inline-start',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = 'inline-start',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'fieldset'> &
|
||||||
|
VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/a11y/useKeyWithClickEvents: Pointer convenience only; the text control remains independently keyboard-focusable.
|
||||||
|
<fieldset
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest('button')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector('input')?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
'flex items-center gap-2 text-sm shadow-none',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: '',
|
||||||
|
'icon-xs':
|
||||||
|
'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
|
||||||
|
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'xs',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = 'button',
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'xs',
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'type'> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants> & {
|
||||||
|
type?: 'button' | 'submit' | 'reset'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'input'>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'textarea'>) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Input as InputPrimitive } from '@base-ui/react/input'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from 'cn'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<'label'>) {
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/a11y/noLabelWithoutControl: Association and content are supplied by the caller through props.
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { mergeProps } from '@base-ui/react/merge-props'
|
||||||
|
import { useRender } from '@base-ui/react/use-render'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
const markerVariants = cva(
|
||||||
|
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: '',
|
||||||
|
separator:
|
||||||
|
'before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border',
|
||||||
|
border: 'border-b border-border pb-2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function Marker({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<'div'> & VariantProps<typeof markerVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'div',
|
||||||
|
props: mergeProps<'div'>(
|
||||||
|
{
|
||||||
|
className: cn(markerVariants({ variant, className })),
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: 'marker',
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkerIcon({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="marker-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkerContent({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="marker-content"
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Marker, MarkerContent, MarkerIcon, markerVariants }
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
MessageScroller as MessageScrollerPrimitive,
|
||||||
|
useMessageScroller,
|
||||||
|
useMessageScrollerScrollable,
|
||||||
|
useMessageScrollerVisibility,
|
||||||
|
} from '@shadcn/react/message-scroller'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
import { ArrowDownIcon } from 'lucide-react'
|
||||||
|
import type * as React from 'react'
|
||||||
|
import { Button } from '#/components/ui/button.tsx'
|
||||||
|
|
||||||
|
function MessageScrollerProvider(
|
||||||
|
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>,
|
||||||
|
) {
|
||||||
|
return <MessageScrollerPrimitive.Provider {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageScroller({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<MessageScrollerPrimitive.Root
|
||||||
|
data-slot="message-scroller"
|
||||||
|
className={cn(
|
||||||
|
'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageScrollerViewport({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
|
||||||
|
return (
|
||||||
|
<MessageScrollerPrimitive.Viewport
|
||||||
|
data-slot="message-scroller-viewport"
|
||||||
|
className={cn(
|
||||||
|
'size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageScrollerContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<MessageScrollerPrimitive.Content
|
||||||
|
data-slot="message-scroller-content"
|
||||||
|
className={cn('flex h-max min-h-full flex-col gap-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageScrollerItem({
|
||||||
|
className,
|
||||||
|
scrollAnchor = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<MessageScrollerPrimitive.Item
|
||||||
|
data-slot="message-scroller-item"
|
||||||
|
scrollAnchor={scrollAnchor}
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageScrollerButton({
|
||||||
|
direction = 'end',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
render,
|
||||||
|
variant = 'secondary',
|
||||||
|
size = 'icon-sm',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
|
||||||
|
return (
|
||||||
|
<MessageScrollerPrimitive.Button
|
||||||
|
data-slot="message-scroller-button"
|
||||||
|
data-direction={direction}
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
direction={direction}
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
render={render ?? <Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
<ArrowDownIcon />
|
||||||
|
<span className="sr-only">
|
||||||
|
{direction === 'end' ? 'Scroll to end' : 'Scroll to start'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MessageScrollerPrimitive.Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
MessageScroller,
|
||||||
|
MessageScrollerButton,
|
||||||
|
MessageScrollerContent,
|
||||||
|
MessageScrollerItem,
|
||||||
|
MessageScrollerProvider,
|
||||||
|
MessageScrollerViewport,
|
||||||
|
useMessageScroller,
|
||||||
|
useMessageScrollerScrollable,
|
||||||
|
useMessageScrollerVisibility,
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { cn } from 'cn'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
function MessageGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="message-group"
|
||||||
|
className={cn('flex min-w-0 flex-col gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Message({
|
||||||
|
className,
|
||||||
|
align = 'start',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & { align?: 'start' | 'end' }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="message"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(
|
||||||
|
'group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageAvatar({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="message-avatar"
|
||||||
|
className={cn(
|
||||||
|
'flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="message-content"
|
||||||
|
className={cn(
|
||||||
|
'flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="message-header"
|
||||||
|
className={cn(
|
||||||
|
'flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="message-footer"
|
||||||
|
className={cn(
|
||||||
|
'flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Message,
|
||||||
|
MessageAvatar,
|
||||||
|
MessageContent,
|
||||||
|
MessageFooter,
|
||||||
|
MessageGroup,
|
||||||
|
MessageHeader,
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { cn } from 'cn'
|
||||||
|
import { ChevronDownIcon } from 'lucide-react'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
type NativeSelectProps = Omit<React.ComponentProps<'select'>, 'size'> & {
|
||||||
|
size?: 'sm' | 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelect({
|
||||||
|
className,
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: NativeSelectProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group/native-select relative w-fit has-[select:disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-slot="native-select-wrapper"
|
||||||
|
data-size={size}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
data-slot="native-select"
|
||||||
|
data-size={size}
|
||||||
|
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="native-select-icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelectOption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'option'>) {
|
||||||
|
return (
|
||||||
|
<option
|
||||||
|
data-slot="native-select-option"
|
||||||
|
className={cn('bg-[Canvas] text-[CanvasText]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelectOptGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'optgroup'>) {
|
||||||
|
return (
|
||||||
|
<optgroup
|
||||||
|
data-slot="native-select-optgroup"
|
||||||
|
className={cn('bg-[Canvas] text-[CanvasText]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
...props
|
||||||
|
}: SeparatorPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Dialog as SheetPrimitive } from '@base-ui/react/dialog'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
import { XIcon } from 'lucide-react'
|
||||||
|
import type * as React from 'react'
|
||||||
|
import { Button } from '#/components/ui/button.tsx'
|
||||||
|
|
||||||
|
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Backdrop
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-overlay transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = 'right',
|
||||||
|
showCloseButton = true,
|
||||||
|
keepMounted = false,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Popup.Props & {
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
showCloseButton?: boolean
|
||||||
|
keepMounted?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal keepMounted={keepMounted}>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Popup
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Popup>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn('flex flex-col gap-0.5 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn('text-base font-medium text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
import { mergeProps } from '@base-ui/react/merge-props'
|
||||||
|
import { useRender } from '@base-ui/react/use-render'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
import { PanelLeftIcon } from 'lucide-react'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { Button } from '#/components/ui/button.tsx'
|
||||||
|
import { Input } from '#/components/ui/input.tsx'
|
||||||
|
import { Separator } from '#/components/ui/separator.tsx'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '#/components/ui/sheet.tsx'
|
||||||
|
import { Skeleton } from '#/components/ui/skeleton.tsx'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '#/components/ui/tooltip.tsx'
|
||||||
|
import { useIsMobile } from '#/hooks/use-mobile.ts'
|
||||||
|
|
||||||
|
const SIDEBAR_WIDTH = '16rem'
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = 'min(25rem, 100vw)'
|
||||||
|
const SIDEBAR_WIDTH_ICON = '3rem'
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
||||||
|
|
||||||
|
type SidebarContextProps = {
|
||||||
|
state: 'expanded' | 'collapsed'
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
openMobile: boolean
|
||||||
|
setOpenMobile: (open: boolean) => void
|
||||||
|
isMobile: boolean
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSidebar must be used within a SidebarProvider.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarProvider({
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||||
|
const open = openProp ?? _open
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === 'function' ? value(open) : value
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState)
|
||||||
|
} else {
|
||||||
|
_setOpen(openState)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpenProp, open],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||||
|
}, [isMobile, setOpen])
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [toggleSidebar])
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? 'expanded' : 'collapsed'
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-wrapper"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--sidebar-width': SIDEBAR_WIDTH,
|
||||||
|
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
side = 'left',
|
||||||
|
variant = 'sidebar',
|
||||||
|
collapsible = 'offcanvas',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
dir,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
side?: 'left' | 'right'
|
||||||
|
variant?: 'sidebar' | 'floating' | 'inset'
|
||||||
|
collapsible?: 'offcanvas' | 'icon' | 'none'
|
||||||
|
}) {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
|
if (collapsible === 'none') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar"
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
keepMounted
|
||||||
|
dir={dir}
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="w-(--sidebar-width)! max-w-none! bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>ワークスペース</SheetTitle>
|
||||||
|
<SheetDescription>調査チャットとデッキ管理</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex h-full w-full flex-row">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group peer hidden text-sidebar-foreground md:block"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
data-slot="sidebar"
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-gap"
|
||||||
|
className={cn(
|
||||||
|
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||||
|
'group-data-[collapsible=offcanvas]:w-0',
|
||||||
|
'group-data-[side=right]:rotate-180',
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-container"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex',
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar-inner"
|
||||||
|
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTrigger({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-sidebar="trigger"
|
||||||
|
data-slot="sidebar-trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeftIcon />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
|
||||||
|
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||||
|
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||||
|
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
|
||||||
|
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||||
|
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Input>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="sidebar-input"
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn('h-8 w-full bg-background shadow-none', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn('flex flex-col gap-2 p-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn('flex flex-col gap-2 p-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn('mx-2 w-auto bg-sidebar-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupLabel({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<'div'> & React.ComponentProps<'div'>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'div',
|
||||||
|
props: mergeProps<'div'>(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
className,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: 'sidebar-group-label',
|
||||||
|
sidebar: 'group-label',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupAction({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<'button'> & React.ComponentProps<'button'>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'button',
|
||||||
|
props: mergeProps<'button'>(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
className,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: 'sidebar-group-action',
|
||||||
|
sidebar: 'group-action',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn('w-full text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn('flex w-full min-w-0 flex-col gap-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn('group/menu-item relative', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
'peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||||
|
outline:
|
||||||
|
'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-8 text-sm',
|
||||||
|
sm: 'h-7 text-xs',
|
||||||
|
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function SidebarMenuButton({
|
||||||
|
render,
|
||||||
|
isActive = false,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<'button'> &
|
||||||
|
React.ComponentProps<'button'> & {
|
||||||
|
isActive?: boolean
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
const comp = useRender({
|
||||||
|
defaultTagName: 'button',
|
||||||
|
props: mergeProps<'button'>(
|
||||||
|
{
|
||||||
|
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
),
|
||||||
|
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
||||||
|
state: {
|
||||||
|
slot: 'sidebar-menu-button',
|
||||||
|
sidebar: 'menu-button',
|
||||||
|
size,
|
||||||
|
active: isActive,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return comp
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === 'string') {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
{comp}
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== 'collapsed' || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuAction({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
showOnHover = false,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<'button'> &
|
||||||
|
React.ComponentProps<'button'> & {
|
||||||
|
showOnHover?: boolean
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'button',
|
||||||
|
props: mergeProps<'button'>(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
showOnHover &&
|
||||||
|
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
|
||||||
|
className,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: 'sidebar-menu-action',
|
||||||
|
sidebar: 'menu-action',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuBadge({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-badge"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSkeleton({
|
||||||
|
className,
|
||||||
|
showIcon = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
showIcon?: boolean
|
||||||
|
}) {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const [width] = React.useState(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--skeleton-width': width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'li'>) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
className={cn('group/menu-sub-item relative', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubButton({
|
||||||
|
render,
|
||||||
|
size = 'md',
|
||||||
|
isActive = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<'a'> &
|
||||||
|
React.ComponentProps<'a'> & {
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
isActive?: boolean
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: 'a',
|
||||||
|
props: mergeProps<'a'>(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
|
||||||
|
className,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: 'sidebar-menu-sub-button',
|
||||||
|
sidebar: 'menu-sub-button',
|
||||||
|
size,
|
||||||
|
active: isActive,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from 'cn'
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn('animate-pulse rounded-md bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { cn } from 'cn'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delay = 0,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Provider.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delay={delay}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = 'top',
|
||||||
|
sideOffset = 4,
|
||||||
|
align = 'center',
|
||||||
|
alignOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
TooltipPrimitive.Positioner.Props,
|
||||||
|
'align' | 'alignOffset' | 'side' | 'sideOffset'
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
className={cn(
|
||||||
|
'z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||||
|
</TooltipPrimitive.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
.connection-manager-description,
|
.connection-manager-description,
|
||||||
.connection-manager-note {
|
.connection-manager-note {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #aab8c2;
|
color: var(--muted-foreground);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
.connection-manager-list {
|
.connection-manager-list {
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding-block: 0.75rem;
|
padding-block: 0.75rem;
|
||||||
border-bottom: 1px solid #38444d;
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.connection-manager-account {
|
.connection-manager-account {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -29,15 +29,9 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.connection-manager-account > span {
|
.connection-manager-account > span:not([data-slot="badge"]) {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #aab8c2;
|
color: var(--muted-foreground);
|
||||||
}
|
|
||||||
.connection-manager-account > .connection-manager-status-connected {
|
|
||||||
color: #71d6ad;
|
|
||||||
}
|
|
||||||
.connection-manager-account > .connection-manager-status-expired {
|
|
||||||
color: #f2c66d;
|
|
||||||
}
|
}
|
||||||
.connection-manager-actions {
|
.connection-manager-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -53,18 +47,6 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
.connection-manager select {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
background: #15202b;
|
|
||||||
color: #e7e9ea;
|
|
||||||
border: 1px solid #536471;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
padding: 0.6rem;
|
|
||||||
}
|
|
||||||
.connection-manager button {
|
|
||||||
min-height: 2.5rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.connection-manager-list > li {
|
.connection-manager-list > li {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useServerFn } from '@tanstack/react-start'
|
import { useServerFn } from '@tanstack/react-start'
|
||||||
import { useId, useState } from 'react'
|
import { useId, useState } from 'react'
|
||||||
|
import { Badge } from '#/components/ui/badge'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { Label } from '#/components/ui/label'
|
||||||
|
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||||
import {
|
import {
|
||||||
disconnectMastodonAccount,
|
disconnectMastodonAccount,
|
||||||
loadMastodonInstances,
|
loadMastodonInstances,
|
||||||
@@ -92,15 +96,18 @@ export function ConnectionManager({
|
|||||||
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '}
|
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '}
|
||||||
{new URL(connection.origin).hostname}
|
{new URL(connection.origin).hostname}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<Badge
|
||||||
className={`connection-manager-status connection-manager-status-${connection.status}`}
|
variant={
|
||||||
|
connection.status === 'connected' ? 'secondary' : 'outline'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{statusNames[connection.status]}
|
{statusNames[connection.status]}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{connection.platform === 'mastodon' && (
|
{connection.platform === 'mastodon' && (
|
||||||
<div className="connection-manager-actions">
|
<div className="connection-manager-actions">
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-label={`${connection.displayName}を再接続`}
|
aria-label={`${connection.displayName}を再接続`}
|
||||||
@@ -109,16 +116,17 @@ export function ConnectionManager({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
再接続
|
再接続
|
||||||
</button>
|
</Button>
|
||||||
{connection.status !== 'disconnected' && (
|
{connection.status !== 'disconnected' && (
|
||||||
<button
|
<Button
|
||||||
|
variant="destructive"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-label={`${connection.displayName}の接続を解除`}
|
aria-label={`${connection.displayName}の接続を解除`}
|
||||||
onClick={() => void remove(connection.id)}
|
onClick={() => void remove(connection.id)}
|
||||||
>
|
>
|
||||||
接続解除
|
接続解除
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -139,30 +147,35 @@ export function ConnectionManager({
|
|||||||
) : instances.isError ? (
|
) : instances.isError ? (
|
||||||
<p role="alert">
|
<p role="alert">
|
||||||
接続先を取得できませんでした。
|
接続先を取得できませんでした。
|
||||||
<button type="button" onClick={() => void instances.refetch()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void instances.refetch()}
|
||||||
|
>
|
||||||
再試行
|
再試行
|
||||||
</button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
) : instances.data.origins.length === 0 ? (
|
) : instances.data.origins.length === 0 ? (
|
||||||
<p>利用するMastodonサーバーがまだ設定されていません。</p>
|
<p>利用するMastodonサーバーがまだ設定されていません。</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<label htmlFor={selectId}>Mastodonサーバー</label>
|
<Label htmlFor={selectId}>Mastodonサーバー</Label>
|
||||||
<select
|
<NativeSelect
|
||||||
|
className="w-full"
|
||||||
id={selectId}
|
id={selectId}
|
||||||
value={origin}
|
value={origin}
|
||||||
onChange={(event) => setSelected(event.target.value)}
|
onChange={(event) => setSelected(event.target.value)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
{instances.data.origins.map((value) => (
|
{instances.data.origins.map((value) => (
|
||||||
<option key={value} value={value}>
|
<NativeSelectOption key={value} value={value}>
|
||||||
{new URL(value).hostname}
|
{new URL(value).hostname}
|
||||||
</option>
|
</NativeSelectOption>
|
||||||
))}
|
))}
|
||||||
</select>
|
</NativeSelect>
|
||||||
<button type="submit" disabled={busy || !origin}>
|
<Button type="submit" disabled={busy || !origin}>
|
||||||
Mastodonで認可する
|
Mastodonで認可する
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasTemporaryDecks && (
|
{hasTemporaryDecks && (
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ it('preserves title and Twitter search options after a rejected save', async ()
|
|||||||
fireEvent.change(screen.getByLabelText('表示順'), {
|
fireEvent.change(screen.getByLabelText('表示順'), {
|
||||||
target: { value: 'Top' },
|
target: { value: 'Top' },
|
||||||
})
|
})
|
||||||
fireEvent.click(screen.getByLabelText('フォロー中のみ'))
|
fireEvent.click(screen.getByRole('checkbox', { name: 'フォロー中のみ' }))
|
||||||
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
|
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
|
||||||
await waitFor(() => expect(save).toHaveBeenCalledOnce())
|
await waitFor(() => expect(save).toHaveBeenCalledOnce())
|
||||||
expect(screen.getByLabelText('カラム名')).toHaveValue('調査')
|
expect(screen.getByLabelText('カラム名')).toHaveValue('調査')
|
||||||
@@ -170,7 +170,7 @@ it('preserves title and Twitter search options after a rejected save', async ()
|
|||||||
'from:owner WebMCP',
|
'from:owner WebMCP',
|
||||||
)
|
)
|
||||||
expect(screen.getByLabelText('表示順')).toHaveValue('Top')
|
expect(screen.getByLabelText('表示順')).toHaveValue('Top')
|
||||||
expect(screen.getByLabelText('フォロー中のみ')).toBeChecked()
|
expect(screen.getByRole('checkbox', { name: 'フォロー中のみ' })).toBeChecked()
|
||||||
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
|
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
|
||||||
await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
|
await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
|
||||||
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
|
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { useId, useState } from 'react'
|
import { useId, useState } from 'react'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { Input } from '#/components/ui/input'
|
||||||
|
import { Label } from '#/components/ui/label'
|
||||||
|
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||||
import type { Connection } from '#/features/connections/model'
|
import type { Connection } from '#/features/connections/model'
|
||||||
import {
|
import {
|
||||||
ColumnSourceEditor,
|
ColumnSourceEditor,
|
||||||
@@ -60,9 +64,9 @@ export function ColumnEditor({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="deck-editor-fields">
|
<div className="deck-editor-fields">
|
||||||
<label htmlFor={`${formId}-title`}>
|
<Label htmlFor={`${formId}-title`}>
|
||||||
カラム名
|
カラム名
|
||||||
<input
|
<Input
|
||||||
id={`${formId}-title`}
|
id={`${formId}-title`}
|
||||||
name="title"
|
name="title"
|
||||||
defaultValue={column?.title ?? ''}
|
defaultValue={column?.title ?? ''}
|
||||||
@@ -70,10 +74,11 @@ export function ColumnEditor({
|
|||||||
placeholder="例:発表元の投稿"
|
placeholder="例:発表元の投稿"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</Label>
|
||||||
<label htmlFor={`${formId}-profile`}>
|
<Label htmlFor={`${formId}-profile`}>
|
||||||
接続プロファイル
|
接続プロファイル
|
||||||
<select
|
<NativeSelect
|
||||||
|
className="w-full"
|
||||||
id={`${formId}-profile`}
|
id={`${formId}-profile`}
|
||||||
value={connectionId}
|
value={connectionId}
|
||||||
required
|
required
|
||||||
@@ -86,22 +91,22 @@ export function ColumnEditor({
|
|||||||
setSource(rebindColumnSource(source, connection.platform))
|
setSource(rebindColumnSource(source, connection.platform))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<NativeSelectOption value="" disabled>
|
||||||
選択してください
|
選択してください
|
||||||
</option>
|
</NativeSelectOption>
|
||||||
{connectionId && !validConnection && (
|
{connectionId && !validConnection && (
|
||||||
<option value={connectionId} disabled>
|
<NativeSelectOption value={connectionId} disabled>
|
||||||
{connectionId}(利用不可)
|
{connectionId}(利用不可)
|
||||||
</option>
|
</NativeSelectOption>
|
||||||
)}
|
)}
|
||||||
{available.map((connection) => (
|
{available.map((connection) => (
|
||||||
<option key={connection.id} value={connection.id}>
|
<NativeSelectOption key={connection.id} value={connection.id}>
|
||||||
{connection.displayName}
|
{connection.displayName}
|
||||||
{connection.platform === 'mastodon' ? ' · Mastodon' : ''}
|
{connection.platform === 'mastodon' ? ' · Mastodon' : ''}
|
||||||
</option>
|
</NativeSelectOption>
|
||||||
))}
|
))}
|
||||||
</select>
|
</NativeSelect>
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<ColumnSourceEditor
|
<ColumnSourceEditor
|
||||||
source={source}
|
source={source}
|
||||||
@@ -117,16 +122,12 @@ export function ColumnEditor({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="deck-actions">
|
<div className="deck-actions">
|
||||||
<button
|
<Button type="submit" disabled={!validConnection}>
|
||||||
type="submit"
|
|
||||||
className="deck-primary"
|
|
||||||
disabled={!validConnection}
|
|
||||||
>
|
|
||||||
{column ? '変更を保存' : '追加して検索'}
|
{column ? '変更を保存' : '追加して検索'}
|
||||||
</button>
|
</Button>
|
||||||
<button type="button" onClick={onCancel}>
|
<Button variant="outline" type="button" onClick={onCancel}>
|
||||||
キャンセル
|
キャンセル
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { Icon } from '#/components/icon'
|
import { Icon } from '#/components/icon'
|
||||||
|
import { Badge } from '#/components/ui/badge'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '#/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
flattenResearchPages,
|
flattenResearchPages,
|
||||||
useResearchFeed,
|
useResearchFeed,
|
||||||
@@ -53,8 +62,9 @@ export function ResearchColumn({
|
|||||||
<div className="deck-column-title">
|
<div className="deck-column-title">
|
||||||
<PlatformIcon platform={column.source.platform} />
|
<PlatformIcon platform={column.source.platform} />
|
||||||
<h2 id={headingId}>{column.title}</h2>
|
<h2 id={headingId}>{column.title}</h2>
|
||||||
<button
|
<Button
|
||||||
className="icon-button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
aria-label="更新"
|
aria-label="更新"
|
||||||
title="更新"
|
title="更新"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -62,55 +72,46 @@ export function ResearchColumn({
|
|||||||
disabled={query.isFetching}
|
disabled={query.isFetching}
|
||||||
>
|
>
|
||||||
<Icon name="refresh" />
|
<Icon name="refresh" />
|
||||||
</button>
|
</Button>
|
||||||
<details className="column-menu">
|
<DropdownMenu>
|
||||||
<summary
|
<DropdownMenuTrigger
|
||||||
className="icon-button"
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
aria-label={`${column.title}の操作`}
|
aria-label={`${column.title}の操作`}
|
||||||
title="カラムの操作"
|
title="カラムの操作"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Icon name="more" />
|
<Icon name="more" />
|
||||||
</summary>
|
</DropdownMenuTrigger>
|
||||||
<div className="column-menu-panel">
|
<DropdownMenuContent align="end">
|
||||||
<button type="button" onClick={onEdit}>
|
<DropdownMenuItem onClick={onEdit}>編集</DropdownMenuItem>
|
||||||
編集
|
<DropdownMenuItem
|
||||||
</button>
|
onClick={() => onMove(-1)}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
onMove(-1)
|
|
||||||
event.currentTarget
|
|
||||||
.closest('details')
|
|
||||||
?.removeAttribute('open')
|
|
||||||
}}
|
|
||||||
disabled={first}
|
disabled={first}
|
||||||
aria-label={`${column.title}を左へ`}
|
aria-label={`${column.title}を左へ`}
|
||||||
>
|
>
|
||||||
← 左へ移動
|
← 左へ移動
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
<button
|
<DropdownMenuItem
|
||||||
type="button"
|
onClick={() => onMove(1)}
|
||||||
onClick={(event) => {
|
|
||||||
onMove(1)
|
|
||||||
event.currentTarget
|
|
||||||
.closest('details')
|
|
||||||
?.removeAttribute('open')
|
|
||||||
}}
|
|
||||||
disabled={last}
|
disabled={last}
|
||||||
aria-label={`${column.title}を右へ`}
|
aria-label={`${column.title}を右へ`}
|
||||||
>
|
>
|
||||||
右へ移動 →
|
右へ移動 →
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
<button
|
<DropdownMenuSeparator />
|
||||||
type="button"
|
<DropdownMenuItem
|
||||||
className="danger"
|
variant="destructive"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
aria-label={`${column.title}を削除`}
|
aria-label={`${column.title}を削除`}
|
||||||
>
|
>
|
||||||
カラムを削除
|
カラムを削除
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
</div>
|
</DropdownMenuContent>
|
||||||
</details>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<div className="deck-column-label">
|
<div className="deck-column-label">
|
||||||
<span
|
<span
|
||||||
@@ -118,10 +119,10 @@ export function ResearchColumn({
|
|||||||
>
|
>
|
||||||
{connectionLabel ?? '接続が見つかりません'}
|
{connectionLabel ?? '接続が見つかりません'}
|
||||||
</span>
|
</span>
|
||||||
<span className="deck-source-label">
|
<Badge variant="outline" className="deck-source-label">
|
||||||
<Icon name={column.source.kind} />
|
<Icon name={column.source.kind} />
|
||||||
{sourceDescription.label}
|
{sourceDescription.label}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="deck-query">{sourceDescription.query}</p>
|
<p className="deck-query">{sourceDescription.query}</p>
|
||||||
<p className="deck-column-status" role="status">
|
<p className="deck-column-status" role="status">
|
||||||
@@ -152,7 +153,8 @@ export function ResearchColumn({
|
|||||||
<div className="deck-message" role="alert">
|
<div className="deck-message" role="alert">
|
||||||
<p>{error.message}</p>
|
<p>{error.message}</p>
|
||||||
{error.retryable ? (
|
{error.retryable ? (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={query.isFetching}
|
disabled={query.isFetching}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -162,7 +164,7 @@ export function ResearchColumn({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
再試行
|
再試行
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -171,13 +173,14 @@ export function ResearchColumn({
|
|||||||
) : null}
|
) : null}
|
||||||
{query.hasNextPage && !query.isFetchNextPageError ? (
|
{query.hasNextPage && !query.isFetchNextPageError ? (
|
||||||
<div className="deck-message">
|
<div className="deck-message">
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={query.isFetching}
|
disabled={query.isFetching}
|
||||||
onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
|
onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
|
||||||
>
|
>
|
||||||
続きを読み込む
|
続きを読み込む
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!query.isPending &&
|
{!query.isPending &&
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ import { useRef, useState } from 'react'
|
|||||||
import { AppShell } from '#/components/app-shell'
|
import { AppShell } from '#/components/app-shell'
|
||||||
import { Dialog } from '#/components/dialog'
|
import { Dialog } from '#/components/dialog'
|
||||||
import { Icon } from '#/components/icon'
|
import { Icon } from '#/components/icon'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from '#/components/ui/empty'
|
||||||
|
import { Input } from '#/components/ui/input'
|
||||||
import { ConnectionManager } from '#/features/connections/connection-manager'
|
import { ConnectionManager } from '#/features/connections/connection-manager'
|
||||||
import { loadConnections } from '#/features/connections/server-functions'
|
import { loadConnections } from '#/features/connections/server-functions'
|
||||||
import { syncResearchDeck } from '#/features/research/research-deck-sync'
|
import { syncResearchDeck } from '#/features/research/research-deck-sync'
|
||||||
@@ -32,7 +42,9 @@ export function DeckPage() {
|
|||||||
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
|
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
|
||||||
const [renaming, setRenaming] = useState(false)
|
const [renaming, setRenaming] = useState(false)
|
||||||
const [managingConnections, setManagingConnections] = useState(false)
|
const [managingConnections, setManagingConnections] = useState(false)
|
||||||
const [switcherEditing, setSwitcherEditing] = useState(false)
|
const [deckDialog, setDeckDialog] = useState<'create' | 'delete' | null>(null)
|
||||||
|
const [newDeckTitle, setNewDeckTitle] = useState('')
|
||||||
|
const switcherEditing = deckDialog !== null
|
||||||
const {
|
const {
|
||||||
deck,
|
deck,
|
||||||
workspace,
|
workspace,
|
||||||
@@ -83,6 +95,8 @@ export function DeckPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function clearEditors() {
|
function clearEditors() {
|
||||||
|
setDeckDialog(null)
|
||||||
|
setNewDeckTitle('')
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
setRenaming(false)
|
setRenaming(false)
|
||||||
setRemoved(undefined)
|
setRemoved(undefined)
|
||||||
@@ -102,9 +116,24 @@ export function DeckPage() {
|
|||||||
return saved
|
return saved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const storageErrorNotice = storageError ? (
|
||||||
|
<p className="deck-error" role="alert">
|
||||||
|
{storageError}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
clearEditors()
|
||||||
|
void refresh(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
最新のデッキを再読み込み
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
|
||||||
researchChat={
|
|
||||||
<ResearchPanel
|
<ResearchPanel
|
||||||
onNavigateCitation={citationNavigation.navigate}
|
onNavigateCitation={citationNavigation.navigate}
|
||||||
ready={ready}
|
ready={ready}
|
||||||
@@ -123,16 +152,25 @@ export function DeckPage() {
|
|||||||
})
|
})
|
||||||
if (activate) clearEditors()
|
if (activate) clearEditors()
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
}
|
{(renderResearchPanel) => (
|
||||||
|
<AppShell
|
||||||
|
researchChat={renderResearchPanel}
|
||||||
sidebar={
|
sidebar={
|
||||||
<>
|
<>
|
||||||
<DeckSwitcher
|
<DeckSwitcher
|
||||||
key={deck.id}
|
key={deck.id}
|
||||||
|
errorNotice={storageErrorNotice}
|
||||||
decks={workspace.decks}
|
decks={workspace.decks}
|
||||||
activeId={deck.id}
|
activeId={deck.id}
|
||||||
ready={ready && !saving}
|
ready={ready && !saving}
|
||||||
onEditingChange={setSwitcherEditing}
|
dialog={deckDialog}
|
||||||
|
newTitle={newDeckTitle}
|
||||||
|
onNewTitleChange={setNewDeckTitle}
|
||||||
|
onDialogChange={(dialog) => {
|
||||||
|
setDeckDialog(dialog)
|
||||||
|
if (dialog !== 'create') setNewDeckTitle('')
|
||||||
|
}}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
select(id)
|
select(id)
|
||||||
clearEditors()
|
clearEditors()
|
||||||
@@ -148,29 +186,32 @@ export function DeckPage() {
|
|||||||
return saved
|
return saved
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
className="deck-primary sidebar-add"
|
className="w-full"
|
||||||
aria-label="カラムを追加"
|
aria-label="カラムを追加"
|
||||||
title="カラムを追加"
|
title="カラムを追加"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS}
|
disabled={
|
||||||
|
!ready || saving || deck.columns.length >= MAX_COLUMNS
|
||||||
|
}
|
||||||
onClick={() => setEditing('new')}
|
onClick={() => setEditing('new')}
|
||||||
>
|
>
|
||||||
<Icon name="plus" />
|
<Icon name="plus" />
|
||||||
<span>カラムを追加</span>
|
<span>カラムを追加</span>
|
||||||
</button>
|
</Button>
|
||||||
<p className="sidebar-note">
|
<p className="sidebar-note">
|
||||||
{deck.persisted
|
{deck.persisted
|
||||||
? 'サーバーに保存・端末間で共有'
|
? 'サーバーに保存・端末間で共有'
|
||||||
: '一時ビュー · このタブのみ'}
|
: '一時ビュー · このタブのみ'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!ready}
|
disabled={!ready}
|
||||||
onClick={() => setManagingConnections(true)}
|
onClick={() => setManagingConnections(true)}
|
||||||
>
|
>
|
||||||
接続アカウントを管理
|
接続アカウントを管理
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -183,7 +224,8 @@ export function DeckPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="deck-actions">
|
<div className="deck-actions">
|
||||||
{deck.persisted ? (
|
{deck.persisted ? (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -192,17 +234,19 @@ export function DeckPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
一時コピーを作成
|
一時コピーを作成
|
||||||
</button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!ready || saving}
|
disabled={!ready || saving}
|
||||||
onClick={() => void persist(deck.id)}
|
onClick={() => void persist(deck.id)}
|
||||||
>
|
>
|
||||||
デッキとして保存
|
デッキとして保存
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
aria-label="名前を変更"
|
aria-label="名前を変更"
|
||||||
title="名前を変更"
|
title="名前を変更"
|
||||||
@@ -211,7 +255,7 @@ export function DeckPage() {
|
|||||||
onClick={() => setRenaming(!renaming)}
|
onClick={() => setRenaming(!renaming)}
|
||||||
>
|
>
|
||||||
<Icon name="edit" />
|
<Icon name="edit" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
@@ -222,13 +266,14 @@ export function DeckPage() {
|
|||||||
{legacyAvailable ? (
|
{legacyAvailable ? (
|
||||||
<p className="deck-save-status">
|
<p className="deck-save-status">
|
||||||
このブラウザに旧デッキがあります。
|
このブラウザに旧デッキがあります。
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={saving || !profiles.data}
|
disabled={saving || !profiles.data}
|
||||||
onClick={() => void importLegacy()}
|
onClick={() => void importLegacy()}
|
||||||
>
|
>
|
||||||
旧デッキを取り込む
|
旧デッキを取り込む
|
||||||
</button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{!ready ? (
|
{!ready ? (
|
||||||
@@ -239,9 +284,13 @@ export function DeckPage() {
|
|||||||
{profiles.isError ? (
|
{profiles.isError ? (
|
||||||
<p role="alert" className="deck-error">
|
<p role="alert" className="deck-error">
|
||||||
接続プロファイルを取得できませんでした。
|
接続プロファイルを取得できませんでした。
|
||||||
<button type="button" onClick={() => void profiles.refetch()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void profiles.refetch()}
|
||||||
|
>
|
||||||
再試行
|
再試行
|
||||||
</button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{oauthResult === 'failed' ? (
|
{oauthResult === 'failed' ? (
|
||||||
@@ -267,28 +316,18 @@ export function DeckPage() {
|
|||||||
<ConnectionManager
|
<ConnectionManager
|
||||||
connections={profiles.data?.connections ?? []}
|
connections={profiles.data?.connections ?? []}
|
||||||
onChanged={() => profiles.refetch()}
|
onChanged={() => profiles.refetch()}
|
||||||
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
|
hasTemporaryDecks={workspace.decks.some(
|
||||||
|
(deck) => !deck.persisted,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
) : null}
|
) : null}
|
||||||
{profiles.isPending ? (
|
{profiles.isPending ? (
|
||||||
<p role="status">接続プロファイルを取得しています…</p>
|
<p role="status">接続プロファイルを取得しています…</p>
|
||||||
) : null}
|
) : null}
|
||||||
{storageError ? (
|
{!renaming && editing === null && !switcherEditing
|
||||||
<p className="deck-error" role="alert">
|
? storageErrorNotice
|
||||||
{storageError}
|
: null}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => {
|
|
||||||
clearEditors()
|
|
||||||
void refresh(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
最新のデッキを再読み込み
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{renaming ? (
|
{renaming ? (
|
||||||
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
|
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
|
||||||
<form
|
<form
|
||||||
@@ -304,7 +343,7 @@ export function DeckPage() {
|
|||||||
>
|
>
|
||||||
<label htmlFor="deck-title">
|
<label htmlFor="deck-title">
|
||||||
調査テーマ・デッキ名
|
調査テーマ・デッキ名
|
||||||
<input
|
<Input
|
||||||
id="deck-title"
|
id="deck-title"
|
||||||
name="title"
|
name="title"
|
||||||
defaultValue={deck.title}
|
defaultValue={deck.title}
|
||||||
@@ -312,8 +351,11 @@ export function DeckPage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">名前を保存</button>
|
<Button variant="outline" type="submit">
|
||||||
|
名前を保存
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
{storageErrorNotice}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
) : null}
|
) : null}
|
||||||
{editing !== null ? (
|
{editing !== null ? (
|
||||||
@@ -332,14 +374,18 @@ export function DeckPage() {
|
|||||||
onSave={saveColumn}
|
onSave={saveColumn}
|
||||||
onCancel={() => setEditing(null)}
|
onCancel={() => setEditing(null)}
|
||||||
/>
|
/>
|
||||||
|
{storageErrorNotice}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
) : null}
|
) : null}
|
||||||
{removed ? (
|
{removed ? (
|
||||||
<div className="deck-undo" role="status">
|
<div className="deck-undo" role="status">
|
||||||
<span>「{removed.column.title}」を削除しました。</span>
|
<span>「{removed.column.title}」を削除しました。</span>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={deck.columns.length >= MAX_COLUMNS || editing === 'new'}
|
disabled={
|
||||||
|
deck.columns.length >= MAX_COLUMNS || editing === 'new'
|
||||||
|
}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const columns = [...deck.columns]
|
const columns = [...deck.columns]
|
||||||
columns.splice(removed.index, 0, removed.column)
|
columns.splice(removed.index, 0, removed.column)
|
||||||
@@ -347,29 +393,37 @@ export function DeckPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
元に戻す
|
元に戻す
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{ready && deck.columns.length === 0 && editing === null ? (
|
{ready && deck.columns.length === 0 && editing === null ? (
|
||||||
<div className="deck-empty">
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
<Icon name="columns" />
|
<Icon name="columns" />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>
|
||||||
<h2>調べたいことから、デッキを作りましょう</h2>
|
<h2>調べたいことから、デッキを作りましょう</h2>
|
||||||
<p>
|
</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
|
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
|
||||||
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
|
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
|
||||||
</p>
|
</EmptyDescription>
|
||||||
<button
|
</EmptyHeader>
|
||||||
className="deck-primary"
|
<EmptyContent>
|
||||||
type="button"
|
<Button type="button" onClick={() => setEditing('new')}>
|
||||||
onClick={() => setEditing('new')}
|
|
||||||
>
|
|
||||||
手動でカラムを追加
|
手動でカラムを追加
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
) : null}
|
) : null}
|
||||||
{ready && deck.columns.length > 0 ? (
|
{ready && deck.columns.length > 0 ? (
|
||||||
|
<section
|
||||||
|
className="deck-board"
|
||||||
|
aria-label="調査カラム"
|
||||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
|
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
|
||||||
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
|
tabIndex={0}
|
||||||
|
>
|
||||||
{deck.columns.map((column, index) => (
|
{deck.columns.map((column, index) => (
|
||||||
<ResearchColumn
|
<ResearchColumn
|
||||||
key={`${deck.id}:${column.id}`}
|
key={`${deck.id}:${column.id}`}
|
||||||
@@ -401,7 +455,10 @@ export function DeckPage() {
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
if (saved) setRemoved({ column, index })
|
if (saved) setRemoved({ column, index })
|
||||||
if (typeof editing === 'object' && editing?.id === column.id)
|
if (
|
||||||
|
typeof editing === 'object' &&
|
||||||
|
editing?.id === column.id
|
||||||
|
)
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -409,5 +466,7 @@ export function DeckPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
)}
|
||||||
|
</ResearchPanel>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Dialog } from '#/components/dialog'
|
import { Dialog } from '#/components/dialog'
|
||||||
import { Icon } from '#/components/icon'
|
import { Icon } from '#/components/icon'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { Input } from '#/components/ui/input'
|
||||||
|
import { Label } from '#/components/ui/label'
|
||||||
|
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||||
import type { DeckView } from './model'
|
import type { DeckView } from './model'
|
||||||
|
|
||||||
export function DeckSwitcher({
|
export function DeckSwitcher({
|
||||||
@@ -10,7 +14,11 @@ export function DeckSwitcher({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onCreate,
|
onCreate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEditingChange,
|
dialog,
|
||||||
|
onDialogChange,
|
||||||
|
newTitle,
|
||||||
|
onNewTitleChange,
|
||||||
|
errorNotice,
|
||||||
}: {
|
}: {
|
||||||
decks: DeckView[]
|
decks: DeckView[]
|
||||||
activeId: string
|
activeId: string
|
||||||
@@ -18,109 +26,118 @@ export function DeckSwitcher({
|
|||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onCreate: (title: string) => Promise<boolean>
|
onCreate: (title: string) => Promise<boolean>
|
||||||
onDelete: () => Promise<boolean>
|
onDelete: () => Promise<boolean>
|
||||||
onEditingChange: (editing: boolean) => void
|
dialog: 'create' | 'delete' | null
|
||||||
|
onDialogChange: (dialog: 'create' | 'delete' | null) => void
|
||||||
|
newTitle: string
|
||||||
|
onNewTitleChange: (title: string) => void
|
||||||
|
errorNotice?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [creating, setCreating] = useState(false)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
useEffect(() => {
|
|
||||||
onEditingChange(creating || deleting)
|
|
||||||
return () => onEditingChange(false)
|
|
||||||
}, [creating, deleting, onEditingChange])
|
|
||||||
return (
|
return (
|
||||||
<div className="deck-workspaces">
|
<div className="deck-workspaces">
|
||||||
<div className="deck-actions">
|
<div className="deck-actions">
|
||||||
<label htmlFor="deck-selection">デッキプロファイル</label>
|
<Label htmlFor="deck-selection">デッキプロファイル</Label>
|
||||||
<select
|
<NativeSelect
|
||||||
|
className="w-full"
|
||||||
id="deck-selection"
|
id="deck-selection"
|
||||||
value={activeId}
|
value={activeId}
|
||||||
disabled={!ready}
|
disabled={!ready}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
onSelect(event.target.value)
|
onSelect(event.target.value)
|
||||||
setCreating(false)
|
onDialogChange(null)
|
||||||
setDeleting(false)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{decks.map((deck) => (
|
{decks.map((deck) => (
|
||||||
<option key={deck.id} value={deck.id}>
|
<NativeSelectOption key={deck.id} value={deck.id}>
|
||||||
{deck.title}
|
{deck.title}
|
||||||
{deck.persisted ? '' : '(一時)'}
|
{deck.persisted ? '' : '(一時)'}
|
||||||
</option>
|
</NativeSelectOption>
|
||||||
))}
|
))}
|
||||||
</select>
|
</NativeSelect>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
className="workspace-create"
|
className="workspace-create"
|
||||||
aria-label="デッキを作成"
|
aria-label="デッキを作成"
|
||||||
title="デッキを作成"
|
title="デッキを作成"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!ready}
|
disabled={!ready}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCreating(!creating)
|
onDialogChange('create')
|
||||||
setDeleting(false)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="plus" />
|
<Icon name="plus" />
|
||||||
<span>デッキを作成</span>
|
<span>デッキを作成</span>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
className="workspace-delete"
|
className="workspace-delete"
|
||||||
aria-label="デッキを削除"
|
aria-label="デッキを削除"
|
||||||
title="デッキを削除"
|
title="デッキを削除"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!ready}
|
disabled={!ready}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDeleting(!deleting)
|
onDialogChange('delete')
|
||||||
setCreating(false)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="trash" />
|
<Icon name="trash" />
|
||||||
<span>デッキを削除</span>
|
<span>デッキを削除</span>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{creating ? (
|
{dialog === 'create' ? (
|
||||||
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
|
<Dialog title="デッキを作成" onClose={() => onDialogChange(null)}>
|
||||||
<form
|
<form
|
||||||
className="deck-rename"
|
className="deck-rename"
|
||||||
onSubmit={async (event) => {
|
onSubmit={async (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const title = String(
|
const title = newTitle.trim()
|
||||||
new FormData(event.currentTarget).get('title') ?? '',
|
|
||||||
).trim()
|
|
||||||
if (!title) return
|
if (!title) return
|
||||||
if (await onCreate(title)) setCreating(false)
|
if (await onCreate(title)) onDialogChange(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label htmlFor="new-deck-title">
|
<Label htmlFor="new-deck-title">
|
||||||
新しいデッキ名
|
新しいデッキ名
|
||||||
<input
|
<Input
|
||||||
id="new-deck-title"
|
id="new-deck-title"
|
||||||
name="title"
|
name="title"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(event) => onNewTitleChange(event.target.value)}
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</Label>
|
||||||
<button type="submit">作成</button>
|
<Button type="submit">作成</Button>
|
||||||
<button type="button" onClick={() => setCreating(false)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDialogChange(null)}
|
||||||
|
>
|
||||||
キャンセル
|
キャンセル
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
{errorNotice}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
) : null}
|
) : null}
|
||||||
{deleting ? (
|
{dialog === 'delete' ? (
|
||||||
<Dialog title="デッキを削除" onClose={() => setDeleting(false)}>
|
<Dialog title="デッキを削除" onClose={() => onDialogChange(null)}>
|
||||||
<div className="deck-undo">
|
<div className="deck-undo">
|
||||||
<p>このデッキとカラムの設定を削除します。</p>
|
<p>このデッキとカラムの設定を削除します。</p>
|
||||||
<button
|
<Button
|
||||||
|
variant="destructive"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (await onDelete()) setDeleting(false)
|
if (await onDelete()) onDialogChange(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
削除する
|
削除する
|
||||||
</button>
|
</Button>
|
||||||
<button type="button" onClick={() => setDeleting(false)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDialogChange(null)}
|
||||||
|
>
|
||||||
キャンセル
|
キャンセル
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{errorNotice}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+14
-197
@@ -1,47 +1,3 @@
|
|||||||
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
|
|
||||||
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
|
|
||||||
.app-deck button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
min-height: 36px;
|
|
||||||
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(--surface);
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.app-deck button:hover:not(:disabled) {
|
|
||||||
background: var(--color-paper-3);
|
|
||||||
}
|
|
||||||
.app-deck button:active:not(:disabled) {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
.app-deck button:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.app-deck .icon-button {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
padding: var(--space-xs);
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.app-deck .deck-primary {
|
|
||||||
color: var(--color-accent-ink);
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
.app-deck .deck-primary:hover:not(:disabled),
|
|
||||||
.app-deck .deck-primary:active:not(:disabled) {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
color: var(--color-accent-ink);
|
|
||||||
}
|
|
||||||
.deck-heading {
|
.deck-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -61,7 +17,7 @@
|
|||||||
}
|
}
|
||||||
.deck-heading p {
|
.deck-heading p {
|
||||||
margin: var(--space-2xs) 0 0;
|
margin: var(--space-2xs) 0 0;
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
.deck-actions {
|
.deck-actions {
|
||||||
@@ -73,7 +29,7 @@
|
|||||||
.deck-help,
|
.deck-help,
|
||||||
.deck-save-status,
|
.deck-save-status,
|
||||||
.deck-column-status {
|
.deck-column-status {
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
@@ -130,7 +86,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
.deck-column-label span {
|
.deck-column-label span {
|
||||||
@@ -149,7 +105,7 @@
|
|||||||
.deck-query {
|
.deck-query {
|
||||||
margin: var(--space-2xs) 0;
|
margin: var(--space-2xs) 0;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -166,47 +122,9 @@
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--control-border) var(--paper);
|
scrollbar-color: var(--control-border) var(--paper);
|
||||||
}
|
}
|
||||||
.column-menu {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.column-menu summary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
list-style: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.column-menu summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.column-menu summary:hover,
|
|
||||||
.column-menu[open] summary {
|
|
||||||
background: var(--color-paper-3);
|
|
||||||
}
|
|
||||||
.column-menu-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
right: 0;
|
|
||||||
z-index: 5;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
min-width: 158px;
|
|
||||||
padding: var(--space-2xs);
|
|
||||||
border: var(--rule-hairline) solid var(--control-border);
|
|
||||||
background: var(--surface);
|
|
||||||
box-shadow: 0 var(--space-xs) var(--space-lg) var(--color-shadow);
|
|
||||||
}
|
|
||||||
.column-menu-panel button {
|
|
||||||
justify-content: flex-start;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
.deck-message {
|
.deck-message {
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
@@ -223,11 +141,6 @@
|
|||||||
.research-post header > div {
|
.research-post header > div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.research-post header img {
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.research-post strong {
|
.research-post strong {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
@@ -250,11 +163,11 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
margin-top: var(--space-sm);
|
margin-top: var(--space-sm);
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
.research-post a {
|
.research-post a {
|
||||||
color: var(--accent);
|
color: var(--primary);
|
||||||
text-underline-offset: 0.2em;
|
text-underline-offset: 0.2em;
|
||||||
}
|
}
|
||||||
.research-media {
|
.research-media {
|
||||||
@@ -265,34 +178,6 @@
|
|||||||
margin-block: var(--space-xs);
|
margin-block: var(--space-xs);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.deck-dialog {
|
|
||||||
width: min(480px, calc(100vw - 32px));
|
|
||||||
max-height: calc(100dvh - 32px);
|
|
||||||
padding: var(--space-lg);
|
|
||||||
overflow-y: auto;
|
|
||||||
border: var(--rule-hairline) solid var(--control-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--paper);
|
|
||||||
color: var(--ink);
|
|
||||||
box-shadow: 0 var(--space-md) var(--space-xl) var(--color-shadow);
|
|
||||||
}
|
|
||||||
.deck-dialog::backdrop {
|
|
||||||
background: var(--color-overlay);
|
|
||||||
}
|
|
||||||
.dialog-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
.dialog-heading h2,
|
|
||||||
.deck-editor h2,
|
|
||||||
.deck-empty h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--text-md);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.deck-editor {
|
.deck-editor {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -312,30 +197,12 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.deck-editor input:not([type="checkbox"]),
|
|
||||||
.deck-editor select,
|
|
||||||
.deck-rename input,
|
|
||||||
.deck-workspaces select {
|
|
||||||
min-width: 0;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 40px;
|
|
||||||
padding: var(--space-xs);
|
|
||||||
border: var(--rule-hairline) solid var(--control-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--canvas);
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.deck-editor .deck-checkbox {
|
.deck-editor .deck-checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
margin-bottom: var(--space-sm);
|
margin-bottom: var(--space-sm);
|
||||||
}
|
}
|
||||||
.deck-checkbox input {
|
|
||||||
accent-color: var(--accent);
|
|
||||||
width: 1.15rem;
|
|
||||||
height: 1.15rem;
|
|
||||||
}
|
|
||||||
.deck-rename {
|
.deck-rename {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -347,7 +214,7 @@
|
|||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
.deck-error {
|
.deck-error {
|
||||||
color: var(--error);
|
color: var(--destructive);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -374,7 +241,7 @@
|
|||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
}
|
}
|
||||||
.deck-empty p {
|
.deck-empty p {
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
margin-bottom: var(--space-lg);
|
margin-bottom: var(--space-lg);
|
||||||
}
|
}
|
||||||
@@ -386,10 +253,10 @@
|
|||||||
}
|
}
|
||||||
.deck-workspaces > .deck-actions > label {
|
.deck-workspaces > .deck-actions > label {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
.deck-workspaces > .deck-actions > select {
|
.deck-workspaces > .deck-actions > [data-slot="native-select-wrapper"] {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
.deck-workspaces > .deck-actions > button {
|
.deck-workspaces > .deck-actions > button {
|
||||||
@@ -404,35 +271,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.deck-workspaces {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.deck-workspaces > .deck-actions {
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: var(--space-2xs);
|
|
||||||
}
|
|
||||||
.deck-workspaces > .deck-actions > label {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
}
|
|
||||||
.deck-workspaces > .deck-actions > select {
|
|
||||||
flex: 1;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
.deck-workspaces > .deck-actions > button {
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.app-deck .sidebar-add {
|
|
||||||
width: 36px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: var(--space-xs);
|
|
||||||
}
|
|
||||||
.sidebar-add span {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.deck-heading {
|
.deck-heading {
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
padding-inline: var(--space-sm);
|
padding-inline: var(--space-sm);
|
||||||
@@ -444,37 +282,19 @@
|
|||||||
grid-auto-columns: min(328px, 100%);
|
grid-auto-columns: min(328px, 100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (pointer: coarse) {
|
|
||||||
.app-deck .icon-button,
|
|
||||||
.column-menu summary {
|
|
||||||
min-width: 40px;
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.deck-count {
|
.deck-count {
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
.research-post header time {
|
.research-post header time {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
.deck-column-status {
|
.deck-column-status {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@media (max-width: 767px) {
|
.research-post header > div:not([data-slot="avatar"]) {
|
||||||
.workspace-create span,
|
|
||||||
.workspace-delete span {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.app-deck .workspace-create,
|
|
||||||
.app-deck .workspace-delete {
|
|
||||||
width: 36px;
|
|
||||||
padding: var(--space-xs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.research-post header > div {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.research-post header time {
|
.research-post header time {
|
||||||
@@ -490,6 +310,3 @@
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
.column-menu-panel .danger {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useServerFn } from '@tanstack/react-start'
|
import { useServerFn } from '@tanstack/react-start'
|
||||||
import { type ReactElement, useId } from 'react'
|
import { type ReactElement, useId } from 'react'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { Input } from '#/components/ui/input'
|
||||||
|
import { Label } from '#/components/ui/label'
|
||||||
|
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||||
import { loadMastodonLists } from '../platforms/mastodon-server-functions'
|
import { loadMastodonLists } from '../platforms/mastodon-server-functions'
|
||||||
import type { MastodonSource } from '../platforms/mastodon-source'
|
import type { MastodonSource } from '../platforms/mastodon-source'
|
||||||
|
|
||||||
@@ -32,9 +36,10 @@ export function MastodonSourceEditor({
|
|||||||
const id = useId()
|
const id = useId()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<label htmlFor={id}>
|
<Label htmlFor={id}>
|
||||||
カラムの種類
|
カラムの種類
|
||||||
<select
|
<NativeSelect
|
||||||
|
className="w-full"
|
||||||
id={id}
|
id={id}
|
||||||
value={source.kind}
|
value={source.kind}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -45,12 +50,12 @@ export function MastodonSourceEditor({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="search">検索</option>
|
<NativeSelectOption value="search">検索</NativeSelectOption>
|
||||||
<option value="user">ユーザー投稿</option>
|
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
|
||||||
<option value="list">リスト</option>
|
<NativeSelectOption value="list">リスト</NativeSelectOption>
|
||||||
<option value="hashtag">ハッシュタグ</option>
|
<NativeSelectOption value="hashtag">ハッシュタグ</NativeSelectOption>
|
||||||
</select>
|
</NativeSelect>
|
||||||
</label>
|
</Label>
|
||||||
<MastodonSourceFields
|
<MastodonSourceFields
|
||||||
source={source}
|
source={source}
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
@@ -90,9 +95,9 @@ function MastodonSearchEditor({
|
|||||||
const id = useId()
|
const id = useId()
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div className="deck-source-fields">
|
||||||
<label htmlFor={id}>
|
<Label htmlFor={id}>
|
||||||
Mastodonの検索条件
|
Mastodonの検索条件
|
||||||
<input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
value={source.query}
|
value={source.query}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -102,7 +107,7 @@ function MastodonSearchEditor({
|
|||||||
placeholder="例:WebMCP"
|
placeholder="例:WebMCP"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</Label>
|
||||||
<p className="deck-help">
|
<p className="deck-help">
|
||||||
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。
|
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。
|
||||||
</p>
|
</p>
|
||||||
@@ -117,9 +122,9 @@ function MastodonUserEditor({
|
|||||||
const id = useId()
|
const id = useId()
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div className="deck-source-fields">
|
||||||
<label htmlFor={id}>
|
<Label htmlFor={id}>
|
||||||
ユーザー
|
ユーザー
|
||||||
<input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
value={source.target}
|
value={source.target}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -129,7 +134,7 @@ function MastodonUserEditor({
|
|||||||
maxLength={256}
|
maxLength={256}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -141,9 +146,9 @@ function MastodonHashtagEditor({
|
|||||||
const id = useId()
|
const id = useId()
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div className="deck-source-fields">
|
||||||
<label htmlFor={id}>
|
<Label htmlFor={id}>
|
||||||
ハッシュタグ
|
ハッシュタグ
|
||||||
<input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
value={source.target}
|
value={source.target}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -153,7 +158,7 @@ function MastodonHashtagEditor({
|
|||||||
maxLength={128}
|
maxLength={128}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -168,9 +173,9 @@ function MastodonListEditor({
|
|||||||
const id = useId()
|
const id = useId()
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div className="deck-source-fields">
|
||||||
<label htmlFor={id}>
|
<Label htmlFor={id}>
|
||||||
リスト
|
リスト
|
||||||
<input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
value={source.target}
|
value={source.target}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -180,7 +185,7 @@ function MastodonListEditor({
|
|||||||
maxLength={32}
|
maxLength={32}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</Label>
|
||||||
{connectionId && (
|
{connectionId && (
|
||||||
<MastodonListChoices
|
<MastodonListChoices
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
@@ -209,9 +214,13 @@ function MastodonListChoices({
|
|||||||
return (
|
return (
|
||||||
<p role="alert">
|
<p role="alert">
|
||||||
リストを取得できませんでした。
|
リストを取得できませんでした。
|
||||||
<button type="button" onClick={() => void lists.refetch()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void lists.refetch()}
|
||||||
|
>
|
||||||
再試行
|
再試行
|
||||||
</button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
@@ -220,9 +229,14 @@ function MastodonListChoices({
|
|||||||
<p>このアカウントのリストはありません。</p>
|
<p>このアカウントのリストはありません。</p>
|
||||||
) : (
|
) : (
|
||||||
lists.data.lists.map((list) => (
|
lists.data.lists.map((list) => (
|
||||||
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
key={list.id}
|
||||||
|
onClick={() => onSelect(list.id)}
|
||||||
|
>
|
||||||
{list.name}
|
{list.name}
|
||||||
</button>
|
</Button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Icon } from '#/components/icon'
|
import { Icon } from '#/components/icon'
|
||||||
import { PostText } from '#/components/post-text'
|
import { PostText } from '#/components/post-text'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '#/components/ui/avatar'
|
||||||
|
import { Badge } from '#/components/ui/badge'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
import type { ResearchPost } from '#/features/platforms/types'
|
import type { ResearchPost } from '#/features/platforms/types'
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||||
@@ -49,20 +52,17 @@ export function ResearchPostCard({
|
|||||||
className={`research-post${highlighted ? ' citation-highlight' : ''}`}
|
className={`research-post${highlighted ? ' citation-highlight' : ''}`}
|
||||||
data-post-url={post.url}
|
data-post-url={post.url}
|
||||||
>
|
>
|
||||||
{citationSnapshot ? <p className="handle">調査時に取得した投稿</p> : null}
|
{citationSnapshot ? (
|
||||||
|
<Badge variant="secondary">調査時に取得した投稿</Badge>
|
||||||
|
) : null}
|
||||||
{post.boostedBy ? (
|
{post.boostedBy ? (
|
||||||
<p className="handle">{post.boostedBy.name} がブースト</p>
|
<p className="handle">{post.boostedBy.name} がブースト</p>
|
||||||
) : null}
|
) : null}
|
||||||
<header>
|
<header>
|
||||||
{post.author.avatarUrl ? (
|
<Avatar className="size-9 shrink-0">
|
||||||
<img
|
<AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" />
|
||||||
src={post.author.avatarUrl}
|
<AvatarFallback>{post.author.name.slice(0, 1)}</AvatarFallback>
|
||||||
alt=""
|
</Avatar>
|
||||||
width={36}
|
|
||||||
height={36}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div>
|
<div>
|
||||||
<strong>{post.author.name}</strong>
|
<strong>{post.author.name}</strong>
|
||||||
<span className="handle">
|
<span className="handle">
|
||||||
@@ -78,13 +78,15 @@ export function ResearchPostCard({
|
|||||||
{concealed ? (
|
{concealed ? (
|
||||||
<div>
|
<div>
|
||||||
<p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p>
|
<p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={revealed}
|
aria-expanded={revealed}
|
||||||
onClick={() => setRevealed(!revealed)}
|
onClick={() => setRevealed(!revealed)}
|
||||||
>
|
>
|
||||||
{revealed ? '内容を隠す' : '内容を表示'}
|
{revealed ? '内容を隠す' : '内容を表示'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!concealed || revealed ? (
|
{!concealed || revealed ? (
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { type ReactElement, useId } from 'react'
|
import { type ReactElement, useId } from 'react'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { Checkbox } from '#/components/ui/checkbox'
|
||||||
|
import { Input } from '#/components/ui/input'
|
||||||
|
import { Label } from '#/components/ui/label'
|
||||||
|
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||||
import { useListChoices } from '../posts/use-list-choices'
|
import { useListChoices } from '../posts/use-list-choices'
|
||||||
import type { DeckColumn } from './model'
|
import type { DeckColumn } from './model'
|
||||||
|
|
||||||
@@ -33,20 +38,21 @@ export function TwitterSourceEditor({
|
|||||||
const id = useId()
|
const id = useId()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<label htmlFor={id}>
|
<Label htmlFor={id}>
|
||||||
カラムの種類
|
カラムの種類
|
||||||
<select
|
<NativeSelect
|
||||||
|
className="w-full"
|
||||||
id={id}
|
id={id}
|
||||||
value={source.kind}
|
value={source.kind}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onChange(defaultTwitterSource(event.target.value as Source['kind']))
|
onChange(defaultTwitterSource(event.target.value as Source['kind']))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="search">検索</option>
|
<NativeSelectOption value="search">検索</NativeSelectOption>
|
||||||
<option value="user">ユーザー投稿</option>
|
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
|
||||||
<option value="list">リスト</option>
|
<NativeSelectOption value="list">リスト</NativeSelectOption>
|
||||||
</select>
|
</NativeSelect>
|
||||||
</label>
|
</Label>
|
||||||
<TwitterSourceFields
|
<TwitterSourceFields
|
||||||
source={source}
|
source={source}
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
@@ -84,9 +90,9 @@ function TwitterSearchEditor({
|
|||||||
const id = useId()
|
const id = useId()
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div className="deck-source-fields">
|
||||||
<label htmlFor={`${id}-query`}>
|
<Label htmlFor={`${id}-query`}>
|
||||||
Twitterの検索条件
|
Twitterの検索条件
|
||||||
<input
|
<Input
|
||||||
id={`${id}-query`}
|
id={`${id}-query`}
|
||||||
value={source.query}
|
value={source.query}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -96,10 +102,11 @@ function TwitterSearchEditor({
|
|||||||
placeholder="例:WebMCP lang:ja"
|
placeholder="例:WebMCP lang:ja"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</Label>
|
||||||
<label htmlFor={`${id}-product`}>
|
<Label htmlFor={`${id}-product`}>
|
||||||
表示順
|
表示順
|
||||||
<select
|
<NativeSelect
|
||||||
|
className="w-full"
|
||||||
id={`${id}-product`}
|
id={`${id}-product`}
|
||||||
value={source.product}
|
value={source.product}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -109,23 +116,22 @@ function TwitterSearchEditor({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="Latest">最新</option>
|
<NativeSelectOption value="Latest">最新</NativeSelectOption>
|
||||||
<option value="Top">話題</option>
|
<NativeSelectOption value="Top">話題</NativeSelectOption>
|
||||||
</select>
|
</NativeSelect>
|
||||||
</label>
|
</Label>
|
||||||
<p className="deck-help">
|
<p className="deck-help">
|
||||||
from:、lang:、since: など、Twitterの検索構文を使えます。
|
from:、lang:、since: など、Twitterの検索構文を使えます。
|
||||||
</p>
|
</p>
|
||||||
<label className="deck-checkbox">
|
<Label className="deck-checkbox">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
checked={source.following}
|
checked={source.following}
|
||||||
onChange={(event) =>
|
onCheckedChange={(checked) =>
|
||||||
onChange({ ...source, following: event.target.checked })
|
onChange({ ...source, following: checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
フォロー中のみ
|
フォロー中のみ
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -137,9 +143,9 @@ function TwitterUserEditor({
|
|||||||
const id = useId()
|
const id = useId()
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div className="deck-source-fields">
|
||||||
<label htmlFor={id}>
|
<Label htmlFor={id}>
|
||||||
ユーザー
|
ユーザー
|
||||||
<input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
value={source.target}
|
value={source.target}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -149,7 +155,7 @@ function TwitterUserEditor({
|
|||||||
maxLength={256}
|
maxLength={256}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -162,9 +168,9 @@ function TwitterListEditor({
|
|||||||
const id = useId()
|
const id = useId()
|
||||||
return (
|
return (
|
||||||
<div className="deck-source-fields">
|
<div className="deck-source-fields">
|
||||||
<label htmlFor={id}>
|
<Label htmlFor={id}>
|
||||||
リスト
|
リスト
|
||||||
<input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
value={source.target}
|
value={source.target}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -174,7 +180,7 @@ function TwitterListEditor({
|
|||||||
maxLength={256}
|
maxLength={256}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</Label>
|
||||||
{connectionId && (
|
{connectionId && (
|
||||||
<TwitterListChoices
|
<TwitterListChoices
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
@@ -198,9 +204,13 @@ function TwitterListChoices({
|
|||||||
return (
|
return (
|
||||||
<p role="alert">
|
<p role="alert">
|
||||||
リストを取得できませんでした。
|
リストを取得できませんでした。
|
||||||
<button type="button" onClick={() => void lists.refetch()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void lists.refetch()}
|
||||||
|
>
|
||||||
再試行
|
再試行
|
||||||
</button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
@@ -212,10 +222,15 @@ function TwitterListChoices({
|
|||||||
<p>リストはありません。URLまたはIDでも指定できます。</p>
|
<p>リストはありません。URLまたはIDでも指定できます。</p>
|
||||||
) : (
|
) : (
|
||||||
lists.data.map((list) => (
|
lists.data.map((list) => (
|
||||||
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
key={list.id}
|
||||||
|
onClick={() => onSelect(list.id)}
|
||||||
|
>
|
||||||
{list.name}
|
{list.name}
|
||||||
{list.isPrivate ? ' · 非公開' : ''}
|
{list.isPrivate ? ' · 非公開' : ''}
|
||||||
</button>
|
</Button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
.research-post.citation-highlight {
|
.research-post.citation-highlight {
|
||||||
outline: 2px solid var(--accent, #67b7ff);
|
outline: 2px solid var(--primary);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
background-color: color-mix(in srgb, var(--accent, #67b7ff) 14%, transparent);
|
background-color: color-mix(in srgb, var(--primary) 14%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.research-post:focus-visible {
|
.research-post:focus-visible {
|
||||||
outline: 2px solid var(--accent, #67b7ff);
|
outline: 2px solid var(--primary);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
.app.app-deck {
|
|
||||||
grid-template-columns: 340px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
.research-panel {
|
.research-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-right: 1px solid var(--border);
|
background: var(--background);
|
||||||
background: var(--paper);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.research-heading {
|
.research-heading {
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -23,13 +21,11 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
.research-heading button {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.research-context {
|
.research-context {
|
||||||
margin: 0.5rem 0 0;
|
margin: 0 0 0.75rem;
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.research-history {
|
.research-history {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -38,20 +34,14 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
.research-history span {
|
.research-history span {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.research-history select {
|
.research-history > [data-slot="native-select-wrapper"] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--ink);
|
|
||||||
background: var(--canvas);
|
|
||||||
border: 1px solid var(--control-border);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
padding: 0.4rem;
|
|
||||||
}
|
}
|
||||||
.research-panel > p {
|
.research-panel > p {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
@@ -61,45 +51,32 @@
|
|||||||
.research-conversation {
|
.research-conversation {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
}
|
||||||
.research-empty {
|
.research-empty {
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
.research-message {
|
.research-message {
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.research-message strong {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--secondary);
|
|
||||||
}
|
|
||||||
.research-message p {
|
.research-message p {
|
||||||
margin: 0.35rem 0 0;
|
margin: 0.35rem 0 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
.research-message-user {
|
.research-tool-message {
|
||||||
border-left: 2px solid var(--accent);
|
color: var(--muted-foreground);
|
||||||
padding-left: 0.75rem;
|
|
||||||
}
|
|
||||||
.research-message-tool {
|
|
||||||
color: var(--secondary);
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.research-start-form {
|
.research-start-form {
|
||||||
|
flex-shrink: 0;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.research-context {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.research-start-form > fieldset {
|
.research-start-form > fieldset {
|
||||||
border: 0;
|
border: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -111,21 +88,9 @@
|
|||||||
.research-start-form label {
|
.research-start-form label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.research-start-form textarea {
|
|
||||||
width: 100%;
|
|
||||||
resize: vertical;
|
|
||||||
color: var(--ink);
|
|
||||||
background: var(--canvas);
|
|
||||||
border: 1px solid var(--control-border);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
padding: 0.6rem;
|
|
||||||
}
|
|
||||||
.research-account-picker {
|
.research-account-picker {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
.research-account-picker summary {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.research-accounts {
|
.research-accounts {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -141,10 +106,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.research-accounts input {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.research-run {
|
.research-run {
|
||||||
|
flex-shrink: 0;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -159,73 +122,6 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
.deck-toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.deck-toolbar .deck-workspaces {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.deck-toolbar .deck-workspaces > .deck-actions {
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
.deck-toolbar .deck-workspaces > .deck-actions > label {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
}
|
|
||||||
.deck-toolbar .deck-workspaces > .deck-actions > select {
|
|
||||||
flex: 1;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
.deck-toolbar .deck-workspaces > .deck-actions > button {
|
|
||||||
flex: none;
|
|
||||||
width: 36px;
|
|
||||||
padding: 0.25rem;
|
|
||||||
}
|
|
||||||
.deck-toolbar .workspace-create span,
|
|
||||||
.deck-toolbar .workspace-delete span,
|
|
||||||
.deck-toolbar .sidebar-add span,
|
|
||||||
.deck-toolbar .sidebar-note {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.app-deck .deck-toolbar .sidebar-add {
|
|
||||||
width: 36px;
|
|
||||||
}
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.app.app-deck {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
grid-template-rows: 44dvh minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
.research-panel {
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.research-heading {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
}
|
|
||||||
.research-conversation {
|
|
||||||
flex: 1 0 80px;
|
|
||||||
min-height: 80px;
|
|
||||||
}
|
|
||||||
.research-start-form {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
}
|
|
||||||
.research-run {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.deck-toolbar > button:last-child {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.research-markdown {
|
.research-markdown {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -238,26 +134,33 @@
|
|||||||
.research-markdown h5,
|
.research-markdown h5,
|
||||||
.research-markdown h6 {
|
.research-markdown h6 {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
|
font-weight: 600;
|
||||||
margin: 1em 0 0.4em;
|
margin: 1em 0 0.4em;
|
||||||
}
|
}
|
||||||
.research-markdown ul,
|
.research-markdown ul,
|
||||||
.research-markdown ol {
|
.research-markdown ol {
|
||||||
padding-left: 1.4rem;
|
padding-left: 1.4rem;
|
||||||
}
|
}
|
||||||
|
.research-markdown ul {
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
.research-markdown ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
.research-markdown li + li {
|
.research-markdown li + li {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
.research-markdown blockquote {
|
.research-markdown blockquote {
|
||||||
margin: 0.75rem 0;
|
margin: 0.75rem 0;
|
||||||
padding-left: 0.75rem;
|
padding-left: 0.75rem;
|
||||||
border-left: 2px solid var(--control-border);
|
border-left: 2px solid var(--border);
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
.research-markdown pre {
|
.research-markdown pre {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: var(--canvas);
|
background: var(--muted);
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -281,7 +184,7 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.research-markdown a {
|
.research-markdown a {
|
||||||
color: var(--accent);
|
color: var(--primary);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 0.15em;
|
text-underline-offset: 0.15em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ function show(connections = [connection], ready = true) {
|
|||||||
const ui = (
|
const ui = (
|
||||||
ready = true,
|
ready = true,
|
||||||
contextDeck = { id: 'manual', title: '手動デッキ', columns: [] },
|
contextDeck = { id: 'manual', title: '手動デッキ', columns: [] },
|
||||||
|
presentation = 'desktop',
|
||||||
) => (
|
) => (
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<ResearchPanel
|
<ResearchPanel
|
||||||
@@ -91,16 +92,23 @@ function show(connections = [connection], ready = true) {
|
|||||||
connections={connections}
|
connections={connections}
|
||||||
onOpenDeck={onOpenDeck}
|
onOpenDeck={onOpenDeck}
|
||||||
onNavigateCitation={onNavigateCitation}
|
onNavigateCitation={onNavigateCitation}
|
||||||
/>
|
>
|
||||||
|
{(renderPanel) => <div key={presentation}>{renderPanel()}</div>}
|
||||||
|
</ResearchPanel>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
return { ...render(ui(ready)), client, ui, onOpenDeck, onNavigateCitation }
|
return { ...render(ui(ready)), client, ui, onOpenDeck, onNavigateCitation }
|
||||||
}
|
}
|
||||||
|
|
||||||
it('retains input and uses the same request ID when retrying a failed start', async () => {
|
it('retains input, selected accounts, and the request ID across responsive view remounts after a failed start', async () => {
|
||||||
actions.start.mockRejectedValue(new Error('internal credential detail'))
|
actions.start.mockRejectedValue(new Error('internal credential detail'))
|
||||||
show()
|
const view = show([
|
||||||
|
connection,
|
||||||
|
{ ...connection, id: 'second', displayName: 'second' },
|
||||||
|
])
|
||||||
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
|
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 2件' }))
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: 'second · Twitter' }))
|
||||||
fireEvent.change(screen.getByLabelText('メッセージ'), {
|
fireEvent.change(screen.getByLabelText('メッセージ'), {
|
||||||
target: { value: 'WebMCPの反応' },
|
target: { value: 'WebMCPの反応' },
|
||||||
})
|
})
|
||||||
@@ -110,6 +118,15 @@ it('retains input and uses the same request ID when retrying a failed start', as
|
|||||||
)
|
)
|
||||||
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応')
|
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応')
|
||||||
expect(screen.queryByText(/internal credential/)).toBeNull()
|
expect(screen.queryByText(/internal credential/)).toBeNull()
|
||||||
|
view.rerender(view.ui(true, undefined, 'mobile'))
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('入力内容は残っています')
|
||||||
|
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 1件' }))
|
||||||
|
expect(
|
||||||
|
screen.getByRole('checkbox', { name: 'second · Twitter' }),
|
||||||
|
).not.toBeChecked()
|
||||||
|
expect(MockEventSource.instances).toHaveLength(1)
|
||||||
|
expect(MockEventSource.instances[0]?.close).not.toHaveBeenCalled()
|
||||||
fireEvent.click(screen.getByRole('button', { name: '送信' }))
|
fireEvent.click(screen.getByRole('button', { name: '送信' }))
|
||||||
await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(2))
|
await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(2))
|
||||||
expect(actions.start.mock.calls[1]?.[0]).toEqual(
|
expect(actions.start.mock.calls[1]?.[0]).toEqual(
|
||||||
@@ -197,9 +214,13 @@ it('limits account selection to six and shows the host report path on completion
|
|||||||
)
|
)
|
||||||
await screen.findByText('/home/owner/reports/webmcp.md')
|
await screen.findByText('/home/owner/reports/webmcp.md')
|
||||||
fireEvent.click(screen.getByText('接続アカウント · 6件'))
|
fireEvent.click(screen.getByText('接続アカウント · 6件'))
|
||||||
expect(screen.getByLabelText('account6 · Twitter')).toBeDisabled()
|
expect(
|
||||||
fireEvent.click(screen.getByLabelText('account0 · Twitter'))
|
screen.getByRole('checkbox', { name: 'account6 · Twitter' }),
|
||||||
expect(screen.getByLabelText('account6 · Twitter')).toBeEnabled()
|
).toHaveAttribute('aria-disabled', 'true')
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: 'account0 · Twitter' }))
|
||||||
|
expect(
|
||||||
|
screen.getByRole('checkbox', { name: 'account6 · Twitter' }),
|
||||||
|
).not.toHaveAttribute('aria-disabled', 'true')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('waits for the workspace before restoring a completed plan from the stream', async () => {
|
it('waits for the workspace before restoring a completed plan from the stream', async () => {
|
||||||
@@ -369,6 +390,8 @@ it('shows streamed conversation and sends follow-up input with the current deck
|
|||||||
|
|
||||||
it('selects newly loaded connected accounts until the user changes their selection', async () => {
|
it('selects newly loaded connected accounts until the user changes their selection', async () => {
|
||||||
const view = show([])
|
const view = show([])
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 0件' }))
|
||||||
await screen.findByText('接続アカウントを先に追加してください。')
|
await screen.findByText('接続アカウントを先に追加してください。')
|
||||||
view.rerender(
|
view.rerender(
|
||||||
<QueryClientProvider client={view.client}>
|
<QueryClientProvider client={view.client}>
|
||||||
@@ -595,9 +618,15 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
|
|||||||
expect(screen.getByLabelText('メッセージ')).toHaveValue('')
|
expect(screen.getByLabelText('メッセージ')).toHaveValue('')
|
||||||
expect(screen.getByText('中断しました・続きから再開できます')).toBeVisible()
|
expect(screen.getByText('中断しました・続きから再開できます')).toBeVisible()
|
||||||
fireEvent.click(screen.getByText('接続アカウント · 1件'))
|
fireEvent.click(screen.getByText('接続アカウント · 1件'))
|
||||||
expect(screen.getByLabelText('調査用アカウント · Twitter')).not.toBeChecked()
|
expect(
|
||||||
expect(screen.getByLabelText('account2 · Twitter')).toBeChecked()
|
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
|
||||||
fireEvent.click(screen.getByLabelText('調査用アカウント · Twitter'))
|
).not.toBeChecked()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('checkbox', { name: 'account2 · Twitter' }),
|
||||||
|
).toBeChecked()
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
|
||||||
|
)
|
||||||
act(() =>
|
act(() =>
|
||||||
MockEventSource.instances[0]?.onmessage?.({
|
MockEventSource.instances[0]?.onmessage?.({
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
@@ -612,7 +641,9 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
|
|||||||
run: { message: '更新' },
|
run: { message: '更新' },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(screen.getByLabelText('調査用アカウント · Twitter')).toBeChecked()
|
expect(
|
||||||
|
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
|
||||||
|
).toBeChecked()
|
||||||
expect(view.onOpenDeck).toHaveBeenCalledOnce()
|
expect(view.onOpenDeck).toHaveBeenCalledOnce()
|
||||||
fireEvent.change(screen.getByLabelText('メッセージ'), {
|
fireEvent.change(screen.getByLabelText('メッセージ'), {
|
||||||
target: { value: '続きを調べて' },
|
target: { value: '続きを調べて' },
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useServerFn } from '@tanstack/react-start'
|
import { useServerFn } from '@tanstack/react-start'
|
||||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { Bubble, BubbleContent } from '#/components/ui/bubble'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { Checkbox } from '#/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '#/components/ui/collapsible'
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupTextarea,
|
||||||
|
} from '#/components/ui/input-group'
|
||||||
|
import { Marker, MarkerContent } from '#/components/ui/marker'
|
||||||
|
import { Message, MessageContent, MessageHeader } from '#/components/ui/message'
|
||||||
|
import {
|
||||||
|
MessageScroller,
|
||||||
|
MessageScrollerButton,
|
||||||
|
MessageScrollerContent,
|
||||||
|
MessageScrollerItem,
|
||||||
|
MessageScrollerProvider,
|
||||||
|
MessageScrollerViewport,
|
||||||
|
} from '#/components/ui/message-scroller'
|
||||||
|
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||||
import type { Connection } from '../connections/model'
|
import type { Connection } from '../connections/model'
|
||||||
import type { Deck } from '../decks/model'
|
import type { Deck } from '../decks/model'
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +60,12 @@ const statusNames: Record<ResearchRun['status'], string> = {
|
|||||||
interrupted: '中断しました・続きから再開できます',
|
interrupted: '中断しました・続きから再開できます',
|
||||||
}
|
}
|
||||||
type ResearchInput = { topic: string; connectionIds: string[] }
|
type ResearchInput = { topic: string; connectionIds: string[] }
|
||||||
|
type ResearchDraft = {
|
||||||
|
runId: string | null
|
||||||
|
topic: string
|
||||||
|
selected: string[] | null
|
||||||
|
}
|
||||||
|
type RenderResearchPanel = (onCitationNavigated?: () => void) => ReactNode
|
||||||
|
|
||||||
export function ResearchPanel({
|
export function ResearchPanel({
|
||||||
ready,
|
ready,
|
||||||
@@ -35,12 +73,14 @@ export function ResearchPanel({
|
|||||||
connections,
|
connections,
|
||||||
onOpenDeck,
|
onOpenDeck,
|
||||||
onNavigateCitation,
|
onNavigateCitation,
|
||||||
|
children,
|
||||||
}: {
|
}: {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
contextDeck: Deck
|
contextDeck: Deck
|
||||||
connections: Connection[]
|
connections: Connection[]
|
||||||
onOpenDeck: (deck: Deck, activate?: boolean) => unknown
|
onOpenDeck: (deck: Deck, activate?: boolean) => unknown
|
||||||
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean
|
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean
|
||||||
|
children?: (renderPanel: RenderResearchPanel) => ReactNode
|
||||||
}) {
|
}) {
|
||||||
const fetchStatus = useServerFn(loadResearchStatus)
|
const fetchStatus = useServerFn(loadResearchStatus)
|
||||||
const start = useServerFn(startResearch)
|
const start = useServerFn(startResearch)
|
||||||
@@ -48,16 +88,33 @@ export function ResearchPanel({
|
|||||||
const reset = useServerFn(resetResearch)
|
const reset = useServerFn(resetResearch)
|
||||||
const select = useServerFn(selectResearch)
|
const select = useServerFn(selectResearch)
|
||||||
const client = useQueryClient()
|
const client = useQueryClient()
|
||||||
|
const historyId = useId()
|
||||||
const status = useQuery({
|
const status = useQuery({
|
||||||
queryKey: ['research-status'],
|
queryKey: ['research-status'],
|
||||||
queryFn: () => fetchStatus(),
|
queryFn: () => fetchStatus(),
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
const run = status.data?.run ?? null
|
const run = status.data?.run ?? null
|
||||||
|
const runId = run?.id ?? null
|
||||||
|
const [draft, setDraft] = useState<ResearchDraft>({
|
||||||
|
runId,
|
||||||
|
topic: '',
|
||||||
|
selected: run?.connectionIds ?? null,
|
||||||
|
})
|
||||||
|
// Conversation changes reset the draft; responsive view remounts do not.
|
||||||
|
if (draft.runId !== runId) {
|
||||||
|
setDraft({ runId, topic: '', selected: run?.connectionIds ?? null })
|
||||||
|
}
|
||||||
|
function updateDraft(
|
||||||
|
update: Partial<Pick<ResearchDraft, 'topic' | 'selected'>>,
|
||||||
|
) {
|
||||||
|
setDraft((current) =>
|
||||||
|
current.runId === runId ? { ...current, ...update } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string>()
|
const [error, setError] = useState<string>()
|
||||||
const [reconnecting, setReconnecting] = useState(false)
|
const [reconnecting, setReconnecting] = useState(false)
|
||||||
const [draftResetVersion, setDraftResetVersion] = useState(0)
|
|
||||||
const previousRunId = useRef<string | null>(null)
|
const previousRunId = useRef<string | null>(null)
|
||||||
const syncedRunId = useRef<string | null>(null)
|
const syncedRunId = useRef<string | null>(null)
|
||||||
const attemptedVersion = useRef<string | null>(null)
|
const attemptedVersion = useRef<string | null>(null)
|
||||||
@@ -79,7 +136,6 @@ export function ResearchPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previousRunId.current && previousRunId.current !== run?.id) {
|
if (previousRunId.current && previousRunId.current !== run?.id) {
|
||||||
setDraftResetVersion((value) => value + 1)
|
|
||||||
request.current = null
|
request.current = null
|
||||||
pendingTurn.current = null
|
pendingTurn.current = null
|
||||||
}
|
}
|
||||||
@@ -209,7 +265,7 @@ export function ResearchPanel({
|
|||||||
)
|
)
|
||||||
request.current = null
|
request.current = null
|
||||||
pendingTurn.current = null
|
pendingTurn.current = null
|
||||||
setDraftResetVersion((value) => value + 1)
|
updateDraft({ topic: '' })
|
||||||
} catch {
|
} catch {
|
||||||
setError(
|
setError(
|
||||||
'新しいチャットを開始できませんでした。会話と入力内容は残っています。もう一度お試しください。',
|
'新しいチャットを開始できませんでした。会話と入力内容は残っています。もう一度お試しください。',
|
||||||
@@ -252,11 +308,13 @@ export function ResearchPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
|
||||||
<aside className="research-panel" aria-label="調査チャット">
|
<aside className="research-panel" aria-label="調査チャット">
|
||||||
<header className="research-heading">
|
<header className="research-heading">
|
||||||
<h2>調査チャット</h2>
|
<h2>調査チャット</h2>
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy || !run || isResearchActive(run)}
|
disabled={busy || !run || isResearchActive(run)}
|
||||||
title={
|
title={
|
||||||
@@ -267,24 +325,26 @@ export function ResearchPanel({
|
|||||||
onClick={() => void newChat()}
|
onClick={() => void newChat()}
|
||||||
>
|
>
|
||||||
新しいチャット
|
新しいチャット
|
||||||
</button>
|
</Button>
|
||||||
{(status.data?.history?.length ?? 0) > 0 && (
|
{(status.data?.history?.length ?? 0) > 0 && (
|
||||||
<label className="research-history">
|
<label className="research-history" htmlFor={historyId}>
|
||||||
<span>チャット履歴</span>
|
<span>チャット履歴</span>
|
||||||
<select
|
<NativeSelect
|
||||||
|
id={historyId}
|
||||||
|
size="sm"
|
||||||
value={run?.id ?? ''}
|
value={run?.id ?? ''}
|
||||||
disabled={busy || isResearchActive(run)}
|
disabled={busy || isResearchActive(run)}
|
||||||
onChange={(event) => void selectChat(event.target.value)}
|
onChange={(event) => void selectChat(event.target.value)}
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<NativeSelectOption value="" disabled>
|
||||||
新しいチャット
|
新しいチャット
|
||||||
</option>
|
</NativeSelectOption>
|
||||||
{status.data?.history?.map((entry) => (
|
{status.data?.history?.map((entry) => (
|
||||||
<option key={entry.id} value={entry.id}>
|
<NativeSelectOption key={entry.id} value={entry.id}>
|
||||||
{entry.title}
|
{entry.title}
|
||||||
</option>
|
</NativeSelectOption>
|
||||||
))}
|
))}
|
||||||
</select>
|
</NativeSelect>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
@@ -296,9 +356,9 @@ export function ResearchPanel({
|
|||||||
{status.isError && (
|
{status.isError && (
|
||||||
<p className="deck-error" role="alert">
|
<p className="deck-error" role="alert">
|
||||||
調査状況を取得できませんでした。
|
調査状況を取得できませんでした。
|
||||||
<button type="button" onClick={() => void status.refetch()}>
|
<Button type="button" onClick={() => void status.refetch()}>
|
||||||
再試行
|
再試行
|
||||||
</button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
@@ -315,7 +375,11 @@ export function ResearchPanel({
|
|||||||
key={`conversation:${run?.id ?? 'new'}`}
|
key={`conversation:${run?.id ?? 'new'}`}
|
||||||
messages={run?.messages ?? []}
|
messages={run?.messages ?? []}
|
||||||
citations={run?.citations ?? []}
|
citations={run?.citations ?? []}
|
||||||
onNavigateCitation={onNavigateCitation}
|
onNavigateCitation={(url, citations) => {
|
||||||
|
const navigated = onNavigateCitation?.(url, citations) ?? false
|
||||||
|
if (navigated) onCitationNavigated?.()
|
||||||
|
return navigated
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{run && (
|
{run && (
|
||||||
<ResearchRunStatus
|
<ResearchRunStatus
|
||||||
@@ -327,8 +391,10 @@ export function ResearchPanel({
|
|||||||
)}
|
)}
|
||||||
<ResearchComposer
|
<ResearchComposer
|
||||||
key={run?.id ?? 'new'}
|
key={run?.id ?? 'new'}
|
||||||
initialConnectionIds={run?.connectionIds}
|
topic={draft.topic}
|
||||||
resetVersion={draftResetVersion}
|
selected={draft.selected}
|
||||||
|
onTopicChange={(topic) => updateDraft({ topic })}
|
||||||
|
onSelectionChange={(selected) => updateDraft({ selected })}
|
||||||
connections={connections}
|
connections={connections}
|
||||||
contextTitle={contextDeck.title}
|
contextTitle={contextDeck.title}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -338,6 +404,7 @@ export function ResearchPanel({
|
|||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
return children ? children(renderPanel) : renderPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResearchConversation({
|
function ResearchConversation({
|
||||||
@@ -349,39 +416,39 @@ function ResearchConversation({
|
|||||||
citations: ResearchCitation[]
|
citations: ResearchCitation[]
|
||||||
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean
|
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean
|
||||||
}) {
|
}) {
|
||||||
const container = useRef<HTMLDivElement>(null)
|
|
||||||
const follow = useRef(true)
|
|
||||||
useEffect(() => {
|
|
||||||
const element = container.current
|
|
||||||
if (element && follow.current && messages.length > 0)
|
|
||||||
element.scrollTop = element.scrollHeight
|
|
||||||
}, [messages])
|
|
||||||
const names = { user: 'あなた', assistant: 'Codex', tool: 'ツール実行' }
|
const names = { user: 'あなた', assistant: 'Codex', tool: 'ツール実行' }
|
||||||
return (
|
return (
|
||||||
<div
|
<MessageScrollerProvider autoScroll>
|
||||||
className="research-conversation"
|
<MessageScroller className="research-conversation">
|
||||||
role="log"
|
<MessageScrollerViewport aria-label="調査メッセージ">
|
||||||
aria-label="調査の会話"
|
<MessageScrollerContent aria-label="調査の会話" className="gap-5 p-4">
|
||||||
aria-live="polite"
|
|
||||||
ref={container}
|
|
||||||
onScroll={() => {
|
|
||||||
const element = container.current
|
|
||||||
if (element)
|
|
||||||
follow.current =
|
|
||||||
element.scrollHeight - element.scrollTop - element.clientHeight < 60
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
|
<MessageScrollerItem messageId="empty">
|
||||||
<p className="research-empty">
|
<p className="research-empty">
|
||||||
気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。
|
気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。
|
||||||
</p>
|
</p>
|
||||||
|
</MessageScrollerItem>
|
||||||
) : (
|
) : (
|
||||||
messages.map((message) => (
|
messages.map((message) => (
|
||||||
<article
|
<MessageScrollerItem
|
||||||
className={`research-message research-message-${message.role}`}
|
|
||||||
key={message.id}
|
key={message.id}
|
||||||
|
messageId={message.id}
|
||||||
|
scrollAnchor={message.role === 'user'}
|
||||||
>
|
>
|
||||||
<strong>{names[message.role]}</strong>
|
{message.role === 'tool' ? (
|
||||||
|
<Marker className="research-tool-message">
|
||||||
|
<MarkerContent>
|
||||||
|
<span className="sr-only">{names.tool}: </span>
|
||||||
|
{message.text}
|
||||||
|
</MarkerContent>
|
||||||
|
</Marker>
|
||||||
|
) : (
|
||||||
|
<Message
|
||||||
|
align={message.role === 'user' ? 'end' : 'start'}
|
||||||
|
className="research-message"
|
||||||
|
>
|
||||||
|
<MessageContent>
|
||||||
|
<MessageHeader>{names[message.role]}</MessageHeader>
|
||||||
{message.role === 'assistant' ? (
|
{message.role === 'assistant' ? (
|
||||||
<ResearchMarkdown
|
<ResearchMarkdown
|
||||||
text={message.text}
|
text={message.text}
|
||||||
@@ -390,25 +457,40 @@ function ResearchConversation({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<Bubble align="end">
|
||||||
|
<BubbleContent>
|
||||||
<p>{message.text}</p>
|
<p>{message.text}</p>
|
||||||
|
</BubbleContent>
|
||||||
|
</Bubble>
|
||||||
)}
|
)}
|
||||||
</article>
|
</MessageContent>
|
||||||
|
</Message>
|
||||||
|
)}
|
||||||
|
</MessageScrollerItem>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</MessageScrollerContent>
|
||||||
|
</MessageScrollerViewport>
|
||||||
|
<MessageScrollerButton aria-label="最新のメッセージへ" />
|
||||||
|
</MessageScroller>
|
||||||
|
</MessageScrollerProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResearchComposer({
|
function ResearchComposer({
|
||||||
initialConnectionIds,
|
topic,
|
||||||
resetVersion,
|
selected,
|
||||||
|
onTopicChange,
|
||||||
|
onSelectionChange,
|
||||||
connections,
|
connections,
|
||||||
contextTitle,
|
contextTitle,
|
||||||
disabled,
|
disabled,
|
||||||
onSend,
|
onSend,
|
||||||
}: {
|
}: {
|
||||||
initialConnectionIds?: string[]
|
topic: string
|
||||||
resetVersion: number
|
selected: string[] | null
|
||||||
|
onTopicChange: (topic: string) => void
|
||||||
|
onSelectionChange: (selected: string[]) => void
|
||||||
connections: Connection[]
|
connections: Connection[]
|
||||||
contextTitle: string
|
contextTitle: string
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
@@ -417,13 +499,6 @@ function ResearchComposer({
|
|||||||
const available = connections.filter(
|
const available = connections.filter(
|
||||||
(connection) => connection.status === 'connected',
|
(connection) => connection.status === 'connected',
|
||||||
)
|
)
|
||||||
const [topic, setTopic] = useState('')
|
|
||||||
useEffect(() => {
|
|
||||||
if (resetVersion > 0) setTopic('')
|
|
||||||
}, [resetVersion])
|
|
||||||
const [selected, setSelected] = useState<string[] | null>(
|
|
||||||
initialConnectionIds ?? null,
|
|
||||||
)
|
|
||||||
const selectedIds =
|
const selectedIds =
|
||||||
selected ?? available.slice(0, 6).map((connection) => connection.id)
|
selected ?? available.slice(0, 6).map((connection) => connection.id)
|
||||||
const id = useId()
|
const id = useId()
|
||||||
@@ -441,7 +516,7 @@ function ResearchComposer({
|
|||||||
topic: topic.trim(),
|
topic: topic.trim(),
|
||||||
connectionIds: validSelection,
|
connectionIds: validSelection,
|
||||||
}).then((sent) => {
|
}).then((sent) => {
|
||||||
if (sent) setTopic('')
|
if (sent) onTopicChange('')
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -449,25 +524,29 @@ function ResearchComposer({
|
|||||||
送信するデッキ: <strong>{contextTitle}</strong>
|
送信するデッキ: <strong>{contextTitle}</strong>
|
||||||
</p>
|
</p>
|
||||||
<fieldset disabled={disabled}>
|
<fieldset disabled={disabled}>
|
||||||
<details className="research-account-picker">
|
<Collapsible className="research-account-picker" disabled={disabled}>
|
||||||
<summary>接続アカウント · {validSelection.length}件</summary>
|
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
|
||||||
|
接続アカウント · {validSelection.length}件
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
<fieldset className="research-accounts">
|
<fieldset className="research-accounts">
|
||||||
<legend>調査に使う接続アカウント(最大6件)</legend>
|
<legend>調査に使う接続アカウント(最大6件)</legend>
|
||||||
{available.length === 0 ? (
|
{available.length === 0 ? (
|
||||||
<p>接続アカウントを先に追加してください。</p>
|
<p>接続アカウントを先に追加してください。</p>
|
||||||
) : (
|
) : (
|
||||||
available.map((connection) => (
|
available.map((connection) => (
|
||||||
<label key={connection.id}>
|
<label key={connection.id} htmlFor={`${id}-${connection.id}`}>
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
id={`${id}-${connection.id}`}
|
||||||
checked={selectedIds.includes(connection.id)}
|
checked={selectedIds.includes(connection.id)}
|
||||||
disabled={
|
disabled={
|
||||||
!selectedIds.includes(connection.id) &&
|
disabled ||
|
||||||
validSelection.length >= 6
|
(!selectedIds.includes(connection.id) &&
|
||||||
|
validSelection.length >= 6)
|
||||||
}
|
}
|
||||||
onChange={(event) =>
|
onCheckedChange={(checked) =>
|
||||||
setSelected(
|
onSelectionChange(
|
||||||
event.target.checked
|
checked
|
||||||
? [...selectedIds, connection.id]
|
? [...selectedIds, connection.id]
|
||||||
: selectedIds.filter(
|
: selectedIds.filter(
|
||||||
(value) => value !== connection.id,
|
(value) => value !== connection.id,
|
||||||
@@ -481,24 +560,33 @@ function ResearchComposer({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</details>
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
<label htmlFor={id}>メッセージ</label>
|
<label htmlFor={id}>メッセージ</label>
|
||||||
<textarea
|
<InputGroup>
|
||||||
|
<InputGroupTextarea
|
||||||
id={id}
|
id={id}
|
||||||
value={topic}
|
value={topic}
|
||||||
onChange={(event) => setTopic(event.target.value)}
|
onChange={(event) => onTopicChange(event.target.value)}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。"
|
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。"
|
||||||
required
|
required
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<button
|
<InputGroupAddon align="block-end" className="justify-end">
|
||||||
|
<InputGroupButton
|
||||||
type="submit"
|
type="submit"
|
||||||
className="deck-primary"
|
variant="default"
|
||||||
disabled={disabled || !topic.trim() || validSelection.length === 0}
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
disabled || !topic.trim() || validSelection.length === 0
|
||||||
|
}
|
||||||
>
|
>
|
||||||
送信
|
送信
|
||||||
</button>
|
</InputGroupButton>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
@@ -518,7 +606,9 @@ function ResearchRunStatus({
|
|||||||
const deck = run.deck ?? run.contextDeck
|
const deck = run.deck ?? run.contextDeck
|
||||||
return (
|
return (
|
||||||
<section className="research-run" aria-label="調査状況">
|
<section className="research-run" aria-label="調査状況">
|
||||||
<p role="status">{statusNames[run.status]}</p>
|
<Marker role="status">
|
||||||
|
<MarkerContent>{statusNames[run.status]}</MarkerContent>
|
||||||
|
</Marker>
|
||||||
{run.error && (
|
{run.error && (
|
||||||
<p className="deck-error" role="alert">
|
<p className="deck-error" role="alert">
|
||||||
{run.error}
|
{run.error}
|
||||||
@@ -533,19 +623,27 @@ function ResearchRunStatus({
|
|||||||
)}
|
)}
|
||||||
<div className="deck-actions">
|
<div className="deck-actions">
|
||||||
{isResearchActive(run) && (
|
{isResearchActive(run) && (
|
||||||
<button type="button" disabled={busy} onClick={onCancel}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
調査を中止
|
調査を中止
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{deck && (
|
{deck && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onOpenDeck(deck)
|
onOpenDeck(deck)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
一時デッキで開く
|
一時デッキで開く
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
|
mql.addEventListener('change', onChange)
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
return () => mql.removeEventListener('change', onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return !!isMobile
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ function RootContent() {
|
|||||||
|
|
||||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="ja">
|
<html lang="ja" className="dark">
|
||||||
<head>
|
<head>
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+18
-61
@@ -1,4 +1,5 @@
|
|||||||
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
|
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: neutral */
|
||||||
|
@import "./ui.css";
|
||||||
@import "../tokens.css";
|
@import "../tokens.css";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -6,10 +7,7 @@
|
|||||||
--surface: var(--color-paper-2);
|
--surface: var(--color-paper-2);
|
||||||
--paper: var(--color-paper);
|
--paper: var(--color-paper);
|
||||||
--ink: var(--color-ink);
|
--ink: var(--color-ink);
|
||||||
--secondary: var(--color-muted);
|
|
||||||
--accent: var(--color-accent);
|
|
||||||
--accent-hover: var(--color-accent-hover);
|
--accent-hover: var(--color-accent-hover);
|
||||||
--border: var(--color-rule);
|
|
||||||
--control-border: var(--color-control-rule);
|
--control-border: var(--color-control-rule);
|
||||||
--error: var(--color-error);
|
--error: var(--color-error);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -37,7 +35,7 @@ select {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
a {
|
a {
|
||||||
color: var(--accent);
|
color: var(--primary);
|
||||||
text-underline-offset: 0.18em;
|
text-underline-offset: 0.18em;
|
||||||
}
|
}
|
||||||
a:active {
|
a:active {
|
||||||
@@ -47,47 +45,6 @@ a:active {
|
|||||||
outline: var(--rule-active) solid var(--color-focus);
|
outline: var(--rule-active) solid var(--color-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
.app {
|
|
||||||
width: 100%;
|
|
||||||
height: 100dvh;
|
|
||||||
min-height: 0;
|
|
||||||
background: var(--canvas);
|
|
||||||
}
|
|
||||||
.app-deck {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 188px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
.app-sidebar {
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
padding: var(--space-md) var(--space-sm);
|
|
||||||
border-right: var(--rule-hairline) solid var(--border);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.sidebar-note {
|
|
||||||
margin-top: auto;
|
|
||||||
margin-bottom: 0;
|
|
||||||
color: var(--secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.reader {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: var(--space-sm);
|
|
||||||
padding: var(--space-xs);
|
|
||||||
border: var(--rule-hairline) solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
background: var(--paper);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -99,24 +56,24 @@ h1 {
|
|||||||
}
|
}
|
||||||
.handle {
|
.handle {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--secondary);
|
color: var(--muted-foreground);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
@media (max-width: 767px) {
|
|
||||||
.app-deck {
|
.workspace .reader {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
overflow: hidden;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
.app-sidebar {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding: var(--space-xs);
|
|
||||||
overflow: visible;
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: var(--rule-hairline) solid var(--border);
|
|
||||||
}
|
}
|
||||||
.sidebar-note {
|
.sidebar-note {
|
||||||
display: none;
|
color: var(--muted-foreground);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
/* biome-ignore lint/complexity/noImportantStyles: Respect reduced motion over registry component animation utilities. */
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
/* biome-ignore lint/complexity/noImportantStyles: Respect reduced motion over registry component transition utilities. */
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
/* Hallmark · macrostructure: sidebar-09 Workbench · tone: compact · anchor hue: neutral */
|
||||||
|
/* Hallmark · pre-emit critique: P5 H4 E4 S5 R5 V4 · mobile: pass 320/375/414/768 */
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.922 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
--overlay: oklch(0 0 0 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: var(--font-body);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-overlay: var(--overlay);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-15
@@ -1,20 +1,20 @@
|
|||||||
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
|
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: neutral */
|
||||||
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
|
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
|
||||||
:root {
|
:root {
|
||||||
--color-canvas: oklch(19% 0.021 250);
|
--color-canvas: var(--background);
|
||||||
--color-paper: oklch(24% 0.025 250);
|
--color-paper: var(--background);
|
||||||
--color-paper-2: oklch(28% 0.027 250);
|
--color-paper-2: var(--card);
|
||||||
--color-paper-3: oklch(33% 0.031 250);
|
--color-paper-3: var(--muted);
|
||||||
--color-ink: oklch(95% 0.005 250);
|
--color-ink: var(--foreground);
|
||||||
--color-ink-2: oklch(85% 0.015 250);
|
--color-ink-2: var(--foreground);
|
||||||
--color-muted: oklch(72% 0.025 250);
|
--color-muted: var(--muted-foreground);
|
||||||
--color-accent: oklch(72% 0.15 240);
|
--color-accent: var(--primary);
|
||||||
--color-accent-hover: oklch(79% 0.13 240);
|
--color-accent-hover: var(--foreground);
|
||||||
--color-accent-ink: oklch(18% 0.03 250);
|
--color-accent-ink: var(--primary-foreground);
|
||||||
--color-rule: oklch(34% 0.023 250);
|
--color-rule: var(--border);
|
||||||
--color-control-rule: oklch(47% 0.029 250);
|
--color-control-rule: var(--input);
|
||||||
--color-focus: oklch(79% 0.13 240);
|
--color-focus: var(--ring);
|
||||||
--color-error: oklch(78% 0.13 20);
|
--color-error: var(--destructive);
|
||||||
--color-success: oklch(78% 0.13 155);
|
--color-success: oklch(78% 0.13 155);
|
||||||
--color-shadow: oklch(10% 0.015 250 / 45%);
|
--color-shadow: oklch(10% 0.015 250 / 45%);
|
||||||
--color-overlay: oklch(10% 0.015 250 / 70%);
|
--color-overlay: oklch(10% 0.015 250 / 70%);
|
||||||
|
|||||||
+2
-1
@@ -1,8 +1,9 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||||
import viteReact from '@vitejs/plugin-react'
|
import viteReact from '@vitejs/plugin-react'
|
||||||
import { nitro } from 'nitro/vite'
|
import { nitro } from 'nitro/vite'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [nitro(), tanstackStart(), viteReact()],
|
plugins: [tailwindcss(), nitro(), tanstackStart(), viteReact()],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user