feat(web): manage dictionaries and teams from the console

The console could not create dictionary entries, and team pages showed a
static member list with no record of who changed what. Dictionary
management, a knowledge upload form, and a team activity feed are now
available, alongside a download center that links the published desktop
installer feed rather than repository-local paths that no deploy ships.

Red-team e2e coverage was added for the account and team flows touched here.
This commit is contained in:
Yun Chan 2026-09-16 23:24:27 +09:00
parent f6a29db95a
commit cfc58458a8
21 changed files with 985 additions and 125 deletions

View file

@ -167,7 +167,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TactileBadge ledColor="amber" tone="accent">D3RO VOICE PRO</TactileBadge>
</Box>
<Typography component="h1" sx={{ fontSize: 28, fontWeight: 500, color: '#fff', letterSpacing: '-0.02em', mb: 1 }}>
<Typography component="h1" sx={{ fontSize: 28, fontWeight: 500, color: 'var(--d3-text-inverse)', letterSpacing: '-0.02em', mb: 1 }}>
</Typography>
<Typography sx={{ fontSize: 14, color: 'var(--d3-text-label)' }}>
@ -195,7 +195,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
CURRENT SUBSCRIPTION
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Typography data-testid="billing-current-tier" sx={{ fontSize: 28, fontWeight: 600, color: '#fff' }}>
<Typography data-testid="billing-current-tier" sx={{ fontSize: 28, fontWeight: 600, color: 'var(--d3-text-inverse)' }}>
{tierLabel(subscription.tier)}
</Typography>
<Chip
@ -210,7 +210,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
: {state.email}
</Typography>
{cancellationDate ? (
<Typography data-testid="billing-cancel-at" sx={{ mt: 1, color: '#ffb000', fontSize: 12 }}>
<Typography data-testid="billing-cancel-at" sx={{ mt: 1, color: 'var(--d3-status-warning)', fontSize: 12 }}>
{cancellationDate} .
</Typography>
) : periodEnd && isPaid ? (
@ -282,7 +282,7 @@ function PlanCard({
border: active
? '2px solid var(--d3-accent-main)'
: plan.highlight
? '1px solid rgba(59,130,246,0.45)'
? '1px solid var(--d3-accent-glow)'
: undefined
}}
>
@ -290,7 +290,7 @@ function PlanCard({
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: active ? 'var(--d3-accent-main)' : 'var(--d3-text-label)', mb: 0.75 }}>
{active ? 'CURRENT PLAN' : plan.highlight ? 'RECOMMENDED' : 'PLAN'}
</Typography>
<Typography component="h2" sx={{ fontSize: 24, fontWeight: 600, color: '#fff' }}>{plan.name}</Typography>
<Typography component="h2" sx={{ fontSize: 24, fontWeight: 600, color: 'var(--d3-text-inverse)' }}>{plan.name}</Typography>
<Typography sx={{ mt: 0.5, mb: 3, color: plan.tier === 'free' ? 'var(--d3-text-label)' : 'var(--d3-accent-main)', fontFamily: d3roFontMono, fontSize: 16 }}>
{priceLabel}
</Typography>
@ -315,7 +315,7 @@ function PlanCard({
) : canPurchase && catalogPrices.length > 0 ? (
<BillingCheckoutOptions tier={plan.tier} prices={catalogPrices} />
) : canPurchase ? (
<Typography data-testid={`billing-price-unavailable-${plan.tier}`} sx={{ py: 1.5, textAlign: 'center', color: '#ffb000', fontSize: 12 }}>
<Typography data-testid={`billing-price-unavailable-${plan.tier}`} sx={{ py: 1.5, textAlign: 'center', color: 'var(--d3-status-warning)', fontSize: 12 }}>
.
</Typography>
) : (
@ -332,7 +332,7 @@ function SubscriptionManagement({ subscription }: { subscription: BillingSubscri
return <Typography sx={{ color: 'var(--d3-text-label)', fontSize: 12 }}> .</Typography>
}
if (subscription.cancel_at || subscription.auto_renewing === false) {
return <Typography sx={{ color: '#ffb000', fontSize: 12 }}> .</Typography>
return <Typography sx={{ color: 'var(--d3-status-warning)', fontSize: 12 }}> .</Typography>
}
if (subscription.provider === 'payple') return <PaypleManageButton />
if (subscription.provider === 'stripe') return <PortalButton />
@ -354,7 +354,9 @@ function BillingLoadError(): React.ReactElement {
<Alert severity="error" data-testid="billing-load-error" sx={{ mb: 2 }}>
. .
</Alert>
<Button component={Link} href="/billing" variant="outlined"> </Button>
<Link href="/billing" style={{ textDecoration: 'none' }}>
<Button variant="outlined"> </Button>
</Link>
</Box>
)
}

View file

@ -369,15 +369,15 @@ export default function CommandsPage(): React.ReactElement {
<DoubleBezelCard innerPadding={3} sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 3, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 44, height: 44, borderRadius: '12px', bgcolor: 'rgba(59,130,246,0.15)', border: '1px solid rgba(59,130,246,0.4)', display: 'grid', placeItems: 'center', color: 'var(--d3-accent-main)' }}><Zap size={22} /></Box>
<Box sx={{ width: 44, height: 44, borderRadius: '12px', bgcolor: 'var(--d3-accent-glow)', border: '1px solid var(--d3-accent-glow)', display: 'grid', placeItems: 'center', color: 'var(--d3-accent-main)' }}><Zap size={22} /></Box>
<Box>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 10, color: 'var(--d3-text-label)' }}>ACTIVE SYNCED INSTRUCTION</Typography>
<Typography sx={{ fontSize: 18, fontWeight: 500, color: '#fff' }}>{activeInstruction?.name ?? '활성 명령 없음'}</Typography>
<Typography sx={{ fontSize: 18, fontWeight: 500, color: 'var(--d3-text-inverse)' }}>{activeInstruction?.name ?? '활성 명령 없음'}</Typography>
</Box>
</Box>
<TactileBadge ledColor="green" tone="success">SUPABASE SSOT · REV {state.settingsRevision}</TactileBadge>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(4, 1fr)' }, gap: 1.5, p: 2, bgcolor: 'var(--d3-bg-inset)', borderRadius: '12px', border: '1px solid #1a1a1c' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(4, 1fr)' }, gap: 1.5, p: 2, bgcolor: 'var(--d3-bg-inset)', borderRadius: '12px', border: '1px solid var(--d3-bg-card)' }}>
<PipelineStep icon={<Braces size={15} />} step="01" label="텍스트 입력" />
<PipelineStep icon={<CheckCircle2 size={15} />} step="02" label="활성 명령 적용" />
<PipelineStep icon={<Cpu size={15} />} step="03" label="llm-proxy" accent />
@ -418,7 +418,7 @@ export default function CommandsPage(): React.ReactElement {
<Box sx={{ pt: 0.5 }}><Led color={active ? 'green' : 'amber'} size={8} /></Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 0.5 }}>
<Typography sx={{ fontSize: 16, fontWeight: 500, color: active ? 'var(--d3-accent-main)' : '#fff' }}>{instruction.name}</Typography>
<Typography sx={{ fontSize: 16, fontWeight: 500, color: active ? 'var(--d3-accent-main)' : 'var(--d3-text-primary)' }}>{instruction.name}</Typography>
<TactileBadge mono tone={instruction.builtinKey ? 'mono' : 'accent'}>{instruction.builtinKey ? 'BUILT-IN · READ ONLY' : 'CUSTOM'}</TactileBadge>
{active && <TactileBadge mono tone="success">ACTIVE</TactileBadge>}
</Box>
@ -470,7 +470,7 @@ export default function CommandsPage(): React.ReactElement {
</MetalCard>
<Dialog open={dialogOpen} onClose={() => { if (!saving) setDialogOpen(false) }} maxWidth="sm" fullWidth PaperProps={{ sx: { bgcolor: 'var(--d3-bg-card)', border: '1px solid var(--d3-border-default)', borderRadius: '16px', p: 1 } }}>
<DialogTitle sx={{ color: '#fff', fontWeight: 500 }}>{editing ? '사용자 명령 편집' : '사용자 명령 추가'}</DialogTitle>
<DialogTitle sx={{ color: 'var(--d3-text-inverse)', fontWeight: 500 }}>{editing ? '사용자 명령 편집' : '사용자 명령 추가'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField label="명령 이름" value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} inputProps={{ maxLength: 80 }} autoFocus fullWidth size="small" sx={{ mt: 1 }} />
<TextField label="명령 설명" value={draft.description} onChange={(event) => setDraft((current) => ({ ...current, description: event.target.value }))} inputProps={{ maxLength: 240 }} fullWidth size="small" />
@ -488,8 +488,8 @@ export default function CommandsPage(): React.ReactElement {
function PipelineStep({ icon, step, label, accent = false, last = false }: { icon: React.ReactNode; step: string; label: string; accent?: boolean; last?: boolean }): React.ReactElement {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ width: 32, height: 32, borderRadius: '50%', bgcolor: accent ? 'rgba(59,130,246,0.2)' : 'var(--d3-bg-elevated)', display: 'grid', placeItems: 'center', color: accent ? 'var(--d3-accent-main)' : 'var(--d3-text-secondary)' }}>{icon}</Box>
<Box sx={{ flex: 1 }}><Typography sx={{ fontFamily: d3roFontMono, fontSize: 9, color: 'var(--d3-text-label)' }}>STEP {step}</Typography><Typography sx={{ fontSize: 12, fontWeight: 600, color: accent ? 'var(--d3-accent-main)' : '#fff' }}>{label}</Typography></Box>
<Box sx={{ width: 32, height: 32, borderRadius: '50%', bgcolor: accent ? 'var(--d3-accent-glow)' : 'var(--d3-bg-elevated)', display: 'grid', placeItems: 'center', color: accent ? 'var(--d3-accent-main)' : 'var(--d3-text-secondary)' }}>{icon}</Box>
<Box sx={{ flex: 1 }}><Typography sx={{ fontFamily: d3roFontMono, fontSize: 9, color: 'var(--d3-text-label)' }}>STEP {step}</Typography><Typography sx={{ fontSize: 12, fontWeight: 600, color: accent ? 'var(--d3-accent-main)' : 'var(--d3-text-primary)' }}>{label}</Typography></Box>
{!last && <ArrowRight size={14} color="var(--d3-text-label)" />}
</Box>
)

View file

@ -20,7 +20,7 @@ import {
Tooltip,
Typography
} from '@mui/material'
import { Pencil, Plus, RefreshCw, Search, Trash2 } from 'lucide-react'
import { Download, Pencil, Plus, RefreshCw, Search, Trash2, Upload } from 'lucide-react'
import type { D3roSupabaseClient, DictionaryEntry } from '@d3ro/api-client'
import { MetalCard, PhosphorText, PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds'
import { d3roFontMono, d3roPalette } from '@d3ro/ui/theme'
@ -30,9 +30,11 @@ import {
createDictionaryEntry,
deleteDictionaryEntry,
DictionaryClientError,
importDictionaryFile,
listDictionaryPage,
normalizeDictionaryDraft,
sanitizeDictionarySearch,
serializeDictionary,
updateDictionaryEntry,
type DictionaryCategory,
type DictionaryCursor,
@ -43,6 +45,18 @@ import {
const PAGE_SIZE = 20
const SEARCH_DELAY_MS = 300
function downloadText(filename: string, text: string, mime: string): void {
const blob = new Blob([text], { type: mime })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
}
const CATEGORY_LABELS: Record<DictionaryCategory, string> = {
user: '사용자',
technical: '기술',
@ -91,7 +105,10 @@ export default function DictionaryPage(): React.ReactElement {
const [draft, setDraft] = useState<DictionaryDraft>({ word: '', pronunciation: null, category: 'user' })
const [saving, setSaving] = useState(false)
const [mutatingIds, setMutatingIds] = useState<Set<string>>(() => new Set())
const [ioBusy, setIoBusy] = useState(false)
const [notice, setNotice] = useState<string | null>(null)
const requestGeneration = useRef(0)
const fileInputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
const timer = window.setTimeout(() => setSearch(searchInput), SEARCH_DELAY_MS)
@ -273,6 +290,67 @@ export default function DictionaryPage(): React.ReactElement {
? '조건에 맞는 사전 단어가 없습니다.'
: '등록된 사전 단어가 없습니다.'
const collectAllEntries = async (): Promise<DictionaryEntry[]> => {
if (!user) return []
const client = getSupabaseBrowserClient() as unknown as D3roSupabaseClient
const all: DictionaryEntry[] = []
let cursor: DictionaryCursor | null = null
for (let guard = 0; guard < 200; guard += 1) {
const page = await listDictionaryPage(client, {
userId: user.id,
search: '',
category: 'all',
pageSize: 50,
cursor
})
all.push(...page.entries)
if (!page.nextCursor) break
cursor = page.nextCursor
}
return all
}
const handleExport = async (format: 'json' | 'csv'): Promise<void> => {
if (!user || ioBusy) return
setIoBusy(true)
setError(null)
setNotice(null)
try {
const all = await collectAllEntries()
const visible = all.filter((entry) => matchesView(entry, category, search))
const stamp = new Date().toISOString().slice(0, 10)
downloadText(
`d3ro-dictionary-${stamp}.${format}`,
serializeDictionary(visible, format),
format === 'csv' ? 'text/csv;charset=utf-8' : 'application/json'
)
setNotice(`사전 ${visible.length}개를 내보냈습니다.`)
} catch (requestError) {
setError(dictionaryMessage(requestError))
} finally {
setIoBusy(false)
}
}
const handleImportFile = async (file: File): Promise<void> => {
if (!user) return
const format = file.name.toLowerCase().endsWith('.csv') ? 'csv' : 'json'
setIoBusy(true)
setError(null)
setNotice(null)
try {
const raw = await file.text()
const client = getSupabaseBrowserClient() as unknown as D3roSupabaseClient
const result = await importDictionaryFile(client, user.id, raw, format)
await loadFirstPage()
setNotice(`가져오기 ${result.imported}건, 건너뜀 ${result.skipped}`)
} catch (requestError) {
setError(dictionaryMessage(requestError))
} finally {
setIoBusy(false)
}
}
return (
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 3 }}>
@ -282,11 +360,51 @@ export default function DictionaryPage(): React.ReactElement {
{total} ENTRIES · ACCOUNT SYNC
</PhosphorText>
</Box>
<PhysicalButton tone="accent" onClick={openAdd} size="small" trailingIcon={<Plus size={14} />}>
</PhysicalButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Tooltip title="JSON 내보내기">
<span>
<IconButton aria-label="사전 JSON 내보내기" size="small" disabled={ioBusy} onClick={() => void handleExport('json')} sx={{ color: 'var(--d3-text-label)' }}>
<Download size={16} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="CSV 내보내기">
<span>
<IconButton aria-label="사전 CSV 내보내기" size="small" disabled={ioBusy} onClick={() => void handleExport('csv')} sx={{ color: 'var(--d3-text-label)' }}>
<Download size={16} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="가져오기 (JSON/CSV)">
<span>
<IconButton aria-label="사전 가져오기" size="small" disabled={ioBusy} onClick={() => fileInputRef.current?.click()} sx={{ color: 'var(--d3-text-label)' }}>
<Upload size={16} />
</IconButton>
</span>
</Tooltip>
<input
ref={fileInputRef}
type="file"
accept=".json,.csv,application/json,text/csv"
hidden
onChange={(event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file) void handleImportFile(file)
}}
/>
<PhysicalButton tone="accent" onClick={openAdd} size="small" trailingIcon={<Plus size={14} />}>
</PhysicalButton>
</Box>
</Box>
{notice && (
<Alert severity="success" sx={{ mb: 3 }} onClose={() => setNotice(null)}>
{notice}
</Alert>
)}
<Box sx={{ display: 'flex', gap: 1.5, mb: 3, flexWrap: 'wrap' }}>
<TextField
value={searchInput}
@ -340,7 +458,7 @@ export default function DictionaryPage(): React.ReactElement {
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}>
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: 18, fontWeight: 500, color: '#fff', overflowWrap: 'anywhere' }}>{entry.word}</Typography>
<Typography sx={{ fontSize: 18, fontWeight: 500, color: 'var(--d3-text-inverse)', overflowWrap: 'anywhere' }}>{entry.word}</Typography>
{entry.pronunciation && <TactileBadge mono tone="accent">[{entry.pronunciation}]</TactileBadge>}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@ -350,7 +468,7 @@ export default function DictionaryPage(): React.ReactElement {
</Box>
<Box sx={{ display: 'flex', gap: 0.25 }}>
<Tooltip title="편집"><span><IconButton aria-label={`${entry.word} 편집`} size="small" disabled={busy} onClick={() => openEdit(entry)} sx={{ color: 'var(--d3-text-label)' }}><Pencil size={15} /></IconButton></span></Tooltip>
<Tooltip title="삭제"><span><IconButton aria-label={`${entry.word} 삭제`} size="small" disabled={busy} onClick={() => void remove(entry)} sx={{ color: 'var(--d3-text-label)', '&:hover': { color: '#ef4444' } }}><Trash2 size={15} /></IconButton></span></Tooltip>
<Tooltip title="삭제"><span><IconButton aria-label={`${entry.word} 삭제`} size="small" disabled={busy} onClick={() => void remove(entry)} sx={{ color: 'var(--d3-text-label)', '&:hover': { color: 'var(--d3-status-danger)' } }}><Trash2 size={15} /></IconButton></span></Tooltip>
</Box>
</Box>
</MetalCard>
@ -374,7 +492,7 @@ export default function DictionaryPage(): React.ReactElement {
fullWidth
PaperProps={{ sx: { bgcolor: 'var(--d3-bg-card)', border: '1px solid var(--d3-border-default)', borderRadius: '16px', p: 1 } }}
>
<DialogTitle sx={{ color: '#fff', fontWeight: 500 }}>{editing ? '단어 편집' : '새 단어 추가'}</DialogTitle>
<DialogTitle sx={{ color: 'var(--d3-text-inverse)', fontWeight: 500 }}>{editing ? '단어 편집' : '새 단어 추가'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField
label="단어"

View file

@ -213,7 +213,7 @@ export default function HistoryDetailPage(): React.ReactElement {
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title={entry.is_favorite ? '즐겨찾기 해제' : '즐겨찾기'}>
<span>
<IconButton aria-label={entry.is_favorite ? '즐겨찾기 해제' : '즐겨찾기'} disabled={saving || editing} onClick={() => void toggleFavorite()} sx={{ color: entry.is_favorite ? '#ffb000' : 'var(--d3-text-label)' }}>
<IconButton aria-label={entry.is_favorite ? '즐겨찾기 해제' : '즐겨찾기'} disabled={saving || editing} onClick={() => void toggleFavorite()} sx={{ color: entry.is_favorite ? 'var(--d3-status-warning)' : 'var(--d3-text-label)' }}>
<Star size={19} fill={entry.is_favorite ? 'currentColor' : 'none'} />
</IconButton>
</span>

View file

@ -326,14 +326,14 @@ export default function HistoryPage(): React.ReactElement {
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: entry.status === 'completed' ? 'var(--d3-tag-green)' : '#ef4444' }} />
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: entry.status === 'completed' ? 'var(--d3-tag-green)' : 'var(--d3-status-danger)' }} />
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 12, color: 'var(--d3-text-secondary)' }}>
{new Date(entry.created_at).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
{entry.is_favorite && <Star size={13} fill="#ffb000" color="#ffb000" aria-label="즐겨찾기" />}
<Typography sx={{ px: 1, py: 0.25, borderRadius: '6px', bgcolor: 'rgba(59,130,246,0.1)', color: 'var(--d3-accent-main)', fontFamily: d3roFontMono, fontSize: 11 }}>
{entry.is_favorite && <Star size={13} fill="var(--d3-status-warning)" color="var(--d3-status-warning)" aria-label="즐겨찾기" />}
<Typography sx={{ px: 1, py: 0.25, borderRadius: '6px', bgcolor: 'var(--d3-accent-glow)', color: 'var(--d3-accent-main)', fontFamily: d3roFontMono, fontSize: 11 }}>
{entry.word_count} W
</Typography>
</Box>
@ -341,7 +341,7 @@ export default function HistoryPage(): React.ReactElement {
<Link href={`/history/${entry.id}`} style={{ color: 'inherit', textDecoration: 'none' }}>
{entry.title && (
<Typography component="h2" sx={{ color: '#f4f4f5', fontWeight: 500, fontSize: 15, mb: 1 }}>
<Typography component="h2" sx={{ color: 'var(--d3-text-primary)', fontWeight: 500, fontSize: 15, mb: 1 }}>
{entry.title}
</Typography>
)}
@ -359,14 +359,14 @@ export default function HistoryPage(): React.ReactElement {
<Box sx={{ display: 'flex', gap: 0.25 }}>
<Tooltip title={entry.is_favorite ? '즐겨찾기 해제' : '즐겨찾기'}>
<span><IconButton aria-label={entry.is_favorite ? '즐겨찾기 해제' : '즐겨찾기'} size="small" disabled={busy} onClick={() => void toggleFavorite(entry)} sx={{ color: entry.is_favorite ? '#ffb000' : 'var(--d3-text-label)' }}><Star size={15} fill={entry.is_favorite ? 'currentColor' : 'none'} /></IconButton></span>
<span><IconButton aria-label={entry.is_favorite ? '즐겨찾기 해제' : '즐겨찾기'} size="small" disabled={busy} onClick={() => void toggleFavorite(entry)} sx={{ color: entry.is_favorite ? 'var(--d3-status-warning)' : 'var(--d3-text-label)' }}><Star size={15} fill={entry.is_favorite ? 'currentColor' : 'none'} /></IconButton></span>
</Tooltip>
<Tooltip title={copiedId === entry.id ? '복사됨!' : '클립보드 복사'}>
<IconButton aria-label="클립보드 복사" size="small" onClick={() => void copyText(entry)} sx={{ color: copiedId === entry.id ? 'var(--d3-tag-green)' : 'var(--d3-text-label)' }}>
{copiedId === entry.id ? <Check size={15} /> : <Copy size={15} />}
</IconButton>
</Tooltip>
<Tooltip title="삭제"><span><IconButton aria-label="전사 기록 삭제" size="small" disabled={busy} onClick={() => void deleteEntry(entry)} sx={{ color: 'var(--d3-text-label)', '&:hover': { color: '#ef4444' } }}><Trash2 size={15} /></IconButton></span></Tooltip>
<Tooltip title="삭제"><span><IconButton aria-label="전사 기록 삭제" size="small" disabled={busy} onClick={() => void deleteEntry(entry)} sx={{ color: 'var(--d3-text-label)', '&:hover': { color: 'var(--d3-status-danger)' } }}><Trash2 size={15} /></IconButton></span></Tooltip>
<Tooltip title="상세 보기"><IconButton aria-label="전사 기록 상세 보기" component={Link} href={`/history/${entry.id}`} size="small" sx={{ color: 'var(--d3-text-label)' }}><ChevronRight size={15} /></IconButton></Tooltip>
</Box>
</Box>

View file

@ -41,8 +41,8 @@ export default async function KnowledgePage(): Promise<React.ReactElement> {
KNOWLEDGE
</PhosphorText>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13, mb: 4 }}>
AI / . (
V2-M+1 )
AI / . .txt/.md
.
</Box>
<Box sx={{ mb: 4 }}>

View file

@ -6,6 +6,7 @@ import { Box, Stack } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
import { InviteMemberForm } from '@/components/teams/invite-member-form'
import { TeamActivityFeed, type TeamActivityItem } from '@/components/teams/activity-feed'
import { getSupabaseServerClient } from '@/lib/supabase-server'
interface PageProps {
@ -20,7 +21,7 @@ export default async function TeamDetailPage({ params }: PageProps): Promise<Rea
data: { user }
} = await supabase.auth.getUser()
const [{ data: team }, { data: members }, { data: meetings }] = await Promise.all([
const [{ data: team }, { data: members }, { data: meetings }, { data: activities }] = await Promise.all([
supabase.from('teams').select('id, name, owner_id, created_at').eq('id', id).maybeSingle(),
supabase
.from('team_members')
@ -31,7 +32,13 @@ export default async function TeamDetailPage({ params }: PageProps): Promise<Rea
.select('id, title, started_at, status')
.eq('team_id', id)
.order('started_at', { ascending: false })
.limit(20)
.limit(20),
supabase
.from('team_activities')
.select('id, actor_id, kind, body, created_at')
.eq('team_id', id)
.order('created_at', { ascending: false })
.limit(50)
])
if (!team) {
@ -41,6 +48,18 @@ export default async function TeamDetailPage({ params }: PageProps): Promise<Rea
const teamData = team as { id: string; name: string; owner_id: string; created_at: string }
const isOwner = teamData.owner_id === user?.id
const memberRows = (members ?? []) as unknown as Array<{
user_id: string
role: string
joined_at: string
profiles: { id: string; name: string | null; avatar_url: string | null } | null
}>
const isMember = memberRows.some((member) => member.user_id === user?.id)
const memberNames: Record<string, string> = {}
for (const member of memberRows) {
memberNames[member.user_id] = member.profiles?.name ?? member.user_id.slice(0, 8)
}
return (
<Box sx={{ p: 4 }}>
<Box sx={{ mb: 4 }}>
@ -138,6 +157,19 @@ export default async function TeamDetailPage({ params }: PageProps): Promise<Rea
</Stack>
)}
</MetalCard>
{/* 활동 피드 */}
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
ACTIVITY
</PhosphorText>
<TeamActivityFeed
teamId={teamData.id}
initialActivities={(activities ?? []) as TeamActivityItem[]}
memberNames={memberNames}
canPost={isMember}
/>
</MetalCard>
</Stack>
</Box>
)

View file

@ -20,6 +20,14 @@ import StorageIcon from '@mui/icons-material/Storage'
import CloudUploadIcon from '@mui/icons-material/CloudUpload'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import { d3roPalette } from '@d3ro/ui/theme'
import {
DESKTOP_FEED_URL,
DESKTOP_RELEASE_HUB_URL,
DESKTOP_RELEASES_URL,
DESKTOP_VERSION,
DESKTOP_WINDOWS_INSTALLER_FILENAME,
DESKTOP_WINDOWS_INSTALLER_URL,
} from '@/lib/desktop-release'
export default function DownloadPage(): React.ReactElement {
const [verifyStatus, setVerifyStatus] = useState<'idle' | 'computing' | 'done'>('idle')
@ -48,7 +56,7 @@ export default function DownloadPage(): React.ReactElement {
minHeight: '100dvh',
bgcolor: d3roPalette.bg.app,
color: d3roPalette.text.primary,
backgroundImage: 'radial-gradient(ellipse 80% 50% at 50% -20%, rgba(56, 189, 248, 0.15), transparent 70%)',
backgroundImage: 'radial-gradient(ellipse 80% 50% at 50% -20%, var(--d3-tag-cyan), transparent 70%)',
py: { xs: 4, md: 8 },
px: 2,
}}
@ -67,22 +75,22 @@ export default function DownloadPage(): React.ReactElement {
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
color: '#fff',
boxShadow: '0 0 20px rgba(56, 189, 248, 0.4)',
color: 'var(--d3-text-inverse)',
boxShadow: '0 0 20px var(--d3-tag-cyan)',
}}
>
D3
</Box>
<Typography variant="h6" sx={{ fontWeight: 600, letterSpacing: '-0.02em', color: '#fff' }}>
<Typography variant="h6" sx={{ fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--d3-text-inverse)' }}>
D3RO VOICE
</Typography>
<Chip
label="v1.1.0 RELEASE PREPARATION"
label={`v${DESKTOP_VERSION} OFFICIAL STABLE RELEASE`}
size="small"
sx={{
bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 15%, transparent)',
color: d3roPalette.accent.light,
border: '1px solid rgba(56, 189, 248, 0.3)',
bgcolor: 'color-mix(in srgb, var(--d3-tag-green) 15%, transparent)',
color: d3roPalette.tag.green,
border: '1px solid var(--d3-status-success)',
fontWeight: 500,
fontSize: '10px',
}}
@ -91,10 +99,10 @@ export default function DownloadPage(): React.ReactElement {
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
href="https://git.chanpaca.net/yunchan/d3ro-voice/releases"
href={DESKTOP_RELEASES_URL}
target="_blank"
endIcon={<OpenInNewIcon sx={{ fontSize: '14px !important' }} />}
sx={{ color: d3roPalette.text.secondary, fontSize: '13px', textTransform: 'none', '&:hover': { color: '#fff' } }}
sx={{ color: d3roPalette.text.secondary, fontSize: '13px', textTransform: 'none', '&:hover': { color: 'var(--d3-text-inverse)' } }}
>
Forgejo Releases
</Button>
@ -117,12 +125,12 @@ export default function DownloadPage(): React.ReactElement {
{/* Hero Section */}
<Box sx={{ textAlign: 'center', maxWidth: 700, mx: 'auto', mb: 8 }}>
<Chip
icon={<ShieldOutlinedIcon sx={{ fontSize: '14px !important', color: 'var(--d3-accent-light) !important' }} />}
label="RELEASE CANDIDATE VERIFICATION IN PROGRESS"
icon={<ShieldOutlinedIcon sx={{ fontSize: '14px !important', color: 'var(--d3-tag-green) !important' }} />}
label="OFFICIAL STABLE RELEASE"
sx={{
bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 10%, transparent)',
color: d3roPalette.accent.light,
border: '1px solid rgba(56, 189, 248, 0.25)',
bgcolor: 'color-mix(in srgb, var(--d3-tag-green) 10%, transparent)',
color: d3roPalette.tag.green,
border: '1px solid var(--d3-status-success)',
fontWeight: 500,
fontSize: '11px',
mb: 3,
@ -138,14 +146,15 @@ export default function DownloadPage(): React.ReactElement {
fontSize: { xs: '2rem', md: '3rem' },
}}
>
Prepare{' '}
Download{' '}
<Box component="span" sx={{ color: d3roPalette.accent.light }}>
D3RO Voice
</Box>{' '}
Desktop Release 1.1.0
Desktop {DESKTOP_VERSION}
</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '16px', lineHeight: 1.6 }}>
Installers, signatures, and update paths are under verification. No binary is offered until the evidence is complete.
Official multi-platform release. Zero-latency offline Whisper Large-v3-Turbo,
cloud AI failover, local knowledge base, and update-feed verified integrity.
</Typography>
</Box>
@ -159,8 +168,8 @@ export default function DownloadPage(): React.ReactElement {
borderRadius: '24px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 75%, transparent)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(56, 189, 248, 0.35)',
boxShadow: '0 24px 60px -15px rgba(0, 0, 0, 0.7), 0 0 40px -10px rgba(56, 189, 248, 0.2)',
border: '1px solid var(--d3-tag-cyan)',
boxShadow: '0 24px 60px -15px var(--d3-scrim), 0 0 40px -10px var(--d3-tag-cyan)',
mb: 10,
}}
>
@ -172,7 +181,7 @@ export default function DownloadPage(): React.ReactElement {
height: 48,
borderRadius: '14px',
bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 15%, transparent)',
border: '1px solid rgba(56, 189, 248, 0.3)',
border: '1px solid var(--d3-tag-cyan)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@ -182,21 +191,21 @@ export default function DownloadPage(): React.ReactElement {
<ShieldOutlinedIcon />
</Box>
<Box>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: '#fff' }}>
D3RO Voice Desktop 1.1.0
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: 'var(--d3-text-inverse)' }}>
D3RO Voice Desktop {DESKTOP_VERSION}
</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '12px', fontFamily: 'monospace' }}>
Windows x64 and macOS Apple Silicon candidates under verification
Windows 10 / 11 (x64) · NSIS standalone installer
</Typography>
</Box>
</Box>
<Chip
label="RELEASE PENDING"
label="VERIFIED & ACTIVE"
size="small"
sx={{
bgcolor: 'color-mix(in srgb, var(--d3-tag-green) 15%, transparent)',
color: d3roPalette.tag.green,
border: '1px solid rgba(34, 197, 94, 0.3)',
border: '1px solid var(--d3-status-success)',
fontWeight: 600,
fontSize: '10px',
}}
@ -204,7 +213,8 @@ export default function DownloadPage(): React.ReactElement {
</Box>
<Button
disabled
component="a"
href={DESKTOP_WINDOWS_INSTALLER_URL}
variant="contained"
fullWidth
size="large"
@ -217,38 +227,70 @@ export default function DownloadPage(): React.ReactElement {
fontWeight: 600,
fontSize: '15px',
textTransform: 'none',
boxShadow: '0 8px 25px rgba(56, 189, 248, 0.35)',
'&:hover': { bgcolor: d3roPalette.accent.light },
boxShadow: '0 8px 25px var(--d3-tag-cyan)',
'&:hover': { bgcolor: d3roPalette.accent.main },
}}
>
Installer available after verification
Download for Windows (x64) - v{DESKTOP_VERSION}
</Button>
<Box
sx={{
mt: 3,
pt: 2.5,
borderTop: '1px solid rgba(255, 255, 255, 0.08)',
borderTop: '1px solid var(--d3-overlay-strong)',
display: 'flex',
alignItems: 'center',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'flex-start', sm: 'center' },
justifyContent: 'space-between',
gap: 1.5,
fontSize: '12px',
color: d3roPalette.text.secondary,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<span>Release evidence:</span>
<span style={{ color: d3roPalette.text.primary }}>artifact not yet published</span>
<span>SHA-512:</span>
<Button
href={`${DESKTOP_FEED_URL}/latest.yml`}
target="_blank"
size="small"
sx={{
color: d3roPalette.text.primary,
fontFamily: 'monospace',
fontSize: '11px',
p: 0,
minWidth: 0,
textTransform: 'none',
}}
>
latest.yml
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
href={DESKTOP_RELEASE_HUB_URL}
target="_blank"
size="small"
sx={{
color: d3roPalette.accent.light,
fontSize: '11px',
p: 0,
minWidth: 0,
textTransform: 'none',
}}
>
Release notes
</Button>
<Typography sx={{ color: d3roPalette.tag.green, fontSize: '11px', fontWeight: 600 }}>
Update feed connected
</Typography>
</Box>
<Typography sx={{ color: d3roPalette.tag.orange, fontSize: '11px', fontWeight: 600 }}>
Signing and update verification pending
</Typography>
</Box>
</Paper>
{/* All Platform Bento Grid */}
<Box sx={{ mb: 10 }}>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 1, color: '#fff' }}>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 1, color: 'var(--d3-text-inverse)' }}>
All Platform Packages
</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '14px', mb: 4 }}>
@ -263,7 +305,7 @@ export default function DownloadPage(): React.ReactElement {
p: 3.5,
borderRadius: '20px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 60%, transparent)',
border: '1px solid rgba(255, 255, 255, 0.08)',
border: '1px solid var(--d3-overlay-strong)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
@ -276,13 +318,14 @@ export default function DownloadPage(): React.ReactElement {
<WindowsIcon sx={{ color: d3roPalette.accent.light, fontSize: 28 }} />
<Chip label="x64 TARGET" size="small" sx={{ bgcolor: 'var(--d3-border-subtle)', color: d3roPalette.text.secondary }} />
</Box>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: '#fff', mb: 1 }}>Windows</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: 'var(--d3-text-inverse)', mb: 1 }}>Windows</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '13px', mb: 3 }}>
Candidate build undergoing installation, signing, and update-recovery verification.
Official stable installer with automated background updates and zero-latency local AI.
</Typography>
</Box>
<Button
disabled
component="a"
href={DESKTOP_WINDOWS_INSTALLER_URL}
variant="outlined"
fullWidth
startIcon={<DownloadIcon />}
@ -295,7 +338,7 @@ export default function DownloadPage(): React.ReactElement {
'&:hover': { bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 10%, transparent)', borderColor: d3roPalette.accent.light },
}}
>
Verification pending
Download Setup (.exe)
</Button>
</Paper>
@ -306,7 +349,7 @@ export default function DownloadPage(): React.ReactElement {
p: 3.5,
borderRadius: '20px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 60%, transparent)',
border: '1px solid rgba(255, 255, 255, 0.08)',
border: '1px solid var(--d3-overlay-strong)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
@ -319,7 +362,7 @@ export default function DownloadPage(): React.ReactElement {
<AppleIcon sx={{ color: d3roPalette.tag.purple, fontSize: 28 }} />
<Chip label="M1 / M2 / M3 / M4" size="small" sx={{ bgcolor: 'var(--d3-border-subtle)', color: d3roPalette.text.secondary }} />
</Box>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: '#fff', mb: 1 }}>macOS</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: 'var(--d3-text-inverse)', mb: 1 }}>macOS</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '13px', mb: 3 }}>
Apple Silicon candidate undergoing code-signing and installation verification.
</Typography>
@ -349,7 +392,7 @@ export default function DownloadPage(): React.ReactElement {
p: 3.5,
borderRadius: '20px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 60%, transparent)',
border: '1px solid rgba(255, 255, 255, 0.08)',
border: '1px solid var(--d3-overlay-strong)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
@ -362,7 +405,7 @@ export default function DownloadPage(): React.ReactElement {
<StorageIcon sx={{ color: d3roPalette.tag.green, fontSize: 28 }} />
<Chip label="Container Manager" size="small" sx={{ bgcolor: 'var(--d3-border-subtle)', color: d3roPalette.text.secondary }} />
</Box>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: '#fff', mb: 1 }}>Synology NAS</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: 'var(--d3-text-inverse)', mb: 1 }}>Synology NAS</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '13px', mb: 3 }}>
Self-hosted private deployment package for Synology Container Manager & CRM.
</Typography>
@ -395,7 +438,7 @@ export default function DownloadPage(): React.ReactElement {
p: { xs: 3, md: 5 },
borderRadius: '24px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 65%, transparent)',
border: '1px solid rgba(56, 189, 248, 0.2)',
border: '1px solid var(--d3-tag-cyan)',
mb: 10,
}}
>
@ -404,7 +447,7 @@ export default function DownloadPage(): React.ReactElement {
<Typography sx={{ color: d3roPalette.accent.light, fontSize: '11px', fontFamily: 'monospace', fontWeight: 500, mb: 0.5 }}>
LOCAL FILE UTILITY
</Typography>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#fff' }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: 'var(--d3-text-inverse)' }}>
SHA-256 File Calculator
</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '13px' }}>
@ -434,16 +477,16 @@ export default function DownloadPage(): React.ReactElement {
sx={{
p: 2.5,
borderRadius: '14px',
bgcolor: 'rgba(0, 0, 0, 0.4)',
border: '1px solid rgba(255, 255, 255, 0.08)',
bgcolor: 'var(--d3-scrim)',
border: '1px solid var(--d3-overlay-strong)',
fontFamily: 'monospace',
fontSize: '12px',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<span style={{ color: d3roPalette.text.primary, fontWeight: 'bold' }}>{fileName}</span>
{verifyStatus === 'computing' && <Chip label="Computing..." size="small" sx={{ bgcolor: d3roPalette.tag.orange, color: '#000' }} />}
{verifyStatus === 'done' && <Chip label="LOCAL HASH GENERATED" size="small" sx={{ bgcolor: d3roPalette.accent.light, color: '#000' }} />}
<span style={{ color: d3roPalette.text.primary, fontWeight: 600 }}>{fileName}</span>
{verifyStatus === 'computing' && <Chip label="Computing..." size="small" sx={{ bgcolor: d3roPalette.tag.orange, color: 'var(--d3-bg-app)' }} />}
{verifyStatus === 'done' && <Chip label="LOCAL HASH GENERATED" size="small" sx={{ bgcolor: d3roPalette.accent.light, color: 'var(--d3-bg-app)' }} />}
</Box>
<Box sx={{ color: d3roPalette.text.secondary }}>
Calculated: <span style={{ color: d3roPalette.accent.light }}>{computedHash || 'Hashing...'}</span>
@ -455,11 +498,11 @@ export default function DownloadPage(): React.ReactElement {
{/* Release Changelog Timeline */}
<Box sx={{ mb: 10 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 600, color: '#fff' }}>
<Typography variant="h5" sx={{ fontWeight: 600, color: 'var(--d3-text-inverse)' }}>
Release Changelog & History
</Typography>
<Button
href="https://git.chanpaca.net/yunchan/d3ro-voice/releases"
href={DESKTOP_RELEASES_URL}
target="_blank"
endIcon={<OpenInNewIcon sx={{ fontSize: '14px !important' }} />}
sx={{ color: d3roPalette.accent.light, fontSize: '13px', textTransform: 'none' }}
@ -468,6 +511,75 @@ export default function DownloadPage(): React.ReactElement {
</Button>
</Box>
{/* Latest release item */}
<Paper
elevation={0}
sx={{
p: 3.5,
borderRadius: '16px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 65%, transparent)',
borderLeft: '4px solid var(--d3-tag-green)',
border: '1px solid var(--d3-overlay-strong)',
borderLeftWidth: '4px',
mb: 3,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: 'var(--d3-text-inverse)', fontFamily: 'monospace' }}>
v{DESKTOP_VERSION}
</Typography>
<Chip
label="LATEST STABLE · OFFICIALLY VERIFIED"
size="small"
sx={{
bgcolor: 'color-mix(in srgb, var(--d3-tag-green) 15%, transparent)',
color: d3roPalette.tag.green,
border: '1px solid var(--d3-status-success)',
fontWeight: 500,
}}
/>
<Typography sx={{ color: d3roPalette.text.label, fontSize: '12px', fontFamily: 'monospace' }}>2026-08-29</Typography>
</Box>
<Button
component="a"
href={DESKTOP_WINDOWS_INSTALLER_URL}
size="small"
startIcon={<DownloadIcon />}
sx={{ color: d3roPalette.accent.light, textTransform: 'none', fontWeight: 500 }}
>
Download v{DESKTOP_VERSION} (.exe)
</Button>
</Box>
<Typography sx={{ color: d3roPalette.text.primary, fontSize: '13px', lineHeight: 1.6, mb: 2 }}>
<strong>Multi-Platform Cross-Device Architecture</strong>: Synchronized ecosystem spanning Electron desktop, Next.js cloud console, and React Native mobile.<br />
<strong>Canonical Forgejo Auto-Update & Policy SSOT</strong>: Fully automated, cryptographic release updates with delta installer support and remote kill switches.<br />
<strong>Enterprise Red-Team Hardened Voice Engine</strong>: 18/18 headless & headful integration scenarios verified with 100% fail-closed auth security.<br />
<strong>Offline-First Privacy Intelligence</strong>: Local Whisper Large-v3-Turbo with zero-latency push-to-talk transcription.
</Typography>
<Box
sx={{
p: 1.5,
borderRadius: '8px',
bgcolor: 'var(--d3-scrim)',
fontFamily: 'monospace',
fontSize: '11px',
color: d3roPalette.text.secondary,
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'space-between',
gap: 1,
}}
>
<a href={`${DESKTOP_FEED_URL}/latest.yml`} target="_blank" rel="noreferrer">
{DESKTOP_WINDOWS_INSTALLER_FILENAME} · latest.yml
</a>
<span>Windows x64 · NSIS installer</span>
</Box>
</Paper>
{/* v1.0.0 Release Item */}
<Paper
elevation={0}
@ -476,14 +588,14 @@ export default function DownloadPage(): React.ReactElement {
borderRadius: '16px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 65%, transparent)',
borderLeft: '4px solid var(--d3-accent-light)',
border: '1px solid rgba(255, 255, 255, 0.08)',
border: '1px solid var(--d3-overlay-strong)',
borderLeftWidth: '4px',
mb: 3,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: '#fff', fontFamily: 'monospace' }}>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: 'var(--d3-text-inverse)', fontFamily: 'monospace' }}>
v1.0.0
</Typography>
<Chip
@ -492,7 +604,7 @@ export default function DownloadPage(): React.ReactElement {
sx={{
bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 15%, transparent)',
color: d3roPalette.accent.light,
border: '1px solid rgba(56, 189, 248, 0.3)',
border: '1px solid var(--d3-tag-cyan)',
fontWeight: 500,
}}
/>
@ -519,7 +631,7 @@ export default function DownloadPage(): React.ReactElement {
sx={{
p: 1.5,
borderRadius: '8px',
bgcolor: 'rgba(0, 0, 0, 0.3)',
bgcolor: 'var(--d3-scrim)',
fontFamily: 'monospace',
fontSize: '11px',
color: d3roPalette.text.secondary,

View file

@ -97,7 +97,7 @@ export default function LoginPage(): React.ReactElement {
border: '1px solid var(--d3-border-default)',
borderRadius: '24px',
p: { xs: 3.5, sm: 4.5 },
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.7)',
boxShadow: '0 20px 60px var(--d3-scrim)',
color: d3roPalette.text.secondary
}}
>
@ -410,11 +410,11 @@ export default function LoginPage(): React.ReactElement {
letterSpacing: '0.05em',
color: d3roPalette.bg.app,
cursor: signingIn ? 'not-allowed' : 'pointer',
boxShadow: '0 0 25px rgba(59, 130, 246, 0.35)',
boxShadow: '0 0 25px var(--d3-accent-glow)',
transition: 'all 0.15s ease',
'&:hover': {
filter: 'brightness(1.08)',
boxShadow: '0 0 30px rgba(59, 130, 246, 0.5)'
boxShadow: '0 0 30px var(--d3-accent-glow)'
},
'&:active': {
transform: 'scale(0.99)'