Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d6edd27
Merge branch 'ui' into feature/add-cf-to-video-fieldtype
edalzell Jun 3, 2025
095fe97
Merge branch 'ui' into feature/add-cf-to-video-fieldtype
edalzell Jun 13, 2025
f35b6a1
video providers
edalzell Jun 13, 2025
e373489
move the provider/embed logic to the fieldtype
edalzell Jun 14, 2025
caa2ded
move the provider/embed logic to the fieldtype
edalzell Jun 14, 2025
63ed860
wip
edalzell Jun 16, 2025
8548d67
don’t know how to identify a CF stream id
edalzell Jun 16, 2025
738f475
move it inside the input group
edalzell Jun 16, 2025
f1c2c6b
Merge branch 'master' into feature/add-cf-to-video-fieldtype
edalzell Jul 4, 2025
0e357ef
wip
edalzell Jun 18, 2025
74f8f9d
wip
edalzell Jul 5, 2025
0f8fd0b
Merge branch 'master' into feature/add-cf-to-video-fieldtype
edalzell Jul 9, 2025
37d64b2
some sugar
edalzell Jul 9, 2025
51c3fa8
wip
edalzell Jul 9, 2025
8a831e7
Discard changes to src/Fields/Fieldtype.php
edalzell Jul 9, 2025
2f3c41b
Discard changes to resources/js/components/fieldtypes/Fieldtype.vue
edalzell Jul 9, 2025
76e7e45
actually set the id
edalzell Jul 9, 2025
a769861
don’t debounce, for now
edalzell Jul 9, 2025
e84848c
tidy
edalzell Jul 9, 2025
a9aa392
fix providers
edalzell Jul 9, 2025
78bbdf5
this gets rid of width/height
edalzell Jul 9, 2025
87141a1
fix CF sizing
edalzell Jul 9, 2025
e884b3a
Not supported
edalzell Jul 9, 2025
994e93b
Merge branch 'master' into pr/11871
duncanmcclean Jul 10, 2025
3924b52
Merge branch 'master' into pr/11871
duncanmcclean Jul 10, 2025
41056dc
null be null
edalzell Jul 10, 2025
6d0ae6f
Merge branch '6.x' into feature/add-cf-to-video-fieldtype
edalzell Mar 18, 2026
5548b80
fix bad merge
edalzell Mar 18, 2026
18650f0
wip
edalzell Mar 18, 2026
b5f0be3
tidy
edalzell Mar 18, 2026
2caaf6d
better display of embed
edalzell Mar 18, 2026
3fcbbf7
tidy
edalzell Mar 19, 2026
560c3fb
tidy
edalzell Mar 19, 2026
5f5d215
tidy
edalzell Mar 19, 2026
6ee9392
set/show the value properly
edalzell Mar 19, 2026
6370ad2
Tests
edalzell Mar 19, 2026
f10c70b
Merge branch '6.x' into feature/add-cf-to-video-fieldtype
edalzell May 6, 2026
8007863
Merge branch '6.x' into feature/add-cf-to-video-fieldtype
edalzell Jun 9, 2026
1567689
Merge remote-tracking branch 'upstream/6.x' into feature/add-cf-to-vi…
edalzell Sep 2, 2026
4943837
Escape Cloudflare ID in embed HTML
edalzell Sep 2, 2026
b3f32c6
Send the stored value to the video details endpoint
edalzell Sep 2, 2026
471ebe0
Restore lazy-loading, handle failed lookups, and stop the provider wa…
edalzell Sep 2, 2026
25c29dd
Remove copy-pasted Text fieldtype tests
edalzell Sep 2, 2026
6845c55
Fix unsafe new static usage
edalzell Sep 2, 2026
932accb
Merge branch '6.x' into feature/add-cf-to-video-fieldtype
edalzell Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"league/glide": "^3.0 || ^4.0",
"maennchen/zipstream-php": "^3.1",
"michelf/php-smartypants": "^1.8.1",
"mpratt/embera": "~2.0",
"nesbot/carbon": "^3.0",
"pragmarx/google2fa": "^8.0 || ^9.0",
"rebing/graphql-laravel": "^9.15",
Expand Down
18 changes: 18 additions & 0 deletions resources/css/components/fieldtypes/video.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.embera-embed-responsive {
position: relative;
display: block;
width: 100%;
padding: 0;
overflow: hidden;
padding-bottom: 50%;
}

.embera-embed-responsive-item {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
1 change: 1 addition & 0 deletions resources/css/cp.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@
@import './components/fieldtypes/relationship.css';
@import './components/fieldtypes/section.css';
@import './components/fieldtypes/table.css';
@import './components/fieldtypes/video.css';
@import './components/fieldtypes/width.css';
149 changes: 86 additions & 63 deletions resources/js/components/fieldtypes/VideoFieldtype.vue
Original file line number Diff line number Diff line change
@@ -1,28 +1,40 @@
<template>
<div class="flex flex-col space-y-3 p-1.5 bg-gray-100 border border-gray-300 dark:bg-gray-900 dark:border-gray-700 rounded-xl">
<ui-input-group>
<ui-input-group-prepend :text="__('URL')" />
<ui-input
:model-value="value"
:isReadOnly="isReadOnly"
:placeholder="__(config.placeholder) || 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'"
:aria-label="__('Video URL')"
@update:model-value="update"
@focus="$emit('focus')"
@blur="$emit('blur')"
input-class="border-s-0"
/>
</ui-input-group>
<ui-description v-if="isInvalid" class="text-red-600">{{ __('statamic::validation.url') }}</ui-description>
<iframe
v-if="shouldShowPreview"
ref="iframe"
:src="isVisible ? embedUrl : null"
frameborder="0"
allow="fullscreen"
class="aspect-video rounded-lg"
loading="lazy"
></iframe>
<ui-combobox
:model-value="provider"
:options="providers"
option-label="provider"
option-value="provider"
:placeholder="__('Provider...')"
@update:model-value="changeProvider"
/>

<ui-input
v-if="provider != 'Cloudflare'"
:aria-label="__('Video URL')"
input-class="border-s-0"
:isReadOnly="isReadOnly"
:model-value="url"
:placeholder="__(config.placeholder) || 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'"
:prepend="__('URL')"
@update:model-value="detailsFromUrl"
@focus="$emit('focus')"
@blur="$emit('blur')"
/>

<ui-input
v-else
:aria-label="__('Video ID')"
input-class="border-s-0"
:isReadOnly="isReadOnly"
:model-value="videoId"
:prepend="__('ID')"
@update:model-value="detailsFromCloudflare"
@focus="$emit('focus')"
@blur="$emit('blur')"
/>

<div v-if="shouldShowPreview" v-html="embed"></div>
Comment thread
edalzell marked this conversation as resolved.
</div>
</template>

Expand All @@ -34,70 +46,81 @@ export default {

data() {
return {
embed: this.meta.embed,
isVisible: false,
observer: null,
provider: this.meta.provider,
savedValue: null,
url: null,
videoId: null,
};
},

computed: {
shouldShowPreview() {
return !this.isInvalid && (this.isEmbeddable || this.isVideo);
return this.isVisible && !!this.embed;
},

embedUrl() {
let embed_url = this.value || '';
providers() {
return this.meta.providers;
}
},

if (embed_url.includes('youtube')) {
embed_url = embed_url.includes('shorts/')
? embed_url.replace('shorts/', 'embed/')
: embed_url.replace('watch?v=', 'embed/');
}
methods: {
changeProvider(provider) {
this.provider = provider;
this.embed = null;
this.url = null;
},

if (embed_url.includes('youtu.be')) {
embed_url = embed_url.replace('youtu.be', 'www.youtube.com/embed');
}
detailsFromCloudflare(id) {
if (id == null) return;

if (embed_url.includes('vimeo')) {
embed_url = embed_url.replace('/vimeo.com', '/player.vimeo.com/video');
this.savedValue = `cloudflare:${id}`;
this.videoId = id;
this.url = null;

if (!this.value.includes('progressive_redirect') && embed_url.split('/').length > 5) {
let hash = embed_url.substr(embed_url.lastIndexOf('/') + 1);
embed_url = embed_url.substr(0, embed_url.lastIndexOf('/')) + '?h=' + hash.replace('?', '&');
}
}
this.getVideoData();
},

if (embed_url.includes('&') && !embed_url.includes('?')) {
embed_url = embed_url.replace('&', '?');
}
detailsFromUrl(url) {
if (url == null) return;

return embed_url;
},
this.savedValue = url;
this.videoId = null;
this.url = url;

isEmbeddable() {
const url = this.value || '';
const isYoutube = url.includes('youtube') || url.includes('youtu.be');
const isVimeo = url.includes('vimeo');
return isYoutube || isVimeo;
this.getVideoData();
},

isInvalid() {
let htmlRegex = new RegExp(/<([A-Z][A-Z0-9]*)\b[^>]*>.*?<\/\1>|<([A-Z][A-Z0-9]*)\b[^\/]*\/>/i);
return htmlRegex.test(this.value || '');
},
getVideoData() {
this.$axios
.get(this.meta.url, { params: { url: this.savedValue } })
.then((response) => response.data)
.then((data) => {
this.embed = data.embed;
this.provider = data.provider;
})
.catch((e) => {
this.embed = null;
this.$toast.error(e.response ? e.response.data.message : __('Something went wrong'));
});

isUrl() {
const url = this.value || '';
return url.startsWith('http://') || url.startsWith('https://');
this.update(this.savedValue);
},

isVideo() {
const url = this.value || '';
const isVideo = url.includes('.mp4') || url.includes('.ogv') || url.includes('.mov') || url.includes('.webm');
return !this.isEmbeddable && isVideo;
},
setUrlOrId() {
if (this.value?.startsWith('cloudflare:')) {
this.videoId = this.value.replace('cloudflare:','');
return;
}

this.url = this.value;
}
},

mounted() {
this.setUrlOrId();
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
Expand Down
126 changes: 126 additions & 0 deletions resources/js/tests/components/fieldtypes/VideoFieldtype.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { flushPromises, mount } from '@vue/test-utils';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import VideoFieldtype from '@/components/fieldtypes/VideoFieldtype.vue';
import { publishContextKey } from '@/components/ui';

window.__ = (key) => key;

let intersect;
let axios;
let toast;

beforeEach(() => {
window.IntersectionObserver = class {
constructor(callback) {
intersect = () => callback([{ isIntersecting: true, intersectionRatio: 1 }]);
}
observe() {}
disconnect() {}
};

axios = { get: vi.fn() };
toast = { error: vi.fn() };
});

afterEach(() => {
vi.restoreAllMocks();
});

const stub = (tag) => ({
props: ['modelValue'],
emits: ['update:modelValue'],
template: `<${tag} :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`,
});

const mountVideoField = (props = {}) => {
return mount(VideoFieldtype, {
props: {
handle: 'video',
config: {},
meta: {
url: '/cp/video/details',
providers: [{ provider: 'Cloudflare' }, { provider: 'Youtube' }],
},
...props,
},
global: {
provide: { [publishContextKey]: {} },
mocks: { $axios: axios, $toast: toast },
stubs: {
'ui-combobox': stub('select'),
'ui-input': stub('input'),
},
},
});
};

test('cloudflare id is sent with the cloudflare prefix', async () => {
axios.get.mockResolvedValue({ data: { embed: '<iframe></iframe>', provider: 'Cloudflare' } });

const wrapper = mountVideoField({ meta: { url: '/cp/video/details', providers: [], provider: 'Cloudflare' } });

await wrapper.find('input').setValue('1234');
await flushPromises();

expect(axios.get).toHaveBeenCalledWith('/cp/video/details', { params: { url: 'cloudflare:1234' } });
expect(wrapper.emitted('update:value')[0]).toEqual(['cloudflare:1234']);
});

test('embed survives the provider being corrected by the lookup', async () => {
axios.get.mockResolvedValue({ data: { embed: '<iframe></iframe>', provider: 'Youtube' } });

const wrapper = mountVideoField();
intersect();

await wrapper.find('input').setValue('https://www.youtube.com/watch?v=FK3dav4bA4s');
await flushPromises();

expect(wrapper.vm.provider).toBe('Youtube');
expect(wrapper.find('input').element.value).toBe('https://www.youtube.com/watch?v=FK3dav4bA4s');
expect(wrapper.find('iframe').exists()).toBe(true);
});

test('changing the provider clears the embed and url', async () => {
const wrapper = mountVideoField({
value: 'https://www.youtube.com/watch?v=FK3dav4bA4s',
meta: { url: '/cp/video/details', providers: [], provider: 'Youtube', embed: '<iframe></iframe>' },
});
intersect();
await flushPromises();
expect(wrapper.find('iframe').exists()).toBe(true);

await wrapper.find('select').setValue('Cloudflare');

expect(wrapper.find('iframe').exists()).toBe(false);
expect(wrapper.vm.url).toBeNull();
});

test('embed is not rendered until the field is visible', async () => {
const wrapper = mountVideoField({
value: 'cloudflare:1234',
meta: { url: '/cp/video/details', providers: [], provider: 'Cloudflare', embed: '<iframe></iframe>' },
});

expect(wrapper.find('iframe').exists()).toBe(false);

intersect();
await flushPromises();

expect(wrapper.find('iframe').exists()).toBe(true);
});

test('failed lookup clears the embed and shows an error', async () => {
axios.get.mockRejectedValue({ response: { data: { message: 'Nope' } } });

const wrapper = mountVideoField({
value: 'cloudflare:1234',
meta: { url: '/cp/video/details', providers: [], provider: 'Cloudflare', embed: '<iframe></iframe>' },
});
intersect();

await wrapper.find('input').setValue('5678');
await flushPromises();

expect(wrapper.find('iframe').exists()).toBe(false);
expect(toast.error).toHaveBeenCalledWith('Nope');
});
2 changes: 2 additions & 0 deletions routes/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
use Statamic\Http\Controllers\CP\Fieldtypes\MarkdownFieldtypeController;
use Statamic\Http\Controllers\CP\Fieldtypes\RelationshipFieldtypeController;
use Statamic\Http\Controllers\CP\Fieldtypes\ReplicatorSetController;
use Statamic\Http\Controllers\CP\Fieldtypes\VideoFieldtypeController;
use Statamic\Http\Controllers\CP\Forms\ActionController as FormActionController;
use Statamic\Http\Controllers\CP\Forms\FormBlueprintController;
use Statamic\Http\Controllers\CP\Forms\FormExportController;
Expand Down Expand Up @@ -388,6 +389,7 @@
Route::post('markdown', [MarkdownFieldtypeController::class, 'preview'])->name('markdown.preview');
Route::post('files/upload', [FilesFieldtypeController::class, 'upload'])->name('files.upload');
Route::post('icons', IconFieldtypeController::class)->name('icon-fieldtype');
Route::get('video/details', [VideoFieldtypeController::class, 'details'])->name('video.details');
Route::post('replicator/set', ReplicatorSetController::class)->name('replicator-fieldtype.set');
});

Expand Down
Loading
Loading