nuxt-og-image 6.5.3 → 6.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +2 -0
  2. package/dist/chunks/tw4.cjs +7 -7
  3. package/dist/chunks/tw4.mjs +7 -7
  4. package/dist/chunks/uno.cjs +6 -6
  5. package/dist/chunks/uno.mjs +6 -6
  6. package/dist/cli.cjs +12 -5
  7. package/dist/cli.mjs +10 -3
  8. package/dist/devtools/components/og-image/AddComponentDialog.vue +85 -0
  9. package/dist/devtools/components/og-image/BlueskyCardRenderer.vue +134 -0
  10. package/dist/devtools/components/og-image/CreateOgImageDialog.vue +56 -0
  11. package/dist/devtools/components/og-image/DiscordCardRenderer.vue +125 -0
  12. package/dist/devtools/components/og-image/FacebookCardRenderer.vue +128 -0
  13. package/dist/devtools/components/og-image/IFrameLoader.vue +93 -0
  14. package/dist/devtools/components/og-image/ImageLoader.vue +197 -0
  15. package/dist/devtools/components/og-image/LinkedInCardRenderer.vue +100 -0
  16. package/dist/devtools/components/og-image/RendererSelectModal.vue +356 -0
  17. package/dist/devtools/components/og-image/SlackCardRenderer.vue +140 -0
  18. package/dist/devtools/components/og-image/TemplateComponentPreview.vue +186 -0
  19. package/dist/devtools/components/og-image/TwitterCardRenderer.vue +170 -0
  20. package/dist/devtools/components/og-image/WhatsAppRenderer.vue +294 -0
  21. package/dist/devtools/lib/og-image/keys.ts +8 -0
  22. package/dist/devtools/lib/og-image/og-image.ts +536 -0
  23. package/dist/devtools/lib/og-image/renderer-select.ts +3 -0
  24. package/dist/devtools/lib/og-image/rpc-types.ts +2 -0
  25. package/dist/devtools/lib/og-image/rpc.ts +73 -0
  26. package/dist/devtools/lib/og-image/runtime-types.ts +10 -0
  27. package/dist/devtools/lib/og-image/shared/urlEncoding.ts +502 -0
  28. package/dist/devtools/lib/og-image/shared.ts +30 -0
  29. package/dist/devtools/lib/og-image/templates.ts +9 -0
  30. package/dist/devtools/lib/og-image/types.ts +38 -0
  31. package/dist/devtools/lib/og-image/util/logic.ts +21 -0
  32. package/dist/devtools/nuxt.config.ts +6 -0
  33. package/dist/devtools/pages/og-image/debug.vue +32 -0
  34. package/dist/devtools/pages/og-image/docs.vue +3 -0
  35. package/dist/devtools/pages/og-image/index.vue +1682 -0
  36. package/dist/devtools/pages/og-image/templates.vue +150 -0
  37. package/dist/devtools/pages/og-image.vue +184 -0
  38. package/dist/module.cjs +5 -5
  39. package/dist/module.d.cts +5 -3
  40. package/dist/module.d.mts +5 -3
  41. package/dist/module.d.ts +5 -3
  42. package/dist/module.json +1 -1
  43. package/dist/module.mjs +5 -5
  44. package/dist/runtime/server/og-image/bindings/font-assets/cloudflare.d.ts +1 -1
  45. package/dist/runtime/server/og-image/bindings/font-assets/cloudflare.js +6 -1
  46. package/dist/runtime/server/og-image/bindings/font-assets/dev-prerender.js +13 -6
  47. package/dist/runtime/server/og-image/bindings/font-assets/external-url.d.ts +36 -0
  48. package/dist/runtime/server/og-image/bindings/font-assets/external-url.js +47 -0
  49. package/dist/runtime/server/og-image/bindings/font-assets/node.d.ts +1 -1
  50. package/dist/runtime/server/og-image/bindings/font-assets/node.js +14 -7
  51. package/dist/runtime/server/og-image/bindings/takumi/node-dev.js +7 -1
  52. package/dist/runtime/server/og-image/core/plugins/imageSrc.js +2 -2
  53. package/dist/runtime/server/og-image/fonts.d.ts +4 -0
  54. package/dist/runtime/server/og-image/fonts.js +21 -5
  55. package/dist/runtime/server/og-image/satori/instances.d.ts +1 -2
  56. package/dist/runtime/server/og-image/takumi/renderer.js +61 -19
  57. package/dist/runtime/server/util/ssrf.d.ts +10 -0
  58. package/dist/runtime/server/util/ssrf.js +9 -1
  59. package/dist/runtime/types.d.ts +13 -0
  60. package/dist/shared/{nuxt-og-image.DdbTs-xp.mjs → nuxt-og-image.CPSxZhVO.mjs} +122 -496
  61. package/dist/shared/nuxt-og-image.DM30kcDz.mjs +660 -0
  62. package/dist/shared/nuxt-og-image.RI9DgjgV.cjs +689 -0
  63. package/dist/shared/{nuxt-og-image.CfTPCtaS.cjs → nuxt-og-image.yDJBWXBt.cjs} +181 -555
  64. package/package.json +48 -45
  65. package/dist/devtools/200.html +0 -1
  66. package/dist/devtools/404.html +0 -1
  67. package/dist/devtools/_fonts/4ppnHhMi-pBsWSPo7mY0avYxlDoAg1N3PTzCwXLZ5rA-d9oibkGnTd1JL3tc_xnaVgBLYmOB8kjrK2cvZaqwj9s.woff2 +0 -0
  68. package/dist/devtools/_fonts/PV2hrQG6wq5BlIPDjdL1IcOflycaghyt5MHzlBqZtlo-lb_WexLz3VZqfTN0oi554iBH5tT2j2UFEV-XErCAS3E.woff2 +0 -0
  69. package/dist/devtools/_fonts/VE4cDVCv5MxbFM7ZLoLCGbIpNd71zhp7MDI9lmN5Y7I-xZyDYCUVrd6LV8eVGF3Um3UZjBFuUtDGtvdyTBBRYBo.woff2 +0 -0
  70. package/dist/devtools/_fonts/fVoGbnMbBFd5L9BBp9fUPavUSkZ_EmsQNSyadkT-108-U4T0khaeLQSIhtt9eVvaCEKJjtWJ4ioRJOf8hvqkWY0.woff2 +0 -0
  71. package/dist/devtools/_fonts/lQAxeCEs1R0Lw-H9XRU1RlOARQN8J6npRsPjyEDMe5s-_DUSLEkO3tKTuun_gSnDLoQPVEnpOnyqZMOw0ByZ6PA.woff2 +0 -0
  72. package/dist/devtools/_fonts/lntlqNHKLV2n82yTwMde70QqOjcfLE2XJ5oKZ3vRPWc-z6TxpIZQdWXztWLr9_OFWqt_WJJoeGtuK_-XQMZGQwE.woff2 +0 -0
  73. package/dist/devtools/_nuxt/BZ_CG9ll.js +0 -30
  74. package/dist/devtools/_nuxt/BdMalyEU.js +0 -4
  75. package/dist/devtools/_nuxt/BhkGaWEe.js +0 -3
  76. package/dist/devtools/_nuxt/Bm4Q-nTW.js +0 -3
  77. package/dist/devtools/_nuxt/BsRKQH5X.js +0 -1
  78. package/dist/devtools/_nuxt/C5797Ieg.js +0 -1
  79. package/dist/devtools/_nuxt/CBcJg2GG.js +0 -2
  80. package/dist/devtools/_nuxt/CDaXKRRu.js +0 -1
  81. package/dist/devtools/_nuxt/CM-t-6ZT.js +0 -1
  82. package/dist/devtools/_nuxt/CP0tQR2M.js +0 -1
  83. package/dist/devtools/_nuxt/CXAM78_A.js +0 -1
  84. package/dist/devtools/_nuxt/CYDI7SLa.js +0 -1
  85. package/dist/devtools/_nuxt/CkbDfutK.js +0 -152
  86. package/dist/devtools/_nuxt/Co8Tk_C2.js +0 -1
  87. package/dist/devtools/_nuxt/CqjbkMN9.js +0 -3
  88. package/dist/devtools/_nuxt/D49I50CU.js +0 -1
  89. package/dist/devtools/_nuxt/DevtoolsSection.DkT2bJJh.css +0 -1
  90. package/dist/devtools/_nuxt/DevtoolsSnippet.zV_SDojn.css +0 -1
  91. package/dist/devtools/_nuxt/HWyD49jH.js +0 -1
  92. package/dist/devtools/_nuxt/IFrameLoader.k_861Nnq.css +0 -1
  93. package/dist/devtools/_nuxt/PONEy9N-.js +0 -1
  94. package/dist/devtools/_nuxt/builds/latest.json +0 -1
  95. package/dist/devtools/_nuxt/builds/meta/0f3dbc15-8308-4956-81b9-5507cac7018c.json +0 -1
  96. package/dist/devtools/_nuxt/entry.D7u2asd2.css +0 -2
  97. package/dist/devtools/_nuxt/fira-code.Bc8wnsZt.woff2 +0 -0
  98. package/dist/devtools/_nuxt/hubot-sans.DLGyhQVu.woff2 +0 -0
  99. package/dist/devtools/_nuxt/pO9PmtPW.js +0 -6
  100. package/dist/devtools/_nuxt/pages.0Jk10xlb.css +0 -1
  101. package/dist/devtools/_nuxt/renderer-select.CN5IWe60.css +0 -1
  102. package/dist/devtools/_nuxt/templates.BByln3BG.css +0 -1
  103. package/dist/devtools/debug/index.html +0 -1
  104. package/dist/devtools/docs/index.html +0 -1
  105. package/dist/devtools/index.html +0 -1
  106. package/dist/devtools/templates/index.html +0 -1
  107. package/dist/shared/nuxt-og-image.CJa2KCie.mjs +0 -189
  108. package/dist/shared/nuxt-og-image.CMYbz66P.cjs +0 -193
@@ -0,0 +1,356 @@
1
+ <script lang="ts" setup>
2
+ import type { RendererType } from '../../lib/og-image/runtime-types'
3
+ import { computed } from 'vue'
4
+ import { useOgImage } from '../../lib/og-image/og-image'
5
+ import { RendererSelectDialogPromise } from '../../lib/og-image/renderer-select'
6
+
7
+ const {
8
+ globalDebug,
9
+ renderer,
10
+ imageFormat,
11
+ availableRenderers,
12
+ isPageScreenshot,
13
+ getComponentVariantForRenderer,
14
+ patchOptions,
15
+ } = useOgImage()
16
+
17
+ function switchRendererAndFormat(newRenderer: RendererType, extension: 'png' | 'jpeg' | 'jpg' | 'svg' | 'html') {
18
+ const variant = getComponentVariantForRenderer(newRenderer)
19
+ if (variant) {
20
+ patchOptions({ renderer: newRenderer, extension, component: variant.pascalName })
21
+ }
22
+ else {
23
+ patchOptions({ renderer: newRenderer, extension })
24
+ }
25
+ }
26
+
27
+ const formatDescriptions: Record<string, string> = {
28
+ png: 'Lossless, best compatibility across all platforms.',
29
+ svg: 'Vector output. Smallest size, satori only.',
30
+ html: 'Raw HTML preview for debugging templates.',
31
+ jpg: 'Lossy compression. Smaller file size via Sharp.',
32
+ }
33
+
34
+ interface FormatItem {
35
+ label: string
36
+ value: string
37
+ description: string
38
+ }
39
+
40
+ interface RendererConfig {
41
+ name: RendererType
42
+ label: string
43
+ icon: string
44
+ iconType: 'component' | 'img'
45
+ description: string
46
+ available: boolean
47
+ binding: string | false
48
+ formats: FormatItem[]
49
+ disabledReason: string | null
50
+ installCommand: string | null
51
+ }
52
+
53
+ const rendererConfigs = computed<RendererConfig[]>(() => {
54
+ const compat = globalDebug.value?.compatibility
55
+ const hasSharp = compat?.sharp
56
+ const hasBrowser = availableRenderers.value.has('browser')
57
+ const hasSatori = availableRenderers.value.has('satori')
58
+ const hasTakumi = availableRenderers.value.has('takumi')
59
+
60
+ function fmt(ext: string): FormatItem {
61
+ return { label: ext.toUpperCase(), value: ext, description: formatDescriptions[ext] || '' }
62
+ }
63
+
64
+ return [
65
+ {
66
+ name: 'satori' as RendererType,
67
+ label: 'Satori',
68
+ icon: 'logos:vercel-icon',
69
+ iconType: 'component' as const,
70
+ description: 'SVG-based. Fast, works everywhere.',
71
+ available: hasSatori,
72
+ binding: compat?.satori || false,
73
+ formats: [
74
+ fmt('png'),
75
+ fmt('svg'),
76
+ ...(!isPageScreenshot.value ? [fmt('html')] : []),
77
+ ...(hasSharp ? [fmt('jpg')] : []),
78
+ ],
79
+ disabledReason: !hasSatori ? 'No satori-compatible templates found' : null,
80
+ installCommand: null,
81
+ },
82
+ {
83
+ name: 'browser' as RendererType,
84
+ label: 'Browser',
85
+ icon: 'logos:chrome',
86
+ iconType: 'component' as const,
87
+ description: 'Headless Chrome screenshot. Full CSS support.',
88
+ available: hasBrowser,
89
+ binding: compat?.browser || false,
90
+ formats: [
91
+ fmt('png'),
92
+ fmt('jpg'),
93
+ ...(!isPageScreenshot.value ? [fmt('html')] : []),
94
+ ],
95
+ disabledReason: !hasBrowser ? 'Requires Playwright or Chrome' : null,
96
+ installCommand: !hasBrowser ? 'npx playwright install chromium' : null,
97
+ },
98
+ {
99
+ name: 'takumi' as RendererType,
100
+ label: 'Takumi',
101
+ icon: 'https://takumi.kane.tw/logo.svg',
102
+ iconType: 'img' as const,
103
+ description: 'Canvas-based renderer with rich styling.',
104
+ available: hasTakumi,
105
+ binding: compat?.takumi || false,
106
+ formats: [
107
+ fmt('png'),
108
+ ...(!isPageScreenshot.value ? [fmt('html')] : []),
109
+ ...(hasSharp ? [fmt('jpg')] : []),
110
+ ],
111
+ disabledReason: !hasTakumi ? 'Module not installed' : null,
112
+ installCommand: !hasTakumi ? 'npx nuxi module add nuxt-og-image-takumi' : null,
113
+ },
114
+ ]
115
+ })
116
+
117
+ const sortedRendererConfigs = computed(() => {
118
+ return rendererConfigs.value.toSorted((a, b) => {
119
+ const aActive = renderer.value === a.name ? -1 : 0
120
+ const bActive = renderer.value === b.name ? -1 : 0
121
+ if (aActive !== bActive)
122
+ return aActive - bActive
123
+ if (a.available !== b.available)
124
+ return a.available ? -1 : 1
125
+ return 0
126
+ })
127
+ })
128
+
129
+ const takumiMigrationAvailable = computed(() => {
130
+ return renderer.value === 'satori' && availableRenderers.value.has('takumi') && getComponentVariantForRenderer('takumi')
131
+ })
132
+
133
+ function activeFormatValue(config: RendererConfig) {
134
+ if (renderer.value !== config.name)
135
+ return undefined
136
+ return imageFormat.value === 'jpeg' ? 'jpg' : (imageFormat.value || 'png')
137
+ }
138
+
139
+ function onFormatChange(config: RendererConfig, ext: string, resolve: () => void) {
140
+ switchRendererAndFormat(config.name, ext as 'png' | 'jpeg' | 'jpg' | 'svg' | 'html')
141
+ resolve()
142
+ }
143
+
144
+ function switchToTakumi(resolve: () => void) {
145
+ const variant = getComponentVariantForRenderer('takumi')
146
+ if (variant) {
147
+ patchOptions({ renderer: 'takumi', component: variant.pascalName })
148
+ resolve()
149
+ }
150
+ }
151
+ </script>
152
+
153
+ <template>
154
+ <RendererSelectDialogPromise v-slot="{ resolve }">
155
+ <UModal
156
+ :open="true"
157
+ title="Renderer & Format"
158
+ @update:open="resolve()"
159
+ @close="resolve()"
160
+ >
161
+ <template #body>
162
+ <div class="renderer-cards stagger-children">
163
+ <div
164
+ v-for="config in sortedRendererConfigs"
165
+ :key="config.name"
166
+ class="renderer-card"
167
+ :class="{
168
+ active: renderer === config.name,
169
+ disabled: !config.available,
170
+ }"
171
+ >
172
+ <!-- Header -->
173
+ <div class="renderer-card-header">
174
+ <div class="flex items-center gap-2">
175
+ <img
176
+ v-if="config.iconType === 'img'"
177
+ :src="config.icon"
178
+ class="w-4 h-4"
179
+ width="16"
180
+ height="16"
181
+ :alt="`${config.label} logo`"
182
+ >
183
+ <UIcon v-else :name="config.icon" class="w-4 h-4" aria-hidden="true" />
184
+ <span class="font-medium text-sm text-[var(--color-text)]">{{ config.label }}</span>
185
+ <UTooltip v-if="config.binding" :text="`Runtime: ${config.binding}`" :delay-duration="0">
186
+ <UBadge color="neutral" variant="subtle" size="xs" class="cursor-help">
187
+ {{ config.binding }}
188
+ </UBadge>
189
+ </UTooltip>
190
+ </div>
191
+ <UBadge v-if="renderer === config.name && config.available" color="primary" variant="soft" size="xs">
192
+ Active
193
+ </UBadge>
194
+ </div>
195
+
196
+ <!-- Description -->
197
+ <p class="text-xs text-[var(--color-text-muted)] mt-1">
198
+ {{ config.description }}
199
+ </p>
200
+
201
+ <!-- Format radio group (available) -->
202
+ <div v-if="config.available" class="format-section">
203
+ <URadioGroup
204
+ :model-value="activeFormatValue(config)"
205
+ :items="config.formats"
206
+ size="sm"
207
+ indicator="start"
208
+ @update:model-value="onFormatChange(config, $event as string, resolve)"
209
+ />
210
+ </div>
211
+
212
+ <!-- Disabled state -->
213
+ <div v-else class="disabled-info">
214
+ <p class="text-xs text-[var(--color-text-subtle)]">
215
+ {{ config.disabledReason }}
216
+ </p>
217
+ <div v-if="config.installCommand" class="install-block">
218
+ <span class="install-label">
219
+ <UIcon name="carbon:terminal" class="w-3 h-3" aria-hidden="true" />
220
+ Install
221
+ </span>
222
+ <code class="install-command">{{ config.installCommand }}</code>
223
+ </div>
224
+ </div>
225
+
226
+ <!-- Migration prompt (satori card only) -->
227
+ <div v-if="config.name === 'satori' && takumiMigrationAvailable" class="migration-prompt">
228
+ <div class="migration-prompt-inner">
229
+ <div class="flex items-center gap-1.5">
230
+ <UIcon name="carbon:arrow-right" class="w-3 h-3 text-[var(--seo-green)]" aria-hidden="true" />
231
+ <span class="text-xs text-[var(--color-text-muted)]">Takumi variant available</span>
232
+ </div>
233
+ <UButton size="xs" variant="soft" color="primary" @click="switchToTakumi(resolve)">
234
+ Switch
235
+ </UButton>
236
+ </div>
237
+ </div>
238
+ </div>
239
+ </div>
240
+ </template>
241
+ </UModal>
242
+ </RendererSelectDialogPromise>
243
+ </template>
244
+
245
+ <style scoped>
246
+ .renderer-cards {
247
+ display: flex;
248
+ flex-direction: column;
249
+ gap: 0.75rem;
250
+ }
251
+
252
+ .renderer-card {
253
+ padding: 0.875rem;
254
+ border-radius: var(--radius-lg);
255
+ border: 1px solid var(--color-border);
256
+ background: var(--color-surface-elevated);
257
+ transition: border-color 150ms, background 150ms, box-shadow 150ms;
258
+ }
259
+
260
+ .renderer-card:not(.disabled):hover {
261
+ border-color: var(--color-neutral-300);
262
+ box-shadow: 0 2px 8px oklch(0% 0 0 / 0.06);
263
+ }
264
+
265
+ :global(.dark) .renderer-card:not(.disabled):hover {
266
+ border-color: var(--color-neutral-700);
267
+ box-shadow: 0 2px 8px oklch(0% 0 0 / 0.2);
268
+ }
269
+
270
+ .renderer-card.active {
271
+ border-color: var(--seo-green);
272
+ background: oklch(65% 0.2 145 / 0.04);
273
+ }
274
+
275
+ :global(.dark) .renderer-card.active {
276
+ background: oklch(65% 0.2 145 / 0.06);
277
+ }
278
+
279
+ .renderer-card.disabled {
280
+ background: var(--color-surface-sunken);
281
+ }
282
+
283
+ .renderer-card.disabled .renderer-card-header,
284
+ .renderer-card.disabled > p {
285
+ opacity: 0.55;
286
+ }
287
+
288
+ .renderer-card-header {
289
+ display: flex;
290
+ align-items: center;
291
+ justify-content: space-between;
292
+ }
293
+
294
+ .format-section {
295
+ margin-top: 0.625rem;
296
+ }
297
+
298
+ .disabled-info {
299
+ margin-top: 0.625rem;
300
+ display: flex;
301
+ flex-direction: column;
302
+ gap: 0.375rem;
303
+ }
304
+
305
+ .install-block {
306
+ display: flex;
307
+ flex-direction: column;
308
+ gap: 0.25rem;
309
+ padding: 0.5rem;
310
+ border-radius: var(--radius-sm);
311
+ background: var(--color-surface-elevated);
312
+ border: 1px solid var(--color-border);
313
+ }
314
+
315
+ .install-label {
316
+ display: flex;
317
+ align-items: center;
318
+ gap: 0.375rem;
319
+ font-size: 0.6875rem;
320
+ font-weight: 500;
321
+ color: var(--color-text-muted);
322
+ text-transform: uppercase;
323
+ letter-spacing: 0.03em;
324
+ }
325
+
326
+ .install-command {
327
+ display: block;
328
+ padding: 0.375rem 0.5rem;
329
+ border-radius: var(--radius-sm);
330
+ background: var(--color-surface-sunken);
331
+ border: 1px solid var(--color-border-subtle);
332
+ font-family: var(--font-mono);
333
+ font-size: 0.75rem;
334
+ color: var(--color-text);
335
+ user-select: all;
336
+ }
337
+
338
+ .migration-prompt {
339
+ margin-top: 0.625rem;
340
+ padding-top: 0.625rem;
341
+ border-top: 1px solid var(--color-border);
342
+ }
343
+
344
+ .migration-prompt-inner {
345
+ display: flex;
346
+ align-items: center;
347
+ justify-content: space-between;
348
+ padding: 0.375rem 0.5rem;
349
+ border-radius: var(--radius-sm);
350
+ background: oklch(65% 0.2 145 / 0.06);
351
+ }
352
+
353
+ :global(.dark) .migration-prompt-inner {
354
+ background: oklch(65% 0.2 145 / 0.08);
355
+ }
356
+ </style>
@@ -0,0 +1,140 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+
4
+ <template>
5
+ <div class="slack-card">
6
+ <div class="slack-bar" />
7
+ <div class="slack-content">
8
+ <div class="slack-header">
9
+ <div class="slack-favicon">
10
+ <slot name="favIcon" />
11
+ </div>
12
+ <div class="slack-sitename">
13
+ <slot name="siteName" />
14
+ </div>
15
+ </div>
16
+ <div class="slack-title">
17
+ <slot name="title" />
18
+ </div>
19
+ <div class="slack-description">
20
+ <slot name="description" />
21
+ </div>
22
+ <div class="slack-image">
23
+ <slot />
24
+ <div class="slack-image-overlay" />
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </template>
29
+
30
+ <style>
31
+ .slack-card {
32
+ display: flex;
33
+ font-family: 'Hubot Sans', Lato, -apple-system, BlinkMacSystemFont, sans-serif;
34
+ font-size: 15px;
35
+ line-height: 1.46;
36
+ -webkit-font-smoothing: antialiased;
37
+ min-height: 200px;
38
+ max-width: 500px;
39
+ margin: 0 auto;
40
+ background: oklch(100% 0 0);
41
+ border-radius: 8px;
42
+ padding: 8px 0;
43
+ }
44
+
45
+ .dark .slack-card {
46
+ background: #1a1d21;
47
+ }
48
+
49
+ .slack-bar {
50
+ flex-shrink: 0;
51
+ width: 4px;
52
+ background: #a855f7;
53
+ border-radius: 4px;
54
+ margin: 4px 0;
55
+ }
56
+
57
+ .slack-content {
58
+ padding: 0 12px;
59
+ flex: 1;
60
+ min-width: 0;
61
+ }
62
+
63
+ .slack-header {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 8px;
67
+ margin-bottom: 4px;
68
+ }
69
+
70
+ .slack-favicon img {
71
+ border-radius: 3px;
72
+ height: 16px;
73
+ width: 16px;
74
+ object-fit: cover;
75
+ }
76
+
77
+ .slack-sitename {
78
+ font-weight: 700;
79
+ font-size: 14px;
80
+ overflow: hidden;
81
+ text-overflow: ellipsis;
82
+ white-space: nowrap;
83
+ }
84
+
85
+ .slack-title {
86
+ color: #1264a3;
87
+ font-weight: 600;
88
+ font-size: 15px;
89
+ margin-bottom: 2px;
90
+ cursor: pointer;
91
+ }
92
+
93
+ .dark .slack-title {
94
+ color: #1d9bd1;
95
+ }
96
+
97
+ .slack-title:hover {
98
+ text-decoration: underline;
99
+ }
100
+
101
+ .slack-description {
102
+ color: oklch(55% 0.04 285);
103
+ font-size: 14px;
104
+ line-height: 1.5;
105
+ margin-bottom: 8px;
106
+ display: -webkit-box;
107
+ -webkit-line-clamp: 2;
108
+ -webkit-box-orient: vertical;
109
+ overflow: hidden;
110
+ }
111
+
112
+ .dark .slack-description {
113
+ color: oklch(70% 0.03 285);
114
+ }
115
+
116
+ .slack-image {
117
+ border-radius: 6px;
118
+ overflow: hidden;
119
+ max-width: 320px;
120
+ position: relative;
121
+ }
122
+
123
+ .slack-image img,
124
+ .slack-image > *:first-child:not(.slack-image-overlay) {
125
+ width: 100%;
126
+ height: auto;
127
+ }
128
+
129
+ .slack-image-overlay {
130
+ position: absolute;
131
+ inset: 0;
132
+ box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.05);
133
+ border-radius: 6px;
134
+ pointer-events: none;
135
+ }
136
+
137
+ .dark .slack-image-overlay {
138
+ box-shadow: inset 0 0 0 1px oklch(100% 0 0 / 0.05);
139
+ }
140
+ </style>
@@ -0,0 +1,186 @@
1
+ <script setup lang="ts">
2
+ import type { OgImageComponent } from '../../lib/og-image/runtime-types'
3
+ import { withQuery } from 'ufo'
4
+ import { computed, ref } from 'vue'
5
+ import { host } from '../../lib/og-image/rpc'
6
+
7
+ const props = defineProps<{
8
+ src: string
9
+ aspectRatio: number
10
+ component: OgImageComponent
11
+ active: boolean
12
+ imageFormat?: string
13
+ width?: string
14
+ height?: string
15
+ }>()
16
+
17
+ const RE_CLOSING_ANGLE = />/g
18
+
19
+ function openComponent() {
20
+ host.value?.openInEditor(props.component.path!)
21
+ }
22
+
23
+ const isHtml = computed(() => {
24
+ return props.src.includes('.html')
25
+ })
26
+
27
+ const creditName = computed(() => {
28
+ return props.component.credits?.split('<')[0]?.trim()
29
+ })
30
+ const creditSite = computed(() => {
31
+ return props.component.credits?.split('<')[1]?.trim().replace(RE_CLOSING_ANGLE, '')
32
+ })
33
+
34
+ const loadStats = ref<{ timeTaken: string, sizeKb: string }>()
35
+ </script>
36
+
37
+ <template>
38
+ <div class="template-card group">
39
+ <div class="template-header">
40
+ <ULink :to="creditSite" target="_blank" class="template-name">
41
+ {{ component.pascalName }}
42
+ </ULink>
43
+ </div>
44
+ <div class="template-preview" :class="active ? 'is-active' : ''">
45
+ <UTooltip :text="`Preview ${component.pascalName} for the current page.`">
46
+ <div class="template-image" :style="{ aspectRatio }">
47
+ <UIcon v-if="active" name="carbon:checkmark-filled" class="active-badge" />
48
+ <ImageLoader
49
+ v-if="!isHtml"
50
+ :src="withQuery(src, { component: component.pascalName })"
51
+ :aspect-ratio="aspectRatio"
52
+ class="w-full h-full"
53
+ @load="(e: { timeTaken: string, sizeKb: string }) => loadStats = e"
54
+ />
55
+ <IFrameLoader
56
+ v-else
57
+ :src="src"
58
+ class="pointer-events-none"
59
+ :aspect-ratio="aspectRatio"
60
+ @load="(e: { timeTaken: string, sizeKb: string }) => loadStats = e"
61
+ />
62
+ </div>
63
+ </UTooltip>
64
+ </div>
65
+ <div class="template-footer">
66
+ <UTooltip :text="`Open the source code of ${component.pascalName}.vue in your IDE`">
67
+ <ULink class="template-action" @click.stop="openComponent">
68
+ <UIcon name="carbon:code" class="w-3 h-3" />
69
+ View source
70
+ </ULink>
71
+ </UTooltip>
72
+ <div v-if="component.credits" class="template-credit">
73
+ <ULink v-if="creditSite" :to="creditSite" target="_blank">
74
+ {{ creditName }}
75
+ </ULink>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ </template>
80
+
81
+ <style scoped>
82
+ .template-card {
83
+ display: flex;
84
+ flex-direction: column;
85
+ gap: 6px;
86
+ }
87
+
88
+ .template-header {
89
+ padding: 0 8px;
90
+ }
91
+
92
+ .template-name {
93
+ font-size: 13px;
94
+ font-weight: 500;
95
+ color: oklch(55% 0.04 285);
96
+ transition: color 0.15s ease;
97
+ text-decoration: none;
98
+ }
99
+
100
+ .group:hover .template-name {
101
+ color: oklch(35% 0.04 285);
102
+ }
103
+
104
+ .dark .template-name {
105
+ color: oklch(70% 0.03 285);
106
+ }
107
+
108
+ .dark .group:hover .template-name {
109
+ color: oklch(90% 0.02 285);
110
+ }
111
+
112
+ .template-preview {
113
+ border-radius: 10px;
114
+ overflow: hidden;
115
+ border: 2px solid transparent;
116
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
117
+ background: oklch(98% 0.005 285);
118
+ }
119
+
120
+ .dark .template-preview {
121
+ background: oklch(18% 0.04 285);
122
+ }
123
+
124
+ .template-preview:hover {
125
+ border-color: var(--seo-green);
126
+ box-shadow: 0 4px 16px oklch(0% 0 0 / 0.08);
127
+ }
128
+
129
+ .template-preview.is-active {
130
+ border-color: var(--seo-green);
131
+ box-shadow: 0 0 0 3px oklch(75% 0.15 145 / 0.2);
132
+ }
133
+
134
+ .template-image {
135
+ position: relative;
136
+ height: 150px;
137
+ overflow: hidden;
138
+ }
139
+
140
+ .active-badge {
141
+ position: absolute;
142
+ top: 8px;
143
+ right: 8px;
144
+ color: var(--seo-green);
145
+ z-index: 1;
146
+ filter: drop-shadow(0 1px 2px oklch(0% 0 0 / 0.2));
147
+ }
148
+
149
+ .template-footer {
150
+ display: flex;
151
+ justify-content: space-between;
152
+ align-items: center;
153
+ padding: 0 8px;
154
+ font-size: 11px;
155
+ }
156
+
157
+ .template-action {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 4px;
161
+ color: oklch(55% 0.04 285);
162
+ transition: color 0.15s ease;
163
+ cursor: pointer;
164
+ }
165
+
166
+ .template-action:hover {
167
+ color: var(--seo-green);
168
+ }
169
+
170
+ .dark .template-action {
171
+ color: oklch(60% 0.03 285);
172
+ }
173
+
174
+ .template-credit {
175
+ color: oklch(60% 0.04 285);
176
+ }
177
+
178
+ .template-credit a {
179
+ color: inherit;
180
+ text-decoration: none;
181
+ }
182
+
183
+ .template-credit a:hover {
184
+ text-decoration: underline;
185
+ }
186
+ </style>