@smartcat/sanity-plugin 1.0.0 → 1.1.0
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.
- package/README.md +294 -0
- package/dist/_chunks-cjs/constants.cjs +13 -0
- package/dist/_chunks-cjs/constants.cjs.map +1 -0
- package/dist/_chunks-cjs/index.cjs +174 -0
- package/dist/_chunks-cjs/index.cjs.map +1 -0
- package/dist/_chunks-cjs/index2.cjs +338 -0
- package/dist/_chunks-cjs/index2.cjs.map +1 -0
- package/dist/_chunks-cjs/workflow.cjs +16 -0
- package/dist/_chunks-cjs/workflow.cjs.map +1 -0
- package/dist/_chunks-es/constants.js +14 -0
- package/dist/_chunks-es/constants.js.map +1 -0
- package/dist/_chunks-es/index.js +175 -0
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_chunks-es/index2.js +340 -0
- package/dist/_chunks-es/index2.js.map +1 -0
- package/dist/_chunks-es/workflow.js +17 -0
- package/dist/_chunks-es/workflow.js.map +1 -0
- package/dist/export.cjs +160 -0
- package/dist/export.cjs.map +1 -0
- package/dist/export.d.cts +162 -0
- package/dist/export.d.ts +162 -0
- package/dist/export.js +162 -0
- package/dist/export.js.map +1 -0
- package/dist/import.cjs +169 -0
- package/dist/import.cjs.map +1 -0
- package/dist/import.d.cts +155 -0
- package/dist/import.d.ts +155 -0
- package/dist/import.js +169 -0
- package/dist/import.js.map +1 -0
- package/dist/index.cjs +2172 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +122 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2179 -0
- package/dist/index.js.map +1 -0
- package/dist/locjson.cjs +12 -0
- package/dist/locjson.cjs.map +1 -0
- package/dist/locjson.d.cts +291 -0
- package/dist/locjson.d.ts +291 -0
- package/dist/locjson.js +12 -0
- package/dist/locjson.js.map +1 -0
- package/dist/progress.cjs +16 -0
- package/dist/progress.cjs.map +1 -0
- package/dist/progress.d.cts +277 -0
- package/dist/progress.d.ts +277 -0
- package/dist/progress.js +16 -0
- package/dist/progress.js.map +1 -0
- package/dist/smartcat.cjs +287 -0
- package/dist/smartcat.cjs.map +1 -0
- package/dist/smartcat.d.cts +234 -0
- package/dist/smartcat.d.ts +234 -0
- package/dist/smartcat.js +287 -0
- package/dist/smartcat.js.map +1 -0
- package/dist/templates.cjs +12 -0
- package/dist/templates.cjs.map +1 -0
- package/dist/templates.d.cts +52 -0
- package/dist/templates.d.ts +52 -0
- package/dist/templates.js +12 -0
- package/dist/templates.js.map +1 -0
- package/package.json +101 -15
- package/src/actions/AddToProjectAction.tsx +274 -0
- package/src/export/export.test.ts +537 -0
- package/src/export/index.ts +393 -0
- package/src/form/TranslationStatusInput.tsx +212 -0
- package/src/import/import.test.ts +346 -0
- package/src/import/index.ts +418 -0
- package/src/index.ts +63 -0
- package/src/lib/constants.ts +23 -0
- package/src/lib/documentTitle.test.ts +43 -0
- package/src/lib/documentTitle.ts +30 -0
- package/src/lib/languageMap.test.ts +56 -0
- package/src/lib/languageMap.ts +71 -0
- package/src/lib/linkedDocuments.test.ts +56 -0
- package/src/lib/linkedDocuments.ts +100 -0
- package/src/lib/locjson/deserialize.ts +60 -0
- package/src/lib/locjson/fields.ts +355 -0
- package/src/lib/locjson/filename.ts +34 -0
- package/src/lib/locjson/index.ts +10 -0
- package/src/lib/locjson/locjson.test.ts +615 -0
- package/src/lib/locjson/paths.test.ts +41 -0
- package/src/lib/locjson/paths.ts +73 -0
- package/src/lib/locjson/portableText.ts +157 -0
- package/src/lib/locjson/serialize.ts +124 -0
- package/src/lib/locjson/types.ts +106 -0
- package/src/lib/log.ts +33 -0
- package/src/lib/projectItems.ts +31 -0
- package/src/lib/resolveConfig.test.ts +62 -0
- package/src/lib/resolveConfig.ts +54 -0
- package/src/lib/workflow.test.ts +37 -0
- package/src/lib/workflow.ts +48 -0
- package/src/lib/zip.fixtures.ts +75 -0
- package/src/lib/zip.test.ts +110 -0
- package/src/lib/zip.ts +164 -0
- package/src/progress/core.ts +409 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/progress.test.ts +290 -0
- package/src/schema/settings.ts +55 -0
- package/src/schema/translatableOptions.ts +25 -0
- package/src/schema/translationProject.ts +268 -0
- package/src/smartcat/client.test.ts +222 -0
- package/src/smartcat/client.ts +351 -0
- package/src/smartcat/index.ts +10 -0
- package/src/smartcat/types.ts +99 -0
- package/src/templates/core.ts +55 -0
- package/src/templates/index.ts +5 -0
- package/src/templates/templates.test.ts +50 -0
- package/src/tool/Dashboard.tsx +46 -0
- package/src/tool/ItemProgress.tsx +176 -0
- package/src/tool/LogPanel.tsx +207 -0
- package/src/tool/ProjectView.tsx +1004 -0
- package/src/tool/ProjectsView.tsx +208 -0
- package/src/tool/StatusBadge.tsx +33 -0
- package/src/tool/StudioInit.tsx +42 -0
- package/src/tool/WorkflowSelect.tsx +41 -0
- package/src/tool/data.ts +86 -0
- package/src/tool/index.ts +19 -0
- package/src/tool/processing.test.ts +698 -0
- package/src/tool/processing.ts +839 -0
- package/src/tool/useTemplates.ts +84 -0
- package/src/tool/useWorkflowSelection.ts +65 -0
- 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,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,46 @@
|
|
|
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, translatableTypes, documentI18nLanguageField, fieldI18nLanguageField} =
|
|
15
|
+
config
|
|
16
|
+
|
|
17
|
+
return function Dashboard() {
|
|
18
|
+
const router = useRouter()
|
|
19
|
+
const projectId = useRouterState(
|
|
20
|
+
useCallback((state) => (state.projectId as string | undefined) ?? null, []),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const openProject = useCallback((id: string) => router.navigate({projectId: id}), [router])
|
|
24
|
+
const goBack = useCallback(() => router.navigate({}), [router])
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Box padding={4} height="fill" overflow="auto">
|
|
28
|
+
<Container width={2}>
|
|
29
|
+
{projectId ? (
|
|
30
|
+
<ProjectView
|
|
31
|
+
projectId={projectId}
|
|
32
|
+
onBack={goBack}
|
|
33
|
+
languages={languages}
|
|
34
|
+
sourceLanguage={sourceLanguage}
|
|
35
|
+
translatableTypes={translatableTypes}
|
|
36
|
+
documentI18nLanguageField={documentI18nLanguageField}
|
|
37
|
+
fieldI18nLanguageField={fieldI18nLanguageField}
|
|
38
|
+
/>
|
|
39
|
+
) : (
|
|
40
|
+
<ProjectsView sourceLanguage={sourceLanguage} onOpenProject={openProject} />
|
|
41
|
+
)}
|
|
42
|
+
</Container>
|
|
43
|
+
</Box>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -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
|
+
}
|