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:
parent
f6a29db95a
commit
cfc58458a8
21 changed files with 985 additions and 125 deletions
9
apps/web/AGENTS.md
Normal file
9
apps/web/AGENTS.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
apps/web/CLAUDE.md
Normal file
1
apps/web/CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
@AGENTS.md
|
||||
108
apps/web/e2e/red_team_cycle4_web.spec.ts
Normal file
108
apps/web/e2e/red_team_cycle4_web.spec.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const SCREENSHOT_DIR = path.resolve('C:/Users/encep/.gemini/antigravity/brain/bbff18a3-721d-4c43-989f-1d6964e15be7/screenshots');
|
||||
|
||||
test.describe.serial('Extreme Red Team - Cycle 4: Web Console & Public Surfaces', () => {
|
||||
let uncaughtExceptions: string[] = [];
|
||||
|
||||
test.beforeAll(() => {
|
||||
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
uncaughtExceptions = [];
|
||||
page.on('pageerror', (err) => {
|
||||
console.error('[WEB PAGEERROR]:', err.message);
|
||||
uncaughtExceptions.push(err.message);
|
||||
});
|
||||
});
|
||||
|
||||
test('RT-14: Web Public Hub - /login, /download, /releases & /accept-invite', async ({ page }) => {
|
||||
// 1. Test /login
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(page.getByText(/D3RO[- ]VOICE/i)).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByRole('button', { name: /Google/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /GitHub/i })).toBeVisible();
|
||||
|
||||
// Screenshot login
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOT_DIR, 'rt14_01_web_login.png'),
|
||||
});
|
||||
|
||||
// 2. Test /download
|
||||
await page.goto('/download');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(page.getByText(/OFFICIAL STABLE RELEASE|D3RO Voice Desktop 1.1.0/i).first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(/Windows|macOS/i).first()).toBeVisible();
|
||||
|
||||
const primaryDownloadBtn = page.getByRole('link', { name: /Download for Windows/i });
|
||||
await expect(primaryDownloadBtn).toBeVisible();
|
||||
await expect(primaryDownloadBtn).toHaveAttribute('href', '/releases/1.1.0/D3RO-Voice-Setup-1.1.0-x64.exe');
|
||||
await expect(primaryDownloadBtn).toHaveAttribute('download', 'D3RO-Voice-Setup-1.1.0-x64.exe');
|
||||
|
||||
const cardDownloadBtn = page.getByRole('link', { name: /Download Setup \(\.exe\)/i });
|
||||
await expect(cardDownloadBtn).toBeVisible();
|
||||
await expect(cardDownloadBtn).toHaveAttribute('href', '/releases/1.1.0/D3RO-Voice-Setup-1.1.0-x64.exe');
|
||||
|
||||
// Verify static release asset HTTP availability
|
||||
const releaseHead = await page.request.head('/releases/1.1.0/D3RO-Voice-Setup-1.1.0-x64.exe');
|
||||
expect(releaseHead.status()).toBe(200);
|
||||
expect(Number(releaseHead.headers()['content-length'])).toBeGreaterThan(100000000);
|
||||
|
||||
// Screenshot download
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOT_DIR, 'rt14_02_web_download.png'),
|
||||
});
|
||||
|
||||
// 3. Test /releases
|
||||
await page.goto('/releases');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(page.getByText(/OFFICIAL STABLE RELEASE|D3RO Voice Desktop 1.1.0/i).first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByRole('link', { name: /Download for Windows/i })).toBeVisible();
|
||||
|
||||
// Screenshot releases
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOT_DIR, 'rt14_03_web_releases.png'),
|
||||
});
|
||||
|
||||
// 4. Test /accept-invite
|
||||
await page.goto('/accept-invite?token=red-team-bogus-token');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(page.getByText(/TEAM INVITE|초대/i).first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
expect(uncaughtExceptions).toEqual([]);
|
||||
});
|
||||
|
||||
test('RT-15: Web Protected Routes - Strict 100% Fail-Closed Auth Guard Redirection', async ({ page }) => {
|
||||
const protectedRoutes = [
|
||||
'/dashboard',
|
||||
'/dictionary',
|
||||
'/commands',
|
||||
'/history',
|
||||
'/knowledge',
|
||||
'/meetings',
|
||||
'/billing',
|
||||
'/chat',
|
||||
'/teams',
|
||||
'/record',
|
||||
'/actions',
|
||||
];
|
||||
|
||||
for (const route of protectedRoutes) {
|
||||
await page.goto(route);
|
||||
await page.waitForURL(/\/login/, { timeout: 10000 });
|
||||
expect(page.url()).toContain('/login');
|
||||
}
|
||||
|
||||
expect(uncaughtExceptions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -17,7 +17,7 @@ test.describe('Smoke: unauthenticated access', () => {
|
|||
|
||||
test('/login 페이지가 로드되고 D3RO VOICE 로고가 표시된다', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await expect(page.getByText(/D3RO VOICE/i)).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText(/D3RO[- ]VOICE/i)).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('/login에 Google/GitHub OAuth 버튼이 보인다', async ({ page }) => {
|
||||
|
|
|
|||
3
apps/web/next-env.d.ts
vendored
3
apps/web/next-env.d.ts
vendored
|
|
@ -1,6 +1,7 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default defineConfig({
|
|||
webServer: process.env.E2E_NO_SERVER
|
||||
? undefined
|
||||
: {
|
||||
command: 'npm run dev',
|
||||
command: 'npm run start',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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="단어"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)'
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ export function ChatPanel(): React.ReactElement {
|
|||
bgcolor: 'var(--d3-bg-card)',
|
||||
borderRadius: '24px',
|
||||
border: '1px solid var(--d3-border-default)',
|
||||
boxShadow: '0 0 50px rgba(0,0,0,0.5)',
|
||||
boxShadow: '0 0 50px var(--d3-scrim)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
|
@ -227,8 +227,8 @@ export function ChatPanel(): React.ReactElement {
|
|||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: error ? '#f87171' : 'var(--d3-tag-green)',
|
||||
boxShadow: error ? '0 0 5px #f87171' : '0 0 5px var(--d3-tag-green)',
|
||||
bgcolor: error ? 'var(--d3-status-danger)' : 'var(--d3-tag-green)',
|
||||
boxShadow: error ? '0 0 5px var(--d3-status-danger)' : '0 0 5px var(--d3-tag-green)',
|
||||
animation: 'pulse 1s infinite'
|
||||
}}
|
||||
/>
|
||||
|
|
@ -269,8 +269,8 @@ export function ChatPanel(): React.ReactElement {
|
|||
sx={{
|
||||
p: 2.5,
|
||||
borderRadius: msg.role === 'user' ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
||||
bgcolor: msg.role === 'user' ? 'rgba(59,130,246,0.1)' : 'var(--d3-bg-elevated)',
|
||||
border: msg.role === 'user' ? '1px solid rgba(59,130,246,0.3)' : '1px solid var(--d3-border-default)',
|
||||
bgcolor: msg.role === 'user' ? 'var(--d3-accent-glow)' : 'var(--d3-bg-elevated)',
|
||||
border: msg.role === 'user' ? '1px solid var(--d3-accent-glow)' : '1px solid var(--d3-border-default)',
|
||||
color: msg.role === 'user' ? 'var(--d3-accent-main)' : 'var(--d3-text-secondary)',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.6
|
||||
|
|
@ -349,9 +349,9 @@ export function ChatPanel(): React.ReactElement {
|
|||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
bgcolor: 'rgba(59,130,246,0.15)',
|
||||
bgcolor: 'var(--d3-accent-glow)',
|
||||
color: 'var(--d3-accent-main)',
|
||||
'&:hover': { bgcolor: 'var(--d3-accent-main)', color: '#fff' }
|
||||
'&:hover': { bgcolor: 'var(--d3-accent-main)', color: 'var(--d3-text-inverse)' }
|
||||
}}
|
||||
>
|
||||
<Send size={16} />
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/knowledge/add-knowledge-form.tsx
|
||||
// 텍스트/URL 기반 지식 문서 추가 (MVP — 파일 업로드는 추후)
|
||||
// 제출 시 knowledge_documents + knowledge_chunks 직접 insert
|
||||
// 텍스트/파일(.txt/.md) 기반 지식 문서 추가.
|
||||
// 제출 시 knowledge_documents + knowledge_chunks를 insert한 뒤 embed-chunks Edge Function으로 임베딩한다.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Box, Button, TextField, Stack, Alert, MenuItem, Select, FormControl, InputLabel } from '@mui/material'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
|
|
@ -12,14 +12,33 @@ import { MetalCard } from '@d3ro/ui/components/ds'
|
|||
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
|
||||
const CHUNK_SIZE = 800 // 문자 단위. 간단한 고정 크기 청킹.
|
||||
const CHUNK_SIZE = 800
|
||||
const MIN_CHUNK_BOUNDARY = 480
|
||||
const MAX_CONTENT_CHARS = 250_000
|
||||
const MAX_FILE_BYTES = 1_048_576
|
||||
|
||||
function chunkText(text: string, size: number): string[] {
|
||||
function chunkText(text: string): string[] {
|
||||
const chunks: string[] = []
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
chunks.push(text.slice(i, i + size))
|
||||
let offset = 0
|
||||
while (offset < text.length) {
|
||||
const hardEnd = Math.min(offset + CHUNK_SIZE, text.length)
|
||||
let end = hardEnd
|
||||
if (hardEnd < text.length) {
|
||||
const boundary = text.lastIndexOf('\n', hardEnd)
|
||||
if (boundary > offset + MIN_CHUNK_BOUNDARY) end = boundary
|
||||
}
|
||||
const chunk = text.slice(offset, end).trim()
|
||||
if (chunk.length > 0) chunks.push(chunk)
|
||||
offset = end
|
||||
}
|
||||
return chunks.filter((c) => c.trim().length > 0)
|
||||
return chunks
|
||||
}
|
||||
|
||||
function classifyKnowledgeFile(fileName: string): 'txt' | 'md' | null {
|
||||
const lower = fileName.toLowerCase()
|
||||
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'md'
|
||||
if (lower.endsWith('.txt')) return 'txt'
|
||||
return null
|
||||
}
|
||||
|
||||
export function AddKnowledgeForm(): React.ReactElement {
|
||||
|
|
@ -28,8 +47,32 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [fileType, setFileType] = useState<'txt' | 'md'>('txt')
|
||||
const [fileName, setFileName] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
async function handleFile(file: File): Promise<void> {
|
||||
const type = classifyKnowledgeFile(file.name)
|
||||
if (!type) {
|
||||
setError('txt 또는 md 파일만 지원합니다.')
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_FILE_BYTES) {
|
||||
setError('파일은 1MB 이하여야 합니다.')
|
||||
return
|
||||
}
|
||||
const text = await file.text()
|
||||
if (text.length > MAX_CONTENT_CHARS) {
|
||||
setError(`본문은 ${MAX_CONTENT_CHARS.toLocaleString()}자 이하여야 합니다.`)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setTitle(file.name.replace(/\.[^.]+$/, ''))
|
||||
setFileType(type)
|
||||
setFileName(file.name)
|
||||
setContent(text)
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!title.trim() || !content.trim()) return
|
||||
|
|
@ -46,19 +89,19 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
return
|
||||
}
|
||||
|
||||
const chunks = chunkText(content, CHUNK_SIZE)
|
||||
const chunks = chunkText(content)
|
||||
|
||||
// 1) 문서 INSERT
|
||||
// 1) 문서 INSERT (indexed=false — 임베딩 성공 후에만 true)
|
||||
const { data: doc, error: docErr } = await supabase
|
||||
.from('knowledge_documents')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: title.trim(),
|
||||
file_name: null,
|
||||
file_name: fileName,
|
||||
file_type: fileType,
|
||||
chunk_count: chunks.length,
|
||||
indexed: true,
|
||||
indexed_at: new Date().toISOString()
|
||||
indexed: false,
|
||||
indexed_at: null
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
|
@ -80,10 +123,51 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
return
|
||||
}
|
||||
|
||||
// 3) 임베딩 생성 (Edge Function, 실패 시 indexed=false 유지)
|
||||
const {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession()
|
||||
if (!session) {
|
||||
setError('세션이 만료되었습니다. 다시 로그인해 주세요.')
|
||||
return
|
||||
}
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
if (!baseUrl) {
|
||||
setError('Supabase URL이 구성되지 않았습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
let indexError: string | null = null
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/functions/v1/embed-chunks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ document_id: doc.id })
|
||||
})
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
error?: string
|
||||
message?: string
|
||||
indexed?: boolean
|
||||
}
|
||||
if (!response.ok || payload.indexed !== true) {
|
||||
indexError = payload.error ?? payload.message ?? `인덱싱 실패 (${response.status})`
|
||||
}
|
||||
} catch (indexRequestError) {
|
||||
indexError = indexRequestError instanceof Error ? indexRequestError.message : '인덱싱 요청 실패'
|
||||
}
|
||||
|
||||
setTitle('')
|
||||
setContent('')
|
||||
setFileName(null)
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
if (indexError) {
|
||||
// 문서는 저장됐지만 임베딩이 실패한 경우 사용자에게 알린다.
|
||||
window.alert(`문서는 저장되었지만 인덱싱에 실패했습니다: ${indexError}\n목록에서 다시 시도할 수 있습니다.`)
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
|
@ -101,6 +185,28 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
<MetalCard sx={{ p: 3, maxWidth: 720 }}>
|
||||
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 1 }}>새 지식 문서</Box>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
파일 선택 (.txt/.md)
|
||||
</Button>
|
||||
{fileName && <Box sx={{ ...typoSx('meta'), color: d3roPalette.text.dimLabel }}>{fileName}</Box>}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.md,.markdown,text/plain,text/markdown"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (file) void handleFile(file)
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<TextField
|
||||
label="제목"
|
||||
size="small"
|
||||
|
|
@ -124,7 +230,7 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
maxRows={16}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="텍스트를 붙여넣기하세요. 800자 단위로 자동 청킹됩니다."
|
||||
placeholder="텍스트를 붙여넣거나 파일을 선택하세요. 줄바꿈 기준 800자 단위로 자동 청킹됩니다."
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
/>
|
||||
|
|
@ -139,7 +245,7 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
onClick={() => void handleSubmit()}
|
||||
disabled={busy || !title.trim() || !content.trim()}
|
||||
>
|
||||
저장
|
||||
{busy ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
|
@ -147,6 +253,7 @@ export function AddKnowledgeForm(): React.ReactElement {
|
|||
setOpen(false)
|
||||
setTitle('')
|
||||
setContent('')
|
||||
setFileName(null)
|
||||
setError(null)
|
||||
}}
|
||||
disabled={busy}
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ export function MicRecorder(): React.ReactElement {
|
|||
bgcolor: 'var(--d3-bg-card)',
|
||||
borderRadius: '24px',
|
||||
border: '1px solid var(--d3-border-default)',
|
||||
boxShadow: '0 0 50px rgba(0,0,0,0.5)',
|
||||
boxShadow: '0 0 50px var(--d3-scrim)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
|
@ -282,7 +282,7 @@ export function MicRecorder(): React.ReactElement {
|
|||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #1a1a1c',
|
||||
borderBottom: '1px solid var(--d3-bg-card)',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
|
|
@ -304,7 +304,7 @@ export function MicRecorder(): React.ReactElement {
|
|||
bgcolor: state === 'recording' ? 'var(--d3-accent-main)' : 'var(--d3-border-default)',
|
||||
borderRadius: '4px',
|
||||
transition: 'height 80ms ease-out',
|
||||
boxShadow: state === 'recording' && h > 0.4 ? '0 0 8px rgba(59,130,246,0.5)' : 'none'
|
||||
boxShadow: state === 'recording' && h > 0.4 ? '0 0 8px var(--d3-accent-glow)' : 'none'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -407,7 +407,7 @@ export function MicRecorder(): React.ReactElement {
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 0 20px rgba(59,130,246,0.4)',
|
||||
boxShadow: '0 0 20px var(--d3-accent-glow)',
|
||||
transition: 'transform 0.15s ease, background-color 0.15s ease',
|
||||
'&:hover': { transform: 'scale(1.05)' }
|
||||
}}
|
||||
|
|
|
|||
171
apps/web/src/components/teams/activity-feed.tsx
Normal file
171
apps/web/src/components/teams/activity-feed.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/teams/activity-feed.tsx
|
||||
// 팀 활동/코멘트 피드 — RPC로 작성하고 Realtime INSERT를 구독한다.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Alert, Box, Button, Stack, TextField } from '@mui/material'
|
||||
import { d3roPalette } from '@d3ro/ui/theme'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
|
||||
export interface TeamActivityItem {
|
||||
id: string
|
||||
actor_id: string | null
|
||||
kind: string
|
||||
body: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface ActivityFeedProps {
|
||||
teamId: string
|
||||
initialActivities: TeamActivityItem[]
|
||||
memberNames: Record<string, string>
|
||||
canPost: boolean
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
note: '메모',
|
||||
member_joined: '멤버 합류',
|
||||
member_left: '멤버 탈퇴',
|
||||
invite_created: '초대 생성',
|
||||
meeting_shared: '회의 공유',
|
||||
document_shared: '문서 공유'
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString('ko-KR')
|
||||
}
|
||||
|
||||
export function TeamActivityFeed({
|
||||
teamId,
|
||||
initialActivities,
|
||||
memberNames,
|
||||
canPost
|
||||
}: ActivityFeedProps): React.ReactElement {
|
||||
const [activities, setActivities] = useState<TeamActivityItem[]>(initialActivities)
|
||||
const [body, setBody] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setActivities(initialActivities)
|
||||
}, [initialActivities])
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const channel = supabase
|
||||
.channel(`web-team-activity-${teamId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'team_activities', filter: `team_id=eq.${teamId}` },
|
||||
(payload) => {
|
||||
const row = payload.new as TeamActivityItem
|
||||
setActivities((current) =>
|
||||
current.some((item) => item.id === row.id) ? current : [row, ...current]
|
||||
)
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
return () => {
|
||||
void supabase.removeChannel(channel)
|
||||
}
|
||||
}, [teamId])
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
const trimmed = body.trim()
|
||||
if (!trimmed || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { data, error: rpcError } = await supabase.rpc('create_team_activity', {
|
||||
p_team_id: teamId,
|
||||
p_kind: 'note',
|
||||
p_body: trimmed,
|
||||
p_metadata: {}
|
||||
})
|
||||
if (rpcError) {
|
||||
setError(rpcError.message)
|
||||
return
|
||||
}
|
||||
setBody('')
|
||||
const created = data as { id?: string; created_at?: string; actor_id?: string } | null
|
||||
if (created?.id) {
|
||||
setActivities((current) =>
|
||||
current.some((item) => item.id === created.id)
|
||||
? current
|
||||
: [
|
||||
{
|
||||
id: created.id as string,
|
||||
actor_id: (created.actor_id as string | undefined) ?? null,
|
||||
kind: 'note',
|
||||
body: trimmed,
|
||||
created_at: created.created_at ?? new Date().toISOString()
|
||||
},
|
||||
...current
|
||||
]
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{canPost && (
|
||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||
<TextField
|
||||
size="small"
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder="팀에 메모 남기기..."
|
||||
inputProps={{ maxLength: 2000 }}
|
||||
fullWidth
|
||||
multiline
|
||||
maxRows={4}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button variant="contained" onClick={() => void submit()} disabled={busy || !body.trim()}>
|
||||
{busy ? '등록 중' : '등록'}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}>아직 활동이 없습니다.</Box>
|
||||
) : (
|
||||
<Stack spacing={1}>
|
||||
{activities.map((activity) => (
|
||||
<Box key={activity.id} sx={{ p: 1.5, bgcolor: d3roPalette.bg.inset, borderRadius: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, mb: 0.5 }}>
|
||||
<Box sx={{ color: d3roPalette.text.primary, fontSize: 12, fontWeight: 500 }}>
|
||||
{activity.actor_id ? memberNames[activity.actor_id] ?? activity.actor_id.slice(0, 8) : '시스템'}
|
||||
<Box component="span" sx={{ color: d3roPalette.text.muted, ml: 1, fontWeight: 400 }}>
|
||||
{KIND_LABELS[activity.kind] ?? activity.kind}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ color: d3roPalette.text.muted, fontSize: 11 }}>
|
||||
{formatTime(activity.created_at)}
|
||||
</Box>
|
||||
</Box>
|
||||
{activity.body && (
|
||||
<Box sx={{ color: d3roPalette.text.primary, fontSize: 13, whiteSpace: 'pre-wrap' }}>
|
||||
{activity.body}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
24
apps/web/src/lib/desktop-release.ts
Normal file
24
apps/web/src/lib/desktop-release.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// apps/web/src/lib/desktop-release.ts
|
||||
// 데스크톱 공식 릴리스 계약 SSOT.
|
||||
//
|
||||
// 설치 파일은 canonical Forgejo feed에서만 배포한다. 웹/사이트 빌드 산출물에는
|
||||
// 설치 바이너리가 포함되지 않으므로 `/releases/...` 로컬 경로는 배포 환경에서 404다.
|
||||
|
||||
export const DESKTOP_VERSION = '1.1.0'
|
||||
|
||||
const FORGEJO_ORIGIN = 'https://git.chanpaca.net'
|
||||
const FORGEJO_OWNER = 'yunchan'
|
||||
const FORGEJO_REPO = 'd3ro-voice'
|
||||
|
||||
/** Registry 안에서 항상 최신 설치 자산을 가리키는 feed 루트 (updater와 동일). */
|
||||
export const DESKTOP_FEED_URL = `${FORGEJO_ORIGIN}/api/packages/${FORGEJO_OWNER}/generic/${FORGEJO_REPO}/latest`
|
||||
|
||||
export const DESKTOP_WINDOWS_INSTALLER_FILENAME = `D3RO-Voice-Setup-${DESKTOP_VERSION}-x64.exe`
|
||||
|
||||
export const DESKTOP_WINDOWS_INSTALLER_URL = `${DESKTOP_FEED_URL}/${DESKTOP_WINDOWS_INSTALLER_FILENAME}`
|
||||
|
||||
/** Forgejo Release 허브 (릴리스 노트 + 자산 첨부). */
|
||||
export const DESKTOP_RELEASE_HUB_URL = `${FORGEJO_ORIGIN}/${FORGEJO_OWNER}/${FORGEJO_REPO}/releases/tag/v${DESKTOP_VERSION}`
|
||||
|
||||
/** Release 자산 목록 (버전 아카이브). */
|
||||
export const DESKTOP_RELEASES_URL = `${FORGEJO_ORIGIN}/${FORGEJO_OWNER}/${FORGEJO_REPO}/releases`
|
||||
|
|
@ -265,3 +265,178 @@ export async function deleteDictionaryEntry(
|
|||
throw mapDictionaryError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export interface DictionaryImportResult {
|
||||
imported: number
|
||||
skipped: number
|
||||
}
|
||||
|
||||
const CSV_HEADER = ['word', 'pronunciation', 'category', 'usageCount', 'createdAt', 'updatedAt']
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
const text = value === null || value === undefined ? '' : String(value)
|
||||
const escaped = text.replace(/"/g, '""')
|
||||
const needsQuotes = /[",\r\n]/.test(escaped) || /^[=+\-@]/.test(escaped)
|
||||
return needsQuotes ? `"${escaped}"` : escaped
|
||||
}
|
||||
|
||||
function parseCsvRows(input: string): string[][] {
|
||||
const rows: string[][] = []
|
||||
let row: string[] = []
|
||||
let field = ''
|
||||
let inQuotes = false
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const char = input[i]
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (input[i + 1] === '"') {
|
||||
field += '"'
|
||||
i += 1
|
||||
} else {
|
||||
inQuotes = false
|
||||
}
|
||||
} else {
|
||||
field += char
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
inQuotes = true
|
||||
} else if (char === ',') {
|
||||
row.push(field)
|
||||
field = ''
|
||||
} else if (char === '\n') {
|
||||
row.push(field)
|
||||
rows.push(row)
|
||||
row = []
|
||||
field = ''
|
||||
} else if (char !== '\r') {
|
||||
field += char
|
||||
}
|
||||
}
|
||||
if (field.length > 0 || row.length > 0) {
|
||||
row.push(field)
|
||||
rows.push(row)
|
||||
}
|
||||
return rows.filter((candidate) => candidate.some((cell) => cell.trim().length > 0))
|
||||
}
|
||||
|
||||
function coerceDraft(record: Record<string, unknown>): DictionaryDraft | null {
|
||||
const word = typeof record.word === 'string' ? record.word : ''
|
||||
const pronunciation = typeof record.pronunciation === 'string' && record.pronunciation.trim() !== ''
|
||||
? record.pronunciation
|
||||
: null
|
||||
const category = typeof record.category === 'string' ? record.category : 'user'
|
||||
try {
|
||||
return normalizeDictionaryDraft({
|
||||
word,
|
||||
pronunciation,
|
||||
category: category as DictionaryCategory
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeDictionary(entries: DictionaryEntry[], format: 'json' | 'csv'): string {
|
||||
if (format === 'json') {
|
||||
return JSON.stringify(
|
||||
{
|
||||
entries: entries.map((entry) => ({
|
||||
word: entry.word,
|
||||
pronunciation: entry.pronunciation,
|
||||
category: entry.category,
|
||||
usageCount: entry.usage_count,
|
||||
lastUsedAt: entry.last_used_at ? Date.parse(entry.last_used_at) : null,
|
||||
createdAt: Date.parse(entry.created_at),
|
||||
updatedAt: Date.parse(entry.updated_at)
|
||||
}))
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
const lines = [CSV_HEADER.join(',')]
|
||||
for (const entry of entries) {
|
||||
lines.push(
|
||||
[
|
||||
csvCell(entry.word),
|
||||
csvCell(entry.pronunciation ?? ''),
|
||||
csvCell(entry.category),
|
||||
csvCell(entry.usage_count),
|
||||
csvCell(Date.parse(entry.created_at)),
|
||||
csvCell(Date.parse(entry.updated_at))
|
||||
].join(',')
|
||||
)
|
||||
}
|
||||
return `\uFEFF${lines.join('\r\n')}\r\n`
|
||||
}
|
||||
|
||||
export function parseDictionaryFile(raw: string, format: 'json' | 'csv'): DictionaryDraft[] {
|
||||
const drafts: DictionaryDraft[] = []
|
||||
if (format === 'json') {
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new DictionaryClientError('validation', '사전 JSON 형식이 올바르지 않습니다.')
|
||||
}
|
||||
const list = Array.isArray(data)
|
||||
? data
|
||||
: data && typeof data === 'object' && Array.isArray((data as { entries?: unknown }).entries)
|
||||
? (data as { entries: unknown[] }).entries
|
||||
: null
|
||||
if (!list) throw new DictionaryClientError('validation', '사전 JSON에 entries 배열이 없습니다.')
|
||||
for (const item of list) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const draft = coerceDraft(item as Record<string, unknown>)
|
||||
if (draft) drafts.push(draft)
|
||||
}
|
||||
} else {
|
||||
const rows = parseCsvRows(raw.replace(/^\uFEFF/, ''))
|
||||
if (rows.length === 0) throw new DictionaryClientError('validation', '빈 CSV 파일입니다.')
|
||||
const header = rows[0].map((cell) => cell.trim())
|
||||
if (!header.includes('word')) {
|
||||
throw new DictionaryClientError('validation', 'CSV에 word 열이 없습니다.')
|
||||
}
|
||||
for (const cells of rows.slice(1)) {
|
||||
const record: Record<string, unknown> = {}
|
||||
header.forEach((key, index) => {
|
||||
record[key] = cells[index] ?? ''
|
||||
})
|
||||
const draft = coerceDraft(record)
|
||||
if (draft) drafts.push(draft)
|
||||
}
|
||||
}
|
||||
if (drafts.length === 0) {
|
||||
throw new DictionaryClientError('validation', '가져올 유효한 단어가 없습니다.')
|
||||
}
|
||||
return drafts
|
||||
}
|
||||
|
||||
export async function importDictionaryFile(
|
||||
client: D3roSupabaseClient,
|
||||
userId: string,
|
||||
raw: string,
|
||||
format: 'json' | 'csv'
|
||||
): Promise<DictionaryImportResult> {
|
||||
requireUuid(userId, 'auth', 'Authenticated user is invalid')
|
||||
const drafts = parseDictionaryFile(raw, format)
|
||||
const result: DictionaryImportResult = { imported: 0, skipped: 0 }
|
||||
for (const draft of drafts) {
|
||||
try {
|
||||
await createDictionaryEntry(client, userId, draft)
|
||||
result.imported += 1
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof DictionaryClientError &&
|
||||
(error.code === 'duplicate' || error.code === 'conflict')
|
||||
) {
|
||||
result.skipped += 1
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue