feat(desktop): two-way cloud sync with mobile and web

Rewrites the desktop mirror as services/sync/SyncEngine: a persistent
outbox, per-account server-clock keyset cursors with paging, pulls that never
overwrite unsent local edits, deletions both ways through sync_tombstones and
per-row failure isolation. It now covers history titles and favorites,
dictionary, every meeting's memos and documents, memo tags, user commands and
dictation/meeting templates, and registers the desktop as a device that the
phone can disconnect.

Fixes shipped defects: the first pull after sign-in fetched nothing, only
the first meeting's children were pushed, team meetings leaked into the
personal database and lost team_id on re-push, and Realtime never connected
because Electron's Node 20 has no global WebSocket (ws is now the transport).
Anonymous local-mode records are imported into the first account that signs
in. The settings sync section is translated and shows pending/rejected
changes; synced screens reload on app:dataChanged.
This commit is contained in:
Yun Chan 2026-09-27 14:04:49 +09:00
parent b5c9ff9f31
commit 0a4f5aee64
49 changed files with 3940 additions and 1033 deletions

View file

@ -3,7 +3,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Box, IconButton, Tooltip, Chip, Collapse } from '@mui/material'
import { Copy, Check, Trash2, Tag, X, ScrollText, ChevronDown, Sparkles, Volume2 } from 'lucide-react'
import { Copy, Check, Trash2, Tag, X, ScrollText, ChevronDown, Sparkles, Volume2, Star } from 'lucide-react'
import { MetalCard, Led, TactileBadge } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
@ -15,6 +15,8 @@ export interface HistoryEntryCardProps {
entry: HistoryEntry
onCopy?: (text: string) => void
onDelete?: (id: string) => void
/** 즐겨찾기 토글 — 모바일·웹과 공유되는 값 */
onToggleFavorite?: (entry: HistoryEntry) => void
showTags?: boolean
onTagClick?: (tag: string) => void
}
@ -23,6 +25,7 @@ export function HistoryEntryCard({
entry,
onCopy,
onDelete,
onToggleFavorite,
showTags = false,
onTagClick,
}: HistoryEntryCardProps): React.ReactElement {
@ -160,6 +163,27 @@ export function HistoryEntryCard({
{/* Quick Action Buttons */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
{onToggleFavorite && (
<Tooltip title={entry.isFavorite ? t('history.unfavorite') : t('history.favorite')}>
<IconButton
size="small"
aria-pressed={entry.isFavorite}
aria-label={entry.isFavorite ? t('history.unfavorite') : t('history.favorite')}
onClick={(e) => {
e.stopPropagation()
onToggleFavorite(entry)
}}
sx={{
p: 0.6,
color: entry.isFavorite ? d3roPalette.tag.orange : d3roPalette.text.inactive,
bgcolor: entry.isFavorite ? d3roPalette.tag.orangeBg : 'transparent',
'&:hover': { color: d3roPalette.tag.orange, bgcolor: d3roPalette.tag.orangeBg },
}}
>
<Star size={14} fill={entry.isFavorite ? 'currentColor' : 'none'} />
</IconButton>
</Tooltip>
)}
<Tooltip title={copied ? t('common.copied') : t('common.copy')}>
<IconButton
size="small"