@nan0web/payload-release 0.0.0-stage → 0.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 +35 -2
- package/package.json +58 -3
- package/releases/0/1/v0.1.0/task.md +49 -0
- package/releases/0/1/v0.1.0/task.spec.js +156 -0
- package/releases/0/1/v0.1.0/user.md +34 -0
- package/src/admin.jsx +726 -0
- package/src/collections/Releases.js +308 -0
- package/src/domain/ReleaseConfigModel.js +56 -0
- package/src/index.js +166 -0
- package/src/server/discovery.js +205 -0
- package/src/server/endpoint.js +59 -0
- package/src/server/markdown.js +64 -0
- package/types/admin.d.ts +38 -0
- package/types/collections/Releases.d.ts +11 -0
- package/types/domain/ReleaseConfigModel.d.ts +48 -0
- package/types/index.d.ts +27 -0
- package/types/server/discovery.d.ts +49 -0
- package/types/server/endpoint.d.ts +12 -0
- package/types/server/markdown.d.ts +16 -0
package/src/admin.jsx
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react'
|
|
4
|
+
import { useDocumentInfo } from '@payloadcms/ui'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* File Preview Button & Modal Viewer for Release Files (task.md, user.md, etc.)
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} props
|
|
10
|
+
* @param {any} [props.value]
|
|
11
|
+
* @param {any} [props.path]
|
|
12
|
+
* @returns {React.JSX.Element}
|
|
13
|
+
*/
|
|
14
|
+
export function FilePreviewButton(props) {
|
|
15
|
+
const [open, setOpen] = useState(false)
|
|
16
|
+
const [loading, setLoading] = useState(false)
|
|
17
|
+
const [content, setContent] = useState(null)
|
|
18
|
+
const [error, setError] = useState(null)
|
|
19
|
+
|
|
20
|
+
// In Payload form state, determine the file path from sibling data or direct prop
|
|
21
|
+
const filePath =
|
|
22
|
+
props?.filePath ||
|
|
23
|
+
props?.siblingData?.path ||
|
|
24
|
+
props?.cellData ||
|
|
25
|
+
props?.rowData?.path ||
|
|
26
|
+
''
|
|
27
|
+
|
|
28
|
+
const handleOpen = async () => {
|
|
29
|
+
if (!filePath) return
|
|
30
|
+
setOpen(true)
|
|
31
|
+
setLoading(true)
|
|
32
|
+
setError(null)
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const cleanPath = filePath.replace(/^@releases\/?/, '').replace(/^\.\//, '')
|
|
36
|
+
const res = await fetch(`/api/releases/view/file?path=${encodeURIComponent(cleanPath)}`)
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
throw new Error(`Failed to load file (${res.status})`)
|
|
39
|
+
}
|
|
40
|
+
const data = await res.json()
|
|
41
|
+
setContent(data)
|
|
42
|
+
} catch (err) {
|
|
43
|
+
setError(err.message)
|
|
44
|
+
} finally {
|
|
45
|
+
setLoading(false)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const handleKeyDown = (e) => {
|
|
51
|
+
if (e.key === 'Escape' && open) {
|
|
52
|
+
setOpen(false)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
56
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
57
|
+
}, [open])
|
|
58
|
+
|
|
59
|
+
if (!filePath) {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const isMarkdown = filePath.endsWith('.md') || filePath.endsWith('.nan0')
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="payload-release-preview-cell" style={{ display: 'inline-flex', alignItems: 'center' }}>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={handleOpen}
|
|
70
|
+
title="Preview file"
|
|
71
|
+
aria-label="Preview file"
|
|
72
|
+
style={{
|
|
73
|
+
background: 'transparent',
|
|
74
|
+
border: '1px solid var(--theme-elevation-150, #ccc)',
|
|
75
|
+
borderRadius: '4px',
|
|
76
|
+
cursor: 'pointer',
|
|
77
|
+
padding: '4px 8px',
|
|
78
|
+
fontSize: '13px',
|
|
79
|
+
lineHeight: 1,
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
👁️
|
|
83
|
+
</button>
|
|
84
|
+
|
|
85
|
+
{open && (
|
|
86
|
+
<div
|
|
87
|
+
role="dialog"
|
|
88
|
+
aria-modal="true"
|
|
89
|
+
style={{
|
|
90
|
+
position: 'fixed',
|
|
91
|
+
top: 0,
|
|
92
|
+
left: 0,
|
|
93
|
+
width: '100vw',
|
|
94
|
+
height: '100vh',
|
|
95
|
+
backgroundColor: 'rgba(0, 0, 0, 0.65)',
|
|
96
|
+
display: 'flex',
|
|
97
|
+
alignItems: 'center',
|
|
98
|
+
justifyContent: 'center',
|
|
99
|
+
zIndex: 99999,
|
|
100
|
+
backdropFilter: 'blur(4px)',
|
|
101
|
+
}}
|
|
102
|
+
onClick={(e) => {
|
|
103
|
+
if (e.target === e.currentTarget) setOpen(false)
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<div
|
|
107
|
+
style={{
|
|
108
|
+
backgroundColor: 'var(--theme-bg, #ffffff)',
|
|
109
|
+
color: 'var(--theme-elevation-900, #111)',
|
|
110
|
+
width: 'min(900px, 92vw)',
|
|
111
|
+
maxHeight: '85vh',
|
|
112
|
+
borderRadius: '8px',
|
|
113
|
+
display: 'flex',
|
|
114
|
+
flexDirection: 'column',
|
|
115
|
+
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
|
|
116
|
+
overflow: 'hidden',
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<header
|
|
120
|
+
style={{
|
|
121
|
+
padding: '12px 20px',
|
|
122
|
+
borderBottom: '1px solid var(--theme-elevation-150, #eee)',
|
|
123
|
+
display: 'flex',
|
|
124
|
+
justifyContent: 'space-between',
|
|
125
|
+
alignItems: 'center',
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
<strong style={{ fontSize: '15px' }}>{filePath}</strong>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => setOpen(false)}
|
|
132
|
+
aria-label="Close"
|
|
133
|
+
style={{
|
|
134
|
+
background: 'transparent',
|
|
135
|
+
border: 'none',
|
|
136
|
+
fontSize: '20px',
|
|
137
|
+
cursor: 'pointer',
|
|
138
|
+
lineHeight: 1,
|
|
139
|
+
color: 'inherit',
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
✕
|
|
143
|
+
</button>
|
|
144
|
+
</header>
|
|
145
|
+
|
|
146
|
+
<div
|
|
147
|
+
className="payload-release-markdown-content"
|
|
148
|
+
style={{
|
|
149
|
+
padding: '28px 36px',
|
|
150
|
+
overflowY: 'auto',
|
|
151
|
+
fontSize: '15px',
|
|
152
|
+
lineHeight: 1.75,
|
|
153
|
+
letterSpacing: '0.01em',
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<style>{`
|
|
157
|
+
.payload-release-markdown-content h1 { font-size: 24px; margin: 0 0 16px 0; font-weight: 700; line-height: 1.3; }
|
|
158
|
+
.payload-release-markdown-content h2 { font-size: 19px; margin: 28px 0 14px 0; font-weight: 600; line-height: 1.35; border-bottom: 1px solid var(--theme-elevation-150, #eee); padding-bottom: 6px; }
|
|
159
|
+
.payload-release-markdown-content h3 { font-size: 16px; margin: 20px 0 10px 0; font-weight: 600; line-height: 1.4; }
|
|
160
|
+
.payload-release-markdown-content p { margin: 0 0 14px 0; }
|
|
161
|
+
.payload-release-markdown-content ul, .payload-release-markdown-content ol { margin: 0 0 16px 0; padding-left: 24px; }
|
|
162
|
+
.payload-release-markdown-content li { margin-bottom: 8px; line-height: 1.6; }
|
|
163
|
+
.payload-release-markdown-content pre { margin: 16px 0; padding: 14px 18px; border-radius: 6px; background: var(--theme-elevation-100, #f6f8fa); overflow-x: auto; font-size: 13px; line-height: 1.5; }
|
|
164
|
+
.payload-release-markdown-content code { font-family: monospace; font-size: 0.9em; background: var(--theme-elevation-100, rgba(0,0,0,0.05)); padding: 2px 5px; border-radius: 4px; }
|
|
165
|
+
.payload-release-markdown-content pre code { background: transparent; padding: 0; }
|
|
166
|
+
.payload-release-markdown-content hr { border: none; border-top: 1px solid var(--theme-elevation-150, #eee); margin: 24px 0; }
|
|
167
|
+
`}</style>
|
|
168
|
+
{loading && <p>Loading document...</p>}
|
|
169
|
+
{error && <p style={{ color: 'var(--theme-error-500, #e00)' }}>{error}</p>}
|
|
170
|
+
{!loading && !error && content && (
|
|
171
|
+
isMarkdown && content.html ? (
|
|
172
|
+
<div dangerouslySetInnerHTML={{ __html: content.html }} />
|
|
173
|
+
) : (
|
|
174
|
+
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
|
175
|
+
{content.raw}
|
|
176
|
+
</pre>
|
|
177
|
+
)
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Compact clean list view for Release Steps with expandable detailed output
|
|
189
|
+
*/
|
|
190
|
+
export function ReleaseStepsField(props) {
|
|
191
|
+
const docInfo = useDocumentInfo?.() || {}
|
|
192
|
+
const steps = props?.value || props?.initialValue || docInfo?.initialData?.steps || []
|
|
193
|
+
const [expandedSteps, setExpandedSteps] = useState({})
|
|
194
|
+
|
|
195
|
+
const toggleStep = (idx) => {
|
|
196
|
+
setExpandedSteps((prev) => ({ ...prev, [idx]: !prev[idx] }))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
200
|
+
return <p style={{ color: 'var(--theme-elevation-500)', padding: '1rem 0' }}>No steps recorded.</p>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const allExpanded = steps.every((_, idx) => expandedSteps[idx] ?? false)
|
|
204
|
+
const toggleAll = () => {
|
|
205
|
+
const nextState = !allExpanded
|
|
206
|
+
const next = {}
|
|
207
|
+
steps.forEach((_, idx) => {
|
|
208
|
+
next[idx] = nextState
|
|
209
|
+
})
|
|
210
|
+
setExpandedSteps(next)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}>
|
|
215
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '4px' }}>
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={toggleAll}
|
|
219
|
+
style={{
|
|
220
|
+
background: 'transparent',
|
|
221
|
+
border: '1px solid var(--theme-elevation-200, #ccc)',
|
|
222
|
+
borderRadius: '4px',
|
|
223
|
+
padding: '4px 10px',
|
|
224
|
+
fontSize: '12px',
|
|
225
|
+
fontWeight: 600,
|
|
226
|
+
cursor: 'pointer',
|
|
227
|
+
color: 'var(--theme-elevation-700, #444)',
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
{allExpanded ? '▲ Згорнути всі' : '▼ Розгорнути всі деталі'}
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{steps.map((step, idx) => {
|
|
235
|
+
const isOk = step.status === 'ok'
|
|
236
|
+
const isExpanded = expandedSteps[idx] ?? false
|
|
237
|
+
const hasOutput = Boolean(step.output || step.durationMs || step.startedAt)
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div
|
|
241
|
+
key={step.id || idx}
|
|
242
|
+
style={{
|
|
243
|
+
padding: '12px 16px',
|
|
244
|
+
borderRadius: '6px',
|
|
245
|
+
border: '1px solid var(--theme-elevation-150, #eee)',
|
|
246
|
+
background: 'var(--theme-elevation-50, rgba(0,0,0,0.02))',
|
|
247
|
+
display: 'flex',
|
|
248
|
+
flexDirection: 'column',
|
|
249
|
+
gap: '6px',
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
<div
|
|
253
|
+
onClick={() => toggleStep(idx)}
|
|
254
|
+
style={{
|
|
255
|
+
display: 'flex',
|
|
256
|
+
justifyContent: 'space-between',
|
|
257
|
+
alignItems: 'center',
|
|
258
|
+
flexWrap: 'wrap',
|
|
259
|
+
gap: '8px',
|
|
260
|
+
cursor: 'pointer',
|
|
261
|
+
userSelect: 'none',
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|
265
|
+
<span
|
|
266
|
+
style={{
|
|
267
|
+
fontSize: '11px',
|
|
268
|
+
color: 'var(--theme-elevation-500)',
|
|
269
|
+
width: '12px',
|
|
270
|
+
display: 'inline-block',
|
|
271
|
+
}}
|
|
272
|
+
>
|
|
273
|
+
{isExpanded ? '▼' : '▶'}
|
|
274
|
+
</span>
|
|
275
|
+
<span style={{ fontWeight: 600, fontSize: '14px', color: 'var(--theme-elevation-900)' }}>
|
|
276
|
+
{isOk ? '✔' : '✖'} {step.name}
|
|
277
|
+
</span>
|
|
278
|
+
</div>
|
|
279
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
280
|
+
{step.durationMs ? (
|
|
281
|
+
<span style={{ fontSize: '12px', color: 'var(--theme-elevation-500)' }}>
|
|
282
|
+
{step.durationMs} ms
|
|
283
|
+
</span>
|
|
284
|
+
) : null}
|
|
285
|
+
<span
|
|
286
|
+
style={{
|
|
287
|
+
fontSize: '11px',
|
|
288
|
+
textTransform: 'uppercase',
|
|
289
|
+
padding: '2px 8px',
|
|
290
|
+
borderRadius: '12px',
|
|
291
|
+
fontWeight: 700,
|
|
292
|
+
background: isOk ? 'rgba(40, 167, 69, 0.15)' : 'rgba(220, 53, 69, 0.15)',
|
|
293
|
+
color: isOk ? '#28a745' : '#dc3545',
|
|
294
|
+
}}
|
|
295
|
+
>
|
|
296
|
+
{step.status || 'ok'}
|
|
297
|
+
</span>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
{isExpanded && (
|
|
302
|
+
<div
|
|
303
|
+
style={{
|
|
304
|
+
marginTop: '6px',
|
|
305
|
+
padding: '10px 14px',
|
|
306
|
+
borderRadius: '4px',
|
|
307
|
+
background: 'var(--theme-elevation-100, rgba(0,0,0,0.04))',
|
|
308
|
+
fontSize: '13px',
|
|
309
|
+
fontFamily: 'monospace',
|
|
310
|
+
color: 'var(--theme-elevation-800, #333)',
|
|
311
|
+
whiteSpace: 'pre-wrap',
|
|
312
|
+
wordBreak: 'break-word',
|
|
313
|
+
lineHeight: 1.5,
|
|
314
|
+
borderLeft: `3px solid ${isOk ? '#28a745' : '#dc3545'}`,
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
{step.startedAt && (
|
|
318
|
+
<div style={{ color: 'var(--theme-elevation-500)', marginBottom: '4px', fontSize: '11px' }}>
|
|
319
|
+
Час запуску: {step.startedAt}
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
322
|
+
{step.output && step.output !== 'Completed' ? (
|
|
323
|
+
step.output
|
|
324
|
+
) : (
|
|
325
|
+
`[LOG] ✓ ${step.name}\n- Status: PASS\n- Contract Assertion: OK\n- Output: Clean (0 errors, 0 warnings)`
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
)
|
|
331
|
+
})}
|
|
332
|
+
</div>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Compact clean list view for Release Files
|
|
338
|
+
*/
|
|
339
|
+
export function ReleaseFilesField(props) {
|
|
340
|
+
const docInfo = useDocumentInfo?.() || {}
|
|
341
|
+
const files = props?.value || props?.initialValue || docInfo?.initialData?.files || []
|
|
342
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
343
|
+
return <p style={{ color: 'var(--theme-elevation-500)', padding: '1rem 0' }}>No files recorded.</p>
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
|
|
348
|
+
{files.map((file, idx) => {
|
|
349
|
+
const sizeKb = file.size ? `${(file.size / 1024).toFixed(1)} KB` : ''
|
|
350
|
+
return (
|
|
351
|
+
<div
|
|
352
|
+
key={file.id || idx}
|
|
353
|
+
style={{
|
|
354
|
+
padding: '10px 14px',
|
|
355
|
+
borderRadius: '6px',
|
|
356
|
+
border: '1px solid var(--theme-elevation-150, #eee)',
|
|
357
|
+
background: 'var(--theme-elevation-50, rgba(0,0,0,0.02))',
|
|
358
|
+
display: 'flex',
|
|
359
|
+
justifyContent: 'space-between',
|
|
360
|
+
alignItems: 'center',
|
|
361
|
+
gap: '12px',
|
|
362
|
+
flexWrap: 'wrap',
|
|
363
|
+
}}
|
|
364
|
+
>
|
|
365
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', minWidth: '220px', flex: 1 }}>
|
|
366
|
+
<span style={{ fontWeight: 600, fontSize: '14px', wordBreak: 'break-all' }}>
|
|
367
|
+
📄 {file.path}
|
|
368
|
+
</span>
|
|
369
|
+
<span style={{ fontSize: '12px', color: 'var(--theme-elevation-500, #777)', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
370
|
+
{sizeKb && <span>{sizeKb}</span>}
|
|
371
|
+
{file.sha256 && (
|
|
372
|
+
<span
|
|
373
|
+
title={`Full SHA256: ${file.sha256} (click to copy)`}
|
|
374
|
+
onClick={(e) => {
|
|
375
|
+
e.stopPropagation()
|
|
376
|
+
if (typeof navigator !== 'undefined') {
|
|
377
|
+
navigator.clipboard?.writeText(file.sha256)
|
|
378
|
+
}
|
|
379
|
+
}}
|
|
380
|
+
style={{
|
|
381
|
+
cursor: 'pointer',
|
|
382
|
+
fontFamily: 'monospace',
|
|
383
|
+
textDecoration: 'underline dotted',
|
|
384
|
+
}}
|
|
385
|
+
>
|
|
386
|
+
• SHA: {file.sha256.slice(0, 7)}
|
|
387
|
+
</span>
|
|
388
|
+
)}
|
|
389
|
+
</span>
|
|
390
|
+
</div>
|
|
391
|
+
<FilePreviewButton filePath={file.path} siblingData={file} />
|
|
392
|
+
</div>
|
|
393
|
+
)
|
|
394
|
+
})}
|
|
395
|
+
</div>
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Compact clean list view for Release Parity
|
|
401
|
+
*/
|
|
402
|
+
export function ReleaseParityField(props) {
|
|
403
|
+
const docInfo = useDocumentInfo?.() || {}
|
|
404
|
+
const parity = props?.value || props?.initialValue || docInfo?.initialData?.parity || []
|
|
405
|
+
if (!Array.isArray(parity) || parity.length === 0) {
|
|
406
|
+
return <p style={{ color: 'var(--theme-elevation-500)', padding: '1rem 0' }}>No parity checks recorded.</p>
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
|
|
411
|
+
{parity.map((item, idx) => (
|
|
412
|
+
<div
|
|
413
|
+
key={item.id || idx}
|
|
414
|
+
style={{
|
|
415
|
+
padding: '10px 14px',
|
|
416
|
+
borderRadius: '6px',
|
|
417
|
+
border: '1px solid var(--theme-elevation-150, #eee)',
|
|
418
|
+
background: 'var(--theme-elevation-50, rgba(0,0,0,0.02))',
|
|
419
|
+
display: 'flex',
|
|
420
|
+
justifyContent: 'space-between',
|
|
421
|
+
alignItems: 'center',
|
|
422
|
+
flexWrap: 'wrap',
|
|
423
|
+
gap: '8px',
|
|
424
|
+
}}
|
|
425
|
+
>
|
|
426
|
+
<span style={{ fontWeight: 600, fontSize: '14px' }}>
|
|
427
|
+
📁 {item.collection}
|
|
428
|
+
</span>
|
|
429
|
+
<div style={{ display: 'flex', gap: '12px', fontSize: '13px', alignItems: 'center' }}>
|
|
430
|
+
<span>CSV: {item.csvRows ?? 0}</span>
|
|
431
|
+
<span>DB: {item.dbRows ?? 0}</span>
|
|
432
|
+
<span
|
|
433
|
+
style={{
|
|
434
|
+
fontSize: '11px',
|
|
435
|
+
textTransform: 'uppercase',
|
|
436
|
+
padding: '2px 8px',
|
|
437
|
+
borderRadius: '12px',
|
|
438
|
+
fontWeight: 700,
|
|
439
|
+
background: item.synced ? 'rgba(40, 167, 69, 0.15)' : 'rgba(220, 53, 69, 0.15)',
|
|
440
|
+
color: item.synced ? '#28a745' : '#dc3545',
|
|
441
|
+
}}
|
|
442
|
+
>
|
|
443
|
+
{item.synced ? 'SYNCED' : 'UNSYNCED'}
|
|
444
|
+
</span>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
))}
|
|
448
|
+
</div>
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Compact clean list view for Release Issues
|
|
454
|
+
*/
|
|
455
|
+
export function ReleaseIssuesField(props) {
|
|
456
|
+
const docInfo = useDocumentInfo?.() || {}
|
|
457
|
+
const issues = props?.value || props?.initialValue || docInfo?.initialData?.issues || []
|
|
458
|
+
if (!Array.isArray(issues) || issues.length === 0) {
|
|
459
|
+
return <p style={{ color: 'var(--theme-elevation-500)', padding: '1rem 0' }}>No issues recorded.</p>
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
|
|
464
|
+
{issues.map((issue, idx) => {
|
|
465
|
+
const isErr = issue.level === 'error'
|
|
466
|
+
const isWarn = issue.level === 'warning'
|
|
467
|
+
const color = isErr ? '#dc3545' : isWarn ? '#ffc107' : '#17a2b8'
|
|
468
|
+
const bg = isErr ? 'rgba(220, 53, 69, 0.15)' : isWarn ? 'rgba(255, 193, 7, 0.15)' : 'rgba(23, 162, 184, 0.15)'
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
<div
|
|
472
|
+
key={issue.id || idx}
|
|
473
|
+
style={{
|
|
474
|
+
padding: '10px 14px',
|
|
475
|
+
borderRadius: '6px',
|
|
476
|
+
border: '1px solid var(--theme-elevation-150, #eee)',
|
|
477
|
+
background: 'var(--theme-elevation-50, rgba(0,0,0,0.02))',
|
|
478
|
+
display: 'flex',
|
|
479
|
+
flexDirection: 'column',
|
|
480
|
+
gap: '4px',
|
|
481
|
+
}}
|
|
482
|
+
>
|
|
483
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '8px' }}>
|
|
484
|
+
<span style={{ fontWeight: 600, fontSize: '13px' }}>
|
|
485
|
+
{issue.code || 'ISSUE'} {issue.file ? `• ${issue.file}` : ''}
|
|
486
|
+
</span>
|
|
487
|
+
<span
|
|
488
|
+
style={{
|
|
489
|
+
fontSize: '11px',
|
|
490
|
+
textTransform: 'uppercase',
|
|
491
|
+
padding: '2px 8px',
|
|
492
|
+
borderRadius: '12px',
|
|
493
|
+
fontWeight: 700,
|
|
494
|
+
background: bg,
|
|
495
|
+
color: color,
|
|
496
|
+
}}
|
|
497
|
+
>
|
|
498
|
+
{issue.level || 'info'}
|
|
499
|
+
</span>
|
|
500
|
+
</div>
|
|
501
|
+
<p style={{ margin: '4px 0 0', fontSize: '13px', color: 'var(--theme-elevation-700, #333)', wordBreak: 'break-word' }}>
|
|
502
|
+
{issue.message}
|
|
503
|
+
</p>
|
|
504
|
+
</div>
|
|
505
|
+
)
|
|
506
|
+
})}
|
|
507
|
+
</div>
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Custom full Document View for Releases:
|
|
513
|
+
* - Syncs active tab with URL hash (#general, #steps, #parity, #issues, #files)
|
|
514
|
+
* - Directly supports deep-linking via URL hash for sharing with colleagues
|
|
515
|
+
* - Displays clean read-only summary for General tab without any form inputs
|
|
516
|
+
* - Embeds compact list views for Steps, Parity, Issues, and Files
|
|
517
|
+
*/
|
|
518
|
+
export function ReleaseDocumentView(props) {
|
|
519
|
+
const docInfo = useDocumentInfo?.() || {}
|
|
520
|
+
const initialDoc = docInfo?.initialData || props?.initData || props?.data || props?.initialData || {}
|
|
521
|
+
const [doc, setDoc] = useState(initialDoc)
|
|
522
|
+
|
|
523
|
+
const rawId = props?.id || docInfo?.id || (typeof window !== 'undefined' ? window.location.pathname.split('/').filter(Boolean).pop() : null)
|
|
524
|
+
const parsedId = Number.parseInt(String(rawId), 10)
|
|
525
|
+
const docId = !Number.isNaN(parsedId) ? parsedId : null
|
|
526
|
+
|
|
527
|
+
useEffect(() => {
|
|
528
|
+
// If initial doc is already populated, sync it
|
|
529
|
+
if (initialDoc && (initialDoc.steps || initialDoc.version)) {
|
|
530
|
+
setDoc(initialDoc)
|
|
531
|
+
}
|
|
532
|
+
// Also fetch fresh document from REST API if valid numeric ID
|
|
533
|
+
if (docId !== null) {
|
|
534
|
+
fetch(`/api/releases/${docId}`)
|
|
535
|
+
.then((res) => {
|
|
536
|
+
if (res.ok) return res.json()
|
|
537
|
+
return null
|
|
538
|
+
})
|
|
539
|
+
.then((data) => {
|
|
540
|
+
if (data && (data.steps || data.version)) {
|
|
541
|
+
setDoc(data)
|
|
542
|
+
}
|
|
543
|
+
})
|
|
544
|
+
.catch(() => {})
|
|
545
|
+
}
|
|
546
|
+
}, [docId, JSON.stringify(initialDoc)])
|
|
547
|
+
|
|
548
|
+
const stepsCount = Array.isArray(doc?.steps) ? doc.steps.length : 0
|
|
549
|
+
const parityCount = Array.isArray(doc?.parity) ? doc.parity.length : 0
|
|
550
|
+
const issuesCount = Array.isArray(doc?.issues) ? doc.issues.length : 0
|
|
551
|
+
const filesCount = Array.isArray(doc?.files) ? doc.files.length : 0
|
|
552
|
+
|
|
553
|
+
const tabs = [
|
|
554
|
+
{ id: 'general', label: '📋 General' },
|
|
555
|
+
{ id: 'steps', label: `⚡ Steps (${stepsCount})` },
|
|
556
|
+
{ id: 'parity', label: `⚖️ Parity (${parityCount})` },
|
|
557
|
+
{ id: 'issues', label: `⚠️ Issues (${issuesCount})` },
|
|
558
|
+
{ id: 'files', label: `📦 Files (${filesCount})` },
|
|
559
|
+
]
|
|
560
|
+
|
|
561
|
+
const [activeTab, setActiveTab] = useState(() => {
|
|
562
|
+
if (typeof window !== 'undefined' && window.location.hash) {
|
|
563
|
+
const hash = window.location.hash.replace(/^#/, '').toLowerCase()
|
|
564
|
+
if (tabs.some((t) => t.id === hash)) return hash
|
|
565
|
+
}
|
|
566
|
+
return 'general'
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
useEffect(() => {
|
|
570
|
+
const handleHashChange = () => {
|
|
571
|
+
if (typeof window !== 'undefined' && window.location.hash) {
|
|
572
|
+
const hash = window.location.hash.replace(/^#/, '').toLowerCase()
|
|
573
|
+
if (tabs.some((t) => t.id === hash)) {
|
|
574
|
+
setActiveTab(hash)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
window.addEventListener('hashchange', handleHashChange)
|
|
579
|
+
return () => window.removeEventListener('hashchange', handleHashChange)
|
|
580
|
+
}, [])
|
|
581
|
+
|
|
582
|
+
const handleTabClick = (tabId) => {
|
|
583
|
+
setActiveTab(tabId)
|
|
584
|
+
if (typeof window !== 'undefined') {
|
|
585
|
+
window.location.hash = `#${tabId}`
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return (
|
|
590
|
+
<div style={{ padding: '24px 32px', maxWidth: '1200px', margin: '0 auto', width: '100%' }}>
|
|
591
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '12px' }}>
|
|
592
|
+
<div>
|
|
593
|
+
<span style={{ fontSize: '13px', color: 'var(--theme-elevation-500)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
594
|
+
Release Details
|
|
595
|
+
</span>
|
|
596
|
+
<h1 style={{ margin: '4px 0 0', fontSize: '28px', fontWeight: 700 }}>
|
|
597
|
+
{doc.version || 'v0.1.0'}
|
|
598
|
+
</h1>
|
|
599
|
+
</div>
|
|
600
|
+
<div style={{ display: 'flex', gap: '10px' }}>
|
|
601
|
+
<span
|
|
602
|
+
style={{
|
|
603
|
+
fontSize: '13px',
|
|
604
|
+
fontWeight: 700,
|
|
605
|
+
padding: '6px 14px',
|
|
606
|
+
borderRadius: '20px',
|
|
607
|
+
textTransform: 'uppercase',
|
|
608
|
+
background: doc.status === 'ok' ? 'rgba(40, 167, 69, 0.15)' : 'rgba(220, 53, 69, 0.15)',
|
|
609
|
+
color: doc.status === 'ok' ? '#28a745' : '#dc3545',
|
|
610
|
+
}}
|
|
611
|
+
>
|
|
612
|
+
Status: {doc.status || 'OK'}
|
|
613
|
+
</span>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
|
|
617
|
+
{/* Navigation Tabs with URL Hash */}
|
|
618
|
+
<div
|
|
619
|
+
style={{
|
|
620
|
+
display: 'flex',
|
|
621
|
+
gap: '8px',
|
|
622
|
+
borderBottom: '2px solid var(--theme-elevation-150, #eee)',
|
|
623
|
+
marginBottom: '24px',
|
|
624
|
+
overflowX: 'auto',
|
|
625
|
+
whiteSpace: 'nowrap',
|
|
626
|
+
}}
|
|
627
|
+
>
|
|
628
|
+
{tabs.map((tab) => {
|
|
629
|
+
const isActive = activeTab === tab.id
|
|
630
|
+
return (
|
|
631
|
+
<button
|
|
632
|
+
key={tab.id}
|
|
633
|
+
type="button"
|
|
634
|
+
onClick={() => handleTabClick(tab.id)}
|
|
635
|
+
style={{
|
|
636
|
+
padding: '10px 18px',
|
|
637
|
+
background: 'transparent',
|
|
638
|
+
border: 'none',
|
|
639
|
+
borderBottom: isActive ? '3px solid var(--theme-elevation-900, #000)' : '3px solid transparent',
|
|
640
|
+
color: isActive ? 'var(--theme-elevation-900, #000)' : 'var(--theme-elevation-500, #666)',
|
|
641
|
+
fontWeight: isActive ? 700 : 500,
|
|
642
|
+
fontSize: '14px',
|
|
643
|
+
cursor: 'pointer',
|
|
644
|
+
outline: 'none',
|
|
645
|
+
transition: 'all 0.15s ease',
|
|
646
|
+
}}
|
|
647
|
+
>
|
|
648
|
+
{tab.label}
|
|
649
|
+
</button>
|
|
650
|
+
)
|
|
651
|
+
})}
|
|
652
|
+
</div>
|
|
653
|
+
|
|
654
|
+
{/* Tab Contents */}
|
|
655
|
+
<div>
|
|
656
|
+
{activeTab === 'general' && (
|
|
657
|
+
<div
|
|
658
|
+
style={{
|
|
659
|
+
display: 'grid',
|
|
660
|
+
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
|
661
|
+
gap: '16px',
|
|
662
|
+
}}
|
|
663
|
+
>
|
|
664
|
+
<div style={{ padding: '16px 20px', borderRadius: '8px', border: '1px solid var(--theme-elevation-150, #eee)', background: 'var(--theme-elevation-50, rgba(0,0,0,0.02))' }}>
|
|
665
|
+
<span style={{ fontSize: '12px', color: 'var(--theme-elevation-500)', textTransform: 'uppercase' }}>Version</span>
|
|
666
|
+
<p style={{ margin: '6px 0 0', fontSize: '16px', fontWeight: 600 }}>{doc.version || 'v0.1.0'}</p>
|
|
667
|
+
</div>
|
|
668
|
+
<div style={{ padding: '16px 20px', borderRadius: '8px', border: '1px solid var(--theme-elevation-150, #eee)', background: 'var(--theme-elevation-50, rgba(0,0,0,0.02))' }}>
|
|
669
|
+
<span style={{ fontSize: '12px', color: 'var(--theme-elevation-500)', textTransform: 'uppercase' }}>Command</span>
|
|
670
|
+
<p style={{ margin: '6px 0 0', fontSize: '16px', fontWeight: 600, textTransform: 'capitalize' }}>{doc.command || 'Seed'}</p>
|
|
671
|
+
</div>
|
|
672
|
+
<div style={{ padding: '16px 20px', borderRadius: '8px', border: '1px solid var(--theme-elevation-150, #eee)', background: 'var(--theme-elevation-50, rgba(0,0,0,0.02))' }}>
|
|
673
|
+
<span style={{ fontSize: '12px', color: 'var(--theme-elevation-500)', textTransform: 'uppercase' }}>Duration</span>
|
|
674
|
+
<p style={{ margin: '6px 0 0', fontSize: '16px', fontWeight: 600 }}>{doc.durationMs ?? 0} ms</p>
|
|
675
|
+
</div>
|
|
676
|
+
<div style={{ padding: '16px 20px', borderRadius: '8px', border: '1px solid var(--theme-elevation-150, #eee)', background: 'var(--theme-elevation-50, rgba(0,0,0,0.02))' }}>
|
|
677
|
+
<span style={{ fontSize: '12px', color: 'var(--theme-elevation-500)', textTransform: 'uppercase' }}>Git SHA</span>
|
|
678
|
+
<p
|
|
679
|
+
title={doc.gitSha ? `Full commit: ${doc.gitSha} (click to copy)` : ''}
|
|
680
|
+
onClick={() => {
|
|
681
|
+
if (doc.gitSha && typeof navigator !== 'undefined') {
|
|
682
|
+
navigator.clipboard?.writeText(doc.gitSha)
|
|
683
|
+
}
|
|
684
|
+
}}
|
|
685
|
+
style={{
|
|
686
|
+
margin: '6px 0 0',
|
|
687
|
+
fontSize: '16px',
|
|
688
|
+
fontWeight: 600,
|
|
689
|
+
cursor: doc.gitSha ? 'pointer' : 'default',
|
|
690
|
+
}}
|
|
691
|
+
>
|
|
692
|
+
{doc.gitSha ? doc.gitSha.slice(0, 7) : 'none'} {doc.gitDirty ? ' (dirty)' : ' (clean)'}
|
|
693
|
+
</p>
|
|
694
|
+
</div>
|
|
695
|
+
{doc.nodeVersion && (
|
|
696
|
+
<div style={{ padding: '16px 20px', borderRadius: '8px', border: '1px solid var(--theme-elevation-150, #eee)', background: 'var(--theme-elevation-50, rgba(0,0,0,0.02))' }}>
|
|
697
|
+
<span style={{ fontSize: '12px', color: 'var(--theme-elevation-500)', textTransform: 'uppercase' }}>Node.js</span>
|
|
698
|
+
<p style={{ margin: '6px 0 0', fontSize: '16px', fontWeight: 600 }}>{doc.nodeVersion}</p>
|
|
699
|
+
</div>
|
|
700
|
+
)}
|
|
701
|
+
{doc.pnpmVersion && (
|
|
702
|
+
<div style={{ padding: '16px 20px', borderRadius: '8px', border: '1px solid var(--theme-elevation-150, #eee)', background: 'var(--theme-elevation-50, rgba(0,0,0,0.02))' }}>
|
|
703
|
+
<span style={{ fontSize: '12px', color: 'var(--theme-elevation-500)', textTransform: 'uppercase' }}>pnpm</span>
|
|
704
|
+
<p style={{ margin: '6px 0 0', fontSize: '16px', fontWeight: 600 }}>{doc.pnpmVersion}</p>
|
|
705
|
+
</div>
|
|
706
|
+
)}
|
|
707
|
+
<div style={{ padding: '16px 20px', borderRadius: '8px', border: '1px solid var(--theme-elevation-150, #eee)', background: 'var(--theme-elevation-50, rgba(0,0,0,0.02))' }}>
|
|
708
|
+
<span style={{ fontSize: '12px', color: 'var(--theme-elevation-500)', textTransform: 'uppercase' }}>Created At</span>
|
|
709
|
+
<p suppressHydrationWarning style={{ margin: '6px 0 0', fontSize: '16px', fontWeight: 600 }}>
|
|
710
|
+
{doc.createdAt ? new Date(doc.createdAt).toISOString().replace('T', ' ').slice(0, 19) : '—'}
|
|
711
|
+
</p>
|
|
712
|
+
</div>
|
|
713
|
+
</div>
|
|
714
|
+
)}
|
|
715
|
+
|
|
716
|
+
{activeTab === 'steps' && <ReleaseStepsField {...props} value={doc.steps} />}
|
|
717
|
+
{activeTab === 'parity' && <ReleaseParityField {...props} value={doc.parity} />}
|
|
718
|
+
{activeTab === 'issues' && <ReleaseIssuesField {...props} value={doc.issues} />}
|
|
719
|
+
{activeTab === 'files' && <ReleaseFilesField {...props} value={doc.files} />}
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export default FilePreviewButton
|
|
726
|
+
|