@maizzle/framework 6.0.0-rc.6 → 6.0.0-rc.8
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/dist/components/Body.vue +105 -36
- package/dist/components/Button.vue +4 -1
- package/dist/components/CodeBlock.vue +11 -18
- package/dist/components/CodeInline.vue +6 -1
- package/dist/components/Column.vue +30 -5
- package/dist/components/Container.vue +10 -2
- package/dist/components/Divider.vue +28 -0
- package/dist/components/Head.vue +22 -0
- package/dist/components/Heading.vue +28 -0
- package/dist/components/Html.vue +98 -47
- package/dist/components/Layout.vue +93 -0
- package/dist/components/Link.vue +26 -0
- package/dist/components/Markdown.vue +83 -0
- package/dist/components/Outlook.vue +36 -0
- package/dist/components/Overlap.vue +25 -5
- package/dist/components/{Preview.vue → Preheader.vue} +1 -1
- package/dist/components/Row.vue +16 -5
- package/dist/components/Section.vue +83 -0
- package/dist/components/Text.vue +29 -0
- package/dist/components/Vml.vue +165 -13
- package/dist/plugins/postcss/tailwindCleanup.mjs +22 -13
- package/dist/plugins/postcss/tailwindCleanup.mjs.map +1 -1
- package/dist/render/createRenderer.d.mts +2 -3
- package/dist/render/createRenderer.d.mts.map +1 -1
- package/dist/render/createRenderer.mjs +67 -4
- package/dist/render/createRenderer.mjs.map +1 -1
- package/dist/serve.d.mts.map +1 -1
- package/dist/serve.mjs +84 -4
- package/dist/serve.mjs.map +1 -1
- package/dist/server/compatibility.d.mts +1 -2
- package/dist/server/compatibility.d.mts.map +1 -1
- package/dist/server/compatibility.mjs +30 -16
- package/dist/server/compatibility.mjs.map +1 -1
- package/dist/server/email.d.mts +17 -0
- package/dist/server/email.d.mts.map +1 -0
- package/dist/server/email.mjs +41 -0
- package/dist/server/email.mjs.map +1 -0
- package/dist/server/linter.d.mts +1 -2
- package/dist/server/linter.d.mts.map +1 -1
- package/dist/server/linter.mjs +60 -71
- package/dist/server/linter.mjs.map +1 -1
- package/dist/server/ui/App.vue +205 -69
- package/dist/server/ui/components/ui/checkbox/Checkbox.vue +35 -0
- package/dist/server/ui/components/ui/checkbox/index.ts +1 -0
- package/dist/server/ui/components/ui/command/CommandDialog.vue +1 -1
- package/dist/server/ui/components/ui/command/CommandInput.vue +19 -1
- package/dist/server/ui/components/ui/command/CommandItem.vue +1 -1
- package/dist/server/ui/components/ui/command/CommandList.vue +1 -1
- package/dist/server/ui/components/ui/command/CommandShortcut.vue +1 -1
- package/dist/server/ui/components/ui/dialog/DialogOverlay.vue +9 -1
- package/dist/server/ui/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/dist/server/ui/components/ui/scroll-area/ScrollBar.vue +1 -1
- package/dist/server/ui/components/ui/sheet/SheetContent.vue +1 -1
- package/dist/server/ui/components/ui/sheet/SheetOverlay.vue +9 -1
- package/dist/server/ui/components/ui/sidebar/Sidebar.vue +8 -1
- package/dist/server/ui/components/ui/sidebar/SidebarProvider.vue +1 -1
- package/dist/server/ui/components/ui/sidebar/SidebarTrigger.vue +5 -4
- package/dist/server/ui/components/ui/tags-input/TagsInput.vue +26 -0
- package/dist/server/ui/components/ui/tags-input/TagsInputInput.vue +17 -0
- package/dist/server/ui/components/ui/tags-input/TagsInputItem.vue +19 -0
- package/dist/server/ui/components/ui/tags-input/TagsInputItemDelete.vue +22 -0
- package/dist/server/ui/components/ui/tags-input/TagsInputItemText.vue +17 -0
- package/dist/server/ui/components/ui/tags-input/index.ts +5 -0
- package/dist/server/ui/components/ui/toggle/index.ts +3 -3
- package/dist/server/ui/components/ui/toggle-group/ToggleGroup.vue +1 -1
- package/dist/server/ui/components/ui/toggle-group/ToggleGroupItem.vue +2 -2
- package/dist/server/ui/main.css +20 -20
- package/dist/server/ui/pages/Home.vue +12 -5
- package/dist/server/ui/pages/Preview.vue +495 -211
- package/dist/transformers/inlineCSS.d.mts +1 -14
- package/dist/transformers/inlineCSS.d.mts.map +1 -1
- package/dist/transformers/inlineCSS.mjs +25 -34
- package/dist/transformers/inlineCSS.mjs.map +1 -1
- package/dist/transformers/purgeCSS.d.mts.map +1 -1
- package/dist/transformers/purgeCSS.mjs +67 -1
- package/dist/transformers/purgeCSS.mjs.map +1 -1
- package/dist/transformers/tailwindcss.mjs +3 -7
- package/dist/transformers/tailwindcss.mjs.map +1 -1
- package/dist/types/config.d.mts +47 -29
- package/dist/types/config.d.mts.map +1 -1
- package/dist/types/index.d.mts +2 -2
- package/package.json +7 -3
- package/dist/server/ui/components/ui/resizable/ResizableHandle.vue +0 -30
- package/dist/server/ui/components/ui/resizable/ResizablePanel.vue +0 -21
- package/dist/server/ui/components/ui/resizable/ResizablePanelGroup.vue +0 -25
- package/dist/server/ui/components/ui/resizable/index.ts +0 -3
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
|
2
|
+
import { ref, computed, 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, Info } 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'
|
|
23
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
|
18
24
|
|
|
19
25
|
import stripesUrl from '../stripes.svg'
|
|
20
26
|
|
|
@@ -24,9 +30,16 @@ interface Device {
|
|
|
24
30
|
height: number
|
|
25
31
|
}
|
|
26
32
|
|
|
33
|
+
interface Template {
|
|
34
|
+
name: string
|
|
35
|
+
path: string
|
|
36
|
+
href: string
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
const props = defineProps<{
|
|
28
40
|
device?: Device | null
|
|
29
41
|
resetKey?: number
|
|
42
|
+
templates?: Template[]
|
|
30
43
|
}>()
|
|
31
44
|
|
|
32
45
|
const viewMode = defineModel<'preview' | 'source'>('viewMode', { default: 'preview' })
|
|
@@ -40,45 +53,45 @@ const sourceView = ref<'compiled' | 'vue' | 'plaintext'>('compiled')
|
|
|
40
53
|
const copied = ref(false)
|
|
41
54
|
|
|
42
55
|
const iframeEl = ref<HTMLIFrameElement>()
|
|
56
|
+
const compiledSourceEl = ref<HTMLElement>()
|
|
43
57
|
const vueSourceEl = ref<HTMLElement>()
|
|
44
58
|
const containerEl = ref<HTMLElement>()
|
|
45
|
-
const
|
|
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>>()
|
|
59
|
+
const wrapperEl = ref<HTMLElement>()
|
|
50
60
|
|
|
51
61
|
const panelWidth = defineModel<number>('panelWidth', { default: 0 })
|
|
52
62
|
const panelHeight = defineModel<number>('panelHeight', { default: 0 })
|
|
53
63
|
const isDragging = defineModel<boolean>('isDragging', { default: false })
|
|
54
64
|
const isFullSize = defineModel<boolean>('isFullSize', { default: true })
|
|
55
65
|
|
|
56
|
-
|
|
66
|
+
// Custom resizable: width/height of the iframe wrapper (null = fill container)
|
|
67
|
+
const iframeWidth = ref<number | null>(null)
|
|
68
|
+
const iframeHeight = ref<number | null>(null)
|
|
69
|
+
const iframeContentHeight = ref<number | null>(null)
|
|
57
70
|
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
&& sideSizes.value.right < 0.5
|
|
61
|
-
&& sideSizes.value.top < 0.5
|
|
62
|
-
&& sideSizes.value.bottom < 0.5
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function copySource() {
|
|
71
|
+
function copySource() {
|
|
72
|
+
let text: string
|
|
66
73
|
if (sourceView.value === 'compiled') {
|
|
67
|
-
|
|
74
|
+
text = srcdoc.value
|
|
68
75
|
} else if (sourceView.value === 'plaintext') {
|
|
69
|
-
|
|
76
|
+
text = plaintextContent.value
|
|
70
77
|
} else {
|
|
71
78
|
const el = document.createElement('div')
|
|
72
79
|
el.innerHTML = vueSourceHtml.value
|
|
73
|
-
|
|
80
|
+
text = el.textContent || ''
|
|
74
81
|
}
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
|
|
83
|
+
const blob = new Blob([text], { type: 'text/plain' })
|
|
84
|
+
const item = new ClipboardItem({ 'text/plain': blob })
|
|
85
|
+
navigator.clipboard.write([item]).then(() => {
|
|
86
|
+
copied.value = true
|
|
87
|
+
setTimeout(() => { copied.value = false }, 2000)
|
|
88
|
+
})
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
interface CompatibilityIssue {
|
|
80
92
|
type: 'error' | 'warning'
|
|
81
93
|
title: string
|
|
94
|
+
category: string
|
|
82
95
|
clients: Array<{ name: string, notes: string[] }>
|
|
83
96
|
url?: string
|
|
84
97
|
line?: number
|
|
@@ -99,14 +112,111 @@ interface TemplateStats {
|
|
|
99
112
|
|
|
100
113
|
const compatibilityIssues = ref<CompatibilityIssue[]>([])
|
|
101
114
|
const compatibilityLoading = ref(false)
|
|
115
|
+
const compatibilityError = ref('')
|
|
116
|
+
const compatibilityCategory = ref('')
|
|
117
|
+
const compatibilityCategories = ['css', 'html', 'image', 'others'] as const
|
|
118
|
+
const activeCompatibilityCategories = computed(() =>
|
|
119
|
+
compatibilityCategories.filter(cat => compatibilityIssues.value.some(i => i.category === cat))
|
|
120
|
+
)
|
|
121
|
+
const filteredCompatibilityIssues = computed(() => {
|
|
122
|
+
if (!compatibilityCategory.value) return compatibilityIssues.value
|
|
123
|
+
return compatibilityIssues.value.filter(i => i.category === compatibilityCategory.value)
|
|
124
|
+
})
|
|
102
125
|
const lintIssues = ref<LintIssue[]>([])
|
|
103
126
|
const lintLoading = ref(false)
|
|
104
127
|
const stats = ref<TemplateStats | null>(null)
|
|
105
128
|
const statsLoading = ref(false)
|
|
106
129
|
|
|
130
|
+
// Email test state
|
|
131
|
+
const emailTo = ref<string[]>([])
|
|
132
|
+
const emailSubject = ref('')
|
|
133
|
+
const emailSending = ref(false)
|
|
134
|
+
const emailPreventThreading = ref(true)
|
|
135
|
+
const emailResult = ref<{ success: boolean; message: string; previewUrl?: string } | null>(null)
|
|
136
|
+
|
|
137
|
+
async function fetchEmailConfig() {
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch('/__maizzle/email-config')
|
|
140
|
+
const data = await res.json()
|
|
141
|
+
if (data.to?.length && !emailTo.value.length) emailTo.value = data.to
|
|
142
|
+
if (data.subject && !emailSubject.value) emailSubject.value = data.subject
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function sendTestEmail() {
|
|
147
|
+
if (!emailTo.value.length) return
|
|
148
|
+
emailSending.value = true
|
|
149
|
+
emailResult.value = null
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const res = await fetch(`/__maizzle/email/${route.params.template}`, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'Content-Type': 'application/json' },
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
to: emailTo.value,
|
|
157
|
+
subject: (() => {
|
|
158
|
+
let subj = emailSubject.value || String(route.params.template)
|
|
159
|
+
if (emailPreventThreading.value) {
|
|
160
|
+
subj += ` | ${new Date().toISOString().slice(0, 19)}`
|
|
161
|
+
}
|
|
162
|
+
return subj
|
|
163
|
+
})(),
|
|
164
|
+
}),
|
|
165
|
+
})
|
|
166
|
+
emailResult.value = await res.json()
|
|
167
|
+
} catch (error: any) {
|
|
168
|
+
emailResult.value = { success: false, message: error.message }
|
|
169
|
+
} finally {
|
|
170
|
+
emailSending.value = false
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let renderedHtml = ''
|
|
175
|
+
|
|
176
|
+
function updateIframeContentHeight() {
|
|
177
|
+
const iframe = iframeEl.value
|
|
178
|
+
const doc = iframe?.contentDocument
|
|
179
|
+
if (!iframe || !doc?.documentElement) return
|
|
180
|
+
|
|
181
|
+
// Hide iframe body overflow — scrolling is handled by the outer ScrollArea
|
|
182
|
+
if (doc.body) doc.body.style.overflow = 'hidden'
|
|
183
|
+
|
|
184
|
+
// Save scroll position of the ScrollArea viewport
|
|
185
|
+
const viewport = wrapperEl.value?.querySelector('[data-slot="scroll-area-viewport"]')
|
|
186
|
+
const scrollTop = viewport?.scrollTop ?? 0
|
|
187
|
+
|
|
188
|
+
// Temporarily collapse to measure true content height
|
|
189
|
+
iframe.style.height = '0'
|
|
190
|
+
iframeContentHeight.value = doc.documentElement.scrollHeight
|
|
191
|
+
iframe.style.height = `${iframeContentHeight.value}px`
|
|
192
|
+
|
|
193
|
+
// Restore scroll position
|
|
194
|
+
if (viewport) {
|
|
195
|
+
viewport.scrollTop = scrollTop
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
107
199
|
async function fetchTemplate() {
|
|
108
200
|
const res = await fetch(`/__maizzle/render/${route.params.template}`)
|
|
109
|
-
|
|
201
|
+
renderedHtml = await res.text()
|
|
202
|
+
|
|
203
|
+
const iframe = iframeEl.value
|
|
204
|
+
const doc = iframe?.contentDocument
|
|
205
|
+
|
|
206
|
+
// Write directly into the iframe document to avoid a full reload,
|
|
207
|
+
// which preserves scroll position natively.
|
|
208
|
+
if (doc) {
|
|
209
|
+
doc.open()
|
|
210
|
+
doc.write(renderedHtml)
|
|
211
|
+
doc.close()
|
|
212
|
+
// Hide iframe body overflow — scrolling is handled by the outer ScrollArea
|
|
213
|
+
if (doc.body) doc.body.style.overflow = 'hidden'
|
|
214
|
+
await nextTick()
|
|
215
|
+
updateIframeContentHeight()
|
|
216
|
+
} else {
|
|
217
|
+
// Fallback for initial load
|
|
218
|
+
srcdoc.value = renderedHtml
|
|
219
|
+
}
|
|
110
220
|
}
|
|
111
221
|
|
|
112
222
|
async function fetchSource() {
|
|
@@ -138,9 +248,22 @@ async function fetchStats() {
|
|
|
138
248
|
|
|
139
249
|
async function fetchCompatibility() {
|
|
140
250
|
compatibilityLoading.value = true
|
|
251
|
+
compatibilityError.value = ''
|
|
141
252
|
try {
|
|
142
|
-
const res = await fetch(
|
|
143
|
-
|
|
253
|
+
const res = await fetch('/__maizzle/compatibility', {
|
|
254
|
+
method: 'POST',
|
|
255
|
+
body: renderedHtml,
|
|
256
|
+
})
|
|
257
|
+
const data = await res.json()
|
|
258
|
+
if (data?.error) {
|
|
259
|
+
compatibilityError.value = data.error
|
|
260
|
+
compatibilityIssues.value = []
|
|
261
|
+
} else {
|
|
262
|
+
compatibilityIssues.value = data
|
|
263
|
+
// Default to first category that has issues
|
|
264
|
+
const firstCat = compatibilityCategories.find(cat => data.some((i: CompatibilityIssue) => i.category === cat))
|
|
265
|
+
compatibilityCategory.value = firstCat || ''
|
|
266
|
+
}
|
|
144
267
|
} catch {
|
|
145
268
|
compatibilityIssues.value = []
|
|
146
269
|
} finally {
|
|
@@ -151,8 +274,11 @@ async function fetchCompatibility() {
|
|
|
151
274
|
async function fetchLint() {
|
|
152
275
|
lintLoading.value = true
|
|
153
276
|
try {
|
|
154
|
-
const
|
|
155
|
-
|
|
277
|
+
const template = props.templates?.find(t => t.href === '/' + route.params.template)
|
|
278
|
+
const filePath = template?.path ?? route.params.template
|
|
279
|
+
const res = await fetch(`/__maizzle/lint/${filePath}`)
|
|
280
|
+
const data = await res.json()
|
|
281
|
+
lintIssues.value = Array.isArray(data) ? data.filter((i: LintIssue) => i.title) : []
|
|
156
282
|
} catch {
|
|
157
283
|
lintIssues.value = []
|
|
158
284
|
} finally {
|
|
@@ -165,13 +291,15 @@ watch(() => route.params.template, () => {
|
|
|
165
291
|
vueSourceHtml.value = ''
|
|
166
292
|
plaintextContent.value = ''
|
|
167
293
|
compatibilityIssues.value = []
|
|
294
|
+
compatibilityError.value = ''
|
|
168
295
|
lintIssues.value = []
|
|
169
296
|
stats.value = null
|
|
297
|
+
emailResult.value = null
|
|
170
298
|
sourceView.value = 'compiled'
|
|
171
|
-
fetchTemplate()
|
|
172
|
-
fetchCompatibility()
|
|
299
|
+
fetchTemplate().then(fetchCompatibility)
|
|
173
300
|
fetchLint()
|
|
174
301
|
fetchStats()
|
|
302
|
+
fetchEmailConfig()
|
|
175
303
|
if (viewMode.value === 'source') fetchSource()
|
|
176
304
|
}, { immediate: true })
|
|
177
305
|
|
|
@@ -191,8 +319,7 @@ watch(sourceView, (view) => {
|
|
|
191
319
|
|
|
192
320
|
if ((import.meta as any).hot) {
|
|
193
321
|
;(import.meta as any).hot.on('maizzle:template-updated', () => {
|
|
194
|
-
fetchTemplate()
|
|
195
|
-
fetchCompatibility()
|
|
322
|
+
fetchTemplate().then(fetchCompatibility)
|
|
196
323
|
fetchLint()
|
|
197
324
|
fetchStats()
|
|
198
325
|
|
|
@@ -237,74 +364,112 @@ async function goToLine(line: number) {
|
|
|
237
364
|
}
|
|
238
365
|
}
|
|
239
366
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
367
|
+
async function goToCompiledLine(line: number) {
|
|
368
|
+
viewMode.value = 'source'
|
|
369
|
+
sourceView.value = 'compiled'
|
|
243
370
|
|
|
244
|
-
|
|
371
|
+
if (!sourceHtml.value) {
|
|
372
|
+
await fetchSource()
|
|
373
|
+
}
|
|
245
374
|
|
|
246
|
-
|
|
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 }
|
|
375
|
+
await nextTick()
|
|
250
376
|
|
|
251
|
-
|
|
252
|
-
if (!
|
|
377
|
+
const el = compiledSourceEl.value
|
|
378
|
+
if (!el) return
|
|
253
379
|
|
|
254
|
-
|
|
255
|
-
if (Math.abs(left - right) < 0.5) return
|
|
380
|
+
el.querySelectorAll('.shiki-highlight-line').forEach(l => l.classList.remove('shiki-highlight-line'))
|
|
256
381
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
382
|
+
const lineEl = el.querySelector(`[data-line="${line}"]`)
|
|
383
|
+
if (lineEl) {
|
|
384
|
+
lineEl.classList.add('shiki-highlight-line')
|
|
385
|
+
lineEl.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
386
|
+
}
|
|
261
387
|
}
|
|
262
388
|
|
|
263
|
-
|
|
264
|
-
|
|
389
|
+
const emit = defineEmits<{ 'clear-device': [] }>()
|
|
390
|
+
|
|
391
|
+
type Edge = 'left' | 'right' | 'top' | 'bottom'
|
|
392
|
+
|
|
393
|
+
function onEdgeDrag(e: MouseEvent | TouchEvent, edge: Edge) {
|
|
394
|
+
e.preventDefault()
|
|
395
|
+
isDragging.value = true
|
|
396
|
+
emit('clear-device')
|
|
397
|
+
|
|
398
|
+
const container = containerEl.value
|
|
399
|
+
if (!container) return
|
|
400
|
+
|
|
401
|
+
const isTouch = e.type === 'touchstart'
|
|
402
|
+
const startPoint = isTouch ? (e as TouchEvent).touches[0] : (e as MouseEvent)
|
|
403
|
+
const startX = startPoint.clientX
|
|
404
|
+
const startY = startPoint.clientY
|
|
405
|
+
const rect = container.getBoundingClientRect()
|
|
406
|
+
const gutter = 40 // 20px padding on each side
|
|
407
|
+
const maxW = rect.width - gutter
|
|
408
|
+
const maxH = rect.height - gutter
|
|
409
|
+
const startW = iframeWidth.value ?? maxW
|
|
410
|
+
const startH = iframeHeight.value ?? maxH
|
|
411
|
+
|
|
412
|
+
const isHorizontal = edge === 'left' || edge === 'right'
|
|
413
|
+
const sign = (edge === 'left' || edge === 'top') ? -1 : 1
|
|
414
|
+
|
|
415
|
+
const onMove = (ev: MouseEvent | TouchEvent) => {
|
|
416
|
+
const point = ev.type === 'touchmove' ? (ev as TouchEvent).touches[0] : (ev as MouseEvent)
|
|
417
|
+
if (isHorizontal) {
|
|
418
|
+
// Symmetric: each side moves by the delta, so total change is 2x
|
|
419
|
+
const delta = (point.clientX - startX) * sign
|
|
420
|
+
iframeWidth.value = Math.max(200, Math.min(maxW, startW + delta * 2))
|
|
421
|
+
} else {
|
|
422
|
+
const delta = (point.clientY - startY) * sign
|
|
423
|
+
iframeHeight.value = Math.max(100, Math.min(maxH, startH + delta * 2))
|
|
424
|
+
}
|
|
425
|
+
}
|
|
265
426
|
|
|
266
|
-
const
|
|
267
|
-
|
|
427
|
+
const onUp = () => {
|
|
428
|
+
isDragging.value = false
|
|
429
|
+
updateFullSize()
|
|
430
|
+
document.removeEventListener('mousemove', onMove)
|
|
431
|
+
document.removeEventListener('mouseup', onUp)
|
|
432
|
+
document.removeEventListener('touchmove', onMove)
|
|
433
|
+
document.removeEventListener('touchend', onUp)
|
|
434
|
+
}
|
|
268
435
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
436
|
+
document.addEventListener('mousemove', onMove)
|
|
437
|
+
document.addEventListener('mouseup', onUp)
|
|
438
|
+
document.addEventListener('touchmove', onMove, { passive: false })
|
|
439
|
+
document.addEventListener('touchend', onUp)
|
|
273
440
|
}
|
|
274
441
|
|
|
275
|
-
function
|
|
276
|
-
const
|
|
277
|
-
if (!
|
|
442
|
+
function updateFullSize() {
|
|
443
|
+
const container = containerEl.value
|
|
444
|
+
if (!container) return
|
|
445
|
+
const rect = container.getBoundingClientRect()
|
|
446
|
+
const gutter = 40
|
|
447
|
+
isFullSize.value = (iframeWidth.value === null || iframeWidth.value >= rect.width - gutter - 2)
|
|
448
|
+
&& (iframeHeight.value === null || iframeHeight.value >= rect.height - gutter - 2)
|
|
449
|
+
}
|
|
278
450
|
|
|
451
|
+
function applyDeviceSize(device: Device | null | undefined) {
|
|
279
452
|
if (!device) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
topPanel.value?.resize(0)
|
|
284
|
-
bottomPanel.value?.resize(0)
|
|
285
|
-
}
|
|
453
|
+
iframeWidth.value = null
|
|
454
|
+
iframeHeight.value = null
|
|
455
|
+
updateFullSize()
|
|
286
456
|
return
|
|
287
457
|
}
|
|
288
458
|
|
|
289
|
-
const
|
|
290
|
-
if (!
|
|
459
|
+
const container = containerEl.value
|
|
460
|
+
if (!container) return
|
|
461
|
+
const rect = container.getBoundingClientRect()
|
|
462
|
+
const gutter = 40
|
|
291
463
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const hSide = Math.max(0, ((hPanelSpace - device.width) / 2) / hPanelSpace * 100)
|
|
297
|
-
const vSide = Math.max(0, ((vPanelSpace - device.height) / 2) / vPanelSpace * 100)
|
|
298
|
-
|
|
299
|
-
leftPanel.value?.resize(hSide)
|
|
300
|
-
rightPanel.value?.resize(hSide)
|
|
301
|
-
topPanel.value?.resize(vSide)
|
|
302
|
-
bottomPanel.value?.resize(vSide)
|
|
464
|
+
iframeWidth.value = Math.min(device.width, rect.width - gutter)
|
|
465
|
+
iframeHeight.value = Math.min(device.height, rect.height - gutter)
|
|
466
|
+
updateFullSize()
|
|
303
467
|
}
|
|
304
468
|
|
|
305
469
|
watch(() => props.device, (device) => {
|
|
306
470
|
if (viewMode.value === 'source') return
|
|
307
|
-
|
|
471
|
+
// Only apply when a device is selected, not when cleared (drag start clears device)
|
|
472
|
+
if (device) applyDeviceSize(device)
|
|
308
473
|
})
|
|
309
474
|
|
|
310
475
|
watch(() => props.resetKey, () => {
|
|
@@ -339,9 +504,9 @@ function forwardIframeKeys(iframe: HTMLIFrameElement) {
|
|
|
339
504
|
}
|
|
340
505
|
|
|
341
506
|
onMounted(() => {
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
344
|
-
const rect =
|
|
507
|
+
const wrapper = wrapperEl.value
|
|
508
|
+
if (wrapper) {
|
|
509
|
+
const rect = wrapper.getBoundingClientRect()
|
|
345
510
|
panelWidth.value = Math.round(rect.width)
|
|
346
511
|
panelHeight.value = Math.round(rect.height)
|
|
347
512
|
observer = new ResizeObserver((entries) => {
|
|
@@ -349,8 +514,13 @@ onMounted(() => {
|
|
|
349
514
|
panelWidth.value = Math.round(entry.contentRect.width)
|
|
350
515
|
panelHeight.value = Math.round(entry.contentRect.height)
|
|
351
516
|
}
|
|
517
|
+
updateIframeContentHeight()
|
|
352
518
|
})
|
|
353
|
-
observer.observe(
|
|
519
|
+
observer.observe(wrapper)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const el = iframeEl.value
|
|
523
|
+
if (el) {
|
|
354
524
|
el.addEventListener('load', () => forwardIframeKeys(el))
|
|
355
525
|
}
|
|
356
526
|
})
|
|
@@ -366,7 +536,7 @@ const activeTab = ref<string | undefined>(undefined)
|
|
|
366
536
|
function toggleBottomPanel() {
|
|
367
537
|
bottomPanelOpen.value = !bottomPanelOpen.value
|
|
368
538
|
if (bottomPanelOpen.value) {
|
|
369
|
-
tabsPanelHeight.value =
|
|
539
|
+
tabsPanelHeight.value = 300
|
|
370
540
|
if (!activeTab.value) activeTab.value = 'compatibility'
|
|
371
541
|
} else {
|
|
372
542
|
tabsPanelHeight.value = 40
|
|
@@ -384,7 +554,7 @@ function onTabClick(tab: string) {
|
|
|
384
554
|
activeTab.value = tab
|
|
385
555
|
if (!bottomPanelOpen.value) {
|
|
386
556
|
bottomPanelOpen.value = true
|
|
387
|
-
tabsPanelHeight.value =
|
|
557
|
+
tabsPanelHeight.value = 300
|
|
388
558
|
}
|
|
389
559
|
}
|
|
390
560
|
|
|
@@ -396,8 +566,11 @@ function onTabsDragStart(e: MouseEvent) {
|
|
|
396
566
|
const startY = e.clientY
|
|
397
567
|
const startHeight = tabsPanelHeight.value
|
|
398
568
|
|
|
569
|
+
const rootEl = containerEl.value?.closest('.relative.h-full') as HTMLElement | null
|
|
570
|
+
const maxHeight = rootEl ? rootEl.getBoundingClientRect().height : Infinity
|
|
571
|
+
|
|
399
572
|
const onMouseMove = (e: MouseEvent) => {
|
|
400
|
-
const newHeight = Math.max(40, startHeight + startY - e.clientY)
|
|
573
|
+
const newHeight = Math.max(40, Math.min(maxHeight, startHeight + startY - e.clientY))
|
|
401
574
|
tabsPanelHeight.value = newHeight
|
|
402
575
|
bottomPanelOpen.value = newHeight > 40
|
|
403
576
|
|
|
@@ -426,187 +599,298 @@ const stripeBg = {
|
|
|
426
599
|
</script>
|
|
427
600
|
|
|
428
601
|
<template>
|
|
429
|
-
<div class="
|
|
430
|
-
<div class="
|
|
602
|
+
<div class="relative h-full">
|
|
603
|
+
<div class="absolute inset-0 bottom-10 overflow-hidden">
|
|
431
604
|
<!-- Source code view -->
|
|
432
605
|
<div v-show="viewMode === 'source'" class="absolute inset-0 min-w-0 overflow-hidden">
|
|
433
606
|
<div class="absolute top-3 left-6 z-10">
|
|
434
607
|
<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-
|
|
608
|
+
<DropdownMenuTrigger class="inline-flex items-center gap-1 rounded-md bg-[#27212e]/80 dark:bg-gray-950/80 backdrop-blur-md border border-white/10 px-2.5 h-7 text-xs font-medium text-gray-300 hover:bg-[#27212e] dark:hover:bg-gray-950 transition-colors">
|
|
436
609
|
{{ sourceView === 'compiled' ? 'HTML' : sourceView === 'vue' ? 'Source' : 'Plaintext' }}
|
|
437
610
|
<ChevronDown class="size-3 opacity-50" />
|
|
438
611
|
</DropdownMenuTrigger>
|
|
439
|
-
<DropdownMenuContent align="start" class="min-w-
|
|
440
|
-
<DropdownMenuItem class="text-xs font-medium text-gray-
|
|
441
|
-
<Check v-if="sourceView === 'vue'" class="size-3
|
|
442
|
-
<span :class="sourceView === 'vue' ? '' : 'pl-5
|
|
612
|
+
<DropdownMenuContent align="start" class="min-w-32 bg-[#27212e]/80 dark:bg-gray-950/80 backdrop-blur-md border-white/10">
|
|
613
|
+
<DropdownMenuItem class="text-xs font-medium text-gray-400 focus:text-gray-200 focus:bg-white/10" @click="sourceView = 'vue'">
|
|
614
|
+
<Check v-if="sourceView === 'vue'" class="size-3 text-gray-200" />
|
|
615
|
+
<span :class="[sourceView === 'vue' ? 'text-gray-200' : 'pl-5']">Source</span>
|
|
443
616
|
</DropdownMenuItem>
|
|
444
|
-
<DropdownMenuItem class="text-xs font-medium text-gray-
|
|
445
|
-
<Check v-if="sourceView === 'compiled'" class="size-3
|
|
446
|
-
<span :class="sourceView === 'compiled' ? '' : 'pl-5
|
|
617
|
+
<DropdownMenuItem class="text-xs font-medium text-gray-400 focus:text-gray-200 focus:bg-white/10" @click="sourceView = 'compiled'">
|
|
618
|
+
<Check v-if="sourceView === 'compiled'" class="size-3 text-gray-200" />
|
|
619
|
+
<span :class="[sourceView === 'compiled' ? 'text-gray-200' : 'pl-5']">HTML</span>
|
|
447
620
|
</DropdownMenuItem>
|
|
448
|
-
<DropdownMenuItem class="text-xs font-medium text-gray-
|
|
449
|
-
<Check v-if="sourceView === 'plaintext'" class="size-3
|
|
450
|
-
<span :class="sourceView === 'plaintext' ? '' : 'pl-5
|
|
621
|
+
<DropdownMenuItem class="text-xs font-medium text-gray-400 focus:text-gray-200 focus:bg-white/10" @click="sourceView = 'plaintext'">
|
|
622
|
+
<Check v-if="sourceView === 'plaintext'" class="size-3 text-gray-200" />
|
|
623
|
+
<span :class="[sourceView === 'plaintext' ? 'text-gray-200' : 'pl-5']">Plaintext</span>
|
|
451
624
|
</DropdownMenuItem>
|
|
452
625
|
</DropdownMenuContent>
|
|
453
626
|
</DropdownMenu>
|
|
454
627
|
</div>
|
|
455
628
|
<button
|
|
456
|
-
class="absolute top-3 right-
|
|
629
|
+
class="absolute top-3 right-[26px] z-10 inline-flex items-center justify-center rounded-md size-7 bg-[#27212e]/80 dark:bg-gray-950/80 backdrop-blur-md border border-white/10 hover:bg-[#27212e] dark:hover:bg-gray-950 group disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
457
630
|
:disabled="copied"
|
|
458
631
|
@click="copySource"
|
|
459
632
|
>
|
|
460
|
-
<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
|
-
<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>
|
|
633
|
+
<svg v-if="!copied" class="size-3.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>
|
|
634
|
+
<svg v-else class="size-3.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
635
|
</button>
|
|
463
|
-
<div
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
636
|
+
<ScrollArea v-show="sourceView === 'compiled'" class="h-full [&_[data-slot=scroll-area-viewport]>div]:flex [&_[data-slot=scroll-area-viewport]>div]:flex-col [&_[data-slot=scroll-area-viewport]>div]:min-h-full">
|
|
637
|
+
<div
|
|
638
|
+
ref="compiledSourceEl"
|
|
639
|
+
class="flex-1 bg-[#27212e] dark:bg-gray-950 shiki-line-numbers [&_pre]:p-6 [&_pre]:pt-14 [&_pre]:text-base [&_pre]:leading-6 [&_pre]:min-h-full dark:[&_pre]:bg-gray-950!"
|
|
640
|
+
v-html="sourceHtml"
|
|
641
|
+
/>
|
|
642
|
+
<ScrollBar orientation="horizontal" />
|
|
643
|
+
</ScrollArea>
|
|
644
|
+
<ScrollArea v-show="sourceView === 'vue'" class="h-full [&_[data-slot=scroll-area-viewport]>div]:flex [&_[data-slot=scroll-area-viewport]>div]:flex-col [&_[data-slot=scroll-area-viewport]>div]:min-h-full">
|
|
645
|
+
<div
|
|
646
|
+
ref="vueSourceEl"
|
|
647
|
+
class="flex-1 bg-[#27212e] dark:bg-gray-950 shiki-line-numbers [&_pre]:p-6 [&_pre]:pt-14 [&_pre]:text-base [&_pre]:leading-6 [&_pre]:min-h-full dark:[&_pre]:bg-gray-950!"
|
|
648
|
+
v-html="vueSourceHtml"
|
|
649
|
+
/>
|
|
650
|
+
<ScrollBar orientation="horizontal" />
|
|
651
|
+
</ScrollArea>
|
|
652
|
+
<ScrollArea v-show="sourceView === 'plaintext'" class="h-full [&_[data-slot=scroll-area-viewport]>div]:flex [&_[data-slot=scroll-area-viewport]>div]:flex-col [&_[data-slot=scroll-area-viewport]>div]:min-h-full">
|
|
653
|
+
<pre
|
|
654
|
+
class="p-6 pt-14 text-sm leading-6 flex-1 text-gray-300 bg-[#27212e] dark:bg-gray-950 whitespace-pre-wrap break-words"
|
|
655
|
+
>{{ plaintextContent }}</pre>
|
|
656
|
+
</ScrollArea>
|
|
478
657
|
</div>
|
|
479
658
|
|
|
659
|
+
<!-- Blocks iframe from stealing pointer events while dragging tabs -->
|
|
660
|
+
<div v-if="tabsDragging" class="fixed inset-0 z-50" />
|
|
661
|
+
|
|
480
662
|
<!-- Preview view -->
|
|
481
663
|
<div v-show="viewMode !== 'source'" class="absolute inset-0">
|
|
482
664
|
<div class="relative h-full opacity-5" :style="stripeBg" />
|
|
483
665
|
</div>
|
|
484
666
|
|
|
485
|
-
<div v-show="viewMode !== 'source'" ref="containerEl" class="absolute inset-0 z-10 flex
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
<
|
|
507
|
-
</
|
|
667
|
+
<div v-show="viewMode !== 'source'" ref="containerEl" class="absolute inset-0 z-10 flex items-center justify-center">
|
|
668
|
+
<!-- Blocks iframe from stealing pointer events while dragging -->
|
|
669
|
+
<div v-if="isDragging" class="absolute inset-0 z-20" />
|
|
670
|
+
<div
|
|
671
|
+
class="relative"
|
|
672
|
+
:style="{
|
|
673
|
+
width: iframeWidth != null ? `${iframeWidth + 40}px` : '100%',
|
|
674
|
+
height: iframeHeight != null ? `${iframeHeight + 40}px` : '100%',
|
|
675
|
+
transition: isDragging ? 'none' : 'width 0.2s ease, height 0.2s ease',
|
|
676
|
+
}"
|
|
677
|
+
>
|
|
678
|
+
<!-- Top handle -->
|
|
679
|
+
<div class="group hidden min-[430px]:flex absolute top-0 left-5 right-5 h-5 items-center justify-center cursor-ns-resize" @mousedown="onEdgeDrag($event, 'top')" @touchstart.prevent="onEdgeDrag($event, 'top')">
|
|
680
|
+
<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" />
|
|
681
|
+
</div>
|
|
682
|
+
<!-- Bottom handle -->
|
|
683
|
+
<div class="group hidden min-[430px]:flex absolute bottom-0 left-5 right-5 h-5 items-center justify-center cursor-ns-resize" @mousedown="onEdgeDrag($event, 'bottom')" @touchstart.prevent="onEdgeDrag($event, 'bottom')">
|
|
684
|
+
<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" />
|
|
685
|
+
</div>
|
|
686
|
+
<!-- Left handle -->
|
|
687
|
+
<div class="group hidden min-[430px]:flex absolute left-0 top-5 bottom-5 w-5 items-center justify-center cursor-ew-resize" @mousedown="onEdgeDrag($event, 'left')" @touchstart.prevent="onEdgeDrag($event, 'left')">
|
|
688
|
+
<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" />
|
|
689
|
+
</div>
|
|
690
|
+
<!-- Right handle -->
|
|
691
|
+
<div class="group hidden min-[430px]:flex absolute right-0 top-5 bottom-5 w-5 items-center justify-center cursor-ew-resize" @mousedown="onEdgeDrag($event, 'right')" @touchstart.prevent="onEdgeDrag($event, 'right')">
|
|
692
|
+
<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" />
|
|
693
|
+
</div>
|
|
694
|
+
<!-- Iframe -->
|
|
695
|
+
<div ref="wrapperEl" class="absolute inset-0 min-[430px]:inset-5 border border-gray-200 dark:border-gray-800">
|
|
696
|
+
<ScrollArea class="h-full w-full bg-white dark:bg-gray-950">
|
|
697
|
+
<iframe
|
|
698
|
+
ref="iframeEl"
|
|
699
|
+
:srcdoc="srcdoc"
|
|
700
|
+
@load="updateIframeContentHeight"
|
|
701
|
+
class="w-full border-0 bg-white dark:bg-gray-950"
|
|
702
|
+
:style="{ height: iframeContentHeight ? `${iframeContentHeight}px` : '100%' }"
|
|
703
|
+
/>
|
|
704
|
+
</ScrollArea>
|
|
705
|
+
</div>
|
|
508
706
|
</div>
|
|
509
707
|
</div>
|
|
510
708
|
</div>
|
|
511
709
|
|
|
512
|
-
<!-- Tabs panel (
|
|
710
|
+
<!-- Tabs panel (overlay) -->
|
|
513
711
|
<div
|
|
514
|
-
class="
|
|
515
|
-
:class="
|
|
712
|
+
class="absolute bottom-0 left-0 right-0 z-20 overflow-hidden border-t border-gray-200 dark:border-gray-800/50"
|
|
713
|
+
:class="[
|
|
714
|
+
!tabsDragging ? 'transition-[height] duration-200 ease-in-out' : '',
|
|
715
|
+
'bg-white dark:bg-gray-950',
|
|
716
|
+
]"
|
|
516
717
|
:style="{ height: `${tabsPanelHeight}px` }"
|
|
517
718
|
>
|
|
518
719
|
<div
|
|
519
|
-
class="relative h-
|
|
720
|
+
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
721
|
@mousedown="onTabsDragStart"
|
|
521
722
|
/>
|
|
522
723
|
<Tabs :model-value="activeTab" class="flex flex-col min-h-0 h-full">
|
|
523
|
-
<div class="flex items-center justify-between min-h-10
|
|
724
|
+
<div class="flex items-center justify-between min-h-10 pl-2 pr-3 shrink-0" :class="bottomPanelOpen ? 'border-b' : ''">
|
|
524
725
|
<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')">
|
|
726
|
+
<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
727
|
Compatibility
|
|
527
728
|
</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')">
|
|
729
|
+
<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
730
|
Linter
|
|
530
731
|
</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')">
|
|
732
|
+
<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
733
|
Stats
|
|
533
734
|
</TabsTrigger>
|
|
735
|
+
<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')">
|
|
736
|
+
Test
|
|
737
|
+
</TabsTrigger>
|
|
534
738
|
</TabsList>
|
|
535
739
|
<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" />
|
|
740
|
+
<ChevronUp v-if="!bottomPanelOpen" class="size-4 dark:text-gray-400" :stroke-width="1" />
|
|
741
|
+
<ChevronDown v-else class="size-4 dark:text-gray-400" :stroke-width="1" />
|
|
538
742
|
</Button>
|
|
539
743
|
</div>
|
|
540
|
-
<div class="flex-1
|
|
541
|
-
<TabsContent value="compatibility" class="mt-0">
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
744
|
+
<div class="flex-1 min-h-0">
|
|
745
|
+
<TabsContent value="compatibility" class="mt-0 h-full flex flex-col"><div v-if="!compatibilityLoading && !compatibilityError && compatibilityIssues.length > 0" class="flex gap-1 pl-3 pr-4 py-2 border-b border-gray-200 dark:border-white/10 shrink-0">
|
|
746
|
+
<button
|
|
747
|
+
v-for="cat in activeCompatibilityCategories"
|
|
748
|
+
:key="cat"
|
|
749
|
+
class="px-2 py-0.5 text-[11px] rounded-full cursor-pointer transition-colors"
|
|
750
|
+
:class="compatibilityCategory === cat
|
|
751
|
+
? 'bg-gray-900 text-white dark:bg-gray-600 dark:text-gray-100'
|
|
752
|
+
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/10'"
|
|
753
|
+
@click="compatibilityCategory = cat"
|
|
549
754
|
>
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
755
|
+
{{ cat === 'css' ? 'CSS' : cat === 'html' ? 'HTML' : cat.charAt(0).toUpperCase() + cat.slice(1) }}
|
|
756
|
+
<span class="ml-0.5 tabular-nums">{{ compatibilityIssues.filter(i => i.category === cat).length }}</span>
|
|
757
|
+
</button>
|
|
758
|
+
</div>
|
|
759
|
+
<ScrollArea class="h-full flex-1 min-h-0 pl-5">
|
|
760
|
+
<p v-if="compatibilityLoading" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">Checking compatibility...</p>
|
|
761
|
+
<p v-else-if="compatibilityError" class="pr-4 py-3 text-xs text-red-500 dark:text-red-400">{{ compatibilityError }}</p>
|
|
762
|
+
<p v-else-if="compatibilityIssues.length === 0" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">No compatibility issues found.</p>
|
|
763
|
+
<ul v-else class="text-xs divide-y">
|
|
764
|
+
<li
|
|
765
|
+
v-for="(issue, i) in filteredCompatibilityIssues"
|
|
766
|
+
:key="i"
|
|
767
|
+
class="pr-4 py-1.5 hover:bg-gray-50 dark:hover:bg-white/5"
|
|
768
|
+
>
|
|
769
|
+
<div class="flex items-center gap-2">
|
|
770
|
+
<a v-if="issue.url" :href="issue.url" target="_blank" rel="noopener" class="font-medium hover:underline shrink-0" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'">
|
|
553
771
|
{{ issue.title }}
|
|
554
772
|
</a>
|
|
555
|
-
<span v-else class="font-medium" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'">
|
|
773
|
+
<span v-else class="font-medium shrink-0" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'">
|
|
556
774
|
{{ issue.title }}
|
|
557
775
|
</span>
|
|
558
|
-
<
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
776
|
+
<span class="text-gray-400 dark:text-gray-600 shrink-0">·</span>
|
|
777
|
+
<span class="text-gray-500 dark:text-gray-400 truncate">{{ issue.type === 'error' ? 'Not supported' : 'Partial support' }} in {{ issue.clients.map((c: any) => c.name).join(', ') }}</span>
|
|
778
|
+
<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 ml-auto" @click="goToCompiledLine(issue.line!)">L{{ issue.line }}</button>
|
|
779
|
+
</div>
|
|
780
|
+
</li>
|
|
781
|
+
</ul>
|
|
782
|
+
</ScrollArea>
|
|
783
|
+
</TabsContent>
|
|
784
|
+
<TabsContent value="lint" class="mt-0 h-full">
|
|
785
|
+
<ScrollArea class="h-full pl-5">
|
|
786
|
+
<p v-if="lintLoading" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">Linting...</p>
|
|
787
|
+
<p v-else-if="lintIssues.length === 0" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">No issues found.</p>
|
|
788
|
+
<ul v-else class="text-xs divide-y">
|
|
789
|
+
<li
|
|
790
|
+
v-for="(issue, i) in lintIssues"
|
|
791
|
+
:key="i"
|
|
792
|
+
class="pr-4 py-2 hover:bg-gray-50 dark:hover:bg-white/5"
|
|
793
|
+
>
|
|
794
|
+
<div class="flex items-start justify-between gap-4">
|
|
795
|
+
<div>
|
|
796
|
+
<span class="font-medium" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'">
|
|
797
|
+
{{ issue.title }}
|
|
798
|
+
</span>
|
|
799
|
+
<div class="text-gray-500 dark:text-gray-400 mt-0.5">{{ issue.message }}</div>
|
|
562
800
|
</div>
|
|
801
|
+
<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>
|
|
563
802
|
</div>
|
|
564
|
-
|
|
803
|
+
</li>
|
|
804
|
+
</ul>
|
|
805
|
+
</ScrollArea>
|
|
806
|
+
</TabsContent>
|
|
807
|
+
<TabsContent value="stats" class="mt-0 h-full">
|
|
808
|
+
<ScrollArea class="h-full pl-5">
|
|
809
|
+
<p v-if="statsLoading" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">Loading stats...</p>
|
|
810
|
+
<p v-else-if="!stats" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">No stats available.</p>
|
|
811
|
+
<div v-else class="pr-4 py-3 flex items-center gap-6 text-xs">
|
|
812
|
+
<div class="flex items-center gap-1.5">
|
|
813
|
+
<span class="text-gray-500 dark:text-gray-400">Size</span>
|
|
814
|
+
<span
|
|
815
|
+
class="font-medium tabular-nums"
|
|
816
|
+
:class="stats.size.bytes > 102400 ? 'text-red-600' : stats.size.bytes > 51200 ? 'text-amber-600' : 'text-gray-900 dark:text-gray-300'"
|
|
817
|
+
>{{ stats.size.formatted }}</span>
|
|
818
|
+
<TooltipProvider :delay-duration="0">
|
|
819
|
+
<Tooltip>
|
|
820
|
+
<TooltipTrigger as-child>
|
|
821
|
+
<button type="button">
|
|
822
|
+
<Info class="size-3 text-gray-400 dark:text-gray-500" />
|
|
823
|
+
</button>
|
|
824
|
+
</TooltipTrigger>
|
|
825
|
+
<TooltipContent class="max-w-60">
|
|
826
|
+
Compiled HTML size, excludes image files. Gmail clips content at ~100KB.
|
|
827
|
+
</TooltipContent>
|
|
828
|
+
</Tooltip>
|
|
829
|
+
</TooltipProvider>
|
|
565
830
|
</div>
|
|
566
|
-
|
|
567
|
-
|
|
831
|
+
<div class="flex items-center gap-1.5">
|
|
832
|
+
<span class="text-gray-500 dark:text-gray-400">Images</span>
|
|
833
|
+
<span class="font-medium tabular-nums">{{ stats.images }}</span>
|
|
834
|
+
</div>
|
|
835
|
+
<div class="flex items-center gap-1.5">
|
|
836
|
+
<span class="text-gray-500 dark:text-gray-400">Links</span>
|
|
837
|
+
<span class="font-medium tabular-nums">{{ stats.links }}</span>
|
|
838
|
+
</div>
|
|
839
|
+
</div>
|
|
840
|
+
</ScrollArea>
|
|
568
841
|
</TabsContent>
|
|
569
|
-
<TabsContent value="
|
|
570
|
-
<
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
842
|
+
<TabsContent value="test" class="mt-0 h-full">
|
|
843
|
+
<ScrollArea class="h-full pl-5">
|
|
844
|
+
<div class="pr-4 py-3 max-w-md">
|
|
845
|
+
<div class="space-y-2">
|
|
846
|
+
<div class="flex items-center gap-2">
|
|
847
|
+
<label class="text-xs text-gray-500 dark:text-gray-400 w-12 shrink-0">To</label>
|
|
848
|
+
<TagsInput v-model="emailTo" delimiter=" " add-on-paste add-on-blur class="flex-1 min-h-7 gap-1 px-2 py-1">
|
|
849
|
+
<TagsInputItem v-for="item in emailTo" :key="item" :value="item" class="h-5 text-xs rounded">
|
|
850
|
+
<TagsInputItemText class="px-1.5 py-0 text-xs" />
|
|
851
|
+
<TagsInputItemDelete class="size-3.5" />
|
|
852
|
+
</TagsInputItem>
|
|
853
|
+
<TagsInputInput class="text-xs min-h-5 px-0.5" placeholder="Add emails..." />
|
|
854
|
+
</TagsInput>
|
|
855
|
+
</div>
|
|
856
|
+
<div class="flex items-center gap-2">
|
|
857
|
+
<label class="text-xs text-gray-500 dark:text-gray-400 w-12 shrink-0">Subject</label>
|
|
858
|
+
<div class="flex-1 flex items-center gap-3">
|
|
859
|
+
<Input v-model="emailSubject" :placeholder="String(route.params.template)" class="flex-1 h-7 text-xs! px-2" />
|
|
860
|
+
<label class="flex items-center gap-1.5 cursor-pointer select-none shrink-0">
|
|
861
|
+
<Checkbox v-model="emailPreventThreading" :default-checked="true" class="size-3.5" />
|
|
862
|
+
<span class="text-xs text-gray-500 dark:text-gray-400">Prevent threading</span>
|
|
863
|
+
</label>
|
|
864
|
+
</div>
|
|
584
865
|
</div>
|
|
585
|
-
<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>
|
|
586
866
|
</div>
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
867
|
+
<div class="flex items-center gap-3 mt-3">
|
|
868
|
+
<Button
|
|
869
|
+
size="sm"
|
|
870
|
+
class="h-7 text-xs px-3"
|
|
871
|
+
:disabled="!emailTo.length || emailSending"
|
|
872
|
+
@click="sendTestEmail"
|
|
873
|
+
>
|
|
874
|
+
<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>
|
|
875
|
+
{{ emailSending ? 'Sending' : 'Send' }}
|
|
876
|
+
</Button>
|
|
877
|
+
</div>
|
|
878
|
+
<div v-if="emailResult" class="mt-2">
|
|
879
|
+
<p class="text-xs" :class="emailResult.success ? 'text-gray-950 dark:text-white' : 'text-red-600'">
|
|
880
|
+
{{ emailResult.message }}
|
|
881
|
+
<a
|
|
882
|
+
v-if="emailResult.previewUrl"
|
|
883
|
+
:href="emailResult.previewUrl"
|
|
884
|
+
target="_blank"
|
|
885
|
+
rel="noopener"
|
|
886
|
+
class="text-gray-500 dark:text-gray-400 hover:underline"
|
|
887
|
+
>
|
|
888
|
+
(view)
|
|
889
|
+
</a>
|
|
890
|
+
</p>
|
|
891
|
+
</div>
|
|
608
892
|
</div>
|
|
609
|
-
</
|
|
893
|
+
</ScrollArea>
|
|
610
894
|
</TabsContent>
|
|
611
895
|
</div>
|
|
612
896
|
</Tabs>
|