feat: add communication support and journal prototypes

This commit is contained in:
2026-09-28 18:03:06 +09:00
parent 4e8957c94a
commit 0ee5d181a5
14 changed files with 1498 additions and 1 deletions
+8
View File
@@ -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",
+5
View File
@@ -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
+34
View File
@@ -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.
+147
View File
@@ -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([])
}
})
+19 -1
View File
@@ -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',
+349
View File
@@ -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')
}
+129
View File
@@ -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>
)
}
+186
View File
@@ -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: [],
},
]
+200
View File
@@ -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>
)
}
+89
View File
@@ -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;
}
}
+42
View File
@@ -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,
} }
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { JournalPage } from '#/features/journal/journal-page'
export const Route = createFileRoute('/journal')({ component: JournalPage })
+3
View File
@@ -0,0 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'
import { SupportPage } from '#/features/support/support-page'
export const Route = createFileRoute('/support')({ component: SupportPage })