F: Rework video embedding. Add VK Video
Some checks are pending
Frontend CI / build (22.x) (push) Waiting to run
Frontend CI / notify-failure (push) Blocked by required conditions

This commit is contained in:
Ivan 2025-08-13 16:20:30 +03:00
parent bd6f72aceb
commit 2ecdbd1719
14 changed files with 210 additions and 28 deletions

View File

@ -177,6 +177,7 @@
"Upvote",
"Viewset",
"viewsets",
"vkvideo",
"wordform",
"Wordforms",
"XCSDATN",

View File

@ -4,6 +4,9 @@ import React from 'react';
import { DialogType, useDialogsStore } from '@/stores/dialogs';
const DlgShowVideo = React.lazy(() =>
import('@/features/help/dialogs/dlg-show-video').then(module => ({ default: module.DlgShowVideo }))
);
const DlgChangeInputSchema = React.lazy(() =>
import('@/features/oss/dialogs/dlg-change-input-schema').then(module => ({ default: module.DlgChangeInputSchema }))
);
@ -161,6 +164,8 @@ export const GlobalDialogs = () => {
return null;
}
switch (active) {
case DialogType.SHOW_VIDEO:
return <DlgShowVideo />;
case DialogType.CONSTITUENTA_TEMPLATE:
return <DlgCstTemplate />;
case DialogType.CREATE_CONSTITUENTA:

View File

@ -39,6 +39,7 @@ export { RiMenuFoldFill as IconMenuFold } from 'react-icons/ri';
export { RiMenuUnfoldFill as IconMenuUnfold } from 'react-icons/ri';
export { LuMoon as IconDarkTheme } from 'react-icons/lu';
export { LuSun as IconLightTheme } from 'react-icons/lu';
export { IoVideocamOutline as IconVideo } from 'react-icons/io5';
export { LuFolderTree as IconFolderTree } from 'react-icons/lu';
export { LuFolder as IconFolder } from 'react-icons/lu';
export { LuFolderSearch as IconFolderSearch } from 'react-icons/lu';

View File

@ -0,0 +1,42 @@
interface EmbedVKVideoProps {
/** Video ID to embed. */
videoID: string;
/** Display height in pixels. */
pxHeight: number;
/** Display width in pixels. */
pxWidth?: number;
}
/**
* Embeds a YouTube video into the page using the given video ID and dimensions.
*/
export function EmbedVKVideo({ videoID, pxHeight, pxWidth }: EmbedVKVideoProps) {
if (!pxWidth) {
pxWidth = (pxHeight * 16) / 9;
}
return (
<div
className='relative h-0 mt-1'
style={{
paddingBottom: `${pxHeight}px`,
paddingLeft: `${pxWidth}px`
}}
>
<iframe
allowFullScreen
title='Встроенное видео ВКонтакте'
allow='autoplay; encrypted-media; fullscreen; picture-in-picture; screen-wake-lock;'
className='absolute top-0 left-0 border'
style={{
minHeight: `${pxHeight}px`,
minWidth: `${pxWidth}px`
}}
width={`${pxWidth}px`}
height={`${pxHeight}px`}
src={`https://vk.com/video_ext.php?${videoID}&hd=1`}
/>
</div>
);
}

View File

@ -0,0 +1,30 @@
'use client';
import { IconVideo } from '@/components/icons';
import { type Styling } from '@/components/props';
import { cn } from '@/components/utils';
import { useDialogsStore } from '@/stores/dialogs';
import { globalIDs, type IVideo } from '@/utils/constants';
interface BadgeVideoProps extends Styling {
video: IVideo;
}
/** Displays a badge with a video icon to click and open the video. */
export function BadgeVideo({ video, className, ...restProps }: BadgeVideoProps) {
const showVideo = useDialogsStore(state => state.showVideo);
function handleShowExplication() {
showVideo({ video: video });
}
return (
<IconVideo
className={cn('cursor-pointer', className)}
onClick={handleShowExplication}
data-tooltip-id={globalIDs.tooltip}
data-tooltip-content='Просмотр видео'
{...restProps}
/>
);
}

View File

@ -0,0 +1,64 @@
'use client';
import { TabList, TabPanel, Tabs } from 'react-tabs';
import { ModalView } from '@/components/modal';
import { TabLabel } from '@/components/tabs';
import { useWindowSize } from '@/hooks/use-window-size';
import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences';
import { type IVideo } from '@/utils/constants';
import { TabVK } from './tab-vk';
import { TabYoutube } from './tab-youtube';
export const TabID = {
VK: 0,
YOUTUBE: 1
} as const;
export type TabID = (typeof TabID)[keyof typeof TabID];
export interface DlgShowVideoProps {
video: IVideo;
}
export function DlgShowVideo() {
const preferredPlayer = usePreferencesStore(state => state.preferredPlayer);
const setPreferredPlayer = usePreferencesStore(state => state.setPreferredPlayer);
const activeTab = preferredPlayer === 'vk' ? TabID.VK : TabID.YOUTUBE;
const { video } = useDialogsStore(state => state.props as DlgShowVideoProps);
const windowSize = useWindowSize();
const videoHeight = (() => {
const viewH = windowSize.height ?? 0;
const viewW = windowSize.width ?? 0;
const availableWidth = viewW - 80;
return Math.min(Math.max(viewH - 150, 300), Math.floor((availableWidth * 9) / 16));
})();
function setActiveTab(newTab: TabID) {
setPreferredPlayer(newTab === TabID.VK ? 'vk' : 'youtube');
}
return (
<ModalView fullScreen className='relative w-[calc(100dvw-3rem)] h-[calc(100dvh-3rem)]'>
<Tabs
className='flex flex-col gap-1 items-center pt-3'
selectedIndex={activeTab}
onSelect={index => setActiveTab(index as TabID)}
>
<TabList className='mx-auto flex border divide-x rounded-none'>
<TabLabel label='ВК Видео' className='w-32' />
<TabLabel label='Youtube' className='w-32' />
</TabList>
<TabPanel>
<TabVK videoHeight={videoHeight} videoID={video.vk} />
</TabPanel>
<TabPanel>
<TabYoutube videoHeight={videoHeight} videoID={video.youtube} />
</TabPanel>
</Tabs>
</ModalView>
);
}

View File

@ -0,0 +1 @@
export { DlgShowVideo as DlgShowVideo } from './dlg-show-video';

View File

@ -0,0 +1,10 @@
import { EmbedVKVideo } from '@/components/view/embed-vkvideo';
interface TabVKProps {
videoHeight: number;
videoID: string;
}
export function TabVK({ videoHeight, videoID }: TabVKProps) {
return <EmbedVKVideo videoID={videoID} pxHeight={videoHeight} />;
}

View File

@ -0,0 +1,10 @@
import { EmbedYoutube } from '@/components/view';
interface TabYoutubeProps {
videoHeight: number;
videoID: string;
}
export function TabYoutube({ videoHeight, videoID }: TabYoutubeProps) {
return <EmbedYoutube videoID={videoID} pxHeight={videoHeight} />;
}

View File

@ -1,4 +1,5 @@
import { TextURL } from '@/components/control';
import { IconVideo } from '@/components/icons';
import { external_urls, prefixes } from '@/utils/constants';
import { LinkTopic } from '../components/link-topic';
@ -11,7 +12,7 @@ export function HelpMain() {
<h1>Портал</h1>
<p>
Портал позволяет анализировать предметные области, формально записывать системы определений и синтезировать их с
помощью математического аппарата <LinkTopic text='Родов структур' topic={HelpTopic.RSLANG} />
помощью математического аппарата <LinkTopic text='Родов структур' topic={HelpTopic.RSLANG} />.
</p>
<p>
Такие системы называются <LinkTopic text='Концептуальными схемами' topic={HelpTopic.CC_SYSTEM} /> и состоят из
@ -19,6 +20,10 @@ export function HelpMain() {
определения. Концептуальные схемы могут связываться путем синтеза в{' '}
<LinkTopic text='Операционной схеме синтеза' topic={HelpTopic.CC_OSS} />.
</p>
<p>
Значок <IconVideo className='inline-icon' /> при нажатии отображает видео о различных темах и подробностях
работы Портала. Просмотр видео доступен на Youtube и ВКонтакте.
</p>
<details>
<summary className='text-center font-semibold'>Разделы Справки</summary>

View File

@ -1,21 +1,10 @@
import { EmbedYoutube } from '@/components/view';
import { useWindowSize } from '@/hooks/use-window-size';
import { external_urls, youtube } from '@/utils/constants';
import { external_urls, videos } from '@/utils/constants';
import { BadgeVideo } from '../components/badge-video';
import { Subtopics } from '../components/subtopics';
import { HelpTopic } from '../models/help-topic';
export function HelpRSLang() {
const windowSize = useWindowSize();
const isSmall = windowSize.isSmall;
const videoHeight = (() => {
const viewH = windowSize.height ?? 0;
const viewW = windowSize.width ?? 0;
const availableWidth = viewW - (isSmall ? 35 : 310);
return Math.min(1080, Math.max(viewH - 450, 300), Math.floor((availableWidth * 9) / 16));
})();
// prettier-ignore
return (
<div className='flex flex-col gap-4'>
@ -23,14 +12,10 @@ export function HelpRSLang() {
<h1>Родоструктурная экспликация концептуальных схем</h1>
<p>Формальная запись (<i>экспликация</i>) концептуальных схем осуществляется с помощью языка родов структур.</p>
<p>Для ознакомления с основами родов структур можно использовать следующие материалы:</p>
<p>1. <a className='underline' href={external_urls.intro_video}>Видео: Краткое введение в мат. аппарат</a></p>
<p>1. <BadgeVideo className='inline-icon' video={videos.explication} /> Видео: Краткое введение в мат. аппарат</p>
<p>2. <a className='underline' href={external_urls.ponomarev}>Текст: Учебник И. Н. Пономарева</a></p>
<p>3. <a className='underline' href={external_urls.full_course}>Видео: лекции для 4 курса (второй семестр 2022-23 год)</a></p>
</div>
<EmbedYoutube
videoID={youtube.intro}
pxHeight={videoHeight}
/>
<div className='dense'>
<Subtopics headTopic={HelpTopic.RSLANG} />
</div>

View File

@ -1,6 +1,7 @@
import { create } from 'zustand';
import { type DlgCreatePromptTemplateProps } from '@/features/ai/dialogs/dlg-create-prompt-template';
import { type DlgShowVideoProps } from '@/features/help/dialogs/dlg-show-video/dlg-show-video';
import { type DlgChangeLocationProps } from '@/features/library/dialogs/dlg-change-location';
import { type DlgCloneLibraryItemProps } from '@/features/library/dialogs/dlg-clone-library-item';
import { type DlgCreateVersionProps } from '@/features/library/dialogs/dlg-create-version';
@ -73,7 +74,9 @@ export const DialogType = {
IMPORT_SCHEMA: 31,
AI_PROMPT: 32,
CREATE_PROMPT_TEMPLATE: 33
CREATE_PROMPT_TEMPLATE: 33,
SHOW_VIDEO: 34
} as const;
export type DialogType = (typeof DialogType)[keyof typeof DialogType];
@ -86,6 +89,7 @@ interface DialogsStore {
props: unknown;
hideDialog: () => void;
showVideo: (props: DlgShowVideoProps) => void;
showCstTemplate: (props: DlgCstTemplateProps) => void;
showCreateCst: (props: DlgCreateCstProps) => void;
showCreateBlock: (props: DlgCreateBlockProps) => void;
@ -131,6 +135,7 @@ export const useDialogsStore = create<DialogsStore>()(set => ({
});
},
showVideo: props => set({ active: DialogType.SHOW_VIDEO, props: props }),
showCstTemplate: props => set({ active: DialogType.CONSTITUENTA_TEMPLATE, props: props }),
showCreateCst: props => set({ active: DialogType.CREATE_CONSTITUENTA, props: props }),
showCreateOperation: props => set({ active: DialogType.CREATE_SYNTHESIS, props: props }),

View File

@ -4,6 +4,11 @@ import { persist } from 'zustand/middleware';
import { PARAMETER } from '@/utils/constants';
export const videoPlayerTypes = ['vk', 'youtube'] as const;
/** Represents video player type. */
export type VideoPlayerType = (typeof videoPlayerTypes)[number];
interface PreferencesStore {
darkMode: boolean;
toggleDarkMode: () => void;
@ -31,6 +36,9 @@ interface PreferencesStore {
showOssSidePanel: boolean;
toggleShowOssSidePanel: () => void;
preferredPlayer: VideoPlayerType;
setPreferredPlayer: (value: VideoPlayerType) => void;
}
export const usePreferencesStore = create<PreferencesStore>()(
@ -66,7 +74,7 @@ export const usePreferencesStore = create<PreferencesStore>()(
adminMode: false,
toggleAdminMode: () => set(state => ({ adminMode: !state.adminMode })),
libraryPagination: 50,
libraryPagination: 20,
setLibraryPagination: value => set({ libraryPagination: value }),
showCstSideList: true,
@ -82,10 +90,13 @@ export const usePreferencesStore = create<PreferencesStore>()(
toggleShowExpressionControls: () => set(state => ({ showExpressionControls: !state.showExpressionControls })),
showOssSidePanel: false,
toggleShowOssSidePanel: () => set(state => ({ showOssSidePanel: !state.showOssSidePanel }))
toggleShowOssSidePanel: () => set(state => ({ showOssSidePanel: !state.showOssSidePanel })),
preferredPlayer: 'vk',
setPreferredPlayer: value => set({ preferredPlayer: value })
}),
{
version: 1,
version: 2,
name: 'portal.preferences'
}
)

View File

@ -53,11 +53,6 @@ export const resources = {
db_schema: '/db_schema.svg'
} as const;
/** Youtube IDs for embedding. */
export const youtube = {
intro: '0Ty9mu9sOJo'
} as const;
/** External URLs. */
export const external_urls = {
concept: 'https://www.acconcept.ru/',
@ -76,6 +71,23 @@ export const external_urls = {
restAPI: 'https://api.portal.acconcept.ru'
} as const;
/** Youtube and VK IDs for embedding. */
export interface IVideo {
/** Youtube ID. */
youtube: string;
/** VK ID. */
vk: string;
}
/** Youtube and VK IDs for embedding. */
export const videos = {
explication: {
youtube: '0Ty9mu9sOJo',
vk: 'oid=-232112636&id=456239017'
}
};
/** Global element ID. */
export const globalIDs = {
tooltip: 'global_tooltip',