Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ee5d181a5 | ||
|
|
4e8957c94a | ||
|
|
96e545401e |
@@ -1,4 +1,28 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-09-28",
|
||||||
|
"macrostructure": "communication workbench and append-only journal",
|
||||||
|
"theme": "shadcn base-nova neutral",
|
||||||
|
"scope": "support",
|
||||||
|
"enrichment": "none",
|
||||||
|
"brief": "Compact execution support integrated with common sidebar, editable reply review and journal corrections"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-09-28",
|
||||||
|
"macrostructure": "sidebar-09 Inbox",
|
||||||
|
"theme": "shadcn base-nova neutral",
|
||||||
|
"scope": "inbox",
|
||||||
|
"enrichment": "none",
|
||||||
|
"brief": "Shared navigation with Twitter Lite, inferred-interest article list and reading pane; no manual interest settings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
@@ -23,7 +23,19 @@ bound to a connection account, so platforms and multiple accounts work side by s
|
|||||||
- Saved chat history with deck, account and citation restoration across devices
|
- Saved chat history with deck, account and citation restoration across devices
|
||||||
and backend restarts
|
and backend restarts
|
||||||
|
|
||||||
Both `/` and `/deck` open the deck workspace. The separate reader, search,
|
Both `/` and `/deck` open the deck workspace. A shared sidebar also opens
|
||||||
|
the article reader at `/inbox`. This UI-only prototype shows articles inferred
|
||||||
|
from past reading, searches, and research, with no manual interest settings.
|
||||||
|
Articles are fictional samples; search and article selection work locally.
|
||||||
|
On mobile, the article list and reader switch views. The shared sidebar’s bottom
|
||||||
|
avatar opens connected-account management from either workspace.
|
||||||
|
|
||||||
|
The shared sidebar also contains `/support` (communication support) and `/journal`
|
||||||
|
(notes and health records). These use fictional conversations and local session
|
||||||
|
state to preview reply review, scheduling, deferral, recording, and corrections.
|
||||||
|
See [execution support](docs/execution-support.md) for scope and limitations.
|
||||||
|
|
||||||
|
The former SNS reader, search,
|
||||||
list, user-profile, and conversation routes have been removed. Original-post
|
list, user-profile, and conversation routes have been removed. Original-post
|
||||||
links on post cards open their source site; SNS reply threads are not rendered
|
links on post cards open their source site; SNS reply threads are not rendered
|
||||||
inside the app.
|
inside the app.
|
||||||
@@ -66,6 +78,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 +178,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,34 @@
|
|||||||
|
# Execution support prototype
|
||||||
|
|
||||||
|
The shared sidebar opens `/support` (連絡) and `/journal` (記録), alongside
|
||||||
|
リーダー and Twitter Lite. These are UI-only prototypes with fictional contacts
|
||||||
|
and in-memory state. Reloading or leaving the route resets the prototype state.
|
||||||
|
No messages, calendar events, medical records, or Rensheng pages are written.
|
||||||
|
|
||||||
|
## Reference and scope
|
||||||
|
|
||||||
|
The functional reference is the user's life brief
|
||||||
|
`briefs/2026-09-25-executive-support-design.md`, together with the public
|
||||||
|
[Beeper Copilot concept](https://beeper-copilot-lp.vercel.app/).
|
||||||
|
The prototype adopts the execution flows, not private facts from the life repo
|
||||||
|
or the reference site's deployment and privacy claims.
|
||||||
|
|
||||||
|
- Communication: unified conversations, a maximum of three initial actions,
|
||||||
|
deferral, waiting, dismissal, and a full list; source messages, summaries,
|
||||||
|
editable reply styles, explicit review, and simulated sending.
|
||||||
|
- Scheduling: choosing a candidate and reviewing a simulated calendar entry.
|
||||||
|
- Context: person notes with links back to the sample conversation and a local
|
||||||
|
record of the simulated outcome. No permanent trait or goal is inferred.
|
||||||
|
- Journal: free notes, resumption points, user-entered medication and weight
|
||||||
|
records; occurrence and recording times, append-only corrections, and a
|
||||||
|
selectable summary preview for a consultation.
|
||||||
|
|
||||||
|
The intended memory boundary remains Rensheng: execution events can eventually
|
||||||
|
be compiled into durable context after source comparison and user correction.
|
||||||
|
This prototype neither implements that compiler nor creates a second personal
|
||||||
|
context database. Missing records remain unknown; there are no medication,
|
||||||
|
dose, weight-target, or treatment recommendations.
|
||||||
|
|
||||||
|
The UI uses existing shadcn Base UI components and neutral theme tokens.
|
||||||
|
Shared account management continues to use the existing connection system;
|
||||||
|
the new communication and journal prototypes do not consume those accounts.
|
||||||
@@ -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.
|
||||||
+11
-6
@@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
## 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` shares its navigation rail with the article reader
|
||||||
independently; only the active deck mounts and fetches its columns. On mobile,
|
at `/inbox`. Within Twitter Lite, it switches between research chat and deck management.
|
||||||
the sidebar becomes a compact top bar. Native dialogs handle creation and
|
Deck management switches decks and adds columns. The avatar at the bottom of the
|
||||||
editing, with Escape and focus restoration. Column menus offer editing,
|
shared sidebar opens connected-account management in a dialog from either workspace.
|
||||||
ordering, and deletion; refresh and pagination are manual.
|
Up to six ordered columns scroll independently; only the active deck
|
||||||
|
mounts and fetches its columns. On mobile, the sidebar opens as a sheet.
|
||||||
|
shadcn Base UI dialogs handle creation and editing, with Escape and focus
|
||||||
|
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,
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ test('opens account management after hydration without inheriting real OAuth con
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
|
||||||
|
).toBeEnabled()
|
||||||
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
|
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
|
||||||
|
if (!(await manage.isVisible())) {
|
||||||
|
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
|
||||||
|
}
|
||||||
await expect(manage).toBeEnabled()
|
await expect(manage).toBeEnabled()
|
||||||
await manage.click()
|
await manage.click()
|
||||||
const dialog = page.getByRole('dialog', { name: '接続アカウント' })
|
const dialog = page.getByRole('dialog', { name: '接続アカウント' })
|
||||||
@@ -20,6 +26,27 @@ test('opens account management after hydration without inheriting real OAuth con
|
|||||||
await expect(manage).toBeFocused()
|
await expect(manage).toBeFocused()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('opens account management from the inbox footer and restores focus on close', async ({
|
||||||
|
page,
|
||||||
|
a11y,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/inbox')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
|
||||||
|
await expect(manage).toBeVisible()
|
||||||
|
await manage.click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: '接続アカウント' })
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
dialog.getByText('利用するMastodonサーバーがまだ設定されていません。'),
|
||||||
|
).toBeVisible()
|
||||||
|
expect((await a11y().analyze()).violations).toEqual([])
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
await expect(manage).toBeFocused()
|
||||||
|
await expect(page.getByRole('textbox', { name: '記事を検索' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
test('rejects an unbound OAuth callback without contacting an instance', async ({
|
test('rejects an unbound OAuth callback without contacting an instance', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -28,6 +55,13 @@ 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 expect(
|
||||||
|
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
|
||||||
|
).toBeEnabled()
|
||||||
|
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
|
||||||
|
if (!(await manage.isVisible())) {
|
||||||
|
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
|
||||||
|
}
|
||||||
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(
|
||||||
|
'画面を回転しても残る調査',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { expect, test } from '../fixtures'
|
||||||
|
|
||||||
|
test('selects articles and keeps the reader focused on their content', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/inbox')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
const rows = page.locator('.inbox-article-row')
|
||||||
|
await expect(rows).toHaveCount(9)
|
||||||
|
const articleTitle = 'エージェントに渡す文脈は、短くてもよい'
|
||||||
|
await rows.filter({ hasText: articleTitle }).click()
|
||||||
|
const reader = page.getByRole('article', { name: articleTitle, exact: true })
|
||||||
|
await expect(reader).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
reader.getByRole('heading', { name: articleTitle, exact: true }),
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
reader.getByText(
|
||||||
|
'まず目的を一文で書き、次に変えてはいけない条件を添える。',
|
||||||
|
{ exact: false },
|
||||||
|
),
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(page.getByText('FOR YOU', { exact: true })).toHaveCount(0)
|
||||||
|
await expect(page.getByText('モック', { exact: true })).toHaveCount(0)
|
||||||
|
await expect(
|
||||||
|
page.getByText('この記事が届いた理由', { exact: true }),
|
||||||
|
).toHaveCount(0)
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: /あなたへ|あとで読む|未読のみ/ }),
|
||||||
|
).toHaveCount(0)
|
||||||
|
const back = page.getByRole('button', { name: '記事一覧に戻る' })
|
||||||
|
if (await back.isVisible()) await back.click()
|
||||||
|
await rows
|
||||||
|
.filter({ hasText: '3カラムの余白が、情報を読むリズムをつくる' })
|
||||||
|
.click()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('article', {
|
||||||
|
name: '3カラムの余白が、情報を読むリズムをつくる',
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recovers from empty search and shares navigation with Twitter Lite', async ({
|
||||||
|
page,
|
||||||
|
a11y,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/inbox')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
const rows = page.locator('.inbox-article-row')
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: '記事を検索' })
|
||||||
|
.fill('存在しない検索語xyz')
|
||||||
|
await expect(rows).toHaveCount(0)
|
||||||
|
await expect(
|
||||||
|
page.getByText('記事がありません', { exact: true }),
|
||||||
|
).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '検索をクリア' }).click()
|
||||||
|
await expect(rows).toHaveCount(9)
|
||||||
|
expect((await a11y().analyze()).violations).toEqual([])
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: '興味を編集', exact: true }),
|
||||||
|
).toHaveCount(0)
|
||||||
|
await page.getByRole('link', { name: 'Twitter Lite', exact: true }).click()
|
||||||
|
await expect(page).toHaveURL(/\/deck$/)
|
||||||
|
await page.getByRole('link', { name: 'リーダー', exact: true }).click()
|
||||||
|
await expect(page).toHaveURL(/\/inbox$/)
|
||||||
|
await expect(rows).toHaveCount(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps article navigation readable without horizontal overflow', async ({
|
||||||
|
page,
|
||||||
|
a11y,
|
||||||
|
}, testInfo) => {
|
||||||
|
await page.goto('/inbox')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
for (const width of [320, 375, 414, 768]) {
|
||||||
|
await page.setViewportSize({ width, height: 850 })
|
||||||
|
await expect(
|
||||||
|
page.getByRole('textbox', { name: '記事を検索' }),
|
||||||
|
).toBeVisible()
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollWidth),
|
||||||
|
).toBe(width)
|
||||||
|
await page.locator('.inbox-article-row').first().click()
|
||||||
|
const reader = page.getByRole('article', {
|
||||||
|
name: 'ブラウザを見ながら考える。AIエージェントとの新しい開発の進め方',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
await expect(reader).toBeVisible()
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollWidth),
|
||||||
|
).toBe(width)
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath(`inbox-reader-${width}.png`),
|
||||||
|
})
|
||||||
|
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
|
||||||
|
const back = page.getByRole('button', { name: '記事一覧に戻る' })
|
||||||
|
if (await back.isVisible()) await back.click()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { expect, test } from '../fixtures'
|
||||||
|
|
||||||
|
const reviewSubject = '来週のレビュー、日程を決める'
|
||||||
|
const coffeeSubject = '久しぶりに、お茶でも'
|
||||||
|
|
||||||
|
test('reviews an edited reply and keeps the simulated result in history', async ({
|
||||||
|
page,
|
||||||
|
a11y,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/support')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
const rows = page.locator('.support-row')
|
||||||
|
await expect(rows).toHaveCount(3)
|
||||||
|
await rows.filter({ hasText: reviewSubject }).click()
|
||||||
|
await page.getByRole('button', { name: '丁寧に', exact: true }).click()
|
||||||
|
const reply = page.getByRole('textbox', { name: '返信', exact: true })
|
||||||
|
await expect(reply).toHaveValue(/資料をご共有/)
|
||||||
|
const edited = '資料を確認しました。初回の導線について当日お話しします。'
|
||||||
|
await reply.fill(edited)
|
||||||
|
const slot = '10月5日(月)14:00–14:30'
|
||||||
|
await page.getByRole('combobox', { name: '日程候補' }).selectOption(slot)
|
||||||
|
await page.getByRole('button', { name: '返信に入れる' }).click()
|
||||||
|
await expect(reply).toHaveValue(`${edited}\n\n${slot}はいかがでしょうか。`)
|
||||||
|
await page.getByRole('button', { name: '送信内容を確認' }).click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: '送信内容の確認' })
|
||||||
|
await expect(dialog).toContainText(edited)
|
||||||
|
await expect(dialog).toContainText(slot)
|
||||||
|
await expect(dialog).toContainText('外部には送信されません')
|
||||||
|
expect((await a11y().analyze()).violations).toEqual([])
|
||||||
|
await dialog.getByRole('button', { name: '戻る', exact: true }).click()
|
||||||
|
await expect(reply).toHaveValue(new RegExp(edited))
|
||||||
|
await page.getByRole('button', { name: '送信内容を確認' }).click()
|
||||||
|
await dialog.getByRole('button', { name: '送信を試す' }).click()
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0)
|
||||||
|
await page.getByRole('button', { name: '履歴', exact: true }).click()
|
||||||
|
await rows.filter({ hasText: reviewSubject }).click()
|
||||||
|
await expect(page.locator('.support-outcome')).toContainText(edited)
|
||||||
|
await expect(page.locator('.support-outcome')).toContainText(slot)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('defers a conversation and restores it to the current queue', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/support')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
const rows = page.locator('.support-row')
|
||||||
|
await rows.filter({ hasText: coffeeSubject }).click()
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: '返信', exact: true })
|
||||||
|
.fill('土曜の予定を確認してから返事するね。')
|
||||||
|
await page.getByRole('button', { name: '今は置いておく' }).click()
|
||||||
|
await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0)
|
||||||
|
await page.getByRole('button', { name: 'あとで', exact: true }).click()
|
||||||
|
await rows.filter({ hasText: coffeeSubject }).click()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('textbox', { name: '返信', exact: true }),
|
||||||
|
).toHaveValue('土曜の予定を確認してから返事するね。')
|
||||||
|
await page.getByRole('button', { name: '今に戻す' }).click()
|
||||||
|
const back = page.getByRole('button', { name: '連絡一覧に戻る' })
|
||||||
|
if (await back.isVisible()) await back.click()
|
||||||
|
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: '今', exact: true }),
|
||||||
|
).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps corrections as new records and summarizes only selected current facts', async ({
|
||||||
|
page,
|
||||||
|
a11y,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/journal')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: '記録する', exact: true }),
|
||||||
|
).toBeDisabled()
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: '記録内容' })
|
||||||
|
.fill('次はレビュー資料の導線を確認する')
|
||||||
|
await page.getByRole('button', { name: '記録する', exact: true }).click()
|
||||||
|
await expect(page.getByRole('article', { name: 'メモの記録' })).toContainText(
|
||||||
|
'発生日時: 不明',
|
||||||
|
)
|
||||||
|
await page.getByRole('button', { name: '体重', exact: true }).click()
|
||||||
|
await page.getByRole('spinbutton', { name: '体重', exact: true }).fill('65.2')
|
||||||
|
await page.getByRole('textbox', { name: '記録元' }).fill('自宅の体重計')
|
||||||
|
await page.getByRole('button', { name: '記録する', exact: true }).click()
|
||||||
|
await page
|
||||||
|
.getByRole('article', { name: '体重の記録' })
|
||||||
|
.getByRole('button', { name: '訂正する' })
|
||||||
|
.click()
|
||||||
|
const correction = page.getByRole('dialog', { name: '記録を訂正' })
|
||||||
|
await correction
|
||||||
|
.getByRole('spinbutton', { name: '体重', exact: true })
|
||||||
|
.fill('65.8')
|
||||||
|
await correction.getByRole('button', { name: '訂正を追加' }).click()
|
||||||
|
const weights = page.getByRole('article', { name: '体重の記録' })
|
||||||
|
await expect(weights).toHaveCount(2)
|
||||||
|
await expect(weights.filter({ hasText: '65.2 kg' })).toContainText('訂正前')
|
||||||
|
await expect(weights.filter({ hasText: '65.8 kg' })).toContainText('訂正')
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: '記録をまとめる', exact: true })
|
||||||
|
.click()
|
||||||
|
const review = page.getByRole('dialog', { name: '記録をまとめる' })
|
||||||
|
const summary = review.getByRole('textbox', { name: '記録のまとめ' })
|
||||||
|
await expect(summary).toHaveValue(/65.8 kg/)
|
||||||
|
await expect(summary).not.toHaveValue(/65.2 kg/)
|
||||||
|
await expect(summary).toHaveValue(/記録元: 自宅の体重計/)
|
||||||
|
await expect(summary).toHaveValue(/発生日時が不明の記録: 2件/)
|
||||||
|
await review.getByRole('checkbox', { name: /次はレビュー資料/ }).uncheck()
|
||||||
|
await expect(summary).not.toHaveValue(/次はレビュー資料/)
|
||||||
|
await expect(summary).toHaveValue(/記録のない期間の服薬・体重は不明/)
|
||||||
|
expect((await a11y().analyze()).violations).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fits communication and journal interactions in narrow screens', async ({
|
||||||
|
page,
|
||||||
|
a11y,
|
||||||
|
}) => {
|
||||||
|
for (const width of [320, 375, 768]) {
|
||||||
|
await page.setViewportSize({ width, height: 850 })
|
||||||
|
await page.goto('/support')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollWidth),
|
||||||
|
).toBe(width)
|
||||||
|
await page.locator('.support-row').first().click()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('textbox', { name: '返信', exact: true }),
|
||||||
|
).toBeVisible()
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollWidth),
|
||||||
|
).toBe(width)
|
||||||
|
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
|
||||||
|
const back = page.getByRole('button', { name: '連絡一覧に戻る' })
|
||||||
|
if (await back.isVisible()) await back.click()
|
||||||
|
await page.getByRole('link', { name: '記録', exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: '体重', exact: true }).click()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('spinbutton', { name: '体重', exact: true }),
|
||||||
|
).toBeVisible()
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollWidth),
|
||||||
|
).toBe(width)
|
||||||
|
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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; [
|
||||||
|
|||||||
+20
-3
@@ -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
+129
-14
@@ -1,21 +1,136 @@
|
|||||||
export function AppShell({
|
import { Columns3, MessageSquare, PanelLeftClose } from 'lucide-react'
|
||||||
|
import { type CSSProperties, type ReactNode, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
} from '#/components/ui/sidebar'
|
||||||
|
import { TooltipProvider } from '#/components/ui/tooltip'
|
||||||
|
import { WorkspaceNavigation } from '#/components/workspace-navigation'
|
||||||
|
|
||||||
|
type ShellProps = {
|
||||||
|
hasTemporaryDecks: boolean
|
||||||
|
sidebar: ReactNode
|
||||||
|
researchChat: (closeMobileSidebar: () => void) => ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell(props: ShellProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider delay={800}>
|
||||||
|
<SidebarProvider
|
||||||
|
className="workspace h-dvh min-h-0 overflow-hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--sidebar-width': '25rem',
|
||||||
|
'--sidebar-width-icon': '4rem',
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Workspace {...props} />
|
||||||
|
</SidebarProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Workspace({
|
||||||
sidebar,
|
sidebar,
|
||||||
researchChat,
|
researchChat,
|
||||||
children,
|
children,
|
||||||
}: {
|
hasTemporaryDecks,
|
||||||
sidebar: React.ReactNode
|
}: ShellProps) {
|
||||||
researchChat: React.ReactNode
|
const [panel, setPanel] = useState<'chat' | 'decks'>('chat')
|
||||||
children: React.ReactNode
|
const { setOpen, setOpenMobile, isMobile } = useSidebar()
|
||||||
}) {
|
const navigation = [
|
||||||
|
{ id: 'chat', title: '調査チャット', icon: MessageSquare },
|
||||||
|
{ id: 'decks', title: 'デッキ管理', icon: Columns3 },
|
||||||
|
] as const
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app app-deck">
|
<>
|
||||||
{researchChat}
|
<Sidebar
|
||||||
<section className="reader">
|
collapsible="icon"
|
||||||
<section className="deck-toolbar" aria-label="デッキ管理">
|
className="overflow-hidden *:data-[sidebar=sidebar]:flex-row"
|
||||||
{sidebar}
|
>
|
||||||
|
<WorkspaceNavigation
|
||||||
|
hasTemporaryDecks={hasTemporaryDecks}
|
||||||
|
footer={
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
aria-label="サイドバーを閉じる"
|
||||||
|
tooltip={{ children: 'サイドバーを閉じる', hidden: false }}
|
||||||
|
onClick={() =>
|
||||||
|
isMobile ? setOpenMobile(false) : setOpen(false)
|
||||||
|
}
|
||||||
|
className="size-10 justify-center p-0"
|
||||||
|
>
|
||||||
|
<PanelLeftClose />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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="size-10 justify-center p-0"
|
||||||
|
>
|
||||||
|
<item.icon />
|
||||||
|
<span className="sr-only">{item.title}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</WorkspaceNavigation>
|
||||||
|
<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>
|
||||||
|
</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}
|
||||||
</section>
|
</section>
|
||||||
{children}
|
</SidebarInset>
|
||||||
</section>
|
</>
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-33
@@ -1,51 +1,54 @@
|
|||||||
import { useEffect, useId, useRef } from 'react'
|
import { cn } from 'cn'
|
||||||
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,
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
|
className?: string
|
||||||
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={cn('max-h-[calc(100dvh-2rem)] overflow-y-auto', className)}
|
||||||
type="button"
|
aria-describedby={undefined}
|
||||||
className="icon-button"
|
>
|
||||||
aria-label="閉じる"
|
<DialogHeader>
|
||||||
onClick={onClose}
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogClose
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="absolute right-2 top-2"
|
||||||
|
aria-label="閉じる"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</button>
|
</DialogClose>
|
||||||
</div>
|
{children}
|
||||||
{children}
|
</DialogContent>
|
||||||
</dialog>
|
</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 }
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Link, useLocation } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
Inbox,
|
||||||
|
Layers3,
|
||||||
|
MessagesSquare,
|
||||||
|
NotebookPen,
|
||||||
|
Search,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { type ReactNode, useState } from 'react'
|
||||||
|
import { Avatar, AvatarFallback } from '#/components/ui/avatar'
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarSeparator,
|
||||||
|
} from '#/components/ui/sidebar'
|
||||||
|
import { ConnectionManagerDialog } from '#/features/connections/connection-manager-dialog'
|
||||||
|
|
||||||
|
type WorkspaceNavigationProps = {
|
||||||
|
children?: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
hasTemporaryDecks?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceNavigation({
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
hasTemporaryDecks,
|
||||||
|
}: WorkspaceNavigationProps) {
|
||||||
|
const [managingAccounts, setManagingAccounts] = useState(false)
|
||||||
|
const pathname = useLocation({ select: (location) => location.pathname })
|
||||||
|
const navigation = [
|
||||||
|
{
|
||||||
|
title: '連絡',
|
||||||
|
to: '/support',
|
||||||
|
icon: MessagesSquare,
|
||||||
|
active: pathname === '/support',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '記録',
|
||||||
|
to: '/journal',
|
||||||
|
icon: NotebookPen,
|
||||||
|
active: pathname === '/journal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'リーダー',
|
||||||
|
to: '/inbox',
|
||||||
|
icon: Inbox,
|
||||||
|
active: pathname === '/inbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Twitter Lite',
|
||||||
|
to: '/deck',
|
||||||
|
icon: Search,
|
||||||
|
active: pathname === '/' || pathname === '/deck',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar
|
||||||
|
collapsible="none"
|
||||||
|
className="workspace-navigation w-16! shrink-0 border-r"
|
||||||
|
>
|
||||||
|
<SidebarHeader className="items-center py-4">
|
||||||
|
<div
|
||||||
|
className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||||
|
role="img"
|
||||||
|
aria-label="ワークスペース"
|
||||||
|
>
|
||||||
|
<Layers3 className="size-5" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup className="px-3">
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.to}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
aria-label={item.title}
|
||||||
|
aria-current={item.active ? 'page' : undefined}
|
||||||
|
tooltip={{ children: item.title, hidden: false }}
|
||||||
|
isActive={item.active}
|
||||||
|
render={<Link to={item.to} />}
|
||||||
|
className="size-10 justify-center p-0"
|
||||||
|
>
|
||||||
|
<item.icon />
|
||||||
|
<span className="sr-only">{item.title}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
{children && (
|
||||||
|
<>
|
||||||
|
<SidebarSeparator className="mx-3 w-auto" />
|
||||||
|
<SidebarGroup className="px-3">
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>{children}</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter className="items-center px-3 pb-4">
|
||||||
|
{footer && <SidebarMenu>{footer}</SidebarMenu>}
|
||||||
|
<SidebarMenu className="mt-2">
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
aria-label="接続アカウントを管理"
|
||||||
|
tooltip={{ children: '接続アカウントを管理', hidden: false }}
|
||||||
|
className="size-10 justify-center p-0"
|
||||||
|
onClick={() => setManagingAccounts(true)}
|
||||||
|
>
|
||||||
|
<Avatar className="size-8">
|
||||||
|
<AvatarFallback>Y</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarFooter>
|
||||||
|
{managingAccounts && (
|
||||||
|
<ConnectionManagerDialog
|
||||||
|
onClose={() => setManagingAccounts(false)}
|
||||||
|
hasTemporaryDecks={hasTemporaryDecks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Sidebar>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useServerFn } from '@tanstack/react-start'
|
||||||
|
import { Dialog } from '#/components/dialog'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { ConnectionManager } from './connection-manager'
|
||||||
|
import { loadConnections } from './server-functions'
|
||||||
|
|
||||||
|
export function ConnectionManagerDialog({
|
||||||
|
onClose,
|
||||||
|
hasTemporaryDecks,
|
||||||
|
}: {
|
||||||
|
onClose: () => void
|
||||||
|
hasTemporaryDecks?: boolean
|
||||||
|
}) {
|
||||||
|
const fetchConnections = useServerFn(loadConnections)
|
||||||
|
const connections = useQuery({
|
||||||
|
queryKey: ['connections'],
|
||||||
|
queryFn: () => fetchConnections(),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title="接続アカウント" onClose={onClose} className="sm:max-w-2xl">
|
||||||
|
{connections.isPending ? (
|
||||||
|
<p role="status">接続アカウントを読み込んでいます…</p>
|
||||||
|
) : connections.isError ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<p role="alert">接続アカウントを取得できませんでした。</p>
|
||||||
|
<Button variant="outline" onClick={() => void connections.refetch()}>
|
||||||
|
再試行
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{connections.data.relayError && (
|
||||||
|
<p role="alert">{connections.data.relayError}</p>
|
||||||
|
)}
|
||||||
|
<ConnectionManager
|
||||||
|
connections={connections.data.connections}
|
||||||
|
onChanged={() => connections.refetch()}
|
||||||
|
hasTemporaryDecks={hasTemporaryDecks}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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={
|
||||||
aria-label={`${column.title}の操作`}
|
<Button
|
||||||
title="カラムの操作"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`${column.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 &&
|
||||||
|
|||||||
+342
-306
@@ -5,7 +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 { ConnectionManager } from '#/features/connections/connection-manager'
|
import { Button } from '#/components/ui/button'
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from '#/components/ui/empty'
|
||||||
|
import { Input } from '#/components/ui/input'
|
||||||
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'
|
||||||
import { ResearchPanel } from '#/features/research/research-panel'
|
import { ResearchPanel } from '#/features/research/research-panel'
|
||||||
@@ -31,8 +40,9 @@ export function DeckPage() {
|
|||||||
const researchDeckBindings = useRef(new Map<string, string>()).current
|
const researchDeckBindings = useRef(new Map<string, string>()).current
|
||||||
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 [deckDialog, setDeckDialog] = useState<'create' | 'delete' | null>(null)
|
||||||
const [switcherEditing, setSwitcherEditing] = useState(false)
|
const [newDeckTitle, setNewDeckTitle] = useState('')
|
||||||
|
const switcherEditing = deckDialog !== null
|
||||||
const {
|
const {
|
||||||
deck,
|
deck,
|
||||||
workspace,
|
workspace,
|
||||||
@@ -83,6 +93,8 @@ export function DeckPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function clearEditors() {
|
function clearEditors() {
|
||||||
|
setDeckDialog(null)
|
||||||
|
setNewDeckTitle('')
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
setRenaming(false)
|
setRenaming(false)
|
||||||
setRemoved(undefined)
|
setRemoved(undefined)
|
||||||
@@ -102,312 +114,336 @@ 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
|
<ResearchPanel
|
||||||
researchChat={
|
onNavigateCitation={citationNavigation.navigate}
|
||||||
<ResearchPanel
|
ready={ready}
|
||||||
onNavigateCitation={citationNavigation.navigate}
|
contextDeck={{
|
||||||
ready={ready}
|
id: deck.id,
|
||||||
contextDeck={{
|
title: deck.title,
|
||||||
id: deck.id,
|
columns: deck.columns,
|
||||||
title: deck.title,
|
}}
|
||||||
columns: deck.columns,
|
connections={profiles.data?.connections ?? []}
|
||||||
}}
|
onOpenDeck={async (plan, activate = false) => {
|
||||||
connections={profiles.data?.connections ?? []}
|
await syncResearchDeck(plan, activate, researchDeckBindings, {
|
||||||
onOpenDeck={async (plan, activate = false) => {
|
getWorkspace,
|
||||||
await syncResearchDeck(plan, activate, researchDeckBindings, {
|
createTemporary,
|
||||||
getWorkspace,
|
save,
|
||||||
createTemporary,
|
select,
|
||||||
save,
|
})
|
||||||
select,
|
if (activate) clearEditors()
|
||||||
})
|
}}
|
||||||
if (activate) clearEditors()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
sidebar={
|
|
||||||
<>
|
|
||||||
<DeckSwitcher
|
|
||||||
key={deck.id}
|
|
||||||
decks={workspace.decks}
|
|
||||||
activeId={deck.id}
|
|
||||||
ready={ready && !saving}
|
|
||||||
onEditingChange={setSwitcherEditing}
|
|
||||||
onSelect={(id) => {
|
|
||||||
select(id)
|
|
||||||
clearEditors()
|
|
||||||
}}
|
|
||||||
onCreate={async (title) => {
|
|
||||||
const saved = await create(title)
|
|
||||||
if (saved) clearEditors()
|
|
||||||
return saved
|
|
||||||
}}
|
|
||||||
onDelete={async () => {
|
|
||||||
const saved = await remove(deck.id)
|
|
||||||
if (saved) clearEditors()
|
|
||||||
return saved
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="deck-primary sidebar-add"
|
|
||||||
aria-label="カラムを追加"
|
|
||||||
title="カラムを追加"
|
|
||||||
type="button"
|
|
||||||
disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS}
|
|
||||||
onClick={() => setEditing('new')}
|
|
||||||
>
|
|
||||||
<Icon name="plus" />
|
|
||||||
<span>カラムを追加</span>
|
|
||||||
</button>
|
|
||||||
<p className="sidebar-note">
|
|
||||||
{deck.persisted
|
|
||||||
? 'サーバーに保存・端末間で共有'
|
|
||||||
: '一時ビュー · このタブのみ'}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!ready}
|
|
||||||
onClick={() => setManagingConnections(true)}
|
|
||||||
>
|
|
||||||
接続アカウントを管理
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="deck-heading">
|
{(renderResearchPanel) => (
|
||||||
<div>
|
<AppShell
|
||||||
<h1>{deck.title}</h1>
|
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
|
||||||
<span className="deck-count">
|
researchChat={renderResearchPanel}
|
||||||
{deck.columns.length} / {MAX_COLUMNS} カラム
|
sidebar={
|
||||||
</span>
|
<>
|
||||||
</div>
|
<DeckSwitcher
|
||||||
<div className="deck-actions">
|
key={deck.id}
|
||||||
{deck.persisted ? (
|
errorNotice={storageErrorNotice}
|
||||||
<button
|
decks={workspace.decks}
|
||||||
type="button"
|
activeId={deck.id}
|
||||||
disabled={saving}
|
ready={ready && !saving}
|
||||||
onClick={() => {
|
dialog={deckDialog}
|
||||||
createTemporary(`${deck.title}のコピー`, deck.columns)
|
newTitle={newDeckTitle}
|
||||||
clearEditors()
|
onNewTitleChange={setNewDeckTitle}
|
||||||
}}
|
onDialogChange={(dialog) => {
|
||||||
>
|
setDeckDialog(dialog)
|
||||||
一時コピーを作成
|
if (dialog !== 'create') setNewDeckTitle('')
|
||||||
</button>
|
}}
|
||||||
) : (
|
onSelect={(id) => {
|
||||||
<button
|
select(id)
|
||||||
type="button"
|
clearEditors()
|
||||||
disabled={!ready || saving}
|
}}
|
||||||
onClick={() => void persist(deck.id)}
|
onCreate={async (title) => {
|
||||||
>
|
const saved = await create(title)
|
||||||
デッキとして保存
|
if (saved) clearEditors()
|
||||||
</button>
|
return saved
|
||||||
)}
|
}}
|
||||||
<button
|
onDelete={async () => {
|
||||||
className="icon-button"
|
const saved = await remove(deck.id)
|
||||||
aria-label="名前を変更"
|
if (saved) clearEditors()
|
||||||
title="名前を変更"
|
return saved
|
||||||
type="button"
|
}}
|
||||||
disabled={!ready || saving}
|
|
||||||
onClick={() => setRenaming(!renaming)}
|
|
||||||
>
|
|
||||||
<Icon name="edit" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{saving ? (
|
|
||||||
<p className="deck-save-status" role="status">
|
|
||||||
保存しています…
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{legacyAvailable ? (
|
|
||||||
<p className="deck-save-status">
|
|
||||||
このブラウザに旧デッキがあります。
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={saving || !profiles.data}
|
|
||||||
onClick={() => void importLegacy()}
|
|
||||||
>
|
|
||||||
旧デッキを取り込む
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{!ready ? (
|
|
||||||
<p className="deck-save-status" role="status">
|
|
||||||
デッキを読み込んでいます…
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{profiles.isError ? (
|
|
||||||
<p role="alert" className="deck-error">
|
|
||||||
接続プロファイルを取得できませんでした。
|
|
||||||
<button type="button" onClick={() => void profiles.refetch()}>
|
|
||||||
再試行
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{oauthResult === 'failed' ? (
|
|
||||||
<p className="deck-error" role="alert">
|
|
||||||
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{oauthResult === 'connected' ? (
|
|
||||||
<p className="deck-save-status" role="status">
|
|
||||||
Mastodonアカウントを接続しました。カラムの接続先に選べます。
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{profiles.data?.relayError ? (
|
|
||||||
<p className="deck-error" role="alert">
|
|
||||||
{profiles.data.relayError}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{managingConnections ? (
|
|
||||||
<Dialog
|
|
||||||
title="接続アカウント"
|
|
||||||
onClose={() => setManagingConnections(false)}
|
|
||||||
>
|
|
||||||
<ConnectionManager
|
|
||||||
connections={profiles.data?.connections ?? []}
|
|
||||||
onChanged={() => profiles.refetch()}
|
|
||||||
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
) : null}
|
|
||||||
{profiles.isPending ? (
|
|
||||||
<p role="status">接続プロファイルを取得しています…</p>
|
|
||||||
) : null}
|
|
||||||
{storageError ? (
|
|
||||||
<p className="deck-error" role="alert">
|
|
||||||
{storageError}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => {
|
|
||||||
clearEditors()
|
|
||||||
void refresh(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
最新のデッキを再読み込み
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{renaming ? (
|
|
||||||
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
|
|
||||||
<form
|
|
||||||
className="deck-rename"
|
|
||||||
onSubmit={async (event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
const title = String(
|
|
||||||
new FormData(event.currentTarget).get('title') ?? '',
|
|
||||||
).trim()
|
|
||||||
if (!title) return
|
|
||||||
if (await save({ ...deck, title })) setRenaming(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label htmlFor="deck-title">
|
|
||||||
調査テーマ・デッキ名
|
|
||||||
<input
|
|
||||||
id="deck-title"
|
|
||||||
name="title"
|
|
||||||
defaultValue={deck.title}
|
|
||||||
maxLength={120}
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<Button
|
||||||
<button type="submit">名前を保存</button>
|
className="w-full"
|
||||||
</form>
|
aria-label="カラムを追加"
|
||||||
</Dialog>
|
title="カラムを追加"
|
||||||
) : null}
|
type="button"
|
||||||
{editing !== null ? (
|
disabled={
|
||||||
<Dialog
|
!ready || saving || deck.columns.length >= MAX_COLUMNS
|
||||||
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
|
}
|
||||||
onClose={() => setEditing(null)}
|
onClick={() => setEditing('new')}
|
||||||
|
>
|
||||||
|
<Icon name="plus" />
|
||||||
|
<span>カラムを追加</span>
|
||||||
|
</Button>
|
||||||
|
<p className="sidebar-note">
|
||||||
|
{deck.persisted
|
||||||
|
? 'サーバーに保存・端末間で共有'
|
||||||
|
: '一時ビュー · このタブのみ'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ColumnEditor
|
<div className="deck-heading">
|
||||||
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
|
<div>
|
||||||
connections={profiles.data?.connections ?? []}
|
<h1>{deck.title}</h1>
|
||||||
column={
|
<span className="deck-count">
|
||||||
editing === 'new'
|
{deck.columns.length} / {MAX_COLUMNS} カラム
|
||||||
? undefined
|
</span>
|
||||||
: deck.columns.find((column) => column.id === editing.id)
|
</div>
|
||||||
}
|
<div className="deck-actions">
|
||||||
onSave={saveColumn}
|
{deck.persisted ? (
|
||||||
onCancel={() => setEditing(null)}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
</Dialog>
|
type="button"
|
||||||
) : null}
|
disabled={saving}
|
||||||
{removed ? (
|
onClick={() => {
|
||||||
<div className="deck-undo" role="status">
|
createTemporary(`${deck.title}のコピー`, deck.columns)
|
||||||
<span>「{removed.column.title}」を削除しました。</span>
|
clearEditors()
|
||||||
<button
|
}}
|
||||||
type="button"
|
>
|
||||||
disabled={deck.columns.length >= MAX_COLUMNS || editing === 'new'}
|
一時コピーを作成
|
||||||
onClick={async () => {
|
</Button>
|
||||||
const columns = [...deck.columns]
|
) : (
|
||||||
columns.splice(removed.index, 0, removed.column)
|
<Button
|
||||||
if (await save({ ...deck, columns })) setRemoved(undefined)
|
variant="outline"
|
||||||
}}
|
type="button"
|
||||||
>
|
disabled={!ready || saving}
|
||||||
元に戻す
|
onClick={() => void persist(deck.id)}
|
||||||
</button>
|
>
|
||||||
</div>
|
デッキとして保存
|
||||||
) : null}
|
</Button>
|
||||||
{ready && deck.columns.length === 0 && editing === null ? (
|
)}
|
||||||
<div className="deck-empty">
|
<Button
|
||||||
<Icon name="columns" />
|
variant="outline"
|
||||||
<h2>調べたいことから、デッキを作りましょう</h2>
|
className="icon-button"
|
||||||
<p>
|
aria-label="名前を変更"
|
||||||
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
|
title="名前を変更"
|
||||||
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
|
type="button"
|
||||||
</p>
|
disabled={!ready || saving}
|
||||||
<button
|
onClick={() => setRenaming(!renaming)}
|
||||||
className="deck-primary"
|
>
|
||||||
type="button"
|
<Icon name="edit" />
|
||||||
onClick={() => setEditing('new')}
|
</Button>
|
||||||
>
|
</div>
|
||||||
手動でカラムを追加
|
</div>
|
||||||
</button>
|
{saving ? (
|
||||||
</div>
|
<p className="deck-save-status" role="status">
|
||||||
) : null}
|
保存しています…
|
||||||
{ready && deck.columns.length > 0 ? (
|
</p>
|
||||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
|
) : null}
|
||||||
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
|
{legacyAvailable ? (
|
||||||
{deck.columns.map((column, index) => (
|
<p className="deck-save-status">
|
||||||
<ResearchColumn
|
このブラウザに旧デッキがあります。
|
||||||
key={`${deck.id}:${column.id}`}
|
<Button
|
||||||
registry={registry}
|
variant="outline"
|
||||||
column={column}
|
type="button"
|
||||||
citation={
|
disabled={saving || !profiles.data}
|
||||||
citationNavigation.target?.deckId === deck.id &&
|
onClick={() => void importLegacy()}
|
||||||
citationNavigation.target.column.id === column.id &&
|
>
|
||||||
sameCitationSource(citationNavigation.target.column, column)
|
旧デッキを取り込む
|
||||||
? citationNavigation.target
|
</Button>
|
||||||
: undefined
|
</p>
|
||||||
}
|
) : null}
|
||||||
connectionLabel={
|
{!ready ? (
|
||||||
profiles.data?.connections.find(
|
<p className="deck-save-status" role="status">
|
||||||
(connection) => connection.id === column.connectionId,
|
デッキを読み込んでいます…
|
||||||
)?.displayName
|
</p>
|
||||||
}
|
) : null}
|
||||||
first={index === 0}
|
{profiles.isError ? (
|
||||||
last={index === deck.columns.length - 1}
|
<p role="alert" className="deck-error">
|
||||||
onEdit={() => setEditing({ id: column.id })}
|
接続プロファイルを取得できませんでした。
|
||||||
onMove={(direction) =>
|
<Button
|
||||||
save(moveColumn(deck, column.id, direction))
|
variant="outline"
|
||||||
}
|
type="button"
|
||||||
onRemove={async () => {
|
onClick={() => void profiles.refetch()}
|
||||||
const saved = await save({
|
>
|
||||||
...deck,
|
再試行
|
||||||
columns: deck.columns.filter(
|
</Button>
|
||||||
(current) => current.id !== column.id,
|
</p>
|
||||||
),
|
) : null}
|
||||||
})
|
{oauthResult === 'failed' ? (
|
||||||
if (saved) setRemoved({ column, index })
|
<p className="deck-error" role="alert">
|
||||||
if (typeof editing === 'object' && editing?.id === column.id)
|
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
|
||||||
setEditing(null)
|
</p>
|
||||||
}}
|
) : null}
|
||||||
/>
|
{oauthResult === 'connected' ? (
|
||||||
))}
|
<p className="deck-save-status" role="status">
|
||||||
</section>
|
Mastodonアカウントを接続しました。カラムの接続先に選べます。
|
||||||
) : null}
|
</p>
|
||||||
</AppShell>
|
) : null}
|
||||||
|
{profiles.data?.relayError ? (
|
||||||
|
<p className="deck-error" role="alert">
|
||||||
|
{profiles.data.relayError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{profiles.isPending ? (
|
||||||
|
<p role="status">接続プロファイルを取得しています…</p>
|
||||||
|
) : null}
|
||||||
|
{!renaming && editing === null && !switcherEditing
|
||||||
|
? storageErrorNotice
|
||||||
|
: null}
|
||||||
|
{renaming ? (
|
||||||
|
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
|
||||||
|
<form
|
||||||
|
className="deck-rename"
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const title = String(
|
||||||
|
new FormData(event.currentTarget).get('title') ?? '',
|
||||||
|
).trim()
|
||||||
|
if (!title) return
|
||||||
|
if (await save({ ...deck, title })) setRenaming(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label htmlFor="deck-title">
|
||||||
|
調査テーマ・デッキ名
|
||||||
|
<Input
|
||||||
|
id="deck-title"
|
||||||
|
name="title"
|
||||||
|
defaultValue={deck.title}
|
||||||
|
maxLength={120}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button variant="outline" type="submit">
|
||||||
|
名前を保存
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{storageErrorNotice}
|
||||||
|
</Dialog>
|
||||||
|
) : null}
|
||||||
|
{editing !== null ? (
|
||||||
|
<Dialog
|
||||||
|
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
>
|
||||||
|
<ColumnEditor
|
||||||
|
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
|
||||||
|
connections={profiles.data?.connections ?? []}
|
||||||
|
column={
|
||||||
|
editing === 'new'
|
||||||
|
? undefined
|
||||||
|
: deck.columns.find((column) => column.id === editing.id)
|
||||||
|
}
|
||||||
|
onSave={saveColumn}
|
||||||
|
onCancel={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
{storageErrorNotice}
|
||||||
|
</Dialog>
|
||||||
|
) : null}
|
||||||
|
{removed ? (
|
||||||
|
<div className="deck-undo" role="status">
|
||||||
|
<span>「{removed.column.title}」を削除しました。</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
deck.columns.length >= MAX_COLUMNS || editing === 'new'
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
const columns = [...deck.columns]
|
||||||
|
columns.splice(removed.index, 0, removed.column)
|
||||||
|
if (await save({ ...deck, columns })) setRemoved(undefined)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
元に戻す
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{ready && deck.columns.length === 0 && editing === null ? (
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<Icon name="columns" />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>
|
||||||
|
<h2>調べたいことから、デッキを作りましょう</h2>
|
||||||
|
</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
|
||||||
|
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<EmptyContent>
|
||||||
|
<Button type="button" onClick={() => setEditing('new')}>
|
||||||
|
手動でカラムを追加
|
||||||
|
</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
|
) : null}
|
||||||
|
{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.
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{deck.columns.map((column, index) => (
|
||||||
|
<ResearchColumn
|
||||||
|
key={`${deck.id}:${column.id}`}
|
||||||
|
registry={registry}
|
||||||
|
column={column}
|
||||||
|
citation={
|
||||||
|
citationNavigation.target?.deckId === deck.id &&
|
||||||
|
citationNavigation.target.column.id === column.id &&
|
||||||
|
sameCitationSource(citationNavigation.target.column, column)
|
||||||
|
? citationNavigation.target
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
connectionLabel={
|
||||||
|
profiles.data?.connections.find(
|
||||||
|
(connection) => connection.id === column.connectionId,
|
||||||
|
)?.displayName
|
||||||
|
}
|
||||||
|
first={index === 0}
|
||||||
|
last={index === deck.columns.length - 1}
|
||||||
|
onEdit={() => setEditing({ id: column.id })}
|
||||||
|
onMove={(direction) =>
|
||||||
|
save(moveColumn(deck, column.id, direction))
|
||||||
|
}
|
||||||
|
onRemove={async () => {
|
||||||
|
const saved = await save({
|
||||||
|
...deck,
|
||||||
|
columns: deck.columns.filter(
|
||||||
|
(current) => current.id !== column.id,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if (saved) setRemoved({ column, index })
|
||||||
|
if (
|
||||||
|
typeof editing === 'object' &&
|
||||||
|
editing?.id === column.id
|
||||||
|
)
|
||||||
|
setEditing(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { ArrowLeft, Search } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { Input } from '#/components/ui/input'
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
} from '#/components/ui/sidebar'
|
||||||
|
import { TooltipProvider } from '#/components/ui/tooltip'
|
||||||
|
import { WorkspaceNavigation } from '#/components/workspace-navigation'
|
||||||
|
import { type Article, articles } from './mock-articles'
|
||||||
|
import './inbox.css'
|
||||||
|
|
||||||
|
export function InboxPage() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [selectedId, setSelectedId] = useState<string | undefined>(
|
||||||
|
articles[0]?.id,
|
||||||
|
)
|
||||||
|
const [readingMobile, setReadingMobile] = useState(false)
|
||||||
|
const visible = articles.filter((article) =>
|
||||||
|
`${article.title} ${article.summary} ${article.source}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query.trim().toLowerCase()),
|
||||||
|
)
|
||||||
|
const selected =
|
||||||
|
visible.find((article) => article.id === selectedId) ?? visible[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delay={500}>
|
||||||
|
<SidebarProvider className="inbox-workspace" data-reading={readingMobile}>
|
||||||
|
<Sidebar collapsible="none" className="inbox-sidebar">
|
||||||
|
<WorkspaceNavigation />
|
||||||
|
<Sidebar collapsible="none" className="inbox-list-panel">
|
||||||
|
<SidebarHeader className="gap-4 border-b p-5">
|
||||||
|
<h1 className="text-base font-semibold">リーダー</h1>
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute left-3 top-2 size-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
aria-label="記事を検索"
|
||||||
|
placeholder="検索"
|
||||||
|
className="pl-9"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent className="gap-0" aria-label="記事一覧">
|
||||||
|
{visible.map((article) => (
|
||||||
|
<ArticleRow
|
||||||
|
key={article.id}
|
||||||
|
article={article}
|
||||||
|
selected={selected?.id === article.id}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedId(article.id)
|
||||||
|
setReadingMobile(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{visible.length === 0 && (
|
||||||
|
<div className="inbox-no-results">
|
||||||
|
<p>記事がありません</p>
|
||||||
|
<Button variant="outline" onClick={() => setQuery('')}>
|
||||||
|
検索をクリア
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SidebarContent>
|
||||||
|
</Sidebar>
|
||||||
|
</Sidebar>
|
||||||
|
<SidebarInset className="inbox-reader">
|
||||||
|
<header className="inbox-reader-toolbar">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="inbox-back"
|
||||||
|
aria-label="記事一覧に戻る"
|
||||||
|
onClick={() => setReadingMobile(false)}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{selected?.source}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<section
|
||||||
|
className="inbox-reader-scroll"
|
||||||
|
key={selected?.id}
|
||||||
|
aria-label="記事本文"
|
||||||
|
// biome-ignore lint/a11y/noNoninteractiveTabindex: The independently scrolling reader must be keyboard-scrollable.
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{selected && <ArticleReader article={selected} />}
|
||||||
|
</section>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArticleRow({
|
||||||
|
article,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
article: Article
|
||||||
|
selected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inbox-article-row"
|
||||||
|
aria-current={selected ? 'true' : undefined}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<span className="inbox-row-meta">
|
||||||
|
<span className="truncate">{article.source}</span>
|
||||||
|
<time className="ml-auto shrink-0">{article.date}</time>
|
||||||
|
</span>
|
||||||
|
<span className="inbox-row-title">{article.title}</span>
|
||||||
|
<span className="inbox-row-summary">{article.summary}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArticleReader({ article }: { article: Article }) {
|
||||||
|
return (
|
||||||
|
<article className="inbox-article" aria-label={article.title}>
|
||||||
|
<h2 className="inbox-article-title">{article.title}</h2>
|
||||||
|
<p className="inbox-byline text-xs text-muted-foreground">
|
||||||
|
{article.author} · {article.date}
|
||||||
|
</p>
|
||||||
|
<div className="inbox-article-body">
|
||||||
|
{article.paragraphs.map((paragraph) => (
|
||||||
|
<p key={paragraph}>{paragraph}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/* Hallmark · macrostructure: sidebar-09 Inbox · tone: reading-first · anchor hue: neutral */
|
||||||
|
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
|
||||||
|
.inbox-workspace {
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.inbox-sidebar {
|
||||||
|
width: 420px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.inbox-list-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.inbox-article-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 11px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 22px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.inbox-article-row:hover {
|
||||||
|
background: var(--sidebar-accent);
|
||||||
|
}
|
||||||
|
.inbox-article-row[aria-current="true"] {
|
||||||
|
background: var(--muted);
|
||||||
|
box-shadow: inset 2px 0 var(--primary);
|
||||||
|
}
|
||||||
|
.inbox-row-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.inbox-row-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.65;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.inbox-row-summary {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.inbox-reader {
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.inbox-reader-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 60px;
|
||||||
|
padding: 10px 26px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.inbox-reader-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.inbox-article {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 54px 52px 40px;
|
||||||
|
}
|
||||||
|
.inbox-article-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(24px, 2.4vw, 34px);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.inbox-byline {
|
||||||
|
margin-block: 30px;
|
||||||
|
}
|
||||||
|
.inbox-article-body {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 2.15;
|
||||||
|
letter-spacing: 0.015em;
|
||||||
|
}
|
||||||
|
.inbox-article-body p + p {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
.inbox-no-results {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 32px 20px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.inbox-back {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) and (max-width: 1100px) {
|
||||||
|
.inbox-sidebar {
|
||||||
|
width: 340px;
|
||||||
|
}
|
||||||
|
.inbox-article {
|
||||||
|
padding: 36px 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.inbox-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
.inbox-reader {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.inbox-workspace[data-reading="true"] .inbox-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.inbox-workspace[data-reading="true"] .inbox-reader {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.inbox-back {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.inbox-reader-toolbar {
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
.inbox-article {
|
||||||
|
padding: 30px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
export type Article = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
source: string
|
||||||
|
author: string
|
||||||
|
date: string
|
||||||
|
paragraphs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// All articles are original mock content. Editorial names are fictional.
|
||||||
|
export const articles: Article[] = [
|
||||||
|
{
|
||||||
|
id: 'agent-browser-workflow',
|
||||||
|
title: 'ブラウザを見ながら考える。AIエージェントとの新しい開発の進め方',
|
||||||
|
summary:
|
||||||
|
'コードを書いて終わりにしない。画面を開き、操作し、確かめるところまでをひとつの作業にする。',
|
||||||
|
source: 'Field Notes',
|
||||||
|
author: 'Field Notes 編集部',
|
||||||
|
date: '09:40',
|
||||||
|
paragraphs: [
|
||||||
|
'UIをつくるとき、コードが正しいことと、画面が使いやすいことの間には距離がある。ボタンが画面の外に出ていないか。入力した文章は画面を切り替えても残るか。こうした問いへの答えは、実際にブラウザを動かして初めて見えてくる。',
|
||||||
|
'そこで試したいのが、実装と確認をひとつの短い循環にする進め方だ。エージェントに変更を依頼するとき、「記事を選び、詳細を読み、一覧に戻る」といった利用者の動作も添える。作業の終点がファイルの変更から、動作の確認へと具体的になる。',
|
||||||
|
'確認には、ふだんの画面幅と狭い画面幅の両方を使う。うまく動いた結果だけでなく、何を操作し、何を確認できなかったかも残しておくと、次の変更で同じ箇所を確かめやすい。最初から網羅するより、重要な操作をひとつ確実に通すことから始めたい。',
|
||||||
|
'人が担うのは、その体験が目的に合っているかを判断すること。操作の繰り返しをエージェントに任せると、余白や言葉、情報の順序といった、使い心地に向き合う時間を取りやすくなる。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'small-cli-tools',
|
||||||
|
title: '毎日使いたくなるCLIは、終了時のひと言まで設計されている',
|
||||||
|
summary:
|
||||||
|
'ヘルプ、エラー、実行結果。小さな道具の使い心地は、出力の細部で変わる。',
|
||||||
|
source: 'Toolbox Letter',
|
||||||
|
author: 'Toolbox Letter 編集部',
|
||||||
|
date: '09:15',
|
||||||
|
paragraphs: [
|
||||||
|
'コマンドを実行したあと、何が起きたのか分からない。これは処理そのものが成功していても、使う側には不安が残る体験だ。作成したファイルの場所や処理件数を一行で伝えるだけで、道具とのやり取りはずっと明快になる。',
|
||||||
|
'エラーも、原因と次の行動が近くにあると扱いやすい。「設定が不正です」より、「出力先が見つかりません。存在するディレクトリを指定してください」のほうが、調べ直す手間を減らせる。内部の事情は必要なときにだけ表示できればよい。',
|
||||||
|
'人が読む出力と、別のプログラムが読む出力の用途も分けて考えたい。通常は簡潔な文章を返し、構造化された結果が必要なら専用の形式を選べるようにする。小さなCLIほど、こうした一貫性が長く使う理由になる。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'three-column-reading',
|
||||||
|
title: '3カラムの余白が、情報を読むリズムをつくる',
|
||||||
|
summary:
|
||||||
|
'場所を選ぶ、候補を眺める、ひとつを読む。役割が分かれた画面について。',
|
||||||
|
source: 'Quiet Interface',
|
||||||
|
author: 'Quiet Interface 編集部',
|
||||||
|
date: '08:50',
|
||||||
|
paragraphs: [
|
||||||
|
'記事を探すとき、私たちはすべての文章を同じ深さで読んでいるわけではない。関心のある分野を選び、見出しを流し読みし、気になるものにだけ腰を据える。3カラムの画面は、この異なる読み方にそれぞれの場所を用意できる。',
|
||||||
|
'左は現在地、中央は候補、右は本文。役割が明確なら、記事を開いても一覧の中での位置を見失いにくい。選択中の項目を穏やかに示すことや、一覧のスクロール位置を保つことが、この安心感を支えている。',
|
||||||
|
'すべてのカラムを等しい幅にする必要はない。見出しを判断できる一覧の幅を確保し、残りを本文に渡す。本文にも適切な最大幅を設ければ、大きな画面で一行が長くなりすぎることを防げる。',
|
||||||
|
'狭い画面では、同じ役割を順番に見せればよい。一覧と本文を切り替えても、戻る場所が明確であること。カラムの数よりも、読む途中の文脈が続いていることが大切だ。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'agent-context',
|
||||||
|
title: 'エージェントに渡す文脈は、短くてもよい',
|
||||||
|
summary:
|
||||||
|
'大量の説明よりも、目的・制約・完了条件をそろえる。依頼を整理する小さな習慣。',
|
||||||
|
source: 'Field Notes',
|
||||||
|
author: 'Field Notes 編集部',
|
||||||
|
date: '08:30',
|
||||||
|
paragraphs: [
|
||||||
|
'依頼を詳しく書こうとするほど、背景と希望と必須条件が混ざってしまうことがある。受け手がエージェントでも人でも、最初に知りたいのは何を実現する作業なのかという一点だ。',
|
||||||
|
'まず目的を一文で書き、次に変えてはいけない条件を添える。最後に、何を確かめたら作業が終わるのかを決める。参考ファイルやスクリーンショットは、その三つを補うために渡すと役割がはっきりする。',
|
||||||
|
'途中で判断が変わったときは、説明を足し続けるより、現在の方針を短く書き直すほうが役立つ場合がある。文脈の量ではなく、いま有効な判断を見つけやすいことを意識したい。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'local-first-notes',
|
||||||
|
title: 'ローカルで完結する、小さな情報収集ワークスペース',
|
||||||
|
summary:
|
||||||
|
'集めることより、あとから見つけられること。ファイルを中心にした道具の組み合わせ。',
|
||||||
|
source: 'Workbench Journal',
|
||||||
|
author: 'Workbench Journal 編集部',
|
||||||
|
date: '昨日',
|
||||||
|
paragraphs: [
|
||||||
|
'気になった記事を保存する場所は増えがちだ。ブラウザのブックマーク、メモアプリ、チャットの自分宛てメッセージ。集める入口が便利でも、読み返す入口が分かれていると、保存したこと自体を忘れてしまう。',
|
||||||
|
'小さく始めるなら、タイトル、出典、保存した理由だけをテキストで残す方法がある。本文をすべて保管する前に、なぜ自分が気になったのかを一行添えておく。その一行が、検索したときの判断材料になる。',
|
||||||
|
'ローカルのファイルを中心にすると、使い慣れた検索やエディタと組み合わせやすい。一方、複数の端末で扱うなら同期や競合の扱いを決める必要がある。道具を増やす前に、どこで読み、いつ振り返るのかを決めておきたい。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quiet-design-system',
|
||||||
|
title: 'デザインシステムを、使う人の言葉で整える',
|
||||||
|
summary: 'コンポーネントをそろえた先にある、ラベルや空状態の一貫性。',
|
||||||
|
source: 'Quiet Interface',
|
||||||
|
author: 'Quiet Interface 編集部',
|
||||||
|
date: '昨日',
|
||||||
|
paragraphs: [
|
||||||
|
'ボタンの角丸や色がそろっていても、同じ操作を画面ごとに違う言葉で呼んでいると迷いが生まれる。「保存」と「追加」が何を意味するのか。見た目と同じように、言葉にも共通のルールが必要だ。',
|
||||||
|
'最初に整理したいのは、よく使う操作と状態の名前だ。未読、保存済み、読み込み中などの基本的な表現を決めておくと、新しい画面をつくるときの判断が少なくなる。専門用語を画面に出す前に、使う人がその違いを知る必要があるかも考えたい。',
|
||||||
|
'空の一覧にも役割がある。何もない理由と、次にできることが簡潔に分かれば、空状態は行き止まりにならない。派手なイラストがなくても、適切な一文と操作があれば十分なことは多い。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'personal-feed',
|
||||||
|
title: 'おすすめの理由が見える、パーソナルな記事フィード',
|
||||||
|
summary:
|
||||||
|
'「なぜこの記事?」に答えられることが、興味に基づく推薦の出発点になる。',
|
||||||
|
source: 'Field Notes',
|
||||||
|
author: 'Field Notes 編集部',
|
||||||
|
date: '昨日',
|
||||||
|
paragraphs: [
|
||||||
|
'興味に合う記事が届くと便利だが、その理由が分からないと、偶然なのか自分の行動が反映されたのか判断しづらい。推薦の根拠を短く添えるだけで、読む側は自分との接点を見つけやすくなる。',
|
||||||
|
'理由は複雑なスコアである必要はない。「選んだトピックに関連」「保存した記事と近いテーマ」など、実際に使っている情報と対応していることが大切だ。まだ推薦処理がない試作なら、その理由もサンプルであることを明示しておく。',
|
||||||
|
'関心は変化するので、利用者が調整できる入口も必要になる。トピックの追加や解除、読みたくない記事の非表示など、小さな操作から始められる。よいフィードは、当て続ける仕組みだけでなく、外れたときに直せる仕組みでもある。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'behavior-tests',
|
||||||
|
title: 'テストに残したいのは、実装よりも利用者の約束',
|
||||||
|
summary: '内部の構造を変えても守りたい動作を、短いシナリオで表す。',
|
||||||
|
source: 'Toolbox Letter',
|
||||||
|
author: 'Toolbox Letter 編集部',
|
||||||
|
date: '2日前',
|
||||||
|
paragraphs: [
|
||||||
|
'コンポーネントを分割しただけなのに、多くのテストが壊れる。そんなときは、テストが利用者の操作よりも内部の構造に寄り添っていないかを見直したい。守りたいのは、どの関数が呼ばれたかではなく、期待する結果が得られたかという約束だ。',
|
||||||
|
'記事一覧なら、「トピックを選ぶと該当する記事が並ぶ」「記事を保存すると保存済みで見つけられる」といったシナリオを考えられる。画面に表示される名前で操作して、結果も画面から確かめると、何を保証するテストかが読み取りやすい。',
|
||||||
|
'もちろん、すべてをブラウザで確かめる必要はない。複雑な絞り込みの計算は小さな単位で検証し、代表的な操作の流れを画面で確認する。変更の影響に合わせて確認の範囲を選ぶことが、続けやすいテストにつながる。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reading-typography',
|
||||||
|
title: '読みやすい本文は、文字サイズだけでは決まらない',
|
||||||
|
summary: '行の長さ、行間、段落の間隔。読み進めやすい記事画面のためのメモ。',
|
||||||
|
source: 'Quiet Interface',
|
||||||
|
author: 'Quiet Interface 編集部',
|
||||||
|
date: '2日前',
|
||||||
|
paragraphs: [
|
||||||
|
'文字が十分に大きくても、一行が長すぎると次の行を見つけるのが難しくなる。逆に幅が狭すぎると、短い単語のまとまりまで細かく折り返され、読むリズムが途切れる。本文の幅は、文字サイズと一緒に考えたい。',
|
||||||
|
'行間は一行ごとの読みやすさを、段落の間隔は話のまとまりを支えている。同じ余白として扱わず、それぞれの役割が見えるように調整すると、装飾を増やさなくても文章の構造が伝わる。',
|
||||||
|
'調整するときは、短いダミーテキストだけで判断しない。長い見出しや複数段落の記事を置き、実際に数分読んでみる。画面の見栄えだけでは気づけない、読み疲れや視線の迷いを見つけやすくなる。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { useId, useState } from 'react'
|
||||||
|
import { Dialog } from '#/components/dialog'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { Checkbox } from '#/components/ui/checkbox'
|
||||||
|
import { Input } from '#/components/ui/input'
|
||||||
|
import { SidebarInset, SidebarProvider } from '#/components/ui/sidebar'
|
||||||
|
import { Textarea } from '#/components/ui/textarea'
|
||||||
|
import { TooltipProvider } from '#/components/ui/tooltip'
|
||||||
|
import { WorkspaceNavigation } from '#/components/workspace-navigation'
|
||||||
|
import './journal.css'
|
||||||
|
|
||||||
|
type EntryKind = 'メモ' | '再開地点' | '服薬' | '体重'
|
||||||
|
type Entry = {
|
||||||
|
id: string
|
||||||
|
kind: EntryKind
|
||||||
|
text: string
|
||||||
|
source: string
|
||||||
|
occurredAt: string
|
||||||
|
recordedAt: string
|
||||||
|
correctionOf?: string
|
||||||
|
}
|
||||||
|
type Draft = Pick<Entry, 'kind' | 'text' | 'source' | 'occurredAt'>
|
||||||
|
const emptyDraft: Draft = { kind: 'メモ', text: '', source: '', occurredAt: '' }
|
||||||
|
const kinds: EntryKind[] = ['メモ', '再開地点', '服薬', '体重']
|
||||||
|
|
||||||
|
function formatTime(value: string | undefined) {
|
||||||
|
if (!value) return '不明'
|
||||||
|
return new Date(value).toLocaleString('ja-JP', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JournalPage() {
|
||||||
|
const [entries, setEntries] = useState<Entry[]>([])
|
||||||
|
const [draft, setDraft] = useState<Draft>(emptyDraft)
|
||||||
|
const [correcting, setCorrecting] = useState<Entry | null>(null)
|
||||||
|
const [reviewing, setReviewing] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const superseded = new Set(entries.map((entry) => entry.correctionOf))
|
||||||
|
const current = entries.filter((entry) => !superseded.has(entry.id))
|
||||||
|
|
||||||
|
function append(value: Draft, correctionOf?: string) {
|
||||||
|
setEntries((previous) => [
|
||||||
|
{
|
||||||
|
...value,
|
||||||
|
text: value.text.trim(),
|
||||||
|
source: value.source.trim(),
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
recordedAt: new Date().toISOString(),
|
||||||
|
correctionOf,
|
||||||
|
},
|
||||||
|
...previous,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delay={500}>
|
||||||
|
<SidebarProvider className="journal-workspace">
|
||||||
|
<WorkspaceNavigation />
|
||||||
|
<SidebarInset className="journal-main">
|
||||||
|
<header className="journal-header">
|
||||||
|
<h1 className="text-base font-semibold">記録</h1>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={current.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(new Set(current.map((entry) => entry.id)))
|
||||||
|
setReviewing(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
記録をまとめる
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div className="journal-scroll">
|
||||||
|
<div className="journal-column">
|
||||||
|
<EntryEditor
|
||||||
|
draft={draft}
|
||||||
|
onChange={setDraft}
|
||||||
|
onSubmit={() => {
|
||||||
|
append(draft)
|
||||||
|
setDraft(emptyDraft)
|
||||||
|
}}
|
||||||
|
submitLabel="記録する"
|
||||||
|
/>
|
||||||
|
<section className="journal-entries" aria-label="記録一覧">
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<p className="journal-empty">まだ記録はありません</p>
|
||||||
|
)}
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<article
|
||||||
|
key={entry.id}
|
||||||
|
className="journal-entry"
|
||||||
|
aria-label={`${entry.kind}の記録`}
|
||||||
|
>
|
||||||
|
<div className="journal-entry-heading">
|
||||||
|
<span className="text-sm font-medium">{entry.kind}</span>
|
||||||
|
{superseded.has(entry.id) && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
訂正前
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.correctionOf && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
訂正
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!superseded.has(entry.id) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => setCorrecting(entry)}
|
||||||
|
>
|
||||||
|
訂正する
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="journal-entry-text">
|
||||||
|
{entry.text}
|
||||||
|
{entry.kind === '体重' ? ' kg' : ''}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
発生日時: {formatTime(entry.occurredAt)} · 記録日時:{' '}
|
||||||
|
{formatTime(entry.recordedAt)}
|
||||||
|
</p>
|
||||||
|
{(entry.kind === '体重' || entry.source) && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
記録元: {entry.source || '不明'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
{correcting && (
|
||||||
|
<CorrectionDialog
|
||||||
|
entry={correcting}
|
||||||
|
onClose={() => setCorrecting(null)}
|
||||||
|
onSave={(value) => {
|
||||||
|
append(value, correcting.id)
|
||||||
|
setCorrecting(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{reviewing && (
|
||||||
|
<Dialog
|
||||||
|
title="記録をまとめる"
|
||||||
|
className="sm:max-w-2xl"
|
||||||
|
onClose={() => setReviewing(false)}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
含める記録を選んで確認できます。外部には送信されません。
|
||||||
|
</p>
|
||||||
|
<div className="journal-review-list">
|
||||||
|
{current.map((entry) => (
|
||||||
|
<label
|
||||||
|
key={entry.id}
|
||||||
|
className="journal-review-row"
|
||||||
|
htmlFor={`review-${entry.id}`}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`review-${entry.id}`}
|
||||||
|
checked={selected.has(entry.id)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSelected((previous) => {
|
||||||
|
const next = new Set(previous)
|
||||||
|
if (checked) next.add(entry.id)
|
||||||
|
else next.delete(entry.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{entry.kind} · {formatTime(entry.occurredAt)}
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm">
|
||||||
|
{entry.text}
|
||||||
|
{entry.kind === '体重' ? ' kg' : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
aria-label="記録のまとめ"
|
||||||
|
className="journal-summary"
|
||||||
|
readOnly
|
||||||
|
value={buildSummary(
|
||||||
|
current.filter((entry) => selected.has(entry.id)),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryEditor({
|
||||||
|
draft,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
submitLabel,
|
||||||
|
}: {
|
||||||
|
draft: Draft
|
||||||
|
onChange: (draft: Draft) => void
|
||||||
|
onSubmit: () => void
|
||||||
|
submitLabel: string
|
||||||
|
}) {
|
||||||
|
const dateId = useId()
|
||||||
|
const valid =
|
||||||
|
draft.text.trim().length > 0 &&
|
||||||
|
(draft.kind !== '体重' ||
|
||||||
|
(Number.isFinite(Number(draft.text)) && Number(draft.text) > 0))
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="journal-editor"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (valid) onSubmit()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<fieldset className="journal-kind-switch" aria-label="記録の種類">
|
||||||
|
{kinds.map((kind) => (
|
||||||
|
<Button
|
||||||
|
key={kind}
|
||||||
|
type="button"
|
||||||
|
variant={draft.kind === kind ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={draft.kind === kind}
|
||||||
|
onClick={() => onChange({ ...draft, kind })}
|
||||||
|
>
|
||||||
|
{kind}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
{draft.kind === '体重' ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
min="0.1"
|
||||||
|
aria-label="体重"
|
||||||
|
placeholder="体重"
|
||||||
|
value={draft.text}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({ ...draft, text: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">kg</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
aria-label="記録内容"
|
||||||
|
placeholder={
|
||||||
|
draft.kind === '服薬'
|
||||||
|
? '服用した薬の名前'
|
||||||
|
: draft.kind === '再開地点'
|
||||||
|
? '次にすること、開いておく場所'
|
||||||
|
: '自由なメモ、次にすること'
|
||||||
|
}
|
||||||
|
value={draft.text}
|
||||||
|
onChange={(event) => onChange({ ...draft, text: event.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{draft.kind === '体重' && (
|
||||||
|
<Input
|
||||||
|
aria-label="記録元"
|
||||||
|
placeholder="記録元(体重計など・任意)"
|
||||||
|
value={draft.source}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({ ...draft, source: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="journal-editor-footer">
|
||||||
|
<label className="journal-date-label" htmlFor={dateId}>
|
||||||
|
発生日時(任意)
|
||||||
|
<Input
|
||||||
|
id={dateId}
|
||||||
|
type="datetime-local"
|
||||||
|
aria-label="発生日時"
|
||||||
|
value={draft.occurredAt}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({ ...draft, occurredAt: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button type="submit" disabled={!valid}>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CorrectionDialog({
|
||||||
|
entry,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
entry: Entry
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (draft: Draft) => void
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState<Draft>(entry)
|
||||||
|
return (
|
||||||
|
<Dialog title="記録を訂正" onClose={onClose} className="sm:max-w-xl">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
元の記録を残して、訂正を追加します。
|
||||||
|
</p>
|
||||||
|
<EntryEditor
|
||||||
|
draft={draft}
|
||||||
|
onChange={setDraft}
|
||||||
|
onSubmit={() => onSave(draft)}
|
||||||
|
submitLabel="訂正を追加"
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummary(entries: Entry[]) {
|
||||||
|
if (!entries.length) return '記録を選択してください。'
|
||||||
|
const dates = entries
|
||||||
|
.map((entry) => entry.occurredAt)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
const period = dates.length
|
||||||
|
? `${formatTime(dates[0])} 〜 ${formatTime(dates[dates.length - 1])}`
|
||||||
|
: '不明'
|
||||||
|
return [
|
||||||
|
`発生日時の期間: ${period}`,
|
||||||
|
`発生日時が不明の記録: ${entries.filter((entry) => !entry.occurredAt).length}件`,
|
||||||
|
'',
|
||||||
|
...entries.map(
|
||||||
|
(entry) =>
|
||||||
|
`[${entry.kind}] ${entry.text}${entry.kind === '体重' ? ' kg' : ''}\n発生日時: ${formatTime(entry.occurredAt)} / 記録日時: ${formatTime(entry.recordedAt)}${entry.kind === '体重' ? ` / 記録元: ${entry.source || '不明'}` : ''}`,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
'記録のない期間の服薬・体重は不明です。',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
.journal-workspace {
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.journal-main {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.journal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 68px;
|
||||||
|
padding: 16px 28px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.journal-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.journal-column {
|
||||||
|
width: min(100%, 760px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 28px;
|
||||||
|
}
|
||||||
|
.journal-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.journal-kind-switch {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.journal-editor textarea {
|
||||||
|
min-height: 112px;
|
||||||
|
}
|
||||||
|
.journal-editor-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.journal-date-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.journal-date-label input {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.journal-entries {
|
||||||
|
margin-top: 36px;
|
||||||
|
}
|
||||||
|
.journal-empty {
|
||||||
|
padding: 40px 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.journal-entry {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
.journal-entry-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
.journal-entry-text {
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.journal-review-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.journal-review-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.journal-review-row [data-slot="checkbox"] {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.journal-summary {
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.journal-header {
|
||||||
|
padding: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.journal-column {
|
||||||
|
padding: 24px 14px;
|
||||||
|
}
|
||||||
|
.journal-editor-footer {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.journal-editor-footer button {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
.journal-kind-switch {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.journal-kind-switch button {
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,66 +416,81 @@ 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"
|
{messages.length === 0 ? (
|
||||||
ref={container}
|
<MessageScrollerItem messageId="empty">
|
||||||
onScroll={() => {
|
<p className="research-empty">
|
||||||
const element = container.current
|
気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。
|
||||||
if (element)
|
</p>
|
||||||
follow.current =
|
</MessageScrollerItem>
|
||||||
element.scrollHeight - element.scrollTop - element.clientHeight < 60
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<p className="research-empty">
|
|
||||||
気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
messages.map((message) => (
|
|
||||||
<article
|
|
||||||
className={`research-message research-message-${message.role}`}
|
|
||||||
key={message.id}
|
|
||||||
>
|
|
||||||
<strong>{names[message.role]}</strong>
|
|
||||||
{message.role === 'assistant' ? (
|
|
||||||
<ResearchMarkdown
|
|
||||||
text={message.text}
|
|
||||||
onNavigateCitation={(url) =>
|
|
||||||
onNavigateCitation?.(url, citations) ?? false
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<p>{message.text}</p>
|
messages.map((message) => (
|
||||||
|
<MessageScrollerItem
|
||||||
|
key={message.id}
|
||||||
|
messageId={message.id}
|
||||||
|
scrollAnchor={message.role === 'user'}
|
||||||
|
>
|
||||||
|
{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' ? (
|
||||||
|
<ResearchMarkdown
|
||||||
|
text={message.text}
|
||||||
|
onNavigateCitation={(url) =>
|
||||||
|
onNavigateCitation?.(url, citations) ?? false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Bubble align="end">
|
||||||
|
<BubbleContent>
|
||||||
|
<p>{message.text}</p>
|
||||||
|
</BubbleContent>
|
||||||
|
</Bubble>
|
||||||
|
)}
|
||||||
|
</MessageContent>
|
||||||
|
</Message>
|
||||||
|
)}
|
||||||
|
</MessageScrollerItem>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</article>
|
</MessageScrollerContent>
|
||||||
))
|
</MessageScrollerViewport>
|
||||||
)}
|
<MessageScrollerButton aria-label="最新のメッセージへ" />
|
||||||
</div>
|
</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,56 +524,69 @@ 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" />}>
|
||||||
<fieldset className="research-accounts">
|
接続アカウント · {validSelection.length}件
|
||||||
<legend>調査に使う接続アカウント(最大6件)</legend>
|
</CollapsibleTrigger>
|
||||||
{available.length === 0 ? (
|
<CollapsibleContent>
|
||||||
<p>接続アカウントを先に追加してください。</p>
|
<fieldset className="research-accounts">
|
||||||
) : (
|
<legend>調査に使う接続アカウント(最大6件)</legend>
|
||||||
available.map((connection) => (
|
{available.length === 0 ? (
|
||||||
<label key={connection.id}>
|
<p>接続アカウントを先に追加してください。</p>
|
||||||
<input
|
) : (
|
||||||
type="checkbox"
|
available.map((connection) => (
|
||||||
checked={selectedIds.includes(connection.id)}
|
<label key={connection.id} htmlFor={`${id}-${connection.id}`}>
|
||||||
disabled={
|
<Checkbox
|
||||||
!selectedIds.includes(connection.id) &&
|
id={`${id}-${connection.id}`}
|
||||||
validSelection.length >= 6
|
checked={selectedIds.includes(connection.id)}
|
||||||
}
|
disabled={
|
||||||
onChange={(event) =>
|
disabled ||
|
||||||
setSelected(
|
(!selectedIds.includes(connection.id) &&
|
||||||
event.target.checked
|
validSelection.length >= 6)
|
||||||
? [...selectedIds, connection.id]
|
}
|
||||||
: selectedIds.filter(
|
onCheckedChange={(checked) =>
|
||||||
(value) => value !== connection.id,
|
onSelectionChange(
|
||||||
),
|
checked
|
||||||
)
|
? [...selectedIds, connection.id]
|
||||||
}
|
: selectedIds.filter(
|
||||||
/>
|
(value) => value !== connection.id,
|
||||||
{connection.displayName} ·{' '}
|
),
|
||||||
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'}
|
)
|
||||||
</label>
|
}
|
||||||
))
|
/>
|
||||||
)}
|
{connection.displayName} ·{' '}
|
||||||
</fieldset>
|
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'}
|
||||||
</details>
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
<label htmlFor={id}>メッセージ</label>
|
<label htmlFor={id}>メッセージ</label>
|
||||||
<textarea
|
<InputGroup>
|
||||||
id={id}
|
<InputGroupTextarea
|
||||||
value={topic}
|
id={id}
|
||||||
onChange={(event) => setTopic(event.target.value)}
|
value={topic}
|
||||||
maxLength={1000}
|
onChange={(event) => onTopicChange(event.target.value)}
|
||||||
rows={3}
|
maxLength={1000}
|
||||||
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。"
|
rows={3}
|
||||||
required
|
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。"
|
||||||
/>
|
required
|
||||||
<button
|
disabled={disabled}
|
||||||
type="submit"
|
/>
|
||||||
className="deck-primary"
|
<InputGroupAddon align="block-end" className="justify-end">
|
||||||
disabled={disabled || !topic.trim() || validSelection.length === 0}
|
<InputGroupButton
|
||||||
>
|
type="submit"
|
||||||
送信
|
variant="default"
|
||||||
</button>
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
disabled || !topic.trim() || validSelection.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
送信
|
||||||
|
</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,283 @@
|
|||||||
|
import { CalendarPlus, Check, ChevronDown, Send } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '#/components/ui/dialog'
|
||||||
|
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||||
|
import { Textarea } from '#/components/ui/textarea'
|
||||||
|
import type { Conversation } from './mock-conversations'
|
||||||
|
|
||||||
|
export function ConversationDetail({
|
||||||
|
conversation,
|
||||||
|
onResolve,
|
||||||
|
}: {
|
||||||
|
conversation: Conversation
|
||||||
|
onResolve: (
|
||||||
|
result: 'sent' | 'waiting' | 'later' | 'dismissed',
|
||||||
|
note?: string,
|
||||||
|
) => void
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(conversation.drafts[0]?.text ?? '')
|
||||||
|
const [tone, setTone] = useState(conversation.drafts[0]?.label ?? '')
|
||||||
|
const [review, setReview] = useState<'send' | 'schedule' | null>(null)
|
||||||
|
const [slot, setSlot] = useState('')
|
||||||
|
const [scheduled, setScheduled] = useState('')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="mx-auto w-full max-w-3xl px-5 py-8 sm:px-10 sm:py-10"
|
||||||
|
aria-label={`${conversation.name}との会話`}
|
||||||
|
>
|
||||||
|
<header className="mb-8 space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{conversation.channels.join(' / ')} · {conversation.role}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-medium tracking-tight">
|
||||||
|
{conversation.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{conversation.subject}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="mb-8 space-y-4" aria-label="会話の要点">
|
||||||
|
<p className="text-sm leading-7">{conversation.summary}</p>
|
||||||
|
<div className="border-l-2 border-border pl-4">
|
||||||
|
<p className="text-sm leading-6">{conversation.nextStep}</p>
|
||||||
|
{conversation.deadline && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
相手の指定期限 · {conversation.deadline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details className="group mb-8 border-y border-border py-3">
|
||||||
|
<summary className="flex cursor-pointer list-none items-center justify-between text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
元のやりとり{' '}
|
||||||
|
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||||
|
</summary>
|
||||||
|
<div className="space-y-5 pt-5">
|
||||||
|
{conversation.messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
id={`source-${message.id}`}
|
||||||
|
className="scroll-mt-5 rounded-lg bg-muted/40 p-4 focus:outline-none"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<p className="mb-2 text-xs text-muted-foreground">
|
||||||
|
{message.direction === 'in' ? conversation.name : '自分'} ·{' '}
|
||||||
|
{message.channel} · {message.time}
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-7">
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<section aria-label="返信を作る" className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<label
|
||||||
|
htmlFor={`support-reply-${conversation.id}`}
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
返信
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{conversation.replyChannel} → {conversation.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{conversation.drafts.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.label}
|
||||||
|
variant={tone === option.label ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={tone === option.label}
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(option.text)
|
||||||
|
setTone(option.label)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id={`support-reply-${conversation.id}`}
|
||||||
|
value={draft}
|
||||||
|
className="min-h-36 resize-y text-sm leading-7"
|
||||||
|
onChange={(event) => {
|
||||||
|
setDraft(event.target.value)
|
||||||
|
setTone('')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{conversation.scheduleOptions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<NativeSelect
|
||||||
|
aria-label="日程候補"
|
||||||
|
value={slot}
|
||||||
|
onChange={(event) => setSlot(event.target.value)}
|
||||||
|
>
|
||||||
|
<NativeSelectOption value="">候補日時を選ぶ</NativeSelectOption>
|
||||||
|
{conversation.scheduleOptions.map((option) => (
|
||||||
|
<NativeSelectOption key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!slot}
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(
|
||||||
|
(current) => `${current}\n\n${slot}はいかがでしょうか。`,
|
||||||
|
)
|
||||||
|
setTone('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
返信に入れる
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!slot}
|
||||||
|
aria-label="予定を仮置きする"
|
||||||
|
onClick={() => setReview('schedule')}
|
||||||
|
>
|
||||||
|
<CalendarPlus />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{scheduled && (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="flex items-center gap-2 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Check className="size-3" />
|
||||||
|
{scheduled} をこの画面に仮置きしました
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||||
|
<Button disabled={!draft.trim()} onClick={() => setReview('send')}>
|
||||||
|
<Send />
|
||||||
|
送信内容を確認
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onResolve('later')}
|
||||||
|
>
|
||||||
|
今は置いておく
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onResolve('waiting')}
|
||||||
|
>
|
||||||
|
相手待ち
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onResolve('dismissed')}
|
||||||
|
>
|
||||||
|
返信せず閉じる
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details className="group mt-10 border-t border-border pt-4">
|
||||||
|
<summary className="flex cursor-pointer list-none items-center justify-between text-xs text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
人物メモ{' '}
|
||||||
|
<ChevronDown className="size-3 transition-transform group-open:rotate-180" />
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-4 space-y-3 text-sm leading-6">
|
||||||
|
{conversation.facts.map((fact) => (
|
||||||
|
<li
|
||||||
|
key={fact.sourceId}
|
||||||
|
className="flex items-start justify-between gap-3"
|
||||||
|
>
|
||||||
|
<span>{fact.text}</span>
|
||||||
|
<a
|
||||||
|
className="shrink-0 text-xs text-muted-foreground underline underline-offset-4"
|
||||||
|
href={`#source-${fact.sourceId}`}
|
||||||
|
onClick={() => {
|
||||||
|
const source = document.getElementById(
|
||||||
|
`source-${fact.sourceId}`,
|
||||||
|
)
|
||||||
|
const details = source?.closest('details')
|
||||||
|
if (details) details.open = true
|
||||||
|
source?.focus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
出典
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={review !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setReview(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{review === 'schedule' ? '予定の確認' : '送信内容の確認'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{review === 'schedule'
|
||||||
|
? '外部カレンダーには登録せず、この画面だけに仮置きします。'
|
||||||
|
: '外部には送信されません。完了後の表示を試せます。'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{conversation.name}
|
||||||
|
{review === 'send' && (
|
||||||
|
<span className="ml-2 font-normal text-muted-foreground">
|
||||||
|
{conversation.replyChannel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="max-h-[45dvh] overflow-y-auto whitespace-pre-wrap rounded-lg bg-muted/50 p-4 text-sm leading-7">
|
||||||
|
{review === 'schedule' ? slot : draft}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setReview(null)}>
|
||||||
|
戻る
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (review === 'schedule') setScheduled(slot)
|
||||||
|
else
|
||||||
|
onResolve(
|
||||||
|
'sent',
|
||||||
|
`${conversation.replyChannel} → ${conversation.name}(送信プレビュー)\n${draft}`,
|
||||||
|
)
|
||||||
|
setReview(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{review === 'schedule' ? '仮置きを試す' : '送信を試す'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
type Channel = 'Slack' | 'LINE' | 'Gmail'
|
||||||
|
|
||||||
|
export type Conversation = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
subject: string
|
||||||
|
channels: Channel[]
|
||||||
|
replyChannel: Channel
|
||||||
|
updatedAt: string
|
||||||
|
deadline?: string
|
||||||
|
summary: string
|
||||||
|
nextStep: string
|
||||||
|
messages: {
|
||||||
|
id: string
|
||||||
|
channel: Channel
|
||||||
|
text: string
|
||||||
|
time: string
|
||||||
|
direction: 'in' | 'out'
|
||||||
|
}[]
|
||||||
|
facts: { text: string; sourceId: string }[]
|
||||||
|
drafts: { label: string; text: string }[]
|
||||||
|
scheduleOptions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// All contacts, conversations and proposed appointments are fictional UI samples.
|
||||||
|
export const conversations: Conversation[] = [
|
||||||
|
{
|
||||||
|
id: 'design-review',
|
||||||
|
name: '青木 遥',
|
||||||
|
role: 'プロダクトデザイナー',
|
||||||
|
subject: '来週のレビュー、日程を決める',
|
||||||
|
channels: ['Slack', 'Gmail'],
|
||||||
|
replyChannel: 'Slack',
|
||||||
|
updatedAt: '10:42',
|
||||||
|
deadline: '9月29日 17:00',
|
||||||
|
summary:
|
||||||
|
'新しい画面のレビュー依頼。資料はメールで届いていて、Slackで日程の返事を待っている。30分あればよい。',
|
||||||
|
nextStep: '資料を確認して、30分の候補日時を返す。',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'aoki-1',
|
||||||
|
channel: 'Gmail',
|
||||||
|
time: '9月27日 16:20',
|
||||||
|
direction: 'in',
|
||||||
|
text: 'レビュー用の画面案を共有します。特に初回の導線について意見を聞きたいです。確認のお時間は30分ほどを想定しています。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aoki-2',
|
||||||
|
channel: 'Slack',
|
||||||
|
time: '9月28日 10:42',
|
||||||
|
direction: 'in',
|
||||||
|
text: '昨日メールしたレビューの件、来週どこかでお時間いただけますか? 9月29日17時までに候補をいただけると助かります。午後だとうれしいです。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
facts: [
|
||||||
|
{ text: 'レビューは30分を想定している。', sourceId: 'aoki-1' },
|
||||||
|
{ text: '今回の日程は午後を希望している。', sourceId: 'aoki-2' },
|
||||||
|
],
|
||||||
|
drafts: [
|
||||||
|
{
|
||||||
|
label: '短く',
|
||||||
|
text: '資料ありがとうございます。確認します! 来週の午後で、30分ほどお話しできればと思います。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '丁寧に',
|
||||||
|
text: '資料をご共有いただきありがとうございます。初回の導線を中心に確認いたします。来週の午後に30分ほどお時間をいただけますでしょうか。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'まず受け取る',
|
||||||
|
text: 'ご連絡ありがとうございます。資料、受け取りました。日程を確認して9月29日17時までにお返事します。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scheduleOptions: ['10月5日(月)14:00–14:30', '10月6日(火)15:00–15:30'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weekend-coffee',
|
||||||
|
name: '森 直人',
|
||||||
|
role: '友人',
|
||||||
|
subject: '久しぶりに、お茶でも',
|
||||||
|
channels: ['LINE'],
|
||||||
|
replyChannel: 'LINE',
|
||||||
|
updatedAt: '昨日',
|
||||||
|
summary:
|
||||||
|
'近くに来るのでお茶をしようという誘い。急ぎの返事は求められていない。',
|
||||||
|
nextStep: '会いたければ都合を返す。今決めなくてもよい。',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'mori-1',
|
||||||
|
channel: 'LINE',
|
||||||
|
time: '9月27日 19:15',
|
||||||
|
direction: 'in',
|
||||||
|
text: '来週そっちの方に行くかも。時間合ったらお茶しない? 土曜の午後なら空いてる。急ぎじゃないので、予定わかったら!',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
facts: [{ text: '今回は土曜の午後が空いている。', sourceId: 'mori-1' }],
|
||||||
|
drafts: [
|
||||||
|
{
|
||||||
|
label: '短く',
|
||||||
|
text: 'いいね、久しぶりに会いたい! 土曜の午後で予定見てみるね。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '丁寧に',
|
||||||
|
text: '誘ってくれてありがとう! ぜひお茶したいです。土曜の午後、都合を確認して連絡するね。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'まず受け取る',
|
||||||
|
text: 'ありがとう! まだ予定が見えないので、わかったら連絡するね。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scheduleOptions: ['10月3日(土)14:00–15:00', '10月3日(土)16:00–17:00'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'estimate-check',
|
||||||
|
name: '藤井 理沙',
|
||||||
|
role: '制作パートナー',
|
||||||
|
subject: '見積もりの前提を確認する',
|
||||||
|
channels: ['Gmail'],
|
||||||
|
replyChannel: 'Gmail',
|
||||||
|
updatedAt: '9:10',
|
||||||
|
deadline: '9月30日 12:00',
|
||||||
|
summary:
|
||||||
|
'見積もりを進めるため、対象画面が3画面でよいか確認が届いている。回答期限は9月30日の正午。',
|
||||||
|
nextStep: '対象画面数を確認して返答する。未確認のまま確約しない。',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'fujii-1',
|
||||||
|
channel: 'Gmail',
|
||||||
|
time: '9月28日 9:10',
|
||||||
|
direction: 'in',
|
||||||
|
text: '概算の作成にあたり、対象は一覧・詳細・設定の3画面という理解でよろしいでしょうか。9月30日正午までにご確認いただければ、同日中にお見積もりをお送りします。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
facts: [
|
||||||
|
{ text: '対象画面数の確認後に概算を作成する予定。', sourceId: 'fujii-1' },
|
||||||
|
],
|
||||||
|
drafts: [
|
||||||
|
{
|
||||||
|
label: '短く',
|
||||||
|
text: 'ありがとうございます。対象の3画面について確認し、9月30日正午までにお返事します。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '丁寧に',
|
||||||
|
text: 'ご確認ありがとうございます。一覧・詳細・設定の3画面で相違ないか確認のうえ、9月30日正午までに回答いたします。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'まず受け取る',
|
||||||
|
text: 'ご連絡を受け取りました。対象範囲を確認して、改めてご連絡します。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scheduleOptions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reading-note',
|
||||||
|
name: '田辺 悠',
|
||||||
|
role: '勉強会の仲間',
|
||||||
|
subject: '前に話していた記事',
|
||||||
|
channels: ['Slack'],
|
||||||
|
replyChannel: 'Slack',
|
||||||
|
updatedAt: '金曜日',
|
||||||
|
summary:
|
||||||
|
'前の勉強会で話題になった記事を共有してくれた。質問や返答の依頼はない。',
|
||||||
|
nextStep: '読む時間がなければ、返信せず閉じてよい。',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'tanabe-1',
|
||||||
|
channel: 'Slack',
|
||||||
|
time: '9月25日 18:30',
|
||||||
|
direction: 'in',
|
||||||
|
text: '前に話していた、道具を増やさず仕事を進める話の記事を見つけたので共有! 時間あるときにどうぞ。返信不要です。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
facts: [
|
||||||
|
{ text: 'この共有への返信は不要と明記している。', sourceId: 'tanabe-1' },
|
||||||
|
],
|
||||||
|
drafts: [
|
||||||
|
{ label: '短く', text: '共有ありがとう! あとで読んでみるね。' },
|
||||||
|
{
|
||||||
|
label: '丁寧に',
|
||||||
|
text: '覚えていてくれてありがとう。ちょうど気になっていた話なので、時間を取って読んでみます。',
|
||||||
|
},
|
||||||
|
{ label: 'まず受け取る', text: 'ありがとう、受け取りました!' },
|
||||||
|
],
|
||||||
|
scheduleOptions: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { ArrowLeft, Search } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { Input } from '#/components/ui/input'
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
} from '#/components/ui/sidebar'
|
||||||
|
import { TooltipProvider } from '#/components/ui/tooltip'
|
||||||
|
import { WorkspaceNavigation } from '#/components/workspace-navigation'
|
||||||
|
import { ConversationDetail } from './conversation-detail'
|
||||||
|
import { conversations } from './mock-conversations'
|
||||||
|
import './support.css'
|
||||||
|
|
||||||
|
type Status = 'now' | 'later' | 'waiting' | 'sent' | 'dismissed'
|
||||||
|
const views = [
|
||||||
|
{ id: 'now', label: '今' },
|
||||||
|
{ id: 'later', label: 'あとで' },
|
||||||
|
{ id: 'waiting', label: '相手待ち' },
|
||||||
|
{ id: 'done', label: '履歴' },
|
||||||
|
{ id: 'all', label: 'すべて' },
|
||||||
|
] as const
|
||||||
|
export function SupportPage() {
|
||||||
|
const [view, setView] = useState<string>('now')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [statuses, setStatuses] = useState<Record<string, Status>>({})
|
||||||
|
const [selectedId, setSelectedId] = useState<string>()
|
||||||
|
const [reading, setReading] = useState(false)
|
||||||
|
const [outcomes, setOutcomes] = useState<Record<string, string>>({})
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const matches = conversations.filter((item) => {
|
||||||
|
const status = statuses[item.id] ?? 'now'
|
||||||
|
return (
|
||||||
|
(view === 'all'
|
||||||
|
? true
|
||||||
|
: view === 'done'
|
||||||
|
? status === 'sent' || status === 'dismissed'
|
||||||
|
: status === view) && `${item.name} ${item.subject}`.includes(query)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const visible = view === 'now' ? matches.slice(0, 3) : matches
|
||||||
|
const selected = visible.find((item) => item.id === selectedId) ?? visible[0]
|
||||||
|
function resolve(id: string, status: Exclude<Status, 'now'>, note?: string) {
|
||||||
|
setStatuses((current) => ({ ...current, [id]: status }))
|
||||||
|
if (note) setOutcomes((current) => ({ ...current, [id]: note }))
|
||||||
|
setNotice(
|
||||||
|
status === 'sent'
|
||||||
|
? '返信の操作を記録しました'
|
||||||
|
: status === 'later'
|
||||||
|
? 'あとでに移しました'
|
||||||
|
: status === 'waiting'
|
||||||
|
? '相手待ちに移しました'
|
||||||
|
: '対応不要にしました',
|
||||||
|
)
|
||||||
|
setReading(false)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<SidebarProvider className="support-workspace" data-reading={reading}>
|
||||||
|
<Sidebar collapsible="none" className="support-sidebar">
|
||||||
|
<WorkspaceNavigation />
|
||||||
|
<Sidebar collapsible="none" className="support-list">
|
||||||
|
<SidebarHeader className="border-b p-5 gap-4">
|
||||||
|
<h1 className="text-base font-semibold">連絡</h1>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{views.map((tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab.id}
|
||||||
|
size="sm"
|
||||||
|
variant={view === tab.id ? 'secondary' : 'ghost'}
|
||||||
|
aria-pressed={view === tab.id}
|
||||||
|
onClick={() => {
|
||||||
|
setView(tab.id)
|
||||||
|
setSelectedId(undefined)
|
||||||
|
setReading(false)
|
||||||
|
setNotice('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
aria-label="連絡を検索"
|
||||||
|
placeholder="検索"
|
||||||
|
className="pl-9"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent className="gap-0">
|
||||||
|
{visible.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="support-row"
|
||||||
|
aria-current={selected?.id === item.id ? 'true' : undefined}
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(item.id)
|
||||||
|
setReading(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-sm">{item.subject}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!visible.length && (
|
||||||
|
<p className="p-5 text-sm text-muted-foreground">
|
||||||
|
{query ? '見つかりませんでした' : 'いま扱う連絡はありません'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{view === 'now' && matches.length > 3 && (
|
||||||
|
<p className="p-5 text-xs text-muted-foreground">
|
||||||
|
ほか {matches.length - 3} 件
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SidebarContent>
|
||||||
|
</Sidebar>
|
||||||
|
</Sidebar>
|
||||||
|
<SidebarInset className="support-main">
|
||||||
|
<header className="support-toolbar">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="support-back"
|
||||||
|
aria-label="連絡一覧に戻る"
|
||||||
|
onClick={() => setReading(false)}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm">{selected?.name}</span>
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
className="ml-auto text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{notice}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<section
|
||||||
|
className="support-detail"
|
||||||
|
aria-label="連絡の詳細"
|
||||||
|
// biome-ignore lint/a11y/noNoninteractiveTabindex: Independent region needs keyboard scrolling.
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{conversations.map((conversation) => (
|
||||||
|
<div
|
||||||
|
key={conversation.id}
|
||||||
|
hidden={selected?.id !== conversation.id}
|
||||||
|
>
|
||||||
|
<ConversationDetail
|
||||||
|
conversation={conversation}
|
||||||
|
onResolve={(status, note) =>
|
||||||
|
resolve(conversation.id, status, note)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
{outcomes[selected.id] && (
|
||||||
|
<div className="support-outcome">
|
||||||
|
<h2 className="font-medium text-sm">活動の記録</h2>
|
||||||
|
<p className="whitespace-pre-wrap mt-2 text-sm">
|
||||||
|
{outcomes[selected.id]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{view !== 'now' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="m-6"
|
||||||
|
onClick={() => {
|
||||||
|
setStatuses((current) => ({
|
||||||
|
...current,
|
||||||
|
[selected.id]: 'now',
|
||||||
|
}))
|
||||||
|
setView('now')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
今に戻す
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="support-empty">連絡を選択</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/* Hallmark · sidebar workbench · neutral · P5 H5 E4 S5 R5 V4 */
|
||||||
|
.support-workspace {
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.support-sidebar {
|
||||||
|
width: 390px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.support-list {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.support-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 22px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.support-row:hover {
|
||||||
|
background: var(--sidebar-accent);
|
||||||
|
}
|
||||||
|
.support-row[aria-current="true"] {
|
||||||
|
background: var(--muted);
|
||||||
|
box-shadow: inset 2px 0 var(--primary);
|
||||||
|
}
|
||||||
|
.support-main {
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.support-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.support-detail {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.support-detail > * {
|
||||||
|
max-width: 800px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
.support-outcome {
|
||||||
|
padding: 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.support-empty {
|
||||||
|
padding: 64px 24px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.support-back {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.support-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.support-main {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.support-back {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.support-workspace[data-reading="true"] .support-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.support-workspace[data-reading="true"] .support-main {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.support-toolbar {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+73
-2
@@ -9,11 +9,29 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as SupportRouteImport } from './routes/support'
|
||||||
|
import { Route as JournalRouteImport } from './routes/journal'
|
||||||
|
import { Route as InboxRouteImport } from './routes/inbox'
|
||||||
import { Route as DeckRouteImport } from './routes/deck'
|
import { Route as DeckRouteImport } from './routes/deck'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback'
|
import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback'
|
||||||
import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events'
|
import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events'
|
||||||
|
|
||||||
|
const SupportRoute = SupportRouteImport.update({
|
||||||
|
id: '/support',
|
||||||
|
path: '/support',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const JournalRoute = JournalRouteImport.update({
|
||||||
|
id: '/journal',
|
||||||
|
path: '/journal',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const InboxRoute = InboxRouteImport.update({
|
||||||
|
id: '/inbox',
|
||||||
|
path: '/inbox',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const DeckRoute = DeckRouteImport.update({
|
const DeckRoute = DeckRouteImport.update({
|
||||||
id: '/deck',
|
id: '/deck',
|
||||||
path: '/deck',
|
path: '/deck',
|
||||||
@@ -38,12 +56,18 @@ const ApiResearchEventsRoute = ApiResearchEventsRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/deck': typeof DeckRoute
|
'/deck': typeof DeckRoute
|
||||||
|
'/inbox': typeof InboxRoute
|
||||||
|
'/journal': typeof JournalRoute
|
||||||
|
'/support': typeof SupportRoute
|
||||||
'/api/research/events': typeof ApiResearchEventsRoute
|
'/api/research/events': typeof ApiResearchEventsRoute
|
||||||
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/deck': typeof DeckRoute
|
'/deck': typeof DeckRoute
|
||||||
|
'/inbox': typeof InboxRoute
|
||||||
|
'/journal': typeof JournalRoute
|
||||||
|
'/support': typeof SupportRoute
|
||||||
'/api/research/events': typeof ApiResearchEventsRoute
|
'/api/research/events': typeof ApiResearchEventsRoute
|
||||||
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
||||||
}
|
}
|
||||||
@@ -51,18 +75,38 @@ export interface FileRoutesById {
|
|||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/deck': typeof DeckRoute
|
'/deck': typeof DeckRoute
|
||||||
|
'/inbox': typeof InboxRoute
|
||||||
|
'/journal': typeof JournalRoute
|
||||||
|
'/support': typeof SupportRoute
|
||||||
'/api/research/events': typeof ApiResearchEventsRoute
|
'/api/research/events': typeof ApiResearchEventsRoute
|
||||||
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/deck' | '/api/research/events' | '/oauth/mastodon/callback'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/deck'
|
||||||
|
| '/inbox'
|
||||||
|
| '/journal'
|
||||||
|
| '/support'
|
||||||
|
| '/api/research/events'
|
||||||
|
| '/oauth/mastodon/callback'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/deck' | '/api/research/events' | '/oauth/mastodon/callback'
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/deck'
|
||||||
|
| '/inbox'
|
||||||
|
| '/journal'
|
||||||
|
| '/support'
|
||||||
|
| '/api/research/events'
|
||||||
|
| '/oauth/mastodon/callback'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/deck'
|
| '/deck'
|
||||||
|
| '/inbox'
|
||||||
|
| '/journal'
|
||||||
|
| '/support'
|
||||||
| '/api/research/events'
|
| '/api/research/events'
|
||||||
| '/oauth/mastodon/callback'
|
| '/oauth/mastodon/callback'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -70,12 +114,36 @@ export interface FileRouteTypes {
|
|||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
DeckRoute: typeof DeckRoute
|
DeckRoute: typeof DeckRoute
|
||||||
|
InboxRoute: typeof InboxRoute
|
||||||
|
JournalRoute: typeof JournalRoute
|
||||||
|
SupportRoute: typeof SupportRoute
|
||||||
ApiResearchEventsRoute: typeof ApiResearchEventsRoute
|
ApiResearchEventsRoute: typeof ApiResearchEventsRoute
|
||||||
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
|
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/support': {
|
||||||
|
id: '/support'
|
||||||
|
path: '/support'
|
||||||
|
fullPath: '/support'
|
||||||
|
preLoaderRoute: typeof SupportRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/journal': {
|
||||||
|
id: '/journal'
|
||||||
|
path: '/journal'
|
||||||
|
fullPath: '/journal'
|
||||||
|
preLoaderRoute: typeof JournalRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/inbox': {
|
||||||
|
id: '/inbox'
|
||||||
|
path: '/inbox'
|
||||||
|
fullPath: '/inbox'
|
||||||
|
preLoaderRoute: typeof InboxRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/deck': {
|
'/deck': {
|
||||||
id: '/deck'
|
id: '/deck'
|
||||||
path: '/deck'
|
path: '/deck'
|
||||||
@@ -110,6 +178,9 @@ declare module '@tanstack/react-router' {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
DeckRoute: DeckRoute,
|
DeckRoute: DeckRoute,
|
||||||
|
InboxRoute: InboxRoute,
|
||||||
|
JournalRoute: JournalRoute,
|
||||||
|
SupportRoute: SupportRoute,
|
||||||
ApiResearchEventsRoute: ApiResearchEventsRoute,
|
ApiResearchEventsRoute: ApiResearchEventsRoute,
|
||||||
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
|
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { InboxPage } from '#/features/inbox/inbox-page'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/inbox')({ component: InboxPage })
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { JournalPage } from '#/features/journal/journal-page'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/journal')({ component: JournalPage })
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { SupportPage } from '#/features/support/support-page'
|
||||||
|
export const Route = createFileRoute('/support')({ component: SupportPage })
|
||||||
+20
-63
@@ -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);
|
}
|
||||||
}
|
.sidebar-note {
|
||||||
.app-sidebar {
|
color: var(--muted-foreground);
|
||||||
flex-direction: row;
|
font-size: var(--text-xs);
|
||||||
align-items: center;
|
}
|
||||||
gap: var(--space-xs);
|
@media (prefers-reduced-motion: reduce) {
|
||||||
padding: var(--space-xs);
|
*,
|
||||||
overflow: visible;
|
*::before,
|
||||||
border-right: 0;
|
*::after {
|
||||||
border-bottom: var(--rule-hairline) solid var(--border);
|
/* biome-ignore lint/complexity/noImportantStyles: Respect reduced motion over registry component animation utilities. */
|
||||||
}
|
animation-duration: 0.01ms !important;
|
||||||
.sidebar-note {
|
/* biome-ignore lint/complexity/noImportantStyles: Respect reduced motion over registry component transition utilities. */
|
||||||
display: none;
|
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