feat: add communication support and journal prototypes
This commit is contained in:
@@ -1,4 +1,12 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
"date": "2026-09-28",
|
||||||
"macrostructure": "sidebar-09 Inbox",
|
"macrostructure": "sidebar-09 Inbox",
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ Articles are fictional samples; search and article selection work locally.
|
|||||||
On mobile, the article list and reader switch views. The shared sidebar’s bottom
|
On mobile, the article list and reader switch views. The shared sidebar’s bottom
|
||||||
avatar opens connected-account management from either workspace.
|
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,
|
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
|
||||||
|
|||||||
@@ -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,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([])
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Link, useLocation } from '@tanstack/react-router'
|
import { Link, useLocation } from '@tanstack/react-router'
|
||||||
import { Inbox, Layers3, Search } from 'lucide-react'
|
import {
|
||||||
|
Inbox,
|
||||||
|
Layers3,
|
||||||
|
MessagesSquare,
|
||||||
|
NotebookPen,
|
||||||
|
Search,
|
||||||
|
} from 'lucide-react'
|
||||||
import { type ReactNode, useState } from 'react'
|
import { type ReactNode, useState } from 'react'
|
||||||
import { Avatar, AvatarFallback } from '#/components/ui/avatar'
|
import { Avatar, AvatarFallback } from '#/components/ui/avatar'
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +36,18 @@ export function WorkspaceNavigation({
|
|||||||
const [managingAccounts, setManagingAccounts] = useState(false)
|
const [managingAccounts, setManagingAccounts] = useState(false)
|
||||||
const pathname = useLocation({ select: (location) => location.pathname })
|
const pathname = useLocation({ select: (location) => location.pathname })
|
||||||
const navigation = [
|
const navigation = [
|
||||||
|
{
|
||||||
|
title: '連絡',
|
||||||
|
to: '/support',
|
||||||
|
icon: MessagesSquare,
|
||||||
|
active: pathname === '/support',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '記録',
|
||||||
|
to: '/journal',
|
||||||
|
icon: NotebookPen,
|
||||||
|
active: pathname === '/journal',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'リーダー',
|
title: 'リーダー',
|
||||||
to: '/inbox',
|
to: '/inbox',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,24 @@
|
|||||||
// 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 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({
|
const InboxRoute = InboxRouteImport.update({
|
||||||
id: '/inbox',
|
id: '/inbox',
|
||||||
path: '/inbox',
|
path: '/inbox',
|
||||||
@@ -45,6 +57,8 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/deck': typeof DeckRoute
|
'/deck': typeof DeckRoute
|
||||||
'/inbox': typeof InboxRoute
|
'/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
|
||||||
}
|
}
|
||||||
@@ -52,6 +66,8 @@ export interface FileRoutesByTo {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/deck': typeof DeckRoute
|
'/deck': typeof DeckRoute
|
||||||
'/inbox': typeof InboxRoute
|
'/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
|
||||||
}
|
}
|
||||||
@@ -60,6 +76,8 @@ export interface FileRoutesById {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/deck': typeof DeckRoute
|
'/deck': typeof DeckRoute
|
||||||
'/inbox': typeof InboxRoute
|
'/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
|
||||||
}
|
}
|
||||||
@@ -69,6 +87,8 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/deck'
|
| '/deck'
|
||||||
| '/inbox'
|
| '/inbox'
|
||||||
|
| '/journal'
|
||||||
|
| '/support'
|
||||||
| '/api/research/events'
|
| '/api/research/events'
|
||||||
| '/oauth/mastodon/callback'
|
| '/oauth/mastodon/callback'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -76,6 +96,8 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/deck'
|
| '/deck'
|
||||||
| '/inbox'
|
| '/inbox'
|
||||||
|
| '/journal'
|
||||||
|
| '/support'
|
||||||
| '/api/research/events'
|
| '/api/research/events'
|
||||||
| '/oauth/mastodon/callback'
|
| '/oauth/mastodon/callback'
|
||||||
id:
|
id:
|
||||||
@@ -83,6 +105,8 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/deck'
|
| '/deck'
|
||||||
| '/inbox'
|
| '/inbox'
|
||||||
|
| '/journal'
|
||||||
|
| '/support'
|
||||||
| '/api/research/events'
|
| '/api/research/events'
|
||||||
| '/oauth/mastodon/callback'
|
| '/oauth/mastodon/callback'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -91,12 +115,28 @@ export interface RootRouteChildren {
|
|||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
DeckRoute: typeof DeckRoute
|
DeckRoute: typeof DeckRoute
|
||||||
InboxRoute: typeof InboxRoute
|
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': {
|
'/inbox': {
|
||||||
id: '/inbox'
|
id: '/inbox'
|
||||||
path: '/inbox'
|
path: '/inbox'
|
||||||
@@ -139,6 +179,8 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
DeckRoute: DeckRoute,
|
DeckRoute: DeckRoute,
|
||||||
InboxRoute: InboxRoute,
|
InboxRoute: InboxRoute,
|
||||||
|
JournalRoute: JournalRoute,
|
||||||
|
SupportRoute: SupportRoute,
|
||||||
ApiResearchEventsRoute: ApiResearchEventsRoute,
|
ApiResearchEventsRoute: ApiResearchEventsRoute,
|
||||||
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
|
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
Reference in New Issue
Block a user