@duffcloudservices/cms 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -2
- package/dist/chunk-DAYLLSEE.js +3 -0
- package/dist/{chunk-KCWMS7P4.js.map → chunk-DAYLLSEE.js.map} +1 -1
- package/dist/{chunk-UPAMLKOQ.js → chunk-F3EIWEZD.js} +158 -11
- package/dist/chunk-F3EIWEZD.js.map +1 -0
- package/dist/index.d.ts +74 -6
- package/dist/index.js +88 -6
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +91 -3
- package/dist/plugins/index.js +350 -82
- package/dist/plugins/index.js.map +1 -1
- package/dist/seo/index.d.ts +132 -226
- package/dist/seo/index.js +2 -2
- package/dist/spliceHeadHtml-CsBEucGy.d.ts +254 -0
- package/dist/{vitepressTransform-DeEzgGWU.d.ts → vitepressTransform-DfmABXmK.d.ts} +53 -2
- package/package.json +14 -2
- package/src/components/DcsCallButton.test.ts +126 -0
- package/src/components/DcsCallButton.vue +185 -0
- package/src/components/DcsReviewShowcase.vue +13 -2
- package/src/components/LiteMediaEmbed.test.ts +229 -0
- package/src/components/LiteMediaEmbed.vue +399 -0
- package/src/composables/useMediaCarousel.ts +6 -1
- package/src/composables/useReviewContent.test.ts +86 -0
- package/src/composables/useReviewContent.ts +34 -2
- package/src/composables/useSiteVisitorSession.test.ts +120 -0
- package/src/composables/useSiteVisitorSession.ts +160 -0
- package/dist/chunk-KCWMS7P4.js +0 -3
- package/dist/chunk-UPAMLKOQ.js.map +0 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import LiteMediaEmbed from './LiteMediaEmbed.vue'
|
|
4
|
+
|
|
5
|
+
const YT_URL = 'https://www.youtube.com/watch?v=vz7F8bc1QQ0'
|
|
6
|
+
const YT_ID = 'vz7F8bc1QQ0'
|
|
7
|
+
const IG_URL = 'https://www.instagram.com/p/AbCdEfGhIjK/'
|
|
8
|
+
|
|
9
|
+
describe('LiteMediaEmbed — YouTube facade', () => {
|
|
10
|
+
it('renders the poster (thumbnail + play button) and NO iframe initially', () => {
|
|
11
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
12
|
+
props: { url: YT_URL, type: 'youtube', alt: 'Reindeer Dash 2025' },
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// No heavy iframe until the user asks for it.
|
|
16
|
+
expect(wrapper.find('iframe').exists()).toBe(false)
|
|
17
|
+
|
|
18
|
+
// Poster = the documented YouTube thumbnail for the video id.
|
|
19
|
+
const poster = wrapper.find('img.dcs-lite-embed__poster')
|
|
20
|
+
expect(poster.exists()).toBe(true)
|
|
21
|
+
expect(poster.attributes('src')).toBe(
|
|
22
|
+
`https://img.youtube.com/vi/${YT_ID}/hqdefault.jpg`,
|
|
23
|
+
)
|
|
24
|
+
expect(poster.attributes('loading')).toBe('lazy')
|
|
25
|
+
|
|
26
|
+
// Accessible, keyboard-operable play affordance.
|
|
27
|
+
const button = wrapper.find('button.dcs-lite-embed__button')
|
|
28
|
+
expect(button.exists()).toBe(true)
|
|
29
|
+
expect(button.attributes('type')).toBe('button')
|
|
30
|
+
expect(button.attributes('aria-label')).toBe('Play video: Reindeer Dash 2025')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('injects the real nocookie autoplay iframe on click and drops the poster button', async () => {
|
|
34
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
35
|
+
props: { url: YT_URL, type: 'youtube', alt: 'Reindeer Dash 2025' },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
await wrapper.find('button.dcs-lite-embed__button').trigger('click')
|
|
39
|
+
|
|
40
|
+
const iframe = wrapper.find('iframe.dcs-lite-embed__frame')
|
|
41
|
+
expect(iframe.exists()).toBe(true)
|
|
42
|
+
expect(iframe.attributes('src')).toBe(
|
|
43
|
+
`https://www.youtube-nocookie.com/embed/${YT_ID}?rel=0&autoplay=1&playsinline=1`,
|
|
44
|
+
)
|
|
45
|
+
expect(iframe.attributes('allow')).toContain('autoplay')
|
|
46
|
+
expect(iframe.attributes('title')).toBe('Reindeer Dash 2025')
|
|
47
|
+
|
|
48
|
+
// The poster button is gone once the iframe is live.
|
|
49
|
+
expect(wrapper.find('button.dcs-lite-embed__button').exists()).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('activates via keyboard (Enter on the play button)', async () => {
|
|
53
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
54
|
+
props: { url: YT_URL, type: 'youtube', alt: 'Reindeer Dash 2025' },
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
expect(wrapper.find('iframe').exists()).toBe(false)
|
|
58
|
+
|
|
59
|
+
await wrapper.find('button.dcs-lite-embed__button').trigger('keydown.enter')
|
|
60
|
+
|
|
61
|
+
expect(wrapper.find('iframe.dcs-lite-embed__frame').exists()).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('activates via keyboard (Space on the play button)', async () => {
|
|
65
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
66
|
+
props: { url: YT_URL, type: 'youtube' },
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
await wrapper.find('button.dcs-lite-embed__button').trigger('keydown.space')
|
|
70
|
+
|
|
71
|
+
expect(wrapper.find('iframe.dcs-lite-embed__frame').exists()).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('accepts a bare 11-char video id', () => {
|
|
75
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
76
|
+
props: { url: YT_ID, type: 'youtube' },
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(wrapper.find('img.dcs-lite-embed__poster').attributes('src')).toBe(
|
|
80
|
+
`https://img.youtube.com/vi/${YT_ID}/hqdefault.jpg`,
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('LiteMediaEmbed — Instagram facade', () => {
|
|
86
|
+
it('renders a branded glyph poster (no hotlinked thumbnail) and no iframe initially', () => {
|
|
87
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
88
|
+
props: { url: IG_URL, type: 'instagram', alt: 'Latest reel' },
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(wrapper.find('iframe').exists()).toBe(false)
|
|
92
|
+
// No image is hotlinked; a branded poster stands in.
|
|
93
|
+
expect(wrapper.find('img.dcs-lite-embed__poster').exists()).toBe(false)
|
|
94
|
+
expect(wrapper.find('.dcs-lite-embed__poster--brand').exists()).toBe(true)
|
|
95
|
+
expect(wrapper.find('button.dcs-lite-embed__button').attributes('aria-label')).toBe(
|
|
96
|
+
'Load Instagram post: Latest reel',
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('uses a caller-supplied poster override when present', () => {
|
|
101
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
102
|
+
props: {
|
|
103
|
+
url: IG_URL,
|
|
104
|
+
type: 'instagram',
|
|
105
|
+
poster: 'https://files.duffcloudservices.com/kdh/assets/ig-cover.webp',
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const poster = wrapper.find('img.dcs-lite-embed__poster')
|
|
110
|
+
expect(poster.exists()).toBe(true)
|
|
111
|
+
expect(poster.attributes('src')).toBe(
|
|
112
|
+
'https://files.duffcloudservices.com/kdh/assets/ig-cover.webp',
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('injects the /embed/ iframe on activation', async () => {
|
|
117
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
118
|
+
props: { url: IG_URL, type: 'instagram' },
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
await wrapper.find('button.dcs-lite-embed__button').trigger('click')
|
|
122
|
+
|
|
123
|
+
const iframe = wrapper.find('iframe.dcs-lite-embed__frame')
|
|
124
|
+
expect(iframe.exists()).toBe(true)
|
|
125
|
+
expect(iframe.attributes('src')).toBe('https://www.instagram.com/p/AbCdEfGhIjK/embed/')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('LiteMediaEmbed — hostile url never reaches the iframe src (no CSP backstop on customer sites)', () => {
|
|
130
|
+
// A portal-editable / visual-editor-AI-controlled carousel `url` must never be
|
|
131
|
+
// passed verbatim into <iframe src>. Invalid/hostile urls fall through to the
|
|
132
|
+
// safe default slot rather than rendering a facade over an untrusted iframe.
|
|
133
|
+
// These produce NO renderable embed at all (invalid scheme/host) → the
|
|
134
|
+
// component renders nothing (no facade button, no media <img> carrying the url).
|
|
135
|
+
const inert = [
|
|
136
|
+
{ type: 'youtube', url: 'javascript:alert(document.domain)' },
|
|
137
|
+
{ type: 'instagram', url: 'javascript:alert(1)//' },
|
|
138
|
+
{ type: 'instagram', url: 'https://evil.com/x' },
|
|
139
|
+
{ type: 'instagram', url: 'data:text/html,<script>alert(1)</script>' },
|
|
140
|
+
{ type: 'instagram', url: 'http://www.instagram.com/p/AbCdEfGhIjK/' }, // non-https
|
|
141
|
+
{ type: 'instagram', url: 'https://instagram.com.evil.com/p/x/' }, // lookalike host
|
|
142
|
+
] as const
|
|
143
|
+
|
|
144
|
+
for (const { type, url } of inert) {
|
|
145
|
+
it(`renders nothing (no button, no iframe, no leaked url) for ${type} url ${url}`, () => {
|
|
146
|
+
const wrapper = mount(LiteMediaEmbed, { props: { url, type } })
|
|
147
|
+
|
|
148
|
+
expect(wrapper.find('button.dcs-lite-embed__button').exists()).toBe(false)
|
|
149
|
+
expect(wrapper.find('iframe').exists()).toBe(false)
|
|
150
|
+
// The untrusted url is never emitted into any attribute (no broken <img src>).
|
|
151
|
+
expect(wrapper.html()).not.toContain('javascript:')
|
|
152
|
+
expect(wrapper.html()).not.toContain('evil.com')
|
|
153
|
+
expect(wrapper.html()).not.toContain('data:text/html')
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
it('extracts an id from a ?v= param on any host but only ever embeds via youtube-nocookie (never the source host)', async () => {
|
|
158
|
+
// extractYouTubeId pulls the sanitised 11-char id; the host is discarded, so
|
|
159
|
+
// the iframe origin is always the hardcoded youtube-nocookie, never evil.com.
|
|
160
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
161
|
+
props: { url: 'https://evil.com/watch?v=notArealID1', type: 'youtube' },
|
|
162
|
+
})
|
|
163
|
+
await wrapper.find('button.dcs-lite-embed__button').trigger('click')
|
|
164
|
+
const src = wrapper.find('iframe.dcs-lite-embed__frame').attributes('src') ?? ''
|
|
165
|
+
expect(src.startsWith('https://www.youtube-nocookie.com/embed/notArealID1')).toBe(true)
|
|
166
|
+
expect(wrapper.html()).not.toContain('evil.com')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('accepts a legitimate www.instagram.com url and rebuilds a clean /embed/ src', async () => {
|
|
170
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
171
|
+
props: { url: 'https://www.instagram.com/p/AbCdEfGhIjK/?igshid=abc#frag', type: 'instagram' },
|
|
172
|
+
})
|
|
173
|
+
await wrapper.find('button.dcs-lite-embed__button').trigger('click')
|
|
174
|
+
// Query/fragment/userinfo are dropped — only host + path survive the rebuild.
|
|
175
|
+
expect(wrapper.find('iframe.dcs-lite-embed__frame').attributes('src')).toBe(
|
|
176
|
+
'https://www.instagram.com/p/AbCdEfGhIjK/embed/',
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('LiteMediaEmbed — non-embed types fall through to existing behavior', () => {
|
|
182
|
+
it('renders a plain <img> for image type (no facade button, no iframe)', () => {
|
|
183
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
184
|
+
props: { url: '/images/staff/kim.webp', type: 'image', alt: 'Kim Duff' },
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
expect(wrapper.find('button.dcs-lite-embed__button').exists()).toBe(false)
|
|
188
|
+
expect(wrapper.find('iframe').exists()).toBe(false)
|
|
189
|
+
|
|
190
|
+
const img = wrapper.find('img.dcs-lite-embed__media')
|
|
191
|
+
expect(img.exists()).toBe(true)
|
|
192
|
+
expect(img.attributes('src')).toBe('/images/staff/kim.webp')
|
|
193
|
+
expect(img.attributes('alt')).toBe('Kim Duff')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('renders a <video> for video type', () => {
|
|
197
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
198
|
+
props: { url: '/videos/intro.mp4', type: 'video', alt: 'Intro' },
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
expect(wrapper.find('button.dcs-lite-embed__button').exists()).toBe(false)
|
|
202
|
+
const video = wrapper.find('video.dcs-lite-embed__media')
|
|
203
|
+
expect(video.exists()).toBe(true)
|
|
204
|
+
expect(video.attributes('src')).toBe('/videos/intro.mp4')
|
|
205
|
+
expect(video.attributes('controls')).toBeDefined()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('falls through to a plain <img> for an unknown type', () => {
|
|
209
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
210
|
+
// Deliberately outside the documented union.
|
|
211
|
+
props: { url: '/whatever.png', type: 'gif' as never },
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
expect(wrapper.find('button.dcs-lite-embed__button').exists()).toBe(false)
|
|
215
|
+
expect(wrapper.find('iframe').exists()).toBe(false)
|
|
216
|
+
expect(wrapper.find('img.dcs-lite-embed__media').attributes('src')).toBe('/whatever.png')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('lets a consumer override non-embed rendering via the default slot', () => {
|
|
220
|
+
const wrapper = mount(LiteMediaEmbed, {
|
|
221
|
+
props: { url: '/images/staff/kim.webp', type: 'image', alt: 'Kim Duff' },
|
|
222
|
+
slots: { default: '<figure class="custom-fallback">custom</figure>' },
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
expect(wrapper.find('figure.custom-fallback').exists()).toBe(true)
|
|
226
|
+
// The built-in default is replaced by slot content.
|
|
227
|
+
expect(wrapper.find('img.dcs-lite-embed__media').exists()).toBe(false)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LiteMediaEmbed
|
|
4
|
+
*
|
|
5
|
+
* Click-to-load facade for `useMediaCarousel` embed media types (`youtube`,
|
|
6
|
+
* `instagram`). The poster state is the thumbnail plus an accessible play
|
|
7
|
+
* affordance; the real (heavy) `<iframe>` is injected only on user interaction.
|
|
8
|
+
* This defers the full YouTube/Instagram player payload (~0.8 MB of script for
|
|
9
|
+
* the YouTube player alone) until a visitor actually chooses to watch, and is
|
|
10
|
+
* the default renderer for future editor video embeds.
|
|
11
|
+
*
|
|
12
|
+
* Non-embed types (`image`, `video`, or anything unknown) fall through to the
|
|
13
|
+
* default `<slot>` — which itself defaults to a plain `<img>`/`<video>` — so a
|
|
14
|
+
* carousel can hand every item to this one component.
|
|
15
|
+
*
|
|
16
|
+
* SSR-safe (VitePress SSG/SSR): no `window`/`document` access at setup, the
|
|
17
|
+
* poster renders server-side, and the poster/iframe swap is a pure `ref` flip
|
|
18
|
+
* that only happens after a client-side user gesture, so server and client
|
|
19
|
+
* markup match on hydration.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```vue
|
|
23
|
+
* <LiteMediaEmbed
|
|
24
|
+
* :url="item.url"
|
|
25
|
+
* :type="item.type"
|
|
26
|
+
* :alt="item.alt"
|
|
27
|
+
* />
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
import { computed, ref } from 'vue'
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(defineProps<{
|
|
33
|
+
/**
|
|
34
|
+
* Media source. For `youtube`: a watch/embed/short URL or a bare 11-char id.
|
|
35
|
+
* For `instagram`: the post/reel URL. For `image`/`video`: the asset URL.
|
|
36
|
+
*/
|
|
37
|
+
url: string
|
|
38
|
+
/**
|
|
39
|
+
* Media type. `youtube`/`instagram` get the click-to-load facade; everything
|
|
40
|
+
* else falls through to the default slot (plain `<img>`/`<video>`).
|
|
41
|
+
*/
|
|
42
|
+
type: 'image' | 'video' | 'youtube' | 'instagram' | (string & {})
|
|
43
|
+
/** Accessible label / alt text for the media. */
|
|
44
|
+
alt?: string
|
|
45
|
+
/**
|
|
46
|
+
* Optional poster image override. Required for a real Instagram thumbnail
|
|
47
|
+
* (Instagram exposes no documented thumbnail endpoint — without an override
|
|
48
|
+
* Instagram renders a branded glyph poster rather than hotlinking one).
|
|
49
|
+
*/
|
|
50
|
+
poster?: string
|
|
51
|
+
/**
|
|
52
|
+
* CSS `aspect-ratio` for the reserved box. Poster and iframe share this exact
|
|
53
|
+
* box so activation causes no layout shift. Defaults to widescreen video.
|
|
54
|
+
*/
|
|
55
|
+
aspectRatio?: string
|
|
56
|
+
}>(), {
|
|
57
|
+
alt: '',
|
|
58
|
+
poster: '',
|
|
59
|
+
aspectRatio: '16 / 9',
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const activated = ref(false)
|
|
63
|
+
|
|
64
|
+
/** Types this component owns as click-to-load embeds (vs pass-through media). */
|
|
65
|
+
const isEmbedType = computed(() => props.type === 'youtube' || props.type === 'instagram')
|
|
66
|
+
|
|
67
|
+
// A route is only rendered as a click-to-load facade when it is a youtube/
|
|
68
|
+
// instagram type AND yields a trusted embed URL. An embed type whose url is
|
|
69
|
+
// unrecognised or hostile renders NOTHING (see the template's media fallback,
|
|
70
|
+
// which is gated to non-embed types) — never a broken <img src="javascript:…">
|
|
71
|
+
// nor a facade wrapping an untrusted iframe.
|
|
72
|
+
const isEmbed = computed(() => isEmbedType.value && embedSrc.value !== '')
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract a YouTube video id from a watch URL, embed URL, short URL, or a bare
|
|
76
|
+
* 11-character id. Returns '' when nothing usable is found.
|
|
77
|
+
*/
|
|
78
|
+
function extractYouTubeId(url: string): string {
|
|
79
|
+
const embedMatch = url.match(/\/embed\/([\w-]{11})/)
|
|
80
|
+
if (embedMatch) return embedMatch[1]
|
|
81
|
+
|
|
82
|
+
const watchMatch = url.match(/[?&]v=([\w-]{11})/)
|
|
83
|
+
if (watchMatch) return watchMatch[1]
|
|
84
|
+
|
|
85
|
+
const shortMatch = url.match(/youtu\.be\/([\w-]{11})/)
|
|
86
|
+
if (shortMatch) return shortMatch[1]
|
|
87
|
+
|
|
88
|
+
if (/^[\w-]{11}$/.test(url)) return url
|
|
89
|
+
return ''
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const youTubeId = computed(() => (props.type === 'youtube' ? extractYouTubeId(props.url) : ''))
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build an Instagram embed URL from a caller-supplied post URL, rebuilt from the
|
|
96
|
+
* parsed host + path so nothing untrusted (userinfo, query, fragment, a foreign
|
|
97
|
+
* host, or a `javascript:`/`data:` scheme) can reach the iframe `src`. Returns
|
|
98
|
+
* '' for anything that is not an `https://…instagram.com/…` URL.
|
|
99
|
+
*/
|
|
100
|
+
function instagramEmbed(rawUrl: string): string {
|
|
101
|
+
let parsed: URL
|
|
102
|
+
try {
|
|
103
|
+
parsed = new URL(rawUrl)
|
|
104
|
+
} catch {
|
|
105
|
+
return ''
|
|
106
|
+
}
|
|
107
|
+
if (parsed.protocol !== 'https:') return ''
|
|
108
|
+
const host = parsed.hostname.toLowerCase()
|
|
109
|
+
if (host !== 'instagram.com' && !host.endsWith('.instagram.com')) return ''
|
|
110
|
+
const path = parsed.pathname.replace(/\/embed\/?$/, '').replace(/\/$/, '')
|
|
111
|
+
return `https://${host}${path}/embed/`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Poster image URL for the facade. YouTube uses the documented thumbnail
|
|
116
|
+
* pattern (`hqdefault.jpg` always exists for a valid id); Instagram uses the
|
|
117
|
+
* caller-supplied override or none (branded glyph poster).
|
|
118
|
+
*/
|
|
119
|
+
const posterSrc = computed(() => {
|
|
120
|
+
if (props.poster) return props.poster
|
|
121
|
+
if (props.type === 'youtube' && youTubeId.value) {
|
|
122
|
+
return `https://img.youtube.com/vi/${youTubeId.value}/hqdefault.jpg`
|
|
123
|
+
}
|
|
124
|
+
return ''
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Real embed URL, only ever used after activation. Only ever a trusted
|
|
129
|
+
* youtube-nocookie / instagram.com URL, or '' — a portal/AI-controlled `url`
|
|
130
|
+
* is never passed through verbatim (no `javascript:`/`data:`/foreign-origin
|
|
131
|
+
* iframe sink; there is no CSP backstop on customer sites).
|
|
132
|
+
*/
|
|
133
|
+
const embedSrc = computed(() => {
|
|
134
|
+
if (props.type === 'youtube') {
|
|
135
|
+
// Privacy-preserving nocookie host + autoplay so one click = playing.
|
|
136
|
+
return youTubeId.value
|
|
137
|
+
? `https://www.youtube-nocookie.com/embed/${youTubeId.value}?rel=0&autoplay=1&playsinline=1`
|
|
138
|
+
: ''
|
|
139
|
+
}
|
|
140
|
+
if (props.type === 'instagram') {
|
|
141
|
+
return instagramEmbed(props.url)
|
|
142
|
+
}
|
|
143
|
+
return ''
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const iframeTitle = computed(() => {
|
|
147
|
+
if (props.alt) return props.alt
|
|
148
|
+
return props.type === 'youtube' ? 'YouTube video' : 'Instagram post'
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const iframeAllow = computed(() =>
|
|
152
|
+
props.type === 'youtube'
|
|
153
|
+
? 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
|
|
154
|
+
: 'encrypted-media; picture-in-picture',
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const playLabel = computed(() => {
|
|
158
|
+
const subject = props.alt?.trim()
|
|
159
|
+
if (props.type === 'youtube') {
|
|
160
|
+
return subject ? `Play video: ${subject}` : 'Play video'
|
|
161
|
+
}
|
|
162
|
+
return subject ? `Load Instagram post: ${subject}` : 'Load Instagram post'
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
function activate() {
|
|
166
|
+
activated.value = true
|
|
167
|
+
}
|
|
168
|
+
</script>
|
|
169
|
+
|
|
170
|
+
<template>
|
|
171
|
+
<div
|
|
172
|
+
class="dcs-lite-embed"
|
|
173
|
+
:class="{ 'dcs-lite-embed--embed': isEmbed }"
|
|
174
|
+
:style="{ aspectRatio }"
|
|
175
|
+
:data-dcs-lite-embed="type"
|
|
176
|
+
>
|
|
177
|
+
<template v-if="isEmbed">
|
|
178
|
+
<iframe
|
|
179
|
+
v-if="activated && embedSrc"
|
|
180
|
+
class="dcs-lite-embed__frame"
|
|
181
|
+
:src="embedSrc"
|
|
182
|
+
:title="iframeTitle"
|
|
183
|
+
:allow="iframeAllow"
|
|
184
|
+
allowfullscreen
|
|
185
|
+
loading="lazy"
|
|
186
|
+
/>
|
|
187
|
+
<button
|
|
188
|
+
v-else
|
|
189
|
+
type="button"
|
|
190
|
+
class="dcs-lite-embed__button"
|
|
191
|
+
:aria-label="playLabel"
|
|
192
|
+
@click="activate"
|
|
193
|
+
@keydown.enter.prevent="activate"
|
|
194
|
+
@keydown.space.prevent="activate"
|
|
195
|
+
>
|
|
196
|
+
<img
|
|
197
|
+
v-if="posterSrc"
|
|
198
|
+
class="dcs-lite-embed__poster"
|
|
199
|
+
:src="posterSrc"
|
|
200
|
+
:alt="alt"
|
|
201
|
+
loading="lazy"
|
|
202
|
+
decoding="async"
|
|
203
|
+
>
|
|
204
|
+
<span
|
|
205
|
+
v-else
|
|
206
|
+
class="dcs-lite-embed__poster dcs-lite-embed__poster--brand"
|
|
207
|
+
:class="`dcs-lite-embed__poster--${type}`"
|
|
208
|
+
aria-hidden="true"
|
|
209
|
+
>
|
|
210
|
+
<svg
|
|
211
|
+
v-if="type === 'instagram'"
|
|
212
|
+
class="dcs-lite-embed__brand-glyph"
|
|
213
|
+
viewBox="0 0 24 24"
|
|
214
|
+
width="56"
|
|
215
|
+
height="56"
|
|
216
|
+
fill="none"
|
|
217
|
+
stroke="currentColor"
|
|
218
|
+
stroke-width="1.6"
|
|
219
|
+
stroke-linecap="round"
|
|
220
|
+
stroke-linejoin="round"
|
|
221
|
+
>
|
|
222
|
+
<rect x="2" y="2" width="20" height="20" rx="5" />
|
|
223
|
+
<circle cx="12" cy="12" r="4" />
|
|
224
|
+
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" />
|
|
225
|
+
</svg>
|
|
226
|
+
</span>
|
|
227
|
+
|
|
228
|
+
<span class="dcs-lite-embed__scrim" aria-hidden="true" />
|
|
229
|
+
|
|
230
|
+
<span class="dcs-lite-embed__play" aria-hidden="true">
|
|
231
|
+
<svg
|
|
232
|
+
v-if="type === 'instagram'"
|
|
233
|
+
class="dcs-lite-embed__play-icon"
|
|
234
|
+
viewBox="0 0 24 24"
|
|
235
|
+
width="30"
|
|
236
|
+
height="30"
|
|
237
|
+
fill="none"
|
|
238
|
+
stroke="currentColor"
|
|
239
|
+
stroke-width="2"
|
|
240
|
+
stroke-linecap="round"
|
|
241
|
+
stroke-linejoin="round"
|
|
242
|
+
>
|
|
243
|
+
<rect x="3" y="3" width="18" height="18" rx="5" />
|
|
244
|
+
<circle cx="12" cy="12" r="3.5" />
|
|
245
|
+
<circle cx="17.5" cy="6.5" r="1.1" fill="currentColor" stroke="none" />
|
|
246
|
+
</svg>
|
|
247
|
+
<svg
|
|
248
|
+
v-else
|
|
249
|
+
class="dcs-lite-embed__play-icon"
|
|
250
|
+
viewBox="0 0 24 24"
|
|
251
|
+
width="30"
|
|
252
|
+
height="30"
|
|
253
|
+
fill="currentColor"
|
|
254
|
+
aria-hidden="true"
|
|
255
|
+
>
|
|
256
|
+
<path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.29-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14z" />
|
|
257
|
+
</svg>
|
|
258
|
+
</span>
|
|
259
|
+
</button>
|
|
260
|
+
</template>
|
|
261
|
+
|
|
262
|
+
<slot v-else>
|
|
263
|
+
<video
|
|
264
|
+
v-if="type === 'video'"
|
|
265
|
+
class="dcs-lite-embed__media"
|
|
266
|
+
:src="url"
|
|
267
|
+
:aria-label="alt || undefined"
|
|
268
|
+
controls
|
|
269
|
+
preload="metadata"
|
|
270
|
+
/>
|
|
271
|
+
<!-- Media fallback is for genuine media types only. An embed type that
|
|
272
|
+
reached here failed url validation → render nothing, never a broken
|
|
273
|
+
<img> carrying the untrusted url. -->
|
|
274
|
+
<img
|
|
275
|
+
v-else-if="!isEmbedType"
|
|
276
|
+
class="dcs-lite-embed__media"
|
|
277
|
+
:src="url"
|
|
278
|
+
:alt="alt"
|
|
279
|
+
loading="lazy"
|
|
280
|
+
>
|
|
281
|
+
</slot>
|
|
282
|
+
</div>
|
|
283
|
+
</template>
|
|
284
|
+
|
|
285
|
+
<style scoped>
|
|
286
|
+
.dcs-lite-embed {
|
|
287
|
+
position: relative;
|
|
288
|
+
display: block;
|
|
289
|
+
width: 100%;
|
|
290
|
+
overflow: hidden;
|
|
291
|
+
aspect-ratio: var(--dcs-lite-embed-aspect, 16 / 9);
|
|
292
|
+
background: var(--dcs-lite-embed-bg, #0b1220);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.dcs-lite-embed__frame,
|
|
296
|
+
.dcs-lite-embed__media,
|
|
297
|
+
.dcs-lite-embed__poster {
|
|
298
|
+
position: absolute;
|
|
299
|
+
inset: 0;
|
|
300
|
+
width: 100%;
|
|
301
|
+
height: 100%;
|
|
302
|
+
border: 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.dcs-lite-embed__media,
|
|
306
|
+
.dcs-lite-embed__poster {
|
|
307
|
+
object-fit: cover;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.dcs-lite-embed__button {
|
|
311
|
+
position: absolute;
|
|
312
|
+
inset: 0;
|
|
313
|
+
width: 100%;
|
|
314
|
+
height: 100%;
|
|
315
|
+
margin: 0;
|
|
316
|
+
padding: 0;
|
|
317
|
+
border: 0;
|
|
318
|
+
cursor: pointer;
|
|
319
|
+
background: transparent;
|
|
320
|
+
color: #ffffff;
|
|
321
|
+
display: block;
|
|
322
|
+
-webkit-tap-highlight-color: transparent;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* Branded fallback poster (Instagram has no documented thumbnail endpoint). */
|
|
326
|
+
.dcs-lite-embed__poster--brand {
|
|
327
|
+
display: flex;
|
|
328
|
+
align-items: center;
|
|
329
|
+
justify-content: center;
|
|
330
|
+
color: rgba(255, 255, 255, 0.85);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.dcs-lite-embed__poster--instagram {
|
|
334
|
+
background: radial-gradient(circle at 30% 107%, #fdf497 0%, #fd5949 45%, #d6249f 60%, #285aeb 90%);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.dcs-lite-embed__scrim {
|
|
338
|
+
position: absolute;
|
|
339
|
+
inset: 0;
|
|
340
|
+
background: linear-gradient(to top, rgba(2, 6, 23, 0.35) 0%, rgba(2, 6, 23, 0) 45%);
|
|
341
|
+
opacity: 0.55;
|
|
342
|
+
transition: opacity var(--motion-duration-sm, 160ms) var(--motion-ease-standard, ease);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.dcs-lite-embed__play {
|
|
346
|
+
position: absolute;
|
|
347
|
+
top: 50%;
|
|
348
|
+
left: 50%;
|
|
349
|
+
display: flex;
|
|
350
|
+
align-items: center;
|
|
351
|
+
justify-content: center;
|
|
352
|
+
width: 4.25rem;
|
|
353
|
+
height: 4.25rem;
|
|
354
|
+
transform: translate(-50%, -50%);
|
|
355
|
+
border-radius: 9999px;
|
|
356
|
+
background: var(--dcs-lite-embed-play-bg, rgba(15, 23, 42, 0.72));
|
|
357
|
+
box-shadow: 0 6px 20px rgba(2, 6, 23, 0.35);
|
|
358
|
+
transition:
|
|
359
|
+
transform var(--motion-duration-sm, 160ms) var(--motion-ease-standard, ease),
|
|
360
|
+
background var(--motion-duration-sm, 160ms) var(--motion-ease-standard, ease);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.dcs-lite-embed__play-icon {
|
|
364
|
+
transform: translateX(1px);
|
|
365
|
+
color: #ffffff;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.dcs-lite-embed--embed[data-dcs-lite-embed='instagram'] .dcs-lite-embed__play {
|
|
369
|
+
background: var(--dcs-lite-embed-play-bg, rgba(15, 23, 42, 0.55));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.dcs-lite-embed__button:hover .dcs-lite-embed__play,
|
|
373
|
+
.dcs-lite-embed__button:focus-visible .dcs-lite-embed__play {
|
|
374
|
+
transform: translate(-50%, -50%) scale(1.06);
|
|
375
|
+
background: var(--dcs-lite-embed-play-bg-hover, #ef4444);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.dcs-lite-embed__button:hover .dcs-lite-embed__scrim,
|
|
379
|
+
.dcs-lite-embed__button:focus-visible .dcs-lite-embed__scrim {
|
|
380
|
+
opacity: 0.85;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.dcs-lite-embed__button:focus-visible {
|
|
384
|
+
outline: 3px solid var(--dcs-lite-embed-focus, #60a5fa);
|
|
385
|
+
outline-offset: -3px;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
@media (prefers-reduced-motion: reduce) {
|
|
389
|
+
.dcs-lite-embed__scrim,
|
|
390
|
+
.dcs-lite-embed__play {
|
|
391
|
+
transition: none;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.dcs-lite-embed__button:hover .dcs-lite-embed__play,
|
|
395
|
+
.dcs-lite-embed__button:focus-visible .dcs-lite-embed__play {
|
|
396
|
+
transform: translate(-50%, -50%);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
</style>
|
|
@@ -45,7 +45,12 @@ import { isCdnAssetUrl } from '@duffcloudservices/cms-core'
|
|
|
45
45
|
export interface MediaCarouselItem {
|
|
46
46
|
/** URL to the image, video file, or embed URL */
|
|
47
47
|
url: string
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Type of media: 'image', 'video' (direct file), 'youtube', or 'instagram'.
|
|
50
|
+
* Render `youtube`/`instagram` items through the click-to-load facade
|
|
51
|
+
* `LiteMediaEmbed` (`@duffcloudservices/cms/lite-media-embed`) so the heavy
|
|
52
|
+
* player iframe loads only on user interaction — never eagerly.
|
|
53
|
+
*/
|
|
49
54
|
type: 'image' | 'video' | 'youtube' | 'instagram'
|
|
50
55
|
/** Accessibility alt text */
|
|
51
56
|
alt?: string
|