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", "Upvote",
"Viewset", "Viewset",
"viewsets", "viewsets",
"vkvideo",
"wordform", "wordform",
"Wordforms", "Wordforms",
"XCSDATN", "XCSDATN",

View File

@ -4,6 +4,9 @@ import React from 'react';
import { DialogType, useDialogsStore } from '@/stores/dialogs'; 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(() => const DlgChangeInputSchema = React.lazy(() =>
import('@/features/oss/dialogs/dlg-change-input-schema').then(module => ({ default: module.DlgChangeInputSchema })) import('@/features/oss/dialogs/dlg-change-input-schema').then(module => ({ default: module.DlgChangeInputSchema }))
); );
@ -161,6 +164,8 @@ export const GlobalDialogs = () => {
return null; return null;
} }
switch (active) { switch (active) {
case DialogType.SHOW_VIDEO:
return <DlgShowVideo />;
case DialogType.CONSTITUENTA_TEMPLATE: case DialogType.CONSTITUENTA_TEMPLATE:
return <DlgCstTemplate />; return <DlgCstTemplate />;
case DialogType.CREATE_CONSTITUENTA: 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 { RiMenuUnfoldFill as IconMenuUnfold } from 'react-icons/ri';
export { LuMoon as IconDarkTheme } from 'react-icons/lu'; export { LuMoon as IconDarkTheme } from 'react-icons/lu';
export { LuSun as IconLightTheme } 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 { LuFolderTree as IconFolderTree } from 'react-icons/lu';
export { LuFolder as IconFolder } from 'react-icons/lu'; export { LuFolder as IconFolder } from 'react-icons/lu';
export { LuFolderSearch as IconFolderSearch } 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 { TextURL } from '@/components/control';
import { IconVideo } from '@/components/icons';
import { external_urls, prefixes } from '@/utils/constants'; import { external_urls, prefixes } from '@/utils/constants';
import { LinkTopic } from '../components/link-topic'; import { LinkTopic } from '../components/link-topic';
@ -11,7 +12,7 @@ export function HelpMain() {
<h1>Портал</h1> <h1>Портал</h1>
<p> <p>
Портал позволяет анализировать предметные области, формально записывать системы определений и синтезировать их с Портал позволяет анализировать предметные области, формально записывать системы определений и синтезировать их с
помощью математического аппарата <LinkTopic text='Родов структур' topic={HelpTopic.RSLANG} /> помощью математического аппарата <LinkTopic text='Родов структур' topic={HelpTopic.RSLANG} />.
</p> </p>
<p> <p>
Такие системы называются <LinkTopic text='Концептуальными схемами' topic={HelpTopic.CC_SYSTEM} /> и состоят из Такие системы называются <LinkTopic text='Концептуальными схемами' topic={HelpTopic.CC_SYSTEM} /> и состоят из
@ -19,6 +20,10 @@ export function HelpMain() {
определения. Концептуальные схемы могут связываться путем синтеза в{' '} определения. Концептуальные схемы могут связываться путем синтеза в{' '}
<LinkTopic text='Операционной схеме синтеза' topic={HelpTopic.CC_OSS} />. <LinkTopic text='Операционной схеме синтеза' topic={HelpTopic.CC_OSS} />.
</p> </p>
<p>
Значок <IconVideo className='inline-icon' /> при нажатии отображает видео о различных темах и подробностях
работы Портала. Просмотр видео доступен на Youtube и ВКонтакте.
</p>
<details> <details>
<summary className='text-center font-semibold'>Разделы Справки</summary> <summary className='text-center font-semibold'>Разделы Справки</summary>

View File

@ -1,21 +1,10 @@
import { EmbedYoutube } from '@/components/view'; import { external_urls, videos } from '@/utils/constants';
import { useWindowSize } from '@/hooks/use-window-size';
import { external_urls, youtube } from '@/utils/constants';
import { BadgeVideo } from '../components/badge-video';
import { Subtopics } from '../components/subtopics'; import { Subtopics } from '../components/subtopics';
import { HelpTopic } from '../models/help-topic'; import { HelpTopic } from '../models/help-topic';
export function HelpRSLang() { 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 // prettier-ignore
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
@ -23,14 +12,10 @@ export function HelpRSLang() {
<h1>Родоструктурная экспликация концептуальных схем</h1> <h1>Родоструктурная экспликация концептуальных схем</h1>
<p>Формальная запись (<i>экспликация</i>) концептуальных схем осуществляется с помощью языка родов структур.</p> <p>Формальная запись (<i>экспликация</i>) концептуальных схем осуществляется с помощью языка родов структур.</p>
<p>Для ознакомления с основами родов структур можно использовать следующие материалы:</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>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> <p>3. <a className='underline' href={external_urls.full_course}>Видео: лекции для 4 курса (второй семестр 2022-23 год)</a></p>
</div> </div>
<EmbedYoutube
videoID={youtube.intro}
pxHeight={videoHeight}
/>
<div className='dense'> <div className='dense'>
<Subtopics headTopic={HelpTopic.RSLANG} /> <Subtopics headTopic={HelpTopic.RSLANG} />
</div> </div>

View File

@ -1,6 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { type DlgCreatePromptTemplateProps } from '@/features/ai/dialogs/dlg-create-prompt-template'; 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 DlgChangeLocationProps } from '@/features/library/dialogs/dlg-change-location';
import { type DlgCloneLibraryItemProps } from '@/features/library/dialogs/dlg-clone-library-item'; import { type DlgCloneLibraryItemProps } from '@/features/library/dialogs/dlg-clone-library-item';
import { type DlgCreateVersionProps } from '@/features/library/dialogs/dlg-create-version'; import { type DlgCreateVersionProps } from '@/features/library/dialogs/dlg-create-version';
@ -73,7 +74,9 @@ export const DialogType = {
IMPORT_SCHEMA: 31, IMPORT_SCHEMA: 31,
AI_PROMPT: 32, AI_PROMPT: 32,
CREATE_PROMPT_TEMPLATE: 33 CREATE_PROMPT_TEMPLATE: 33,
SHOW_VIDEO: 34
} as const; } as const;
export type DialogType = (typeof DialogType)[keyof typeof DialogType]; export type DialogType = (typeof DialogType)[keyof typeof DialogType];
@ -86,6 +89,7 @@ interface DialogsStore {
props: unknown; props: unknown;
hideDialog: () => void; hideDialog: () => void;
showVideo: (props: DlgShowVideoProps) => void;
showCstTemplate: (props: DlgCstTemplateProps) => void; showCstTemplate: (props: DlgCstTemplateProps) => void;
showCreateCst: (props: DlgCreateCstProps) => void; showCreateCst: (props: DlgCreateCstProps) => void;
showCreateBlock: (props: DlgCreateBlockProps) => 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 }), showCstTemplate: props => set({ active: DialogType.CONSTITUENTA_TEMPLATE, props: props }),
showCreateCst: props => set({ active: DialogType.CREATE_CONSTITUENTA, props: props }), showCreateCst: props => set({ active: DialogType.CREATE_CONSTITUENTA, props: props }),
showCreateOperation: props => set({ active: DialogType.CREATE_SYNTHESIS, 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'; import { PARAMETER } from '@/utils/constants';
export const videoPlayerTypes = ['vk', 'youtube'] as const;
/** Represents video player type. */
export type VideoPlayerType = (typeof videoPlayerTypes)[number];
interface PreferencesStore { interface PreferencesStore {
darkMode: boolean; darkMode: boolean;
toggleDarkMode: () => void; toggleDarkMode: () => void;
@ -31,6 +36,9 @@ interface PreferencesStore {
showOssSidePanel: boolean; showOssSidePanel: boolean;
toggleShowOssSidePanel: () => void; toggleShowOssSidePanel: () => void;
preferredPlayer: VideoPlayerType;
setPreferredPlayer: (value: VideoPlayerType) => void;
} }
export const usePreferencesStore = create<PreferencesStore>()( export const usePreferencesStore = create<PreferencesStore>()(
@ -66,7 +74,7 @@ export const usePreferencesStore = create<PreferencesStore>()(
adminMode: false, adminMode: false,
toggleAdminMode: () => set(state => ({ adminMode: !state.adminMode })), toggleAdminMode: () => set(state => ({ adminMode: !state.adminMode })),
libraryPagination: 50, libraryPagination: 20,
setLibraryPagination: value => set({ libraryPagination: value }), setLibraryPagination: value => set({ libraryPagination: value }),
showCstSideList: true, showCstSideList: true,
@ -82,10 +90,13 @@ export const usePreferencesStore = create<PreferencesStore>()(
toggleShowExpressionControls: () => set(state => ({ showExpressionControls: !state.showExpressionControls })), toggleShowExpressionControls: () => set(state => ({ showExpressionControls: !state.showExpressionControls })),
showOssSidePanel: false, 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' name: 'portal.preferences'
} }
) )

View File

@ -53,11 +53,6 @@ export const resources = {
db_schema: '/db_schema.svg' db_schema: '/db_schema.svg'
} as const; } as const;
/** Youtube IDs for embedding. */
export const youtube = {
intro: '0Ty9mu9sOJo'
} as const;
/** External URLs. */ /** External URLs. */
export const external_urls = { export const external_urls = {
concept: 'https://www.acconcept.ru/', concept: 'https://www.acconcept.ru/',
@ -76,6 +71,23 @@ export const external_urls = {
restAPI: 'https://api.portal.acconcept.ru' restAPI: 'https://api.portal.acconcept.ru'
} as const; } 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. */ /** Global element ID. */
export const globalIDs = { export const globalIDs = {
tooltip: 'global_tooltip', tooltip: 'global_tooltip',