mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
feat(scenes): render the data-pack attribution cards from a store
The data-pack presentations keep the Cesium resources, object URLs and pick targets and present each pack's attribution card, with its media player, to the gev-data-packs store; the DataPacks view teleports one panel per set of presentations into the viewer's container with the same markup and inline styles, and stops a removed card's player as it goes.
This commit is contained in:
1 parent
59e3aaa271
commit
e0abf74308
8 files changed
+290
-69
No files matched your search
@@ -152,7 +152,10 @@ a feature supplying only its accent.
|
|||||||
owner's own timer), the Scene actions panel (`SceneInteractions`
|
owner's own timer), the Scene actions panel (`SceneInteractions`
|
||||||
teleports what the scene interactions owner publishes to the
|
teleports what the scene interactions owner publishes to the
|
||||||
`gev-scene-interactions` store into the viewer's container; the owner
|
`gev-scene-interactions` store into the viewer's container; the owner
|
||||||
keeps the pick handler and focuses the picked action), then
|
keeps the pick handler and focuses the picked action), the data-pack
|
||||||
|
attribution cards (`DataPacks` teleports what the data-pack presentations
|
||||||
|
publish to the `gev-data-packs` store into the viewer's container and
|
||||||
|
stops a removed card's player), then
|
||||||
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
|
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
|
||||||
pages, cycle toggle, regional news and local conditions, and
|
pages, cycle toggle, regional news and local conditions, and
|
||||||
`CockpitContext` the Contact card's scope, navigation, counts, nearest
|
`CockpitContext` the Contact card's scope, navigation, counts, nearest
|
||||||
|
|||||||
@@ -1134,5 +1134,7 @@
|
|||||||
"src/ui/toast.js",
|
"src/ui/toast.js",
|
||||||
"src/ui/toastView.test.mjs",
|
"src/ui/toastView.test.mjs",
|
||||||
"src/ui/stores/sceneInteractions.js",
|
"src/ui/stores/sceneInteractions.js",
|
||||||
|
"src/scenes/dataPacks/presentationView.test.mjs",
|
||||||
|
"src/ui/stores/dataPacks.js",
|
||||||
"src/scenes/interactionsView.test.mjs"
|
"src/scenes/interactionsView.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -99,7 +99,9 @@
|
|||||||
"src/ui/views/panels/SplitFlapText.vue",
|
"src/ui/views/panels/SplitFlapText.vue",
|
||||||
"src/splitFlap.js",
|
"src/splitFlap.js",
|
||||||
"src/ui/stores/sceneInteractions.js",
|
"src/ui/stores/sceneInteractions.js",
|
||||||
"src/ui/views/panels/SceneInteractions.vue"
|
"src/ui/views/panels/SceneInteractions.vue",
|
||||||
|
"src/ui/stores/dataPacks.js",
|
||||||
|
"src/ui/views/panels/DataPacks.vue"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia"]
|
"external": ["vue", "pinia"]
|
||||||
},
|
},
|
||||||
@@ -1150,7 +1152,8 @@
|
|||||||
"src/ui/vueRuntime.js",
|
"src/ui/vueRuntime.js",
|
||||||
"src/ui/stores/status.js",
|
"src/ui/stores/status.js",
|
||||||
"src/ui/toast.js",
|
"src/ui/toast.js",
|
||||||
"src/ui/stores/sceneInteractions.js"
|
"src/ui/stores/sceneInteractions.js",
|
||||||
|
"src/ui/stores/dataPacks.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "vue", "pinia"]
|
"external": ["cesium", "vue", "pinia"]
|
||||||
},
|
},
|
||||||
@@ -3008,7 +3011,8 @@
|
|||||||
"src/ui/stores/imagerySplit.js",
|
"src/ui/stores/imagerySplit.js",
|
||||||
"src/ui/stores/status.js",
|
"src/ui/stores/status.js",
|
||||||
"src/ui/toast.js",
|
"src/ui/toast.js",
|
||||||
"src/ui/stores/sceneInteractions.js"
|
"src/ui/stores/sceneInteractions.js",
|
||||||
|
"src/ui/stores/dataPacks.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
|
|||||||
@@ -1,55 +1,56 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { renderPackGeometry } from './geometry.js';
|
import { renderPackGeometry } from './geometry.js';
|
||||||
import { t } from '../../i18n/index.js';
|
import { useDataPacksStore } from '../../ui/stores/dataPacks.js';
|
||||||
|
import { applicationPinia } from '../../ui/vueRuntime.js';
|
||||||
|
|
||||||
function caption(viewer, pack) {
|
|
||||||
const card = document.createElement('section');
|
|
||||||
card.dataset.directorPack = pack.id;
|
|
||||||
Object.assign(card.style, {
|
|
||||||
color: '#e3faff',
|
|
||||||
background: '#08141eed',
|
|
||||||
border: '1px solid #2491a8',
|
|
||||||
padding: '8px',
|
|
||||||
margin: '4px',
|
|
||||||
maxWidth: '260px',
|
|
||||||
font: '12px sans-serif',
|
|
||||||
});
|
|
||||||
const title = document.createElement('div');
|
|
||||||
title.textContent = `${pack.id} · ${pack.attribution.text} · ${pack.attribution.license}`;
|
|
||||||
card.append(title);
|
|
||||||
if (pack.attribution.url) {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.textContent = t('Source');
|
|
||||||
link.href = pack.attribution.url;
|
|
||||||
link.target = '_blank';
|
|
||||||
link.rel = 'noopener noreferrer';
|
|
||||||
link.style.color = '#6eeaff';
|
|
||||||
card.append(link);
|
|
||||||
}
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
|
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
|
||||||
|
|
||||||
/** Create rendering adapters; data acquisition and authored placement stay independent. */
|
// Key every panel and card on the page, so a new one never reuses an old
|
||||||
export function createPackPresentations(viewer, targets = new Map()) {
|
// one's DOM.
|
||||||
let panel;
|
let panelKeys = 0,
|
||||||
|
cardKeys = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create rendering adapters; data acquisition and authored placement stay
|
||||||
|
* independent. Each pack's attribution card is presented to the data packs
|
||||||
|
* store, which its view renders inside the viewer's container.
|
||||||
|
*/
|
||||||
|
export function createPackPresentations(
|
||||||
|
viewer,
|
||||||
|
targets = new Map(),
|
||||||
|
{ store = useDataPacksStore(applicationPinia()) } = {},
|
||||||
|
) {
|
||||||
|
const owner = {};
|
||||||
|
let key,
|
||||||
|
cards = [];
|
||||||
|
function present() {
|
||||||
|
store.publish(
|
||||||
|
owner,
|
||||||
|
cards.length ? { key, container: viewer.container, cards } : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
function addCard(pack) {
|
function addCard(pack) {
|
||||||
if (!panel) {
|
if (!cards.length) key = ++panelKeys;
|
||||||
panel = document.createElement('div');
|
const card = {
|
||||||
panel.dataset.directorPacks = '';
|
key: ++cardKeys,
|
||||||
Object.assign(panel.style, {
|
id: pack.id,
|
||||||
position: 'absolute',
|
title: `${pack.id} · ${pack.attribution.text} · ${pack.attribution.license}`,
|
||||||
right: '10px',
|
url: pack.attribution.url || null,
|
||||||
bottom: '70px',
|
media: null,
|
||||||
maxHeight: '40vh',
|
};
|
||||||
overflowY: 'auto',
|
cards = [...cards, card];
|
||||||
zIndex: '30',
|
present();
|
||||||
});
|
return card.key;
|
||||||
viewer.container.append(panel);
|
}
|
||||||
}
|
function updateCard(key, patch) {
|
||||||
const card = caption(viewer, pack);
|
cards = cards.map((card) =>
|
||||||
panel.append(card);
|
card.key === key ? { ...card, ...patch } : card,
|
||||||
return card;
|
);
|
||||||
|
present();
|
||||||
|
}
|
||||||
|
function removeCard(key) {
|
||||||
|
cards = cards.filter((card) => card.key !== key);
|
||||||
|
present();
|
||||||
}
|
}
|
||||||
function resource(pack) {
|
function resource(pack) {
|
||||||
const entities = [],
|
const entities = [],
|
||||||
@@ -78,7 +79,12 @@ export function createPackPresentations(viewer, targets = new Map()) {
|
|||||||
viewer.scene.requestRender();
|
viewer.scene.requestRender();
|
||||||
},
|
},
|
||||||
card() {
|
card() {
|
||||||
return (card ||= addCard(pack));
|
card ||= addCard(pack);
|
||||||
|
},
|
||||||
|
/** @param {{kind: 'video'|'audio', src: string}} media */
|
||||||
|
media(media) {
|
||||||
|
card ||= addCard(pack);
|
||||||
|
updateCard(card, { media });
|
||||||
},
|
},
|
||||||
url(blob) {
|
url(blob) {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -89,16 +95,8 @@ export function createPackPresentations(viewer, targets = new Map()) {
|
|||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
for (const key of featureKeys.splice(0)) targets.delete(key);
|
for (const key of featureKeys.splice(0)) targets.delete(key);
|
||||||
for (const media of card?.querySelectorAll('video,audio') || []) {
|
// The view stops a removed card's player as it goes.
|
||||||
media.pause();
|
if (card) removeCard(card);
|
||||||
media.removeAttribute('src');
|
|
||||||
media.load();
|
|
||||||
}
|
|
||||||
card?.remove();
|
|
||||||
if (panel && !panel.childElementCount) {
|
|
||||||
panel.remove();
|
|
||||||
panel = null;
|
|
||||||
}
|
|
||||||
for (const entity of entities.splice(0)) viewer.entities.remove(entity);
|
for (const entity of entities.splice(0)) viewer.entities.remove(entity);
|
||||||
for (const primitive of primitives.splice(0))
|
for (const primitive of primitives.splice(0))
|
||||||
viewer.scene.primitives.remove(primitive);
|
viewer.scene.primitives.remove(primitive);
|
||||||
@@ -212,14 +210,10 @@ export function createPackPresentations(viewer, targets = new Map()) {
|
|||||||
pixelOffset: new Cesium.Cartesian2(0, -24),
|
pixelOffset: new Cesium.Cartesian2(0, -24),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const media = document.createElement(
|
handle.media({
|
||||||
asset.mimeType.startsWith('video/') ? 'video' : 'audio',
|
kind: asset.mimeType.startsWith('video/') ? 'video' : 'audio',
|
||||||
);
|
src: handle.url(new Blob([asset.bytes], { type: asset.mimeType })),
|
||||||
media.controls = true;
|
});
|
||||||
media.preload = 'none';
|
|
||||||
media.style.width = '240px';
|
|
||||||
media.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
|
|
||||||
handle.card().append(media);
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// src/scenes/dataPacks/presentationView.test.mjs — loaded packs' attribution
|
||||||
|
// cards render in the viewer's container from what the data-pack
|
||||||
|
// presentations present, and a removed card's player stops.
|
||||||
|
import '../../testSupport/registerDom.mjs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createApp, nextTick } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import { useDataPacksStore } from '../../ui/stores/dataPacks.js';
|
||||||
|
import DataPacks from '../../ui/views/panels/DataPacks.vue';
|
||||||
|
import { createPackPresentations } from './presentation.js';
|
||||||
|
|
||||||
|
const ANCHOR = { id: 'here', lat: 30, lon: -97, alt: 10 };
|
||||||
|
const pack = (id, url) => ({
|
||||||
|
id,
|
||||||
|
attribution: { text: 'Synthetic', license: 'CC0-1.0', url },
|
||||||
|
placement: { anchorId: 'here' },
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
const container = document.body.appendChild(document.createElement('div'));
|
||||||
|
const pinia = createPinia();
|
||||||
|
const app = createApp(DataPacks).use(pinia);
|
||||||
|
app.mount(document.body.appendChild(document.createElement('div')));
|
||||||
|
const viewer = {
|
||||||
|
container,
|
||||||
|
scene: { requestRender() {} },
|
||||||
|
entities: { add: (spec) => spec, remove() {} },
|
||||||
|
};
|
||||||
|
const store = useDataPacksStore(pinia);
|
||||||
|
const presentations = createPackPresentations(viewer, new Map(), { store });
|
||||||
|
t.after(() => app.unmount());
|
||||||
|
const settle = async () => {
|
||||||
|
for (let flush = 0; flush < 4; flush += 1) await nextTick();
|
||||||
|
};
|
||||||
|
const $ = (selector) => container.querySelector(selector);
|
||||||
|
const $$ = (selector) => [...container.querySelectorAll(selector)];
|
||||||
|
return { presentations, viewer, store, settle, $, $$ };
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = (presentations, id, mimeType = 'audio/mpeg') =>
|
||||||
|
presentations.media({
|
||||||
|
pack: pack(id),
|
||||||
|
asset: { bytes: new Uint8Array([1, 2, 3]), mimeType },
|
||||||
|
anchors: [ANCHOR],
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a loaded pack shows its attribution card in the viewer container', async (t) => {
|
||||||
|
const { presentations, settle, $, $$ } = fixture(t);
|
||||||
|
assert.equal($('[data-director-packs]'), null);
|
||||||
|
const handle = await media(presentations, 'clip', 'video/webm');
|
||||||
|
await settle();
|
||||||
|
const panel = $('[data-director-packs]');
|
||||||
|
assert.equal(panel.tagName, 'DIV');
|
||||||
|
assert.equal(panel.style.position, 'absolute');
|
||||||
|
assert.equal(panel.style.right, '10px');
|
||||||
|
assert.equal(panel.style.bottom, '70px');
|
||||||
|
assert.equal(panel.style.zIndex, '30');
|
||||||
|
const [card] = $$('[data-director-pack]');
|
||||||
|
assert.equal(card.tagName, 'SECTION');
|
||||||
|
assert.equal(card.dataset.directorPack, 'clip');
|
||||||
|
assert.equal(card.style.maxWidth, '260px');
|
||||||
|
assert.equal(
|
||||||
|
card.querySelector('div').textContent,
|
||||||
|
'clip · Synthetic · CC0-1.0',
|
||||||
|
);
|
||||||
|
assert.equal(card.querySelector('a'), null, 'no link without a source URL');
|
||||||
|
const video = card.querySelector('video');
|
||||||
|
assert.equal(video.controls, true);
|
||||||
|
assert.equal(video.preload, 'none');
|
||||||
|
assert.equal(video.style.width, '240px');
|
||||||
|
assert.match(video.getAttribute('src'), /^blob:/);
|
||||||
|
handle.dispose();
|
||||||
|
await settle();
|
||||||
|
assert.equal($('[data-director-packs]'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a card links its source, and a removed card stops its player', async (t) => {
|
||||||
|
const { presentations, settle, $, $$ } = fixture(t);
|
||||||
|
const first = await media(presentations, 'one');
|
||||||
|
const second = await presentations.media({
|
||||||
|
pack: pack('two', 'https://example.org/two'),
|
||||||
|
asset: { bytes: new Uint8Array([1]), mimeType: 'audio/ogg' },
|
||||||
|
anchors: [ANCHOR],
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(
|
||||||
|
$$('[data-director-pack]').map((card) => card.dataset.directorPack),
|
||||||
|
['one', 'two'],
|
||||||
|
);
|
||||||
|
const link = $('[data-director-pack="two"] a');
|
||||||
|
assert.equal(link.textContent, 'Source');
|
||||||
|
assert.equal(link.getAttribute('href'), 'https://example.org/two');
|
||||||
|
assert.equal(link.target, '_blank');
|
||||||
|
assert.equal(link.rel, 'noopener noreferrer');
|
||||||
|
const audio = $('[data-director-pack="one"] audio');
|
||||||
|
const calls = [];
|
||||||
|
audio.pause = () => calls.push('pause');
|
||||||
|
audio.load = () => calls.push('load');
|
||||||
|
first.dispose();
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(calls, ['pause', 'load']);
|
||||||
|
assert.equal(audio.getAttribute('src'), null);
|
||||||
|
assert.equal(audio.isConnected, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
$$('[data-director-pack]').map((card) => card.dataset.directorPack),
|
||||||
|
['two'],
|
||||||
|
);
|
||||||
|
second.dispose();
|
||||||
|
await settle();
|
||||||
|
assert.equal($('[data-director-packs]'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each set of presentations keeps its own panel', async (t) => {
|
||||||
|
const { presentations, viewer, store, settle, $$ } = fixture(t);
|
||||||
|
const one = await media(presentations, 'one');
|
||||||
|
await media(presentations, 'two');
|
||||||
|
const later = createPackPresentations(viewer, new Map(), { store });
|
||||||
|
const three = await media(later, 'three');
|
||||||
|
await settle();
|
||||||
|
const cards = () =>
|
||||||
|
$$('[data-director-packs]').map((panel) =>
|
||||||
|
[...panel.querySelectorAll('[data-director-pack]')].map(
|
||||||
|
(card) => card.dataset.directorPack,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.deepEqual(cards(), [['one', 'two'], ['three']]);
|
||||||
|
one.dispose();
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(cards(), [['two'], ['three']]);
|
||||||
|
three.dispose();
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(cards(), [['two']]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { markRaw, shallowRef } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loaded data packs' attribution cards as their view renders them. Each
|
||||||
|
* set of data-pack presentations (src/scenes/dataPacks/presentation.js)
|
||||||
|
* presents its own panel, with a card for each pack and its media player
|
||||||
|
* when the pack is media.
|
||||||
|
*/
|
||||||
|
export const useDataPacksStore = defineStore('gev-data-packs', () => {
|
||||||
|
/**
|
||||||
|
* `[{owner, key, container, cards: [{key, id, title, url, media}]}]`, one
|
||||||
|
* per owner showing cards. `container` is the element the panel sits in;
|
||||||
|
* `media` is `{kind: 'video'|'audio', src}` or null.
|
||||||
|
*/
|
||||||
|
const panels = shallowRef([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} owner The presentations publishing.
|
||||||
|
* @param {?object} panel Their fresh panel, or null to remove it.
|
||||||
|
*/
|
||||||
|
function publish(owner, panel) {
|
||||||
|
const index = panels.value.findIndex((item) => item.owner === owner);
|
||||||
|
const next = panels.value.slice();
|
||||||
|
if (!panel) {
|
||||||
|
if (index < 0) return;
|
||||||
|
next.splice(index, 1);
|
||||||
|
} else if (index < 0) next.push(markRaw({ ...panel, owner }));
|
||||||
|
else next[index] = markRaw({ ...panel, owner });
|
||||||
|
panels.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { panels, publish };
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { useLocationStore } from '../stores/location.js';
|
|||||||
import { useStatusStore } from '../stores/status.js';
|
import { useStatusStore } from '../stores/status.js';
|
||||||
import { vClassToggle } from '../rail/classToggle.js';
|
import { vClassToggle } from '../rail/classToggle.js';
|
||||||
import ImagerySplitView from './panels/ImagerySplitView.vue';
|
import ImagerySplitView from './panels/ImagerySplitView.vue';
|
||||||
|
import DataPacks from './panels/DataPacks.vue';
|
||||||
import SceneInteractions from './panels/SceneInteractions.vue';
|
import SceneInteractions from './panels/SceneInteractions.vue';
|
||||||
import SplitFlapText from './panels/SplitFlapText.vue';
|
import SplitFlapText from './panels/SplitFlapText.vue';
|
||||||
|
|
||||||
@@ -96,5 +97,6 @@ const vToastText = {
|
|||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<ImagerySplitView />
|
<ImagerySplitView />
|
||||||
|
<DataPacks />
|
||||||
<SceneInteractions />
|
<SceneInteractions />
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup>
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { t } from '../../../i18n/index.js';
|
||||||
|
import { useDataPacksStore } from '../../stores/dataPacks.js';
|
||||||
|
|
||||||
|
// The loaded data packs' attribution cards, one panel per set of data-pack
|
||||||
|
// presentations (src/scenes/dataPacks/presentation.js), inside the viewer's
|
||||||
|
// container.
|
||||||
|
const { panels } = storeToRefs(useDataPacksStore());
|
||||||
|
|
||||||
|
const panelStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
right: '10px',
|
||||||
|
bottom: '70px',
|
||||||
|
maxHeight: '40vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
zIndex: '30',
|
||||||
|
};
|
||||||
|
const cardStyle = {
|
||||||
|
color: '#e3faff',
|
||||||
|
background: '#08141eed',
|
||||||
|
border: '1px solid #2491a8',
|
||||||
|
padding: '8px',
|
||||||
|
margin: '4px',
|
||||||
|
maxWidth: '260px',
|
||||||
|
font: '12px sans-serif',
|
||||||
|
};
|
||||||
|
|
||||||
|
// A removed player stops and lets go of its media, which a detached
|
||||||
|
// element would otherwise keep playing. This runs as Vue renders the
|
||||||
|
// removal, in the same task as the dispose that removed it.
|
||||||
|
const vPackMedia = {
|
||||||
|
beforeUnmount(el) {
|
||||||
|
el.pause();
|
||||||
|
el.removeAttribute('src');
|
||||||
|
el.load();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport v-for="panel in panels" :key="panel.key" :to="panel.container">
|
||||||
|
<div data-director-packs="" :style="panelStyle"><section v-for="card in panel.cards" :key="card.key" :data-director-pack="card.id" :style="cardStyle"><div>{{ card.title }}</div><a v-if="card.url" :href="card.url" target="_blank" rel="noopener noreferrer" style="color: #6eeaff">{{ t('Source') }}</a><video v-if="card.media?.kind === 'video'" v-pack-media controls preload="none" style="width: 240px" :src="card.media.src"></video><audio v-else-if="card.media?.kind === 'audio'" v-pack-media controls preload="none" style="width: 240px" :src="card.media.src"></audio></section></div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
Reference in new issue
Block a user