@maizzle/framework 6.0.0-rc.6 → 6.0.0-rc.7

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 (63) hide show
  1. package/dist/components/CodeBlock.vue +12 -19
  2. package/dist/components/Markdown.vue +70 -0
  3. package/dist/plugins/postcss/tailwindCleanup.mjs +22 -13
  4. package/dist/plugins/postcss/tailwindCleanup.mjs.map +1 -1
  5. package/dist/render/createRenderer.d.mts +2 -3
  6. package/dist/render/createRenderer.d.mts.map +1 -1
  7. package/dist/render/createRenderer.mjs +55 -4
  8. package/dist/render/createRenderer.mjs.map +1 -1
  9. package/dist/serve.d.mts.map +1 -1
  10. package/dist/serve.mjs +83 -3
  11. package/dist/serve.mjs.map +1 -1
  12. package/dist/server/compatibility.d.mts +1 -2
  13. package/dist/server/compatibility.d.mts.map +1 -1
  14. package/dist/server/compatibility.mjs +15 -15
  15. package/dist/server/compatibility.mjs.map +1 -1
  16. package/dist/server/email.d.mts +17 -0
  17. package/dist/server/email.d.mts.map +1 -0
  18. package/dist/server/email.mjs +40 -0
  19. package/dist/server/email.mjs.map +1 -0
  20. package/dist/server/ui/App.vue +204 -68
  21. package/dist/server/ui/components/ui/checkbox/Checkbox.vue +35 -0
  22. package/dist/server/ui/components/ui/checkbox/index.ts +1 -0
  23. package/dist/server/ui/components/ui/command/CommandDialog.vue +1 -1
  24. package/dist/server/ui/components/ui/command/CommandInput.vue +19 -1
  25. package/dist/server/ui/components/ui/command/CommandItem.vue +1 -1
  26. package/dist/server/ui/components/ui/command/CommandList.vue +1 -1
  27. package/dist/server/ui/components/ui/command/CommandShortcut.vue +1 -1
  28. package/dist/server/ui/components/ui/dialog/DialogOverlay.vue +9 -1
  29. package/dist/server/ui/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  30. package/dist/server/ui/components/ui/scroll-area/ScrollBar.vue +1 -1
  31. package/dist/server/ui/components/ui/sheet/SheetContent.vue +1 -1
  32. package/dist/server/ui/components/ui/sheet/SheetOverlay.vue +9 -1
  33. package/dist/server/ui/components/ui/sidebar/Sidebar.vue +8 -1
  34. package/dist/server/ui/components/ui/sidebar/SidebarProvider.vue +1 -1
  35. package/dist/server/ui/components/ui/sidebar/SidebarTrigger.vue +5 -4
  36. package/dist/server/ui/components/ui/tags-input/TagsInput.vue +26 -0
  37. package/dist/server/ui/components/ui/tags-input/TagsInputInput.vue +17 -0
  38. package/dist/server/ui/components/ui/tags-input/TagsInputItem.vue +19 -0
  39. package/dist/server/ui/components/ui/tags-input/TagsInputItemDelete.vue +22 -0
  40. package/dist/server/ui/components/ui/tags-input/TagsInputItemText.vue +17 -0
  41. package/dist/server/ui/components/ui/tags-input/index.ts +5 -0
  42. package/dist/server/ui/components/ui/toggle/index.ts +3 -3
  43. package/dist/server/ui/components/ui/toggle-group/ToggleGroup.vue +1 -1
  44. package/dist/server/ui/components/ui/toggle-group/ToggleGroupItem.vue +2 -2
  45. package/dist/server/ui/main.css +20 -20
  46. package/dist/server/ui/pages/Home.vue +12 -5
  47. package/dist/server/ui/pages/Preview.vue +369 -150
  48. package/dist/transformers/inlineCSS.mjs +9 -0
  49. package/dist/transformers/inlineCSS.mjs.map +1 -1
  50. package/dist/transformers/purgeCSS.d.mts.map +1 -1
  51. package/dist/transformers/purgeCSS.mjs +67 -1
  52. package/dist/transformers/purgeCSS.mjs.map +1 -1
  53. package/dist/transformers/tailwindcss.mjs +3 -7
  54. package/dist/transformers/tailwindcss.mjs.map +1 -1
  55. package/dist/types/config.d.mts +38 -4
  56. package/dist/types/config.d.mts.map +1 -1
  57. package/dist/types/index.d.mts +2 -2
  58. package/package.json +7 -3
  59. package/dist/server/ui/components/ui/resizable/ResizableHandle.vue +0 -30
  60. package/dist/server/ui/components/ui/resizable/ResizablePanel.vue +0 -21
  61. package/dist/server/ui/components/ui/resizable/ResizablePanelGroup.vue +0 -25
  62. package/dist/server/ui/components/ui/resizable/index.ts +0 -3
  63. /package/dist/components/{Preview.vue → Preheader.vue} +0 -0
@@ -1,20 +1,25 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
3
3
  import { useRoute } from 'vue-router'
4
- import { ChevronUp, ChevronDown, Check } from 'lucide-vue-next'
4
+ import { ChevronUp, ChevronDown, Check, ExternalLink } from 'lucide-vue-next'
5
5
  import {
6
6
  DropdownMenu,
7
7
  DropdownMenuContent,
8
8
  DropdownMenuItem,
9
9
  DropdownMenuTrigger,
10
10
  } from '@/components/ui/dropdown-menu'
11
- import {
12
- ResizableHandle,
13
- ResizablePanel,
14
- ResizablePanelGroup,
15
- } from '@/components/ui/resizable'
16
11
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
17
12
  import { Button } from '@/components/ui/button'
13
+ import { Input } from '@/components/ui/input'
14
+ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
15
+ import { Checkbox } from '@/components/ui/checkbox'
16
+ import {
17
+ TagsInput,
18
+ TagsInputInput,
19
+ TagsInputItem,
20
+ TagsInputItemDelete,
21
+ TagsInputItemText,
22
+ } from '@/components/ui/tags-input'
18
23
 
19
24
  import stripesUrl from '../stripes.svg'
20
25
 
@@ -40,27 +45,20 @@ const sourceView = ref<'compiled' | 'vue' | 'plaintext'>('compiled')
40
45
  const copied = ref(false)
41
46
 
42
47
  const iframeEl = ref<HTMLIFrameElement>()
48
+ const compiledSourceEl = ref<HTMLElement>()
43
49
  const vueSourceEl = ref<HTMLElement>()
44
50
  const containerEl = ref<HTMLElement>()
45
- const previewEl = ref<InstanceType<typeof ResizablePanel>>()
46
- const leftPanel = ref<InstanceType<typeof ResizablePanel>>()
47
- const rightPanel = ref<InstanceType<typeof ResizablePanel>>()
48
- const topPanel = ref<InstanceType<typeof ResizablePanel>>()
49
- const bottomPanel = ref<InstanceType<typeof ResizablePanel>>()
51
+ const wrapperEl = ref<HTMLElement>()
50
52
 
51
53
  const panelWidth = defineModel<number>('panelWidth', { default: 0 })
52
54
  const panelHeight = defineModel<number>('panelHeight', { default: 0 })
53
55
  const isDragging = defineModel<boolean>('isDragging', { default: false })
54
56
  const isFullSize = defineModel<boolean>('isFullSize', { default: true })
55
57
 
56
- const sideSizes = ref({ left: 0, right: 0, top: 0, bottom: 0 })
57
-
58
- function updateFullSize() {
59
- isFullSize.value = sideSizes.value.left < 0.5
60
- && sideSizes.value.right < 0.5
61
- && sideSizes.value.top < 0.5
62
- && sideSizes.value.bottom < 0.5
63
- }
58
+ // Custom resizable: width/height of the iframe wrapper (null = fill container)
59
+ const iframeWidth = ref<number | null>(null)
60
+ const iframeHeight = ref<number | null>(null)
61
+ const iframeContentHeight = ref<number | null>(null)
64
62
 
65
63
  async function copySource() {
66
64
  if (sourceView.value === 'compiled') {
@@ -99,14 +97,102 @@ interface TemplateStats {
99
97
 
100
98
  const compatibilityIssues = ref<CompatibilityIssue[]>([])
101
99
  const compatibilityLoading = ref(false)
100
+ const compatibilityError = ref('')
102
101
  const lintIssues = ref<LintIssue[]>([])
103
102
  const lintLoading = ref(false)
104
103
  const stats = ref<TemplateStats | null>(null)
105
104
  const statsLoading = ref(false)
106
105
 
106
+ // Email test state
107
+ const emailTo = ref<string[]>([])
108
+ const emailSubject = ref('')
109
+ const emailSending = ref(false)
110
+ const emailPreventThreading = ref(true)
111
+ const emailResult = ref<{ success: boolean; message: string; previewUrl?: string } | null>(null)
112
+
113
+ async function fetchEmailConfig() {
114
+ try {
115
+ const res = await fetch('/__maizzle/email-config')
116
+ const data = await res.json()
117
+ if (data.to?.length && !emailTo.value.length) emailTo.value = data.to
118
+ if (data.subject && !emailSubject.value) emailSubject.value = data.subject
119
+ } catch {}
120
+ }
121
+
122
+ async function sendTestEmail() {
123
+ if (!emailTo.value.length) return
124
+ emailSending.value = true
125
+ emailResult.value = null
126
+
127
+ try {
128
+ const res = await fetch(`/__maizzle/email/${route.params.template}`, {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({
132
+ to: emailTo.value,
133
+ subject: (() => {
134
+ let subj = emailSubject.value || String(route.params.template)
135
+ if (emailPreventThreading.value) {
136
+ subj += ` | ${new Date().toISOString().slice(0, 19)}`
137
+ }
138
+ return subj
139
+ })(),
140
+ }),
141
+ })
142
+ emailResult.value = await res.json()
143
+ } catch (error: any) {
144
+ emailResult.value = { success: false, message: error.message }
145
+ } finally {
146
+ emailSending.value = false
147
+ }
148
+ }
149
+
150
+ let renderedHtml = ''
151
+
152
+ function updateIframeContentHeight() {
153
+ const iframe = iframeEl.value
154
+ const doc = iframe?.contentDocument
155
+ if (!iframe || !doc?.documentElement) return
156
+
157
+ // Hide iframe body overflow — scrolling is handled by the outer ScrollArea
158
+ if (doc.body) doc.body.style.overflow = 'hidden'
159
+
160
+ // Save scroll position of the ScrollArea viewport
161
+ const viewport = wrapperEl.value?.querySelector('[data-slot="scroll-area-viewport"]')
162
+ const scrollTop = viewport?.scrollTop ?? 0
163
+
164
+ // Temporarily collapse to measure true content height
165
+ iframe.style.height = '0'
166
+ iframeContentHeight.value = doc.documentElement.scrollHeight
167
+ iframe.style.height = `${iframeContentHeight.value}px`
168
+
169
+ // Restore scroll position
170
+ if (viewport) {
171
+ viewport.scrollTop = scrollTop
172
+ }
173
+ }
174
+
107
175
  async function fetchTemplate() {
108
176
  const res = await fetch(`/__maizzle/render/${route.params.template}`)
109
- srcdoc.value = await res.text()
177
+ renderedHtml = await res.text()
178
+
179
+ const iframe = iframeEl.value
180
+ const doc = iframe?.contentDocument
181
+
182
+ // Write directly into the iframe document to avoid a full reload,
183
+ // which preserves scroll position natively.
184
+ if (doc) {
185
+ doc.open()
186
+ doc.write(renderedHtml)
187
+ doc.close()
188
+ // Hide iframe body overflow — scrolling is handled by the outer ScrollArea
189
+ if (doc.body) doc.body.style.overflow = 'hidden'
190
+ await nextTick()
191
+ updateIframeContentHeight()
192
+ } else {
193
+ // Fallback for initial load
194
+ srcdoc.value = renderedHtml
195
+ }
110
196
  }
111
197
 
112
198
  async function fetchSource() {
@@ -138,9 +224,19 @@ async function fetchStats() {
138
224
 
139
225
  async function fetchCompatibility() {
140
226
  compatibilityLoading.value = true
227
+ compatibilityError.value = ''
141
228
  try {
142
- const res = await fetch(`/__maizzle/compatibility/${route.params.template}`)
143
- compatibilityIssues.value = await res.json()
229
+ const res = await fetch('/__maizzle/compatibility', {
230
+ method: 'POST',
231
+ body: renderedHtml,
232
+ })
233
+ const data = await res.json()
234
+ if (data?.error) {
235
+ compatibilityError.value = data.error
236
+ compatibilityIssues.value = []
237
+ } else {
238
+ compatibilityIssues.value = data
239
+ }
144
240
  } catch {
145
241
  compatibilityIssues.value = []
146
242
  } finally {
@@ -165,13 +261,15 @@ watch(() => route.params.template, () => {
165
261
  vueSourceHtml.value = ''
166
262
  plaintextContent.value = ''
167
263
  compatibilityIssues.value = []
264
+ compatibilityError.value = ''
168
265
  lintIssues.value = []
169
266
  stats.value = null
267
+ emailResult.value = null
170
268
  sourceView.value = 'compiled'
171
- fetchTemplate()
172
- fetchCompatibility()
269
+ fetchTemplate().then(fetchCompatibility)
173
270
  fetchLint()
174
271
  fetchStats()
272
+ fetchEmailConfig()
175
273
  if (viewMode.value === 'source') fetchSource()
176
274
  }, { immediate: true })
177
275
 
@@ -191,8 +289,7 @@ watch(sourceView, (view) => {
191
289
 
192
290
  if ((import.meta as any).hot) {
193
291
  ;(import.meta as any).hot.on('maizzle:template-updated', () => {
194
- fetchTemplate()
195
- fetchCompatibility()
292
+ fetchTemplate().then(fetchCompatibility)
196
293
  fetchLint()
197
294
  fetchStats()
198
295
 
@@ -237,74 +334,105 @@ async function goToLine(line: number) {
237
334
  }
238
335
  }
239
336
 
240
- // Track which axis is being user-dragged so we can sync the opposite panel
241
- let hDragging = false
242
- let vDragging = false
337
+ async function goToCompiledLine(line: number) {
338
+ viewMode.value = 'source'
339
+ sourceView.value = 'compiled'
243
340
 
244
- const emit = defineEmits<{ 'clear-device': [] }>()
341
+ if (!sourceHtml.value) {
342
+ await fetchSource()
343
+ }
245
344
 
246
- function onHDragStart() { hDragging = true; isDragging.value = true; emit('clear-device') }
247
- function onHDragEnd() { setTimeout(() => { hDragging = false }, 50); isDragging.value = false }
248
- function onVDragStart() { vDragging = true; isDragging.value = true; emit('clear-device') }
249
- function onVDragEnd() { setTimeout(() => { vDragging = false }, 50); isDragging.value = false }
345
+ await nextTick()
250
346
 
251
- function onHorizontalLayout(sizes: number[]) {
252
- if (!hDragging) return
347
+ const el = compiledSourceEl.value
348
+ if (!el) return
253
349
 
254
- const [left, , right] = sizes
255
- if (Math.abs(left - right) < 0.5) return
350
+ el.querySelectorAll('.shiki-highlight-line').forEach(l => l.classList.remove('shiki-highlight-line'))
256
351
 
257
- hDragging = false
258
- const side = Math.max(left, right)
259
- if (left < side) leftPanel.value?.resize(side)
260
- if (right < side) rightPanel.value?.resize(side)
352
+ const lineEl = el.querySelector(`[data-line="${line}"]`)
353
+ if (lineEl) {
354
+ lineEl.classList.add('shiki-highlight-line')
355
+ lineEl.scrollIntoView({ block: 'center', behavior: 'smooth' })
356
+ }
261
357
  }
262
358
 
263
- function onVerticalLayout(sizes: number[]) {
264
- if (!vDragging) return
359
+ const emit = defineEmits<{ 'clear-device': [] }>()
265
360
 
266
- const [top, , bottom] = sizes
267
- if (Math.abs(top - bottom) < 0.5) return
361
+ type Edge = 'left' | 'right' | 'top' | 'bottom'
268
362
 
269
- vDragging = false
270
- const side = Math.max(top, bottom)
271
- if (top < side) topPanel.value?.resize(side)
272
- if (bottom < side) bottomPanel.value?.resize(side)
273
- }
363
+ function onEdgeDrag(e: MouseEvent, edge: Edge) {
364
+ e.preventDefault()
365
+ isDragging.value = true
366
+ emit('clear-device')
274
367
 
275
- function applyDeviceSize(device: Device | null | undefined) {
276
- const el = containerEl.value
277
- if (!el) return
368
+ const container = containerEl.value
369
+ if (!container) return
278
370
 
279
- if (!device) {
280
- if (!hDragging && !vDragging) {
281
- leftPanel.value?.resize(0)
282
- rightPanel.value?.resize(0)
283
- topPanel.value?.resize(0)
284
- bottomPanel.value?.resize(0)
371
+ const startX = e.clientX
372
+ const startY = e.clientY
373
+ const rect = container.getBoundingClientRect()
374
+ const gutter = 40 // 20px padding on each side
375
+ const maxW = rect.width - gutter
376
+ const maxH = rect.height - gutter
377
+ const startW = iframeWidth.value ?? maxW
378
+ const startH = iframeHeight.value ?? maxH
379
+
380
+ const isHorizontal = edge === 'left' || edge === 'right'
381
+ const sign = (edge === 'left' || edge === 'top') ? -1 : 1
382
+
383
+ const onMove = (ev: MouseEvent) => {
384
+ if (isHorizontal) {
385
+ // Symmetric: each side moves by the delta, so total change is 2x
386
+ const delta = (ev.clientX - startX) * sign
387
+ iframeWidth.value = Math.max(200, Math.min(maxW, startW + delta * 2))
388
+ } else {
389
+ const delta = (ev.clientY - startY) * sign
390
+ iframeHeight.value = Math.max(100, Math.min(maxH, startH + delta * 2))
285
391
  }
286
- return
287
392
  }
288
393
 
289
- const rect = el.getBoundingClientRect()
290
- if (!rect.width || !rect.height) return
394
+ const onUp = () => {
395
+ isDragging.value = false
396
+ updateFullSize()
397
+ document.removeEventListener('mousemove', onMove)
398
+ document.removeEventListener('mouseup', onUp)
399
+ }
400
+
401
+ document.addEventListener('mousemove', onMove)
402
+ document.addEventListener('mouseup', onUp)
403
+ }
404
+
405
+ function updateFullSize() {
406
+ const container = containerEl.value
407
+ if (!container) return
408
+ const rect = container.getBoundingClientRect()
409
+ const gutter = 40
410
+ isFullSize.value = (iframeWidth.value === null || iframeWidth.value >= rect.width - gutter - 2)
411
+ && (iframeHeight.value === null || iframeHeight.value >= rect.height - gutter - 2)
412
+ }
291
413
 
292
- const handleSize = 16
293
- const hPanelSpace = rect.width - handleSize * 2
294
- const vPanelSpace = rect.height - handleSize * 2
414
+ function applyDeviceSize(device: Device | null | undefined) {
415
+ if (!device) {
416
+ iframeWidth.value = null
417
+ iframeHeight.value = null
418
+ updateFullSize()
419
+ return
420
+ }
295
421
 
296
- const hSide = Math.max(0, ((hPanelSpace - device.width) / 2) / hPanelSpace * 100)
297
- const vSide = Math.max(0, ((vPanelSpace - device.height) / 2) / vPanelSpace * 100)
422
+ const container = containerEl.value
423
+ if (!container) return
424
+ const rect = container.getBoundingClientRect()
425
+ const gutter = 40
298
426
 
299
- leftPanel.value?.resize(hSide)
300
- rightPanel.value?.resize(hSide)
301
- topPanel.value?.resize(vSide)
302
- bottomPanel.value?.resize(vSide)
427
+ iframeWidth.value = Math.min(device.width, rect.width - gutter)
428
+ iframeHeight.value = Math.min(device.height, rect.height - gutter)
429
+ updateFullSize()
303
430
  }
304
431
 
305
432
  watch(() => props.device, (device) => {
306
433
  if (viewMode.value === 'source') return
307
- applyDeviceSize(device)
434
+ // Only apply when a device is selected, not when cleared (drag start clears device)
435
+ if (device) applyDeviceSize(device)
308
436
  })
309
437
 
310
438
  watch(() => props.resetKey, () => {
@@ -339,9 +467,9 @@ function forwardIframeKeys(iframe: HTMLIFrameElement) {
339
467
  }
340
468
 
341
469
  onMounted(() => {
342
- const el = iframeEl.value
343
- if (el) {
344
- const rect = el.getBoundingClientRect()
470
+ const wrapper = wrapperEl.value
471
+ if (wrapper) {
472
+ const rect = wrapper.getBoundingClientRect()
345
473
  panelWidth.value = Math.round(rect.width)
346
474
  panelHeight.value = Math.round(rect.height)
347
475
  observer = new ResizeObserver((entries) => {
@@ -349,8 +477,13 @@ onMounted(() => {
349
477
  panelWidth.value = Math.round(entry.contentRect.width)
350
478
  panelHeight.value = Math.round(entry.contentRect.height)
351
479
  }
480
+ updateIframeContentHeight()
352
481
  })
353
- observer.observe(el)
482
+ observer.observe(wrapper)
483
+ }
484
+
485
+ const el = iframeEl.value
486
+ if (el) {
354
487
  el.addEventListener('load', () => forwardIframeKeys(el))
355
488
  }
356
489
  })
@@ -366,7 +499,7 @@ const activeTab = ref<string | undefined>(undefined)
366
499
  function toggleBottomPanel() {
367
500
  bottomPanelOpen.value = !bottomPanelOpen.value
368
501
  if (bottomPanelOpen.value) {
369
- tabsPanelHeight.value = 200
502
+ tabsPanelHeight.value = 300
370
503
  if (!activeTab.value) activeTab.value = 'compatibility'
371
504
  } else {
372
505
  tabsPanelHeight.value = 40
@@ -384,7 +517,7 @@ function onTabClick(tab: string) {
384
517
  activeTab.value = tab
385
518
  if (!bottomPanelOpen.value) {
386
519
  bottomPanelOpen.value = true
387
- tabsPanelHeight.value = 200
520
+ tabsPanelHeight.value = 300
388
521
  }
389
522
  }
390
523
 
@@ -396,8 +529,11 @@ function onTabsDragStart(e: MouseEvent) {
396
529
  const startY = e.clientY
397
530
  const startHeight = tabsPanelHeight.value
398
531
 
532
+ const rootEl = containerEl.value?.closest('.relative.h-full') as HTMLElement | null
533
+ const maxHeight = rootEl ? rootEl.getBoundingClientRect().height : Infinity
534
+
399
535
  const onMouseMove = (e: MouseEvent) => {
400
- const newHeight = Math.max(40, startHeight + startY - e.clientY)
536
+ const newHeight = Math.max(40, Math.min(maxHeight, startHeight + startY - e.clientY))
401
537
  tabsPanelHeight.value = newHeight
402
538
  bottomPanelOpen.value = newHeight > 40
403
539
 
@@ -426,120 +562,152 @@ const stripeBg = {
426
562
  </script>
427
563
 
428
564
  <template>
429
- <div class="flex flex-col h-full">
430
- <div class="relative flex-1 min-h-0">
565
+ <div class="relative h-full">
566
+ <div class="absolute inset-0 bottom-10 overflow-hidden">
431
567
  <!-- Source code view -->
432
568
  <div v-show="viewMode === 'source'" class="absolute inset-0 min-w-0 overflow-hidden">
433
569
  <div class="absolute top-3 left-6 z-10">
434
570
  <DropdownMenu :modal="false">
435
- <DropdownMenuTrigger class="inline-flex items-center gap-1 rounded-md bg-white/10 px-2.5 h-7 text-xs font-medium text-gray-300 hover:bg-white/15 transition-colors">
571
+ <DropdownMenuTrigger class="inline-flex items-center gap-1 rounded-md bg-white/80 dark:bg-white/10 backdrop-blur-md px-2.5 h-7 text-xs font-medium text-gray-600 dark:text-gray-300 hover:bg-white/90 dark:hover:bg-white/15 transition-colors">
436
572
  {{ sourceView === 'compiled' ? 'HTML' : sourceView === 'vue' ? 'Source' : 'Plaintext' }}
437
573
  <ChevronDown class="size-3 opacity-50" />
438
574
  </DropdownMenuTrigger>
439
- <DropdownMenuContent align="start" class="min-w-0 bg-white/10 backdrop-blur-md border-white/10">
440
- <DropdownMenuItem class="text-xs font-medium text-gray-300 hover:text-white focus:bg-white/10 focus:text-white" @click="sourceView = 'vue'">
441
- <Check v-if="sourceView === 'vue'" class="size-3.5" />
442
- <span :class="sourceView === 'vue' ? '' : 'pl-5.5'">Source</span>
575
+ <DropdownMenuContent align="start" class="min-w-32 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md dark:border-white/10">
576
+ <DropdownMenuItem class="text-xs font-medium text-gray-600 dark:text-gray-400 focus:text-gray-900 dark:focus:text-gray-200 dark:focus:bg-white/10" @click="sourceView = 'vue'">
577
+ <Check v-if="sourceView === 'vue'" class="size-3 text-gray-900 dark:text-gray-200" />
578
+ <span :class="[sourceView === 'vue' ? 'text-gray-900 dark:text-gray-200' : 'pl-5']">Source</span>
443
579
  </DropdownMenuItem>
444
- <DropdownMenuItem class="text-xs font-medium text-gray-300 hover:text-white focus:bg-white/10 focus:text-white" @click="sourceView = 'compiled'">
445
- <Check v-if="sourceView === 'compiled'" class="size-3.5" />
446
- <span :class="sourceView === 'compiled' ? '' : 'pl-5.5'">HTML</span>
580
+ <DropdownMenuItem class="text-xs font-medium text-gray-600 dark:text-gray-400 focus:text-gray-900 dark:focus:text-gray-200 dark:focus:bg-white/10" @click="sourceView = 'compiled'">
581
+ <Check v-if="sourceView === 'compiled'" class="size-3 text-gray-900 dark:text-gray-200" />
582
+ <span :class="[sourceView === 'compiled' ? 'text-gray-900 dark:text-gray-200' : 'pl-5']">HTML</span>
447
583
  </DropdownMenuItem>
448
- <DropdownMenuItem class="text-xs font-medium text-gray-300 hover:text-white focus:bg-white/10 focus:text-white" @click="sourceView = 'plaintext'">
449
- <Check v-if="sourceView === 'plaintext'" class="size-3.5" />
450
- <span :class="sourceView === 'plaintext' ? '' : 'pl-5.5'">Plaintext</span>
584
+ <DropdownMenuItem class="text-xs font-medium text-gray-600 dark:text-gray-400 focus:text-gray-900 dark:focus:text-gray-200 dark:focus:bg-white/10" @click="sourceView = 'plaintext'">
585
+ <Check v-if="sourceView === 'plaintext'" class="size-3 text-gray-900 dark:text-gray-200" />
586
+ <span :class="[sourceView === 'plaintext' ? 'text-gray-900 dark:text-gray-200' : 'pl-5']">Plaintext</span>
451
587
  </DropdownMenuItem>
452
588
  </DropdownMenuContent>
453
589
  </DropdownMenu>
454
590
  </div>
455
591
  <button
456
- class="absolute top-3 right-6 z-10 inline-flex items-center justify-center rounded-md px-2.5 h-8 bg-transparent hover:bg-transparent group disabled:opacity-50 disabled:cursor-not-allowed transition-all"
592
+ class="absolute top-3 right-3 z-10 inline-flex items-center justify-center rounded-md size-9 backdrop-blur-sm bg-white/10 hover:bg-white/20 group disabled:opacity-50 disabled:cursor-not-allowed transition-all"
457
593
  :disabled="copied"
458
594
  @click="copySource"
459
595
  >
460
596
  <svg v-if="!copied" class="size-5 text-gray-400 group-hover:text-gray-300" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></svg>
461
597
  <svg v-else class="size-5 text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
462
598
  </button>
463
- <div
464
- v-show="sourceView === 'compiled'"
465
- class="shiki-line-numbers h-full overflow-auto [&_pre]:p-6 [&_pre]:pt-14 [&_pre]:text-base [&_pre]:leading-6 [&_pre]:min-h-full [&_pre]:overflow-x-auto"
466
- v-html="sourceHtml"
467
- />
468
- <div
469
- ref="vueSourceEl"
470
- v-show="sourceView === 'vue'"
471
- class="shiki-line-numbers h-full overflow-auto [&_pre]:p-6 [&_pre]:pt-14 [&_pre]:text-base [&_pre]:leading-6 [&_pre]:min-h-full [&_pre]:overflow-x-auto"
472
- v-html="vueSourceHtml"
473
- />
474
- <pre
475
- v-show="sourceView === 'plaintext'"
476
- class="h-full overflow-auto p-6 pt-14 text-sm leading-6 min-h-full text-gray-300 bg-[#27212e] whitespace-pre-wrap break-words"
477
- >{{ plaintextContent }}</pre>
599
+ <ScrollArea v-show="sourceView === 'compiled'" class="h-full">
600
+ <div
601
+ ref="compiledSourceEl"
602
+ class="shiki-line-numbers [&_pre]:p-6 [&_pre]:pt-14 [&_pre]:text-base [&_pre]:leading-6 [&_pre]:min-h-full dark:[&_pre]:bg-gray-950!"
603
+ v-html="sourceHtml"
604
+ />
605
+ <ScrollBar orientation="horizontal" />
606
+ </ScrollArea>
607
+ <ScrollArea v-show="sourceView === 'vue'" class="h-full">
608
+ <div
609
+ ref="vueSourceEl"
610
+ class="shiki-line-numbers [&_pre]:p-6 [&_pre]:pt-14 [&_pre]:text-base [&_pre]:leading-6 [&_pre]:min-h-full dark:[&_pre]:bg-gray-950!"
611
+ v-html="vueSourceHtml"
612
+ />
613
+ <ScrollBar orientation="horizontal" />
614
+ </ScrollArea>
615
+ <ScrollArea v-show="sourceView === 'plaintext'" class="h-full">
616
+ <pre
617
+ class="p-6 pt-14 text-sm leading-6 min-h-full text-gray-300 bg-[#27212e] dark:bg-gray-950 whitespace-pre-wrap break-words"
618
+ >{{ plaintextContent }}</pre>
619
+ </ScrollArea>
478
620
  </div>
479
621
 
622
+ <!-- Blocks iframe from stealing pointer events while dragging tabs -->
623
+ <div v-if="tabsDragging" class="fixed inset-0 z-50" />
624
+
480
625
  <!-- Preview view -->
481
626
  <div v-show="viewMode !== 'source'" class="absolute inset-0">
482
627
  <div class="relative h-full opacity-5" :style="stripeBg" />
483
628
  </div>
484
629
 
485
- <div v-show="viewMode !== 'source'" ref="containerEl" class="absolute inset-0 z-10 flex flex-col">
486
- <div class="flex-1 min-h-0">
487
- <ResizablePanelGroup direction="vertical" class="h-full" @layout="onVerticalLayout">
488
- <ResizablePanel ref="topPanel" :default-size="0" @resize="(s: number) => { sideSizes.top = s; updateFullSize() }" />
489
- <ResizableHandle class="h-4! bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10 transition-colors after:hidden!" @dragging="(v: boolean) => v ? onVDragStart() : onVDragEnd()" />
490
- <ResizablePanel :default-size="100" :min-size="20">
491
- <ResizablePanelGroup direction="horizontal" class="h-full" @layout="onHorizontalLayout">
492
- <ResizablePanel ref="leftPanel" :default-size="0" @resize="(s: number) => { sideSizes.left = s; updateFullSize() }" />
493
- <ResizableHandle class="w-4 bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10 transition-colors after:hidden!" @dragging="(v: boolean) => v ? onHDragStart() : onHDragEnd()" />
494
- <ResizablePanel ref="previewEl" :default-size="100" :min-size="20">
495
- <iframe
496
- ref="iframeEl"
497
- :srcdoc="srcdoc"
498
- class="h-full w-full border-0 bg-white"
499
- />
500
- </ResizablePanel>
501
- <ResizableHandle class="w-4 bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10 transition-colors after:hidden!" @dragging="(v: boolean) => v ? onHDragStart() : onHDragEnd()" />
502
- <ResizablePanel ref="rightPanel" :default-size="0" @resize="(s: number) => { sideSizes.right = s; updateFullSize() }" />
503
- </ResizablePanelGroup>
504
- </ResizablePanel>
505
- <ResizableHandle class="h-4! bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10 transition-colors after:hidden!" @dragging="(v: boolean) => v ? onVDragStart() : onVDragEnd()" />
506
- <ResizablePanel ref="bottomPanel" :default-size="0" @resize="(s: number) => { sideSizes.bottom = s; updateFullSize() }" />
507
- </ResizablePanelGroup>
630
+ <div v-show="viewMode !== 'source'" ref="containerEl" class="absolute inset-0 z-10 flex items-center justify-center">
631
+ <!-- Blocks iframe from stealing pointer events while dragging -->
632
+ <div v-if="isDragging" class="absolute inset-0 z-20" />
633
+ <div
634
+ class="relative"
635
+ :style="{
636
+ width: iframeWidth != null ? `${iframeWidth + 40}px` : '100%',
637
+ height: iframeHeight != null ? `${iframeHeight + 40}px` : '100%',
638
+ transition: isDragging ? 'none' : 'width 0.2s ease, height 0.2s ease',
639
+ }"
640
+ >
641
+ <!-- Top handle -->
642
+ <div class="group absolute top-0 left-5 right-5 h-5 flex items-center justify-center cursor-ns-resize" @mousedown="onEdgeDrag($event, 'top')">
643
+ <div class="h-1 w-12 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 group-active:bg-gray-500 dark:group-hover:bg-gray-500 dark:group-active:bg-gray-400 transition-colors" />
644
+ </div>
645
+ <!-- Bottom handle -->
646
+ <div class="group absolute bottom-0 left-5 right-5 h-5 flex items-center justify-center cursor-ns-resize" @mousedown="onEdgeDrag($event, 'bottom')">
647
+ <div class="h-1 w-12 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 group-active:bg-gray-500 dark:group-hover:bg-gray-500 dark:group-active:bg-gray-400 transition-colors" />
648
+ </div>
649
+ <!-- Left handle -->
650
+ <div class="group absolute left-0 top-5 bottom-5 w-5 flex items-center justify-center cursor-ew-resize" @mousedown="onEdgeDrag($event, 'left')">
651
+ <div class="w-1 h-12 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 group-active:bg-gray-500 dark:group-hover:bg-gray-500 dark:group-active:bg-gray-400 transition-colors" />
652
+ </div>
653
+ <!-- Right handle -->
654
+ <div class="group absolute right-0 top-5 bottom-5 w-5 flex items-center justify-center cursor-ew-resize" @mousedown="onEdgeDrag($event, 'right')">
655
+ <div class="w-1 h-12 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 group-active:bg-gray-500 dark:group-hover:bg-gray-500 dark:group-active:bg-gray-400 transition-colors" />
656
+ </div>
657
+ <!-- Iframe -->
658
+ <div ref="wrapperEl" class="absolute inset-5 border border-gray-200 dark:border-gray-800">
659
+ <ScrollArea class="h-full w-full bg-white dark:bg-gray-950">
660
+ <iframe
661
+ ref="iframeEl"
662
+ :srcdoc="srcdoc"
663
+ @load="updateIframeContentHeight"
664
+ class="w-full border-0 bg-white dark:bg-gray-950"
665
+ :style="{ height: iframeContentHeight ? `${iframeContentHeight}px` : '100%' }"
666
+ />
667
+ </ScrollArea>
668
+ </div>
508
669
  </div>
509
670
  </div>
510
671
  </div>
511
672
 
512
- <!-- Tabs panel (always visible) -->
673
+ <!-- Tabs panel (overlay) -->
513
674
  <div
514
- class="shrink-0 bg-white dark:bg-gray-950 overflow-hidden"
515
- :class="!tabsDragging ? 'transition-[height] duration-200 ease-in-out' : ''"
675
+ class="absolute bottom-0 left-0 right-0 z-20 overflow-hidden border-t border-gray-200 dark:border-gray-800/50"
676
+ :class="[
677
+ !tabsDragging ? 'transition-[height] duration-200 ease-in-out' : '',
678
+ 'bg-white dark:bg-gray-950',
679
+ ]"
516
680
  :style="{ height: `${tabsPanelHeight}px` }"
517
681
  >
518
682
  <div
519
- class="relative h-px bg-gray-200 dark:bg-gray-800 cursor-row-resize before:absolute before:-top-2 before:left-0 before:right-0 before:h-5 before:content-['']"
683
+ class="relative h-0 cursor-row-resize before:absolute before:top-0 before:left-0 before:right-0 before:h-3.25 before:content-['']"
520
684
  @mousedown="onTabsDragStart"
521
685
  />
522
686
  <Tabs :model-value="activeTab" class="flex flex-col min-h-0 h-full">
523
687
  <div class="flex items-center justify-between min-h-10 px-4 shrink-0" :class="bottomPanelOpen ? 'border-b' : ''">
524
688
  <TabsList class="h-full bg-transparent! rounded-none! p-0 gap-1">
525
- <TabsTrigger value="compatibility" class="text-xs px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('compatibility')">
689
+ <TabsTrigger value="compatibility" class="text-xs font-normal px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent select-none data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('compatibility')">
526
690
  Compatibility
527
691
  </TabsTrigger>
528
- <TabsTrigger value="lint" class="text-xs px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('lint')">
692
+ <TabsTrigger value="lint" class="text-xs font-normal px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent select-none data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('lint')">
529
693
  Linter
530
694
  </TabsTrigger>
531
- <TabsTrigger value="stats" class="text-xs px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('stats')">
695
+ <TabsTrigger value="stats" class="text-xs font-normal px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent select-none data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('stats')">
532
696
  Stats
533
697
  </TabsTrigger>
698
+ <TabsTrigger value="test" class="text-xs font-normal px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent select-none data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('test')">
699
+ Test
700
+ </TabsTrigger>
534
701
  </TabsList>
535
702
  <Button variant="ghost" size="icon" class="h-7 w-7 hover:bg-transparent!" @click="toggleBottomPanel">
536
- <ChevronUp v-if="!bottomPanelOpen" class="size-4" />
537
- <ChevronDown v-else class="size-4" />
703
+ <ChevronUp v-if="!bottomPanelOpen" class="size-4 dark:text-gray-400" :stroke-width="1" />
704
+ <ChevronDown v-else class="size-4 dark:text-gray-400" :stroke-width="1" />
538
705
  </Button>
539
706
  </div>
540
- <div class="flex-1 overflow-auto">
541
- <TabsContent value="compatibility" class="mt-0">
707
+ <div class="flex-1 min-h-0">
708
+ <TabsContent value="compatibility" class="mt-0 h-full"><ScrollArea class="h-full">
542
709
  <p v-if="compatibilityLoading" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">Checking compatibility...</p>
710
+ <p v-else-if="compatibilityError" class="px-4 py-3 text-xs text-red-500 dark:text-red-400">{{ compatibilityError }}</p>
543
711
  <p v-else-if="compatibilityIssues.length === 0" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">No compatibility issues found.</p>
544
712
  <ul v-else class="text-xs divide-y">
545
713
  <li
@@ -561,12 +729,12 @@ const stripeBg = {
561
729
  </div>
562
730
  </div>
563
731
  </div>
564
- <button v-if="issue.line" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer tabular-nums shrink-0" @click="goToLine(issue.line!)">L{{ issue.line }}</button>
732
+ <button v-if="issue.line" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer tabular-nums shrink-0" @click="goToCompiledLine(issue.line!)">L{{ issue.line }}</button>
565
733
  </div>
566
734
  </li>
567
735
  </ul>
568
- </TabsContent>
569
- <TabsContent value="lint" class="mt-0">
736
+ </ScrollArea></TabsContent>
737
+ <TabsContent value="lint" class="mt-0 h-full"><ScrollArea class="h-full">
570
738
  <p v-if="lintLoading" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">Linting...</p>
571
739
  <p v-else-if="lintIssues.length === 0" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">No issues found.</p>
572
740
  <ul v-else class="text-xs divide-y">
@@ -586,8 +754,8 @@ const stripeBg = {
586
754
  </div>
587
755
  </li>
588
756
  </ul>
589
- </TabsContent>
590
- <TabsContent value="stats" class="mt-0">
757
+ </ScrollArea></TabsContent>
758
+ <TabsContent value="stats" class="mt-0 h-full"><ScrollArea class="h-full">
591
759
  <p v-if="statsLoading" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">Loading stats...</p>
592
760
  <p v-else-if="!stats" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">No stats available.</p>
593
761
  <div v-else class="px-4 py-3 flex items-center gap-6 text-xs">
@@ -595,7 +763,7 @@ const stripeBg = {
595
763
  <span class="text-gray-500 dark:text-gray-400">Size</span>
596
764
  <span
597
765
  class="font-medium tabular-nums"
598
- :class="stats.size.bytes > 102400 ? 'text-red-600' : stats.size.bytes > 51200 ? 'text-amber-600' : 'text-gray-900 dark:text-gray-100'"
766
+ :class="stats.size.bytes > 102400 ? 'text-red-600' : stats.size.bytes > 51200 ? 'text-amber-600' : 'text-gray-900 dark:text-gray-300'"
599
767
  >{{ stats.size.formatted }}</span>
600
768
  </div>
601
769
  <div class="flex items-center gap-1.5">
@@ -607,7 +775,58 @@ const stripeBg = {
607
775
  <span class="font-medium tabular-nums">{{ stats.links }}</span>
608
776
  </div>
609
777
  </div>
610
- </TabsContent>
778
+ </ScrollArea></TabsContent>
779
+ <TabsContent value="test" class="mt-0 h-full"><ScrollArea class="h-full">
780
+ <div class="px-4 py-3 max-w-md">
781
+ <div class="space-y-2">
782
+ <div class="flex items-center gap-2">
783
+ <label class="text-xs text-gray-500 dark:text-gray-400 w-12 shrink-0">To</label>
784
+ <TagsInput v-model="emailTo" delimiter=" " add-on-paste class="flex-1 min-h-7 gap-1 px-2 py-1">
785
+ <TagsInputItem v-for="item in emailTo" :key="item" :value="item" class="h-5 text-xs rounded">
786
+ <TagsInputItemText class="px-1.5 py-0 text-xs" />
787
+ <TagsInputItemDelete class="size-3.5" />
788
+ </TagsInputItem>
789
+ <TagsInputInput class="text-xs min-h-5 px-0.5" placeholder="Add emails..." />
790
+ </TagsInput>
791
+ </div>
792
+ <div class="flex items-center gap-2">
793
+ <label class="text-xs text-gray-500 dark:text-gray-400 w-12 shrink-0">Subject</label>
794
+ <div class="flex-1 flex items-center gap-3">
795
+ <Input v-model="emailSubject" :placeholder="String(route.params.template)" class="flex-1 h-7 text-xs! px-2" />
796
+ <label class="flex items-center gap-1.5 cursor-pointer select-none shrink-0">
797
+ <Checkbox v-model="emailPreventThreading" :default-checked="true" class="size-3.5" />
798
+ <span class="text-xs text-gray-500 dark:text-gray-400">Prevent threading</span>
799
+ </label>
800
+ </div>
801
+ </div>
802
+ </div>
803
+ <div class="flex items-center gap-3 mt-3">
804
+ <Button
805
+ size="sm"
806
+ class="h-7 text-xs px-3"
807
+ :disabled="!emailTo.length || emailSending"
808
+ @click="sendTestEmail"
809
+ >
810
+ <svg v-if="emailSending" class="size-3.5 animate-spin [animation-duration:0.6s]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /></svg>
811
+ {{ emailSending ? 'Sending' : 'Send' }}
812
+ </Button>
813
+ </div>
814
+ <div v-if="emailResult" class="mt-2">
815
+ <p class="text-xs" :class="emailResult.success ? 'text-gray-950 dark:text-white' : 'text-red-600'">
816
+ {{ emailResult.message }}
817
+ <a
818
+ v-if="emailResult.previewUrl"
819
+ :href="emailResult.previewUrl"
820
+ target="_blank"
821
+ rel="noopener"
822
+ class="inline-flex items-center gap-0.5 text-gray-500 dark:text-gray-400 hover:underline ml-1"
823
+ >
824
+ View <ExternalLink class="size-3" />
825
+ </a>
826
+ </p>
827
+ </div>
828
+ </div>
829
+ </ScrollArea></TabsContent>
611
830
  </div>
612
831
  </Tabs>
613
832
  </div>