mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(sandbox): rebuild sandbox with shell UI and expanded templates (#773)
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PLATFORMS, PRESETS, STYLINGS } from '../constants';
|
||||
import type { SourceId } from '../shared/sources';
|
||||
import { DEFAULT_AUDIO_SOURCE, MP4_SOURCE_IDS, SOURCE_IDS, SOURCES } from '../shared/sources';
|
||||
import type { Platform, Preset, Styling } from '../types';
|
||||
import { useSkinSwitcher } from '../utils/use-skin-switcher';
|
||||
import { useSourceSwitcher } from '../utils/use-source-switcher';
|
||||
import { Navbar } from './navbar';
|
||||
import { Preview } from './preview';
|
||||
|
||||
function getPagePath(platform: Platform, styling: Styling, preset: Preset): string {
|
||||
if (preset === 'background-video') return `/${platform}-background-video/`;
|
||||
if (styling === 'tailwind') return `/${platform}-${preset}-tailwind/`;
|
||||
return `/${platform}-${preset}/`;
|
||||
}
|
||||
|
||||
function readParams() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
return {
|
||||
platform: (params.get('platform') ?? 'html') as Platform,
|
||||
styling: (params.get('styling') ?? 'css') as Styling,
|
||||
preset: (params.get('preset') ?? 'video') as Preset,
|
||||
};
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const initial = useMemo(readParams, []);
|
||||
const [platform, setPlatform] = useState<Platform>(initial.platform);
|
||||
const [styling, setStyling] = useState<Styling>(initial.styling);
|
||||
const [preset, setPreset] = useState<Preset>(initial.preset);
|
||||
const [skin, setSkin] = useSkinSwitcher();
|
||||
const [source, setSource] = useSourceSwitcher();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Derive the page URL
|
||||
const pagePath = getPagePath(platform, styling, preset);
|
||||
|
||||
// Update URL when filters change
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams({ platform, styling, preset });
|
||||
const url = `/?${params.toString()}`;
|
||||
history.replaceState(null, '', url);
|
||||
}, [platform, styling, preset]);
|
||||
|
||||
// Send postMessage to iframe for skin changes (skip initial mount — iframe reads localStorage)
|
||||
const skinRef = useRef(skin);
|
||||
useEffect(() => {
|
||||
if (skinRef.current === skin) return;
|
||||
skinRef.current = skin;
|
||||
iframeRef.current?.contentWindow?.postMessage({ type: 'skin-change', skin }, '*');
|
||||
}, [skin]);
|
||||
|
||||
// Send postMessage to iframe for source changes (skip initial mount — iframe reads localStorage)
|
||||
const sourceRef = useRef(source);
|
||||
useEffect(() => {
|
||||
if (sourceRef.current === source) return;
|
||||
sourceRef.current = source;
|
||||
iframeRef.current?.contentWindow?.postMessage({ type: 'source-change', source }, '*');
|
||||
}, [source]);
|
||||
|
||||
// Constrain source to MP4 when switching to audio
|
||||
useEffect(() => {
|
||||
if (preset === 'audio' && SOURCES[source].type !== 'mp4') {
|
||||
setSource(DEFAULT_AUDIO_SOURCE);
|
||||
}
|
||||
}, [preset, source, setSource]);
|
||||
|
||||
// Constrain styling when switching to background-video
|
||||
useEffect(() => {
|
||||
if (preset === 'background-video' && styling === 'tailwind') {
|
||||
setStyling('css');
|
||||
}
|
||||
}, [preset, styling]);
|
||||
|
||||
const availableSources = preset === 'audio' ? MP4_SOURCE_IDS : SOURCE_IDS;
|
||||
|
||||
const handleSourceChange = useCallback((value: string) => setSource(value as SourceId), [setSource]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden">
|
||||
<Navbar
|
||||
platform={platform}
|
||||
onPlatformChange={setPlatform}
|
||||
styling={styling}
|
||||
onStylingChange={setStyling}
|
||||
preset={preset}
|
||||
onPresetChange={setPreset}
|
||||
skin={skin}
|
||||
onSkinChange={setSkin}
|
||||
source={source}
|
||||
onSourceChange={handleSourceChange}
|
||||
availableSources={availableSources}
|
||||
isBackgroundVideo={preset === 'background-video'}
|
||||
platforms={PLATFORMS}
|
||||
stylings={STYLINGS}
|
||||
presets={PRESETS}
|
||||
sources={SOURCES}
|
||||
/>
|
||||
<Preview ref={iframeRef} pagePath={pagePath} skin={skin} source={source} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { SKINS } from '../constants';
|
||||
import type { SourceId } from '../shared/sources';
|
||||
import type { Platform, Preset, Skin, Styling } from '../types';
|
||||
|
||||
type NavbarProps = {
|
||||
platform: Platform;
|
||||
onPlatformChange: (value: Platform) => void;
|
||||
styling: Styling;
|
||||
onStylingChange: (value: Styling) => void;
|
||||
preset: Preset;
|
||||
onPresetChange: (value: Preset) => void;
|
||||
skin: Skin;
|
||||
onSkinChange: (value: Skin) => void;
|
||||
source: SourceId;
|
||||
onSourceChange: (value: string) => void;
|
||||
availableSources: readonly SourceId[];
|
||||
isBackgroundVideo: boolean;
|
||||
platforms: readonly Platform[];
|
||||
stylings: readonly Styling[];
|
||||
presets: readonly Preset[];
|
||||
sources: Record<SourceId, { label: string; url: string; type: string }>;
|
||||
};
|
||||
|
||||
const SKIN_OPTIONS: readonly Skin[] = ['default', 'minimal'] satisfies readonly (typeof SKINS)[number][];
|
||||
|
||||
const PLATFORM_LABELS: Record<Platform, string> = {
|
||||
html: 'HTML',
|
||||
react: 'React',
|
||||
};
|
||||
|
||||
const PRESET_LABELS: Record<Preset, string> = {
|
||||
video: 'Video',
|
||||
audio: 'Audio',
|
||||
'background-video': 'Background Video',
|
||||
};
|
||||
|
||||
export function Navbar({
|
||||
platform,
|
||||
onPlatformChange,
|
||||
styling,
|
||||
onStylingChange,
|
||||
preset,
|
||||
onPresetChange,
|
||||
skin,
|
||||
onSkinChange,
|
||||
source,
|
||||
onSourceChange,
|
||||
availableSources,
|
||||
isBackgroundVideo,
|
||||
platforms,
|
||||
stylings,
|
||||
presets,
|
||||
sources,
|
||||
}: NavbarProps) {
|
||||
return (
|
||||
<header className="shrink-0 border-b border-zinc-200 bg-white flex items-center px-4 h-14 gap-6">
|
||||
<span className="text-sm font-semibold tracking-tight whitespace-nowrap text-zinc-950">Video.js v10</span>
|
||||
|
||||
<div className="h-5 w-px bg-zinc-200" />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
label="Platform"
|
||||
value={platform}
|
||||
onChange={(v) => onPlatformChange(v as Platform)}
|
||||
options={platforms.map((p) => ({ value: p, label: PLATFORM_LABELS[p] }))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Styling"
|
||||
value={styling}
|
||||
onChange={(v) => onStylingChange(v as Styling)}
|
||||
options={stylings.map((s) => ({
|
||||
value: s,
|
||||
label: s === 'css' ? 'CSS' : 'Tailwind',
|
||||
disabled: s === 'tailwind' && isBackgroundVideo,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Preset"
|
||||
value={preset}
|
||||
onChange={(v) => onPresetChange(v as Preset)}
|
||||
options={presets.map((p) => ({ value: p, label: PRESET_LABELS[p] }))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Skin"
|
||||
value={skin}
|
||||
onChange={(v) => onSkinChange(v as Skin)}
|
||||
options={SKIN_OPTIONS.map((s) => ({ value: s, label: capitalize(s) }))}
|
||||
disabled={isBackgroundVideo}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Source"
|
||||
value={source}
|
||||
onChange={onSourceChange}
|
||||
options={availableSources.map((id) => ({ value: id, label: sources[id].label }))}
|
||||
disabled={isBackgroundVideo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto">
|
||||
<a
|
||||
href="https://github.com/videojs/v10"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center size-8 rounded-md text-zinc-500 hover:text-zinc-950 hover:bg-zinc-100 transition-colors"
|
||||
>
|
||||
<span className="sr-only">GitHub repository</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.385-1.335-1.755-1.335-1.755-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type SelectProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function Select({ label, value, onChange, options, disabled }: SelectProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-zinc-500">{label}</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="h-8 appearance-none rounded-md border-none bg-clip-border ring ring-zinc-800/10 bg-white pl-3 pr-8 text-[13px] font-medium text-zinc-950 shadow-xs shadow-black/20 transition-colors hover:bg-zinc-50 focus:outline-2 focus:outline-zinc-950 focus:outline-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<svg
|
||||
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 size-3.5 text-zinc-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type { SourceId } from '../shared/sources';
|
||||
import type { Skin } from '../types';
|
||||
|
||||
type PreviewProps = {
|
||||
pagePath: string;
|
||||
skin: Skin;
|
||||
source: SourceId;
|
||||
};
|
||||
|
||||
export const Preview = forwardRef<HTMLIFrameElement, PreviewProps>(function Preview({ pagePath, skin, source }, ref) {
|
||||
const openUrl = `${pagePath}?skin=${encodeURIComponent(skin)}&source=${encodeURIComponent(source)}`;
|
||||
|
||||
return (
|
||||
<main className="flex-1 min-h-0 relative bg-zinc-50">
|
||||
<a
|
||||
href={openUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute top-3 right-3 z-10 inline-flex items-center gap-1 h-7 rounded-md bg-clip-border ring ring-zinc-800/10 bg-white px-2.5 text-xs font-medium text-zinc-600 shadow-xs shadow-black/20 transition-colors hover:bg-zinc-50 hover:text-zinc-950"
|
||||
title="Open in new tab"
|
||||
>
|
||||
Open
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" x2="21" y1="14" y2="3" />
|
||||
</svg>
|
||||
</a>
|
||||
<iframe ref={ref} src={pagePath} className="absolute inset-0 w-full h-full border-0" title="player demo" />
|
||||
</main>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user