@smartcat/sanity-plugin 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +294 -0
  2. package/dist/_chunks-cjs/constants.cjs +13 -0
  3. package/dist/_chunks-cjs/constants.cjs.map +1 -0
  4. package/dist/_chunks-cjs/index.cjs +168 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +311 -0
  7. package/dist/_chunks-cjs/index2.cjs.map +1 -0
  8. package/dist/_chunks-cjs/workflow.cjs +16 -0
  9. package/dist/_chunks-cjs/workflow.cjs.map +1 -0
  10. package/dist/_chunks-es/constants.js +14 -0
  11. package/dist/_chunks-es/constants.js.map +1 -0
  12. package/dist/_chunks-es/index.js +169 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +313 -0
  15. package/dist/_chunks-es/index2.js.map +1 -0
  16. package/dist/_chunks-es/workflow.js +17 -0
  17. package/dist/_chunks-es/workflow.js.map +1 -0
  18. package/dist/export.cjs +160 -0
  19. package/dist/export.cjs.map +1 -0
  20. package/dist/export.d.cts +162 -0
  21. package/dist/export.d.ts +162 -0
  22. package/dist/export.js +162 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/import.cjs +169 -0
  25. package/dist/import.cjs.map +1 -0
  26. package/dist/import.d.cts +155 -0
  27. package/dist/import.d.ts +155 -0
  28. package/dist/import.js +169 -0
  29. package/dist/import.js.map +1 -0
  30. package/dist/index.cjs +2103 -0
  31. package/dist/index.cjs.map +1 -0
  32. package/dist/index.d.cts +122 -0
  33. package/dist/index.d.ts +122 -0
  34. package/dist/index.js +2110 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/locjson.cjs +12 -0
  37. package/dist/locjson.cjs.map +1 -0
  38. package/dist/locjson.d.cts +287 -0
  39. package/dist/locjson.d.ts +287 -0
  40. package/dist/locjson.js +12 -0
  41. package/dist/locjson.js.map +1 -0
  42. package/dist/progress.cjs +16 -0
  43. package/dist/progress.cjs.map +1 -0
  44. package/dist/progress.d.cts +262 -0
  45. package/dist/progress.d.ts +262 -0
  46. package/dist/progress.js +16 -0
  47. package/dist/progress.js.map +1 -0
  48. package/dist/smartcat.cjs +287 -0
  49. package/dist/smartcat.cjs.map +1 -0
  50. package/dist/smartcat.d.cts +234 -0
  51. package/dist/smartcat.d.ts +234 -0
  52. package/dist/smartcat.js +287 -0
  53. package/dist/smartcat.js.map +1 -0
  54. package/dist/templates.cjs +12 -0
  55. package/dist/templates.cjs.map +1 -0
  56. package/dist/templates.d.cts +52 -0
  57. package/dist/templates.d.ts +52 -0
  58. package/dist/templates.js +12 -0
  59. package/dist/templates.js.map +1 -0
  60. package/package.json +101 -15
  61. package/src/actions/AddToProjectAction.tsx +258 -0
  62. package/src/export/export.test.ts +537 -0
  63. package/src/export/index.ts +393 -0
  64. package/src/form/TranslationStatusInput.tsx +212 -0
  65. package/src/import/import.test.ts +346 -0
  66. package/src/import/index.ts +418 -0
  67. package/src/index.ts +63 -0
  68. package/src/lib/constants.ts +23 -0
  69. package/src/lib/documentTitle.test.ts +43 -0
  70. package/src/lib/documentTitle.ts +30 -0
  71. package/src/lib/languageMap.test.ts +56 -0
  72. package/src/lib/languageMap.ts +71 -0
  73. package/src/lib/linkedDocuments.test.ts +56 -0
  74. package/src/lib/linkedDocuments.ts +100 -0
  75. package/src/lib/locjson/deserialize.ts +60 -0
  76. package/src/lib/locjson/fields.ts +355 -0
  77. package/src/lib/locjson/filename.ts +27 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +545 -0
  80. package/src/lib/locjson/paths.test.ts +41 -0
  81. package/src/lib/locjson/paths.ts +73 -0
  82. package/src/lib/locjson/portableText.ts +98 -0
  83. package/src/lib/locjson/serialize.ts +124 -0
  84. package/src/lib/locjson/types.ts +106 -0
  85. package/src/lib/log.ts +33 -0
  86. package/src/lib/projectItems.ts +17 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +47 -0
  89. package/src/lib/workflow.test.ts +37 -0
  90. package/src/lib/workflow.ts +48 -0
  91. package/src/lib/zip.fixtures.ts +75 -0
  92. package/src/lib/zip.test.ts +110 -0
  93. package/src/lib/zip.ts +164 -0
  94. package/src/progress/core.ts +387 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +268 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +264 -0
  100. package/src/smartcat/client.test.ts +222 -0
  101. package/src/smartcat/client.ts +351 -0
  102. package/src/smartcat/index.ts +10 -0
  103. package/src/smartcat/types.ts +99 -0
  104. package/src/templates/core.ts +55 -0
  105. package/src/templates/index.ts +5 -0
  106. package/src/templates/templates.test.ts +50 -0
  107. package/src/tool/Dashboard.tsx +44 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +975 -0
  111. package/src/tool/ProjectsView.tsx +208 -0
  112. package/src/tool/StatusBadge.tsx +33 -0
  113. package/src/tool/StudioInit.tsx +42 -0
  114. package/src/tool/WorkflowSelect.tsx +41 -0
  115. package/src/tool/data.ts +79 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +623 -0
  118. package/src/tool/processing.ts +766 -0
  119. package/src/tool/useTemplates.ts +84 -0
  120. package/src/tool/useWorkflowSelection.ts +65 -0
  121. package/src/types.ts +77 -0
@@ -0,0 +1,55 @@
1
+ import type {SmartcatTemplatesResponse} from '../smartcat/types'
2
+ import {SETTINGS_DOC_ID} from '../lib/constants'
3
+
4
+ /**
5
+ * Refreshes the cached Smartcat project-template list onto the `smartcat.settings`
6
+ * singleton. Run by the thin `smartcat-templates` Function when the browser bumps
7
+ * `templatesRequestedAt` (on Studio init or when a create dialog opens).
8
+ *
9
+ * Dependency-free (no DOM, no @sanity/* runtime) so it bundles into the Function.
10
+ */
11
+
12
+ /** Minimal structural subset of @sanity/client used by the templates refresh. */
13
+ export interface SanityLikeClient {
14
+ patch(id: string): {
15
+ set(attrs: Record<string, unknown>): {commit(): Promise<unknown>}
16
+ }
17
+ }
18
+
19
+ /** Structural subset of SmartcatClient the templates refresh needs. */
20
+ export interface SmartcatTemplatesClient {
21
+ getTemplates(): Promise<SmartcatTemplatesResponse>
22
+ }
23
+
24
+ export interface RunTemplatesSyncOptions {
25
+ sanity: SanityLikeClient
26
+ smartcat: SmartcatTemplatesClient
27
+ /** `_id` of the settings singleton (defaults to the fixed id). */
28
+ settingsId?: string
29
+ now?: () => string
30
+ }
31
+
32
+ /** Sanitizes a template id into a stable, Sanity-safe array `_key`. */
33
+ function keyify(value: string): string {
34
+ return value.replace(/[^a-zA-Z0-9_-]/g, '-') || 'x'
35
+ }
36
+
37
+ /**
38
+ * Fetches templates from Smartcat and writes them (sorted by name) to the
39
+ * settings singleton, stamping `templatesSyncedAt`. Always writes the timestamp —
40
+ * even for an empty list — so the cache reads as initialized.
41
+ */
42
+ export async function runTemplatesSync(
43
+ options: RunTemplatesSyncOptions,
44
+ ): Promise<{count: number}> {
45
+ const {sanity, smartcat, settingsId = SETTINGS_DOC_ID, now = () => new Date().toISOString()} = options
46
+
47
+ const {templates} = await smartcat.getTemplates()
48
+ const value = [...(templates ?? [])]
49
+ .sort((a, b) => a.name.localeCompare(b.name))
50
+ .map((t) => ({_key: keyify(t.id), id: t.id, name: t.name}))
51
+
52
+ await sanity.patch(settingsId).set({templates: value, templatesSyncedAt: now()}).commit()
53
+
54
+ return {count: value.length}
55
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Public entry for the `./templates` subpath, consumed by the thin
3
+ * `smartcat-templates` Function.
4
+ */
5
+ export * from './core'
@@ -0,0 +1,50 @@
1
+ import {describe, it, expect, vi} from 'vitest'
2
+ import {runTemplatesSync} from './core'
3
+
4
+ function fakeSanity() {
5
+ const sets: Record<string, unknown>[] = []
6
+ const client = {
7
+ patch: (_id: string) => ({
8
+ set: (attrs: Record<string, unknown>) => {
9
+ sets.push(attrs)
10
+ return {commit: () => Promise.resolve(undefined)}
11
+ },
12
+ }),
13
+ }
14
+ return {client, sets}
15
+ }
16
+
17
+ describe('runTemplatesSync', () => {
18
+ it('caches templates sorted by name with stable keys and a sync timestamp', async () => {
19
+ const {client: sanity, sets} = fakeSanity()
20
+ const smartcat = {
21
+ getTemplates: vi.fn().mockResolvedValue({
22
+ templates: [
23
+ {id: 'b-2', name: 'Translate file (2x review)'},
24
+ {id: 'a-1', name: 'AI + Human'},
25
+ ],
26
+ }),
27
+ }
28
+
29
+ const {count} = await runTemplatesSync({sanity, smartcat, now: () => 'T'})
30
+
31
+ expect(count).toBe(2)
32
+ expect(sets.at(-1)).toEqual({
33
+ templates: [
34
+ {_key: 'a-1', id: 'a-1', name: 'AI + Human'},
35
+ {_key: 'b-2', id: 'b-2', name: 'Translate file (2x review)'},
36
+ ],
37
+ templatesSyncedAt: 'T',
38
+ })
39
+ })
40
+
41
+ it('marks the cache initialized even when no templates exist', async () => {
42
+ const {client: sanity, sets} = fakeSanity()
43
+ const smartcat = {getTemplates: vi.fn().mockResolvedValue({templates: []})}
44
+
45
+ const {count} = await runTemplatesSync({sanity, smartcat, now: () => 'T'})
46
+
47
+ expect(count).toBe(0)
48
+ expect(sets.at(-1)).toEqual({templates: [], templatesSyncedAt: 'T'})
49
+ })
50
+ })
@@ -0,0 +1,44 @@
1
+ import {useCallback} from 'react'
2
+ import {useRouter, useRouterState} from 'sanity/router'
3
+ import {Box, Container} from '@sanity/ui'
4
+ import {ProjectsView} from './ProjectsView'
5
+ import {ProjectView} from './ProjectView'
6
+ import type {ResolvedSmartcatConfig} from '../lib/resolveConfig'
7
+
8
+ /**
9
+ * Builds the dashboard component for the Translations tool.
10
+ * Uses the tool router to switch between the projects list and a project detail
11
+ * view, so the selected project is reflected in the URL (deep-linkable).
12
+ */
13
+ export function createDashboardComponent(config: ResolvedSmartcatConfig) {
14
+ const {sourceLanguage, languages, documentI18nLanguageField, fieldI18nLanguageField} = config
15
+
16
+ return function Dashboard() {
17
+ const router = useRouter()
18
+ const projectId = useRouterState(
19
+ useCallback((state) => (state.projectId as string | undefined) ?? null, []),
20
+ )
21
+
22
+ const openProject = useCallback((id: string) => router.navigate({projectId: id}), [router])
23
+ const goBack = useCallback(() => router.navigate({}), [router])
24
+
25
+ return (
26
+ <Box padding={4} height="fill" overflow="auto">
27
+ <Container width={2}>
28
+ {projectId ? (
29
+ <ProjectView
30
+ projectId={projectId}
31
+ onBack={goBack}
32
+ languages={languages}
33
+ sourceLanguage={sourceLanguage}
34
+ documentI18nLanguageField={documentI18nLanguageField}
35
+ fieldI18nLanguageField={fieldI18nLanguageField}
36
+ />
37
+ ) : (
38
+ <ProjectsView sourceLanguage={sourceLanguage} onOpenProject={openProject} />
39
+ )}
40
+ </Container>
41
+ </Box>
42
+ )
43
+ }
44
+ }
@@ -0,0 +1,176 @@
1
+ import {Badge, Box, Button, Flex, Inline, Stack, Text, useTheme_v2, Card} from '@sanity/ui'
2
+ import {LaunchIcon} from '@sanity/icons'
3
+ import {useIntentLink} from 'sanity/router'
4
+ import {targetPercent, type DocProgress, type TargetProgress} from '../progress/core'
5
+ import type {SmartcatLanguage} from '../types'
6
+
7
+ /** Readable name for a language id, falling back to the id itself. */
8
+ export function languageTitle(languages: SmartcatLanguage[], id: string): string {
9
+ return languages.find((l) => l.id === id)?.title || id
10
+ }
11
+
12
+ /** Locale variants for an item: language id -> Sanity variant document id. */
13
+ export interface VariantLinks {
14
+ type: string
15
+ byLanguage: Record<string, string>
16
+ }
17
+
18
+ /** Icon-only link that opens a language variant document in the Studio. */
19
+ function OpenVariantInStudio({id, type}: {id: string; type: string}) {
20
+ const {onClick, href} = useIntentLink({intent: 'edit', params: {id, type}})
21
+ return (
22
+ <Button
23
+ as="a"
24
+ href={href}
25
+ onClick={onClick}
26
+ mode="bleed"
27
+ icon={LaunchIcon}
28
+ title="Open this language version in Studio"
29
+ fontSize={0}
30
+ padding={1}
31
+ />
32
+ )
33
+ }
34
+
35
+ /**
36
+ * Smartcat editor URL for a target document. Host comes from the project's
37
+ * Smartcat URL; the file guid and numeric language id are the two halves of the
38
+ * target document id (`<fileGuid>_<langNum>`).
39
+ */
40
+ function editorUrl(projectUrl: string | null | undefined, smartcatDocumentId?: string): string | null {
41
+ if (!projectUrl || !smartcatDocumentId) return null
42
+ let origin: string
43
+ try {
44
+ origin = new URL(projectUrl).origin
45
+ } catch {
46
+ return null
47
+ }
48
+ const sep = smartcatDocumentId.lastIndexOf('_')
49
+ if (sep < 0) return null
50
+ const fileGuid = smartcatDocumentId.slice(0, sep)
51
+ const langNum = smartcatDocumentId.slice(sep + 1)
52
+ return `${origin}/open-editor/${fileGuid}?targetLanguageId=${langNum}`
53
+ }
54
+
55
+ /**
56
+ * A thin track with a vivid fill whose width reflects the actual percentage.
57
+ * Uses the design system's solid button colors (the same vivid blue/green used on
58
+ * primary/positive buttons) rather than the muted Card surface tones — green once
59
+ * the target is complete, blue while in progress.
60
+ */
61
+ function ProgressBar({percent, complete}: {percent: number; complete: boolean}) {
62
+ const {color} = useTheme_v2()
63
+ const pct = Math.max(0, Math.min(100, percent))
64
+ const fill = (complete ? color.button.default.positive : color.button.default.primary).enabled.bg
65
+ return (
66
+ <Card tone="transparent" border radius={3} style={{height: 10, overflow: 'hidden'}}>
67
+ <div
68
+ style={{
69
+ width: `${pct}%`,
70
+ height: '100%',
71
+ background: fill,
72
+ borderRadius: 'inherit',
73
+ transition: 'width 0.3s ease',
74
+ }}
75
+ />
76
+ </Card>
77
+ )
78
+ }
79
+
80
+ function TargetRow({
81
+ target,
82
+ languages,
83
+ smartcatProjectUrl,
84
+ variants,
85
+ }: {
86
+ target: TargetProgress
87
+ languages: SmartcatLanguage[]
88
+ smartcatProjectUrl?: string | null
89
+ variants?: VariantLinks
90
+ }) {
91
+ const percent = targetPercent(target.stages)
92
+ const href = editorUrl(smartcatProjectUrl, target.smartcatDocumentId)
93
+ const label = languageTitle(languages, target.language)
94
+ const variantId = variants?.byLanguage[target.language]
95
+ return (
96
+ <Stack space={2}>
97
+ <Flex align="center" gap={3}>
98
+ <Box style={{width: 150, flex: 'none'}}>
99
+ <Flex align="center" gap={1}>
100
+ <Text size={1} weight="medium">
101
+ {href ? (
102
+ <a
103
+ href={href}
104
+ target="_blank"
105
+ rel="noopener noreferrer"
106
+ title="Open document in Smartcat editor"
107
+ >
108
+ {label}
109
+ </a>
110
+ ) : (
111
+ label
112
+ )}
113
+ </Text>
114
+ {variantId && variants && <OpenVariantInStudio id={variantId} type={variants.type} />}
115
+ </Flex>
116
+ </Box>
117
+ <Box flex={1}>
118
+ <ProgressBar percent={percent} complete={target.complete} />
119
+ </Box>
120
+ <Box style={{width: 38, flex: 'none'}}>
121
+ <Text size={1} muted align="right">
122
+ {percent}%
123
+ </Text>
124
+ </Box>
125
+ <Box style={{width: 72, flex: 'none'}}>
126
+ {target.imported ? (
127
+ <Badge tone="positive" fontSize={0} padding={2}>
128
+ imported
129
+ </Badge>
130
+ ) : target.complete ? (
131
+ <Badge tone="primary" fontSize={0} padding={2}>
132
+ ready
133
+ </Badge>
134
+ ) : null}
135
+ </Box>
136
+ </Flex>
137
+ {target.stages.length > 0 && (
138
+ <Inline space={3} style={{paddingLeft: 162}}>
139
+ {target.stages.map((stage) => (
140
+ <Text key={stage.name} size={0} muted>
141
+ {stage.name}: {stage.percent}%
142
+ </Text>
143
+ ))}
144
+ </Inline>
145
+ )}
146
+ </Stack>
147
+ )
148
+ }
149
+
150
+ /** Per-language, per-stage progress for a single project item. */
151
+ export function ItemProgress({
152
+ doc,
153
+ languages,
154
+ smartcatProjectUrl,
155
+ variants,
156
+ }: {
157
+ doc: DocProgress
158
+ languages: SmartcatLanguage[]
159
+ smartcatProjectUrl?: string | null
160
+ variants?: VariantLinks
161
+ }) {
162
+ if (!doc.targets || doc.targets.length === 0) return null
163
+ return (
164
+ <Stack space={3} paddingTop={1}>
165
+ {doc.targets.map((target) => (
166
+ <TargetRow
167
+ key={target._key}
168
+ target={target}
169
+ languages={languages}
170
+ smartcatProjectUrl={smartcatProjectUrl}
171
+ variants={variants}
172
+ />
173
+ ))}
174
+ </Stack>
175
+ )
176
+ }
@@ -0,0 +1,207 @@
1
+ import {useEffect, useLayoutEffect, useRef, useState} from 'react'
2
+ import {Box, Button, Card, Flex, Heading, Stack, Text, useTheme} from '@sanity/ui'
3
+ import {CheckmarkIcon, CopyIcon} from '@sanity/icons'
4
+ import type {LogLine, LogLevel} from '../lib/log'
5
+
6
+ /** Flat, paste-friendly text for the whole log (indented lines + any values). */
7
+ function logsToText(logs: LogLine[]): string {
8
+ return logs
9
+ .map((l) => {
10
+ const prefix = l.indent ? ' ' : ''
11
+ const detail = [
12
+ l.before !== undefined ? `before: ${l.before}` : null,
13
+ l.value !== undefined ? `${l.before !== undefined ? 'after' : 'value'}: ${l.value}` : null,
14
+ l.error !== undefined ? `error: ${l.error}` : null,
15
+ ]
16
+ .filter(Boolean)
17
+ .map((d) => ` ${d}`)
18
+ .join('\n')
19
+ return `${prefix}${l.message}${detail ? `\n${detail}` : ''}`
20
+ })
21
+ .join('\n')
22
+ }
23
+
24
+ const MONO = 'var(--font-family-mono, monospace)'
25
+ const VALUE_BG = 'var(--card-code-bg-color, rgba(127,127,127,0.12))'
26
+
27
+ /** Status glyph per level. Info/update have none — only success/error/skip are marked. */
28
+ const GLYPH: Record<LogLevel, string> = {info: '', success: '✓', skip: '–', error: '✗', update: ''}
29
+
30
+ /** Level → a theme-provided foreground colour (muted tone, good contrast in both themes). */
31
+ function levelColor(theme: ReturnType<typeof useTheme>, level: LogLevel): string | undefined {
32
+ const muted = theme.sanity.color.muted
33
+ if (level === 'success') return muted.positive.enabled.fg
34
+ if (level === 'error') return muted.critical.enabled.fg
35
+ if (level === 'update') return muted.primary.enabled.fg
36
+ return undefined
37
+ }
38
+
39
+ /** Background for a value box: light red for errors, the neutral gray otherwise. */
40
+ function valueBg(theme: ReturnType<typeof useTheme>, level: LogLevel): string {
41
+ return level === 'error' ? theme.sanity.color.muted.critical.enabled.bg : VALUE_BG
42
+ }
43
+
44
+ /** A value box; optional label (e.g. "Before"/"After") shown above the text. */
45
+ function ValueBox({label, text, bg}: {label?: string; text: string; bg: string}) {
46
+ return (
47
+ <Box flex={1} padding={2} style={{background: bg, borderRadius: 3, minWidth: 0}}>
48
+ <Stack space={2}>
49
+ {label && (
50
+ <Text size={0} muted weight="semibold">
51
+ {label}
52
+ </Text>
53
+ )}
54
+ <Text size={0} style={{fontFamily: MONO, whiteSpace: 'pre-wrap'}}>
55
+ {text}
56
+ </Text>
57
+ </Stack>
58
+ </Box>
59
+ )
60
+ }
61
+
62
+ function LogRow({line}: {line: LogLine}) {
63
+ const theme = useTheme()
64
+ const [open, setOpen] = useState(false)
65
+ const hasDetail = line.value !== undefined || line.before !== undefined || line.error !== undefined
66
+ const toggle = hasDetail ? () => setOpen((v) => !v) : undefined
67
+ return (
68
+ <Box paddingLeft={line.indent ? 4 : 0}>
69
+ <Text
70
+ size={1}
71
+ muted={line.level === 'skip'}
72
+ onClick={toggle}
73
+ style={{
74
+ fontFamily: MONO,
75
+ whiteSpace: 'pre-wrap',
76
+ cursor: hasDetail ? 'pointer' : 'default',
77
+ color: levelColor(theme, line.level),
78
+ }}
79
+ >
80
+ {/* Triangle is part of the line text, pushing the message right. */}
81
+ {hasDetail && <span style={{opacity: 0.6, userSelect: 'none'}}>{open ? '▾' : '▸'} </span>}
82
+ {GLYPH[line.level] ? <span style={{opacity: 0.7}}>{GLYPH[line.level]} </span> : null}
83
+ {line.message}
84
+ </Text>
85
+ {hasDetail && open && (
86
+ <Box marginTop={1}>
87
+ <Stack space={2}>
88
+ {line.before !== undefined ? (
89
+ // Before/after side by side, two boxes with a gap.
90
+ <Flex gap={3} align="stretch">
91
+ <ValueBox label="Before" text={line.before} bg={valueBg(theme, line.level)} />
92
+ <ValueBox label="After" text={line.value ?? ''} bg={valueBg(theme, line.level)} />
93
+ </Flex>
94
+ ) : line.value !== undefined ? (
95
+ <ValueBox text={line.value} bg={valueBg(theme, line.level)} />
96
+ ) : null}
97
+ {line.error !== undefined && (
98
+ <ValueBox label="JSON error" text={line.error} bg={valueBg(theme, line.level)} />
99
+ )}
100
+ </Stack>
101
+ </Box>
102
+ )}
103
+ </Box>
104
+ )
105
+ }
106
+
107
+ /**
108
+ * Live, collapsible log of the export/import flow. Collapsed by default. While
109
+ * expanded it auto-scrolls to the newest line only when the user is already at
110
+ * the bottom; once they scroll up to inspect, it stops following so the view
111
+ * doesn't jump out from under them.
112
+ */
113
+ export function LogPanel({
114
+ logs,
115
+ expanded: expandedProp,
116
+ onExpandedChange,
117
+ }: {
118
+ logs: LogLine[]
119
+ /** Controlled expansion; omit to let the panel manage its own (collapsed default). */
120
+ expanded?: boolean
121
+ onExpandedChange?: (expanded: boolean) => void
122
+ }) {
123
+ const [internalExpanded, setInternalExpanded] = useState(false)
124
+ const expanded = expandedProp ?? internalExpanded
125
+ const setExpanded = (next: boolean) => {
126
+ onExpandedChange?.(next)
127
+ if (expandedProp === undefined) setInternalExpanded(next)
128
+ }
129
+ const [copied, setCopied] = useState(false)
130
+ const scrollRef = useRef<HTMLDivElement>(null)
131
+ const stickToBottom = useRef(true)
132
+ const copyTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
133
+
134
+ useEffect(() => () => {
135
+ if (copyTimeout.current) clearTimeout(copyTimeout.current)
136
+ }, [])
137
+
138
+ const handleCopy = () => {
139
+ navigator.clipboard?.writeText(logsToText(logs))
140
+ setCopied(true)
141
+ if (copyTimeout.current) clearTimeout(copyTimeout.current)
142
+ copyTimeout.current = setTimeout(() => setCopied(false), 1500)
143
+ }
144
+
145
+ const handleScroll = () => {
146
+ const el = scrollRef.current
147
+ if (!el) return
148
+ stickToBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 8
149
+ }
150
+
151
+ useLayoutEffect(() => {
152
+ const el = scrollRef.current
153
+ if (el && stickToBottom.current) el.scrollTop = el.scrollHeight
154
+ }, [logs, expanded])
155
+
156
+ return (
157
+ <Stack space={3}>
158
+ <Flex
159
+ align="center"
160
+ gap={2}
161
+ onClick={() => setExpanded(!expanded)}
162
+ style={{cursor: 'pointer', userSelect: 'none'}}
163
+ >
164
+ <Heading size={1}>
165
+ {expanded ? '▾' : '▸'} {logs.length > 0 ? `Log (${logs.length})` : 'Log'}
166
+ </Heading>
167
+ </Flex>
168
+
169
+ {expanded && (
170
+ <Card radius={2} border style={{position: 'relative'}}>
171
+ {logs.length === 0 ? (
172
+ <Box padding={3}>
173
+ <Text size={1} muted>
174
+ The log is empty — it will show the status of any send or import operation.
175
+ </Text>
176
+ </Box>
177
+ ) : (
178
+ <>
179
+ <Box style={{position: 'absolute', top: 6, right: 6, zIndex: 1}}>
180
+ <Button
181
+ mode="ghost"
182
+ padding={2}
183
+ fontSize={1}
184
+ icon={copied ? CheckmarkIcon : CopyIcon}
185
+ tone={copied ? 'positive' : 'default'}
186
+ onClick={handleCopy}
187
+ title={copied ? 'Copied' : 'Copy log'}
188
+ />
189
+ </Box>
190
+ <div
191
+ ref={scrollRef}
192
+ onScroll={handleScroll}
193
+ style={{maxHeight: 320, overflowY: 'auto', padding: 12}}
194
+ >
195
+ <Stack space={2}>
196
+ {logs.map((line, i) => (
197
+ <LogRow key={i} line={line} />
198
+ ))}
199
+ </Stack>
200
+ </div>
201
+ </>
202
+ )}
203
+ </Card>
204
+ )}
205
+ </Stack>
206
+ )
207
+ }