@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/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
+