From 4a43f8c8211b9a114098cbbab3901a825c7a8ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 31 Mar 2026 16:38:42 +0200 Subject: [PATCH 01/35] add tutorials button in the navbar --- web/messages/en/components.json | 3 ++- .../components/Navigation/Navigation.tsx | 9 ++++++++ .../shared/components/Navigation/style.scss | 22 ++++++++++++++----- .../NavTutorialsButton/NavTutorialsButton.tsx | 11 ++++++++++ 4 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 web/src/shared/video-support/components/NavTutorialsButton/NavTutorialsButton.tsx diff --git a/web/messages/en/components.json b/web/messages/en/components.json index 9f2388e947..d659c4ab9b 100644 --- a/web/messages/en/components.json +++ b/web/messages/en/components.json @@ -178,5 +178,6 @@ "cmp_video_support_launcher": "Video support", "cmp_video_support_list_label": "Video tutorials", "cmp_video_support_overlay_error": "Video unavailable", - "cmp_video_support_overlay_watch_on_youtube": "Watch on YouTube:" + "cmp_video_support_overlay_watch_on_youtube": "Watch on YouTube:", + "cmp_nav_item_tutorials": "Tutorials" } diff --git a/web/src/shared/components/Navigation/Navigation.tsx b/web/src/shared/components/Navigation/Navigation.tsx index 14fe54e308..d98db4ac4e 100644 --- a/web/src/shared/components/Navigation/Navigation.tsx +++ b/web/src/shared/components/Navigation/Navigation.tsx @@ -24,8 +24,10 @@ import { getDestinationsCountQueryOptions, getLicenseInfoQueryOptions, getRulesCountQueryOptions, + videoSupportQueryOptions, } from '../../query'; import { canUseBusinessFeature } from '../../utils/license'; +import { NavTutorialsButton } from '../../video-support/components/NavTutorialsButton/NavTutorialsButton'; interface NavGroupProps { id: string; @@ -196,6 +198,8 @@ export const Navigation = () => { enabled: isAdmin, }); + const { data: videoSupportData } = useQuery(videoSupportQueryOptions); + const navigationGroups = useMemo(() => { const pendingCounts = { rules: rulesCount?.pending, @@ -234,6 +238,11 @@ export const Navigation = () => { ))}
+ {videoSupportData && ( +
+ +
+ )}
diff --git a/web/src/shared/components/Navigation/style.scss b/web/src/shared/components/Navigation/style.scss index fdde1c77cd..c310583db5 100644 --- a/web/src/shared/components/Navigation/style.scss +++ b/web/src/shared/components/Navigation/style.scss @@ -45,14 +45,24 @@ width: 100%; margin-top: auto; box-sizing: border-box; - padding: var(--spacing-md); display: flex; - flex-flow: row nowrap; - align-items: center; + flex-flow: column; + align-items: flex-start; justify-content: flex-start; flex: none; border-top: var(--border-1) solid var(--border-disabled); - column-gap: var(--spacing-md); + + .nav-tutorials { + box-sizing: border-box; + padding: var(--spacing-sm) var(--spacing-lg); + width: 100%; + border-bottom: var(--border-1) solid var(--border-disabled); + } + + .nav-controls { + padding: var(--spacing-md); + width: 100%; + } } } @@ -88,7 +98,7 @@ } } -.nav-group .nav-item { +.navigation .nav-item { --bg-color: var(--bg-default); --color: var(--fg-neutral); @@ -103,6 +113,8 @@ width: 100%; user-select: none; border-radius: var(--radius-md); + border: none; + cursor: pointer; @include animate(color, background-color); diff --git a/web/src/shared/video-support/components/NavTutorialsButton/NavTutorialsButton.tsx b/web/src/shared/video-support/components/NavTutorialsButton/NavTutorialsButton.tsx new file mode 100644 index 0000000000..d341baa901 --- /dev/null +++ b/web/src/shared/video-support/components/NavTutorialsButton/NavTutorialsButton.tsx @@ -0,0 +1,11 @@ +import { m } from '../../../../paraglide/messages'; +import { Icon } from '../../../defguard-ui/components/Icon/Icon'; + +export const NavTutorialsButton = () => { + return ( + + ); +}; From 4248e955356ac87bf5ef5d90165bb1b485a44a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 31 Mar 2026 16:44:09 +0200 Subject: [PATCH 02/35] remove duplicate theme buttons --- .../components/Navigation/Navigation.tsx | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/web/src/shared/components/Navigation/Navigation.tsx b/web/src/shared/components/Navigation/Navigation.tsx index d98db4ac4e..5584376b2d 100644 --- a/web/src/shared/components/Navigation/Navigation.tsx +++ b/web/src/shared/components/Navigation/Navigation.tsx @@ -10,15 +10,12 @@ import { NavLogo } from './assets/NavLogo'; import './style.scss'; import { useQuery } from '@tanstack/react-query'; import { Link, type LinkProps } from '@tanstack/react-router'; -import clsx from 'clsx'; import { type LicenseInfo, LicenseTier, type LicenseTierValue } from '../../api/types'; -import { externalLink } from '../../constants'; import { Fold } from '../../defguard-ui/components/Fold/Fold'; import { TooltipContent } from '../../defguard-ui/providers/tooltip/TooltipContent'; import { TooltipProvider } from '../../defguard-ui/providers/tooltip/TooltipContext'; import { TooltipTrigger } from '../../defguard-ui/providers/tooltip/TooltipTrigger'; import { isPresent } from '../../defguard-ui/utils/isPresent'; -import { useTheme } from '../../hooks/theme/useTheme'; import { getAliasesCountQueryOptions, getDestinationsCountQueryOptions, @@ -243,7 +240,6 @@ export const Navigation = () => { )} - ); @@ -323,35 +319,3 @@ const NavItem = ({ ); }; - -const NavControls = () => { - const { changeTheme, theme } = useTheme(); - - return ( -
- { - changeTheme('light'); - }} - /> - { - changeTheme('dark'); - }} - /> -
- - - -
-
- ); -}; From 73a6b77e5b65073dec6c918d30491d6cf246e8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 31 Mar 2026 21:43:20 +0200 Subject: [PATCH 03/35] change naming convention --- web/messages/en/components.json | 10 ++-- web/src/routes/_authorized/_default.tsx | 4 +- .../components/Navigation/Navigation.tsx | 8 +-- web/src/shared/query.ts | 10 ++-- .../README.md | 42 ++++++------- .../VideoTutorialsWidget.tsx} | 28 ++++----- .../NavTutorialsButton/NavTutorialsButton.tsx | 0 .../components/Thumbnail/Thumbnail.tsx | 6 +- .../components/Thumbnail/style.scss | 4 +- .../components/VideoCard/VideoCard.tsx | 10 ++-- .../components/VideoCard/style.scss | 6 +- .../components/VideoOverlay/VideoOverlay.tsx | 36 ++++++----- .../components/VideoOverlay/style.scss | 22 +++---- .../data.ts | 26 ++++---- .../resolved.tsx | 22 +++---- .../resolver.ts | 10 ++-- .../route-key.ts | 0 .../style.scss | 10 ++-- .../types.ts | 4 +- .../version.ts | 0 ...upport.test.ts => video-tutorials.test.ts} | 60 +++++++++---------- 21 files changed, 161 insertions(+), 157 deletions(-) rename web/src/shared/{video-support => video-tutorials}/README.md (80%) rename web/src/shared/{video-support/VideoSupportWidget.tsx => video-tutorials/VideoTutorialsWidget.tsx} (70%) rename web/src/shared/{video-support => video-tutorials}/components/NavTutorialsButton/NavTutorialsButton.tsx (100%) rename web/src/shared/{video-support => video-tutorials}/components/Thumbnail/Thumbnail.tsx (82%) rename web/src/shared/{video-support => video-tutorials}/components/Thumbnail/style.scss (89%) rename web/src/shared/{video-support => video-tutorials}/components/VideoCard/VideoCard.tsx (56%) rename web/src/shared/{video-support => video-tutorials}/components/VideoCard/style.scss (89%) rename web/src/shared/{video-support => video-tutorials}/components/VideoOverlay/VideoOverlay.tsx (74%) rename web/src/shared/{video-support => video-tutorials}/components/VideoOverlay/style.scss (86%) rename web/src/shared/{video-support => video-tutorials}/data.ts (76%) rename web/src/shared/{video-support => video-tutorials}/resolved.tsx (61%) rename web/src/shared/{video-support => video-tutorials}/resolver.ts (81%) rename web/src/shared/{video-support => video-tutorials}/route-key.ts (100%) rename web/src/shared/{video-support => video-tutorials}/style.scss (92%) rename web/src/shared/{video-support => video-tutorials}/types.ts (54%) rename web/src/shared/{video-support => video-tutorials}/version.ts (100%) rename web/tests/{video-support.test.ts => video-tutorials.test.ts} (79%) diff --git a/web/messages/en/components.json b/web/messages/en/components.json index d659c4ab9b..ae876b5d25 100644 --- a/web/messages/en/components.json +++ b/web/messages/en/components.json @@ -175,9 +175,9 @@ "acl_rule_error_allow_deny_conflict": "The same entry cannot be both allowed and restricted.", "acl_rule_error_permissions_required": "Configure at least one allowed user, group, or network device.", "acl_rule_error_manual_destination_required": "Manual destination settings are enabled. Provide a value or enable Any.", - "cmp_video_support_launcher": "Video support", - "cmp_video_support_list_label": "Video tutorials", - "cmp_video_support_overlay_error": "Video unavailable", - "cmp_video_support_overlay_watch_on_youtube": "Watch on YouTube:", - "cmp_nav_item_tutorials": "Tutorials" + "cmp_video_tutorials_launcher": "Video support", + "cmp_video_tutorials_list_label": "Video support", + "cmp_video_tutorials_overlay_error": "Video unavailable", + "cmp_video_tutorials_overlay_watch_on_youtube": "Watch on YouTube:", + "cmp_nav_item_tutorials": "Tutorials" } diff --git a/web/src/routes/_authorized/_default.tsx b/web/src/routes/_authorized/_default.tsx index e31d25ddc2..51c3f5c20f 100644 --- a/web/src/routes/_authorized/_default.tsx +++ b/web/src/routes/_authorized/_default.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Outlet } from '@tanstack/react-router'; import { Navigation } from '../../shared/components/Navigation/Navigation'; -import { VideoSupportWidget } from '../../shared/video-support/VideoSupportWidget'; +import { VideoTutorialsWidget } from '../../shared/video-tutorials/VideoTutorialsWidget'; export const Route = createFileRoute('/_authorized/_default')({ component: RouteComponent, @@ -11,7 +11,7 @@ function RouteComponent() { <> - + ); } diff --git a/web/src/shared/components/Navigation/Navigation.tsx b/web/src/shared/components/Navigation/Navigation.tsx index 5584376b2d..870e04684b 100644 --- a/web/src/shared/components/Navigation/Navigation.tsx +++ b/web/src/shared/components/Navigation/Navigation.tsx @@ -21,10 +21,10 @@ import { getDestinationsCountQueryOptions, getLicenseInfoQueryOptions, getRulesCountQueryOptions, - videoSupportQueryOptions, + videoTutorialsQueryOptions, } from '../../query'; import { canUseBusinessFeature } from '../../utils/license'; -import { NavTutorialsButton } from '../../video-support/components/NavTutorialsButton/NavTutorialsButton'; +import { NavTutorialsButton } from '../../video-tutorials/components/NavTutorialsButton/NavTutorialsButton'; interface NavGroupProps { id: string; @@ -195,7 +195,7 @@ export const Navigation = () => { enabled: isAdmin, }); - const { data: videoSupportData } = useQuery(videoSupportQueryOptions); + const { data: videoTutorialsData } = useQuery(videoTutorialsQueryOptions); const navigationGroups = useMemo(() => { const pendingCounts = { @@ -235,7 +235,7 @@ export const Navigation = () => { ))}
- {videoSupportData && ( + {videoTutorialsData && (
diff --git a/web/src/shared/query.ts b/web/src/shared/query.ts index e20bc69abe..f2a6b6d84a 100644 --- a/web/src/shared/query.ts +++ b/web/src/shared/query.ts @@ -3,7 +3,7 @@ import api from './api/api'; import { AclDeploymentState, type UserProfile } from './api/types'; import { updateServiceApi, updateServiceClient } from './api/update-service'; import { resourceDisplayMap } from './utils/resourceById'; -import { parseVideoSupport, videoSupportPath } from './video-support/data'; +import { parseVideoTutorials, videoTutorialsPath } from './video-tutorials/data'; export const getExternalProviderQueryOptions = queryOptions({ queryFn: api.openIdProvider.getOpenIdProvider, @@ -117,10 +117,10 @@ export const clientArtifactsQueryOptions = queryOptions({ refetchOnReconnect: true, }); -export const videoSupportQueryOptions = queryOptions({ - queryKey: ['update-service', 'video-support'], - queryFn: () => updateServiceClient.get(videoSupportPath), - select: (resp) => parseVideoSupport(resp.data), +export const videoTutorialsQueryOptions = queryOptions({ + queryKey: ['update-service', 'video-tutorials'], + queryFn: () => updateServiceClient.get(videoTutorialsPath), + select: (resp) => parseVideoTutorials(resp.data), // Mappings are version-tied and won't meaningfully change within a session. staleTime: Infinity, // Silent failure: if the fetch or parse fails, the widget simply won't appear. diff --git a/web/src/shared/video-support/README.md b/web/src/shared/video-tutorials/README.md similarity index 80% rename from web/src/shared/video-support/README.md rename to web/src/shared/video-tutorials/README.md index feb84032fa..cc15425a5e 100644 --- a/web/src/shared/video-support/README.md +++ b/web/src/shared/video-tutorials/README.md @@ -1,7 +1,7 @@ -# Video Support +# Video Tutorials -The video support widget displays route-specific YouTube video tutorials inside -the authenticated app shell. A floating "Video support" button appears in the +The video tutorials widget displays route-specific YouTube video tutorials inside +the authenticated app shell. A floating "Video tutorials" button appears in the bottom-right corner when videos are available for the current route. Clicking it opens a panel listing the relevant videos; clicking a video opens a full-screen overlay with an embedded YouTube player. @@ -16,14 +16,14 @@ skeleton placeholder is shown; if the video fails to load within 8 seconds, a ## Module structure ``` -video-support/ -├── VideoSupportWidget.tsx — root component; mounted in _default.tsx -├── resolved.tsx — useResolvedVideoSupport, useVideoSupportRouteKey hooks -├── data.ts — Zod schema, parseVideoSupport(), videoSupportPath -├── resolver.ts — resolveVideoSupport() — version/route resolution logic +video-tutorials/ +├── VideoTutorialsWidget.tsx — root component; mounted in _default.tsx +├── resolved.tsx — useResolvedVideoTutorials, useVideoTutorialsRouteKey hooks +├── data.ts — Zod schema, parseVideoTutorials(), videoTutorialsPath +├── resolver.ts — resolveVideoTutorials() — version/route resolution logic ├── route-key.ts — canonicalizeRouteKey() ├── version.ts — parseVersion(), compareVersions() -├── types.ts — VideoSupport, VideoSupportMappings types +├── types.ts — VideoTutorial, VideoTutorialsMappings types ├── style.scss — widget, launcher, and panel styles └── components/ ├── Thumbnail/ — thumbnail with skeleton while loading and icon on error @@ -39,19 +39,19 @@ In production the widget fetches its video mapping via the shared update-service axios client. The default URL is: ``` -https://pkgs.defguard.net/api/content/video-support +https://pkgs.defguard.net/api/content/video-tutorials ``` This is composed from the client's base URL (`https://pkgs.defguard.net/api`, overridable via `VITE_UPDATE_BASE_URL`) and the default path -(`/content/video-support`, overridable via `VITE_VIDEO_SUPPORT_URL`). +(`/content/video-tutorials`, overridable via `VITE_VIDEO_TUTORIALS_URL`). Both variables are read at build time and live in `web/.env.local` (git-ignored). ### Serve a local file via the Vite dev server (recommended) -The simplest approach: place your test JSON at `web/public/content/video-support` -(no file extension) or `web/public/content/video-support.json`. The Vite dev +The simplest approach: place your test JSON at `web/public/content/video-tutorials` +(no file extension) or `web/public/content/video-tutorials.json`. The Vite dev server serves everything in `web/public/` at the root, so the file is immediately available on the same origin — no extra process, no CORS issues. @@ -59,15 +59,15 @@ available on the same origin — no extra process, no CORS issues. ```bash mkdir -p web/public/content - # create web/public/content/video-support with the JSON structure shown below + # create web/public/content/video-tutorials with the JSON structure shown below ``` 2. If you used no file extension, no env override is needed — the default path - `/content/video-support` already matches. If you used `.json`, set: + `/content/video-tutorials` already matches. If you used `.json`, set: ``` # web/.env.local - VITE_VIDEO_SUPPORT_URL=/content/video-support.json + VITE_VIDEO_TUTORIALS_URL=/content/video-tutorials.json ``` 3. Start the dev server as usual (`pnpm dev`). The widget will pick up the file @@ -80,14 +80,14 @@ available on the same origin — no extra process, no CORS issues. ### Redirect the entire update service to a separate local server Use this approach when you need to test the client artifact check alongside the -video support fetch, or when you want to simulate a real remote server: +video tutorials fetch, or when you want to simulate a real remote server: ``` # web/.env.local VITE_UPDATE_BASE_URL=http://localhost:4000 ``` -Then serve a JSON file at `http://localhost:4000/content/video-support`: +Then serve a JSON file at `http://localhost:4000/content/video-tutorials`: ```bash # example using Python's built-in server from the directory containing your file @@ -97,14 +97,14 @@ python3 -m http.server 4000 > The local server must respond with appropriate `Access-Control-Allow-Origin` > CORS headers since it runs on a different origin than the Vite dev server. -### Override only the video support path +### Override only the video tutorials path -To redirect just the video support fetch without affecting other update-service +To redirect just the video tutorials fetch without affecting other update-service calls, override the path only: ``` # web/.env.local -VITE_VIDEO_SUPPORT_URL=/content/my-test-config +VITE_VIDEO_TUTORIALS_URL=/content/my-test-config ``` The path is still resolved against `VITE_UPDATE_BASE_URL` (or the default diff --git a/web/src/shared/video-support/VideoSupportWidget.tsx b/web/src/shared/video-tutorials/VideoTutorialsWidget.tsx similarity index 70% rename from web/src/shared/video-support/VideoSupportWidget.tsx rename to web/src/shared/video-tutorials/VideoTutorialsWidget.tsx index fe9efeffea..c2ecefe026 100644 --- a/web/src/shared/video-support/VideoSupportWidget.tsx +++ b/web/src/shared/video-tutorials/VideoTutorialsWidget.tsx @@ -6,15 +6,15 @@ import { IconButton } from '../defguard-ui/components/IconButton/IconButton'; import { ThemeVariable } from '../defguard-ui/types'; import { VideoCard } from './components/VideoCard/VideoCard'; import { VideoOverlay } from './components/VideoOverlay/VideoOverlay'; -import { useResolvedVideoSupport, useVideoSupportRouteKey } from './resolved'; -import type { VideoSupport } from './types'; +import { useResolvedVideoTutorials, useVideoTutorialsRouteKey } from './resolved'; +import type { VideoTutorial } from './types'; -export const VideoSupportWidget = () => { - const videos = useResolvedVideoSupport(); - const routeKey = useVideoSupportRouteKey(); +export const VideoTutorialsWidget = () => { + const videos = useResolvedVideoTutorials(); + const routeKey = useVideoTutorialsRouteKey(); const [panelOpen, setPanelOpen] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false); - const [selectedVideo, setSelectedVideo] = useState(null); + const [selectedVideo, setSelectedVideo] = useState(null); // Reset UI state when the route changes. // biome-ignore lint/correctness/useExhaustiveDependencies: routeKey is the trigger, not used in body @@ -26,7 +26,7 @@ export const VideoSupportWidget = () => { if (videos.length === 0) return null; - const handleCardClick = (video: VideoSupport) => { + const handleCardClick = (video: VideoTutorial) => { setSelectedVideo(video); setOverlayOpen(true); setPanelOpen(false); @@ -34,11 +34,11 @@ export const VideoSupportWidget = () => { return ( <> -
+
{panelOpen && (
    {videos.map((v) => (
  • @@ -50,18 +50,18 @@ export const VideoSupportWidget = () => { {panelOpen ? ( setPanelOpen(false)} /> ) : ( )}
diff --git a/web/src/shared/video-support/components/NavTutorialsButton/NavTutorialsButton.tsx b/web/src/shared/video-tutorials/components/NavTutorialsButton/NavTutorialsButton.tsx similarity index 100% rename from web/src/shared/video-support/components/NavTutorialsButton/NavTutorialsButton.tsx rename to web/src/shared/video-tutorials/components/NavTutorialsButton/NavTutorialsButton.tsx diff --git a/web/src/shared/video-support/components/Thumbnail/Thumbnail.tsx b/web/src/shared/video-tutorials/components/Thumbnail/Thumbnail.tsx similarity index 82% rename from web/src/shared/video-support/components/Thumbnail/Thumbnail.tsx rename to web/src/shared/video-tutorials/components/Thumbnail/Thumbnail.tsx index d5b87cb16e..9030be91a5 100644 --- a/web/src/shared/video-support/components/Thumbnail/Thumbnail.tsx +++ b/web/src/shared/video-tutorials/components/Thumbnail/Thumbnail.tsx @@ -15,8 +15,8 @@ export const Thumbnail = ({ url, title }: ThumbnailProps) => { if (errored) { return ( -
-
+
+
@@ -24,7 +24,7 @@ export const Thumbnail = ({ url, title }: ThumbnailProps) => { } return ( -
+
{!loaded && } void; } export const VideoCard = ({ video, onClick }: VideoCardProps) => ( - ); diff --git a/web/src/shared/video-support/components/VideoCard/style.scss b/web/src/shared/video-tutorials/components/VideoCard/style.scss similarity index 89% rename from web/src/shared/video-support/components/VideoCard/style.scss rename to web/src/shared/video-tutorials/components/VideoCard/style.scss index 19800cd0be..df79b04372 100644 --- a/web/src/shared/video-support/components/VideoCard/style.scss +++ b/web/src/shared/video-tutorials/components/VideoCard/style.scss @@ -1,6 +1,6 @@ // Individual video card -.video-support-card { +.video-tutorials-card { display: flex; align-items: center; gap: var(--spacing-lg); @@ -20,13 +20,13 @@ } } -.video-support-card-info { +.video-tutorials-card-info { display: flex; flex-direction: column; overflow: hidden; } -.video-support-card-title { +.video-tutorials-card-title { font: var(--t-body-xs-400); color: var(--fg-default); display: -webkit-box; diff --git a/web/src/shared/video-support/components/VideoOverlay/VideoOverlay.tsx b/web/src/shared/video-tutorials/components/VideoOverlay/VideoOverlay.tsx similarity index 74% rename from web/src/shared/video-support/components/VideoOverlay/VideoOverlay.tsx rename to web/src/shared/video-tutorials/components/VideoOverlay/VideoOverlay.tsx index 104d1db573..0ed9667cc9 100644 --- a/web/src/shared/video-support/components/VideoOverlay/VideoOverlay.tsx +++ b/web/src/shared/video-tutorials/components/VideoOverlay/VideoOverlay.tsx @@ -5,19 +5,19 @@ import * as m from '../../../../paraglide/messages'; import { Icon } from '../../../defguard-ui/components/Icon/Icon'; import { IconButton } from '../../../defguard-ui/components/IconButton/IconButton'; import { ModalFoundation } from '../../../defguard-ui/components/ModalFoundation/ModalFoundation'; -import type { VideoSupport } from '../../types'; +import type { VideoTutorial } from '../../types'; const LOAD_TIMEOUT_MS = 8_000; export interface VideoOverlayProps { - video: VideoSupport | null; + video: VideoTutorial | null; isOpen: boolean; onClose: () => void; afterClose: () => void; } interface VideoOverlayContentProps { - video: VideoSupport; + video: VideoTutorial; onClose: () => void; } @@ -49,24 +49,28 @@ const VideoOverlayContent = ({ video, onClose }: VideoOverlayContentProps) => { return ( <> - -
+ +
{errored ? ( -
-
-
+
+
+
-

- {m.cmp_video_support_overlay_error()} +

+ {m.cmp_video_tutorials_overlay_error()}

-
-

- {m.cmp_video_support_overlay_watch_on_youtube()} +

+

+ {m.cmp_video_tutorials_overlay_watch_on_youtube()}

{ ) : ( <> {!loaded && ( -
+
)} @@ -107,7 +111,7 @@ export const VideoOverlay = ({ return ( {video && } diff --git a/web/src/shared/video-support/components/VideoOverlay/style.scss b/web/src/shared/video-tutorials/components/VideoOverlay/style.scss similarity index 86% rename from web/src/shared/video-support/components/VideoOverlay/style.scss rename to web/src/shared/video-tutorials/components/VideoOverlay/style.scss index 2465abe8bc..52982b95dc 100644 --- a/web/src/shared/video-support/components/VideoOverlay/style.scss +++ b/web/src/shared/video-tutorials/components/VideoOverlay/style.scss @@ -3,13 +3,13 @@ // ModalFoundation provides the backdrop and portal; we style the content here. // --------------------------------------------------------------------------- -.video-support-modal-container { +.video-tutorials-modal-container { position: relative; display: inline-block; padding-top: var(--size-2xl); } -.video-support-modal { +.video-tutorials-modal { background: var(--bg-default); border-radius: var(--radius-lg); box-shadow: 0 6px 30px 0 rgb(0 0 0 / 9%); @@ -34,7 +34,7 @@ // aspect-ratio matches the iframe so the modal does not resize on swap. // overflow: hidden clips the skeleton shimmer to the rounded corners. -.video-support-overlay-skeleton { +.video-tutorials-overlay-skeleton { aspect-ratio: 16 / 9; width: 100%; border-radius: var(--radius-sm); @@ -42,7 +42,7 @@ line-height: 1; } -.video-support-overlay-error { +.video-tutorials-overlay-error { display: flex; flex-direction: column; align-items: center; @@ -56,7 +56,7 @@ gap: 48px; } -.video-support-overlay-error-icon-group { +.video-tutorials-overlay-error-icon-group { display: flex; flex-direction: column; align-items: center; @@ -65,7 +65,7 @@ gap: 20px; } -.video-support-overlay-error-badge { +.video-tutorials-overlay-error-badge { display: flex; align-items: center; justify-content: center; @@ -77,14 +77,14 @@ box-shadow: 0 4px 12px 0 rgb(0 0 0 / 7%); } -.video-support-overlay-error-title { +.video-tutorials-overlay-error-title { font: var(--t-title-h5); color: var(--fg-muted); text-align: center; margin: 0; } -.video-support-overlay-error-link-group { +.video-tutorials-overlay-error-link-group { display: flex; flex-direction: column; align-items: center; @@ -94,14 +94,14 @@ gap: 8px; } -.video-support-overlay-error-label { +.video-tutorials-overlay-error-label { font: var(--t-body-xxs-500); color: var(--fg-muted); text-align: center; margin: 0; } -.video-support-overlay-error-url { +.video-tutorials-overlay-error-url { font: var(--t-body-sm-400); color: var(--fg-faded); text-align: center; @@ -112,7 +112,7 @@ // Modal close button — floats outside the modal card (top-right). // Uses IconButton; overrides border-radius token to circular and adds background/shadow. -.video-support-modal-close { +.video-tutorials-modal-close { --button-border-radius-sm: var(--radius-full); position: absolute; diff --git a/web/src/shared/video-support/data.ts b/web/src/shared/video-tutorials/data.ts similarity index 76% rename from web/src/shared/video-support/data.ts rename to web/src/shared/video-tutorials/data.ts index e4ea68d06b..3e30d9c9b3 100644 --- a/web/src/shared/video-support/data.ts +++ b/web/src/shared/video-tutorials/data.ts @@ -1,31 +1,31 @@ import { z } from 'zod'; import { canonicalizeRouteKey } from './route-key'; -import type { VideoSupportMappings } from './types'; +import type { VideoTutorialsMappings } from './types'; // --------------------------------------------------------------------------- // Source resolution // --------------------------------------------------------------------------- /** - * Path (relative to the update service base URL) to load the video support + * Path (relative to the update service base URL) to load the video tutorials * mapping from. Resolved by Vite at build time. * * The shared updateServiceClient resolves this against its baseURL * (VITE_UPDATE_BASE_URL ?? 'https://pkgs.defguard.net/api'), so the - * production URL is https://pkgs.defguard.net/api/content/video-support. + * production URL is https://pkgs.defguard.net/api/content/video-tutorials. * - * Override VITE_VIDEO_SUPPORT_URL to use a different path on the same server, + * Override VITE_VIDEO_TUTORIALS_URL to use a different path on the same server, * or VITE_UPDATE_BASE_URL to redirect all update-service calls to a local * server for development. */ -export const videoSupportPath: string = - import.meta.env.VITE_VIDEO_SUPPORT_URL ?? '/content/video-support'; +export const videoTutorialsPath: string = + import.meta.env.VITE_VIDEO_TUTORIALS_URL ?? '/content/video-tutorials'; // --------------------------------------------------------------------------- // Zod schema + parser // --------------------------------------------------------------------------- -const videoSupportSchema = z +const videoTutorialsSchema = z .object({ youtubeVideoId: z .string() @@ -42,7 +42,7 @@ const routeMapSchema = z.record( // canonicalizeRouteKey() adds it at runtime for widget use, but the JSON // must supply it explicitly — keeping authoring intent unambiguous. z.string().regex(/^\//, 'route key must start with "/"'), - z.array(videoSupportSchema), + z.array(videoTutorialsSchema), ); const mappingsSchema = z.object({ @@ -58,17 +58,17 @@ const mappingsSchema = z.object({ }); /** - * Validates raw JSON against the video support mapping contract and returns a - * trusted VideoSupportMappings object with canonicalized route keys. + * Validates raw JSON against the video tutorials mapping contract and returns a + * trusted VideoTutorialsMappings object with canonicalized route keys. * Throws a ZodError if the contract is violated. */ -export function parseVideoSupport(raw: unknown): VideoSupportMappings { +export function parseVideoTutorials(raw: unknown): VideoTutorialsMappings { const parsed = mappingsSchema.parse(raw); - const result: VideoSupportMappings = {}; + const result: VideoTutorialsMappings = {}; for (const [versionKey, routeMap] of Object.entries(parsed.versions)) { - const canonicalRouteMap: Record = {}; + const canonicalRouteMap: Record = {}; for (const [routeKey, videos] of Object.entries(routeMap)) { const canonical = canonicalizeRouteKey(routeKey); diff --git a/web/src/shared/video-support/resolved.tsx b/web/src/shared/video-tutorials/resolved.tsx similarity index 61% rename from web/src/shared/video-support/resolved.tsx rename to web/src/shared/video-tutorials/resolved.tsx index 91af0b251a..91e09d113a 100644 --- a/web/src/shared/video-support/resolved.tsx +++ b/web/src/shared/video-tutorials/resolved.tsx @@ -1,23 +1,23 @@ import { useQuery } from '@tanstack/react-query'; import { useMatches } from '@tanstack/react-router'; import { useApp } from '../hooks/useApp'; -import { videoSupportQueryOptions } from '../query'; -import { resolveVideoSupport } from './resolver'; +import { videoTutorialsQueryOptions } from '../query'; +import { resolveVideoTutorials } from './resolver'; import { canonicalizeRouteKey } from './route-key'; -import type { VideoSupport } from './types'; +import type { VideoTutorial } from './types'; // Matches routes defined under src/routes/_authorized/_default.tsx const CONTENT_ROUTE_PREFIX = '/_authorized/_default/'; // Stable empty reference — avoids triggering effects/memos that depend on this value. -const EMPTY_VIDEO_SUPPORT: VideoSupport[] = []; +const EMPTY_VIDEO_TUTORIALS: VideoTutorial[] = []; /** * Derives the canonical route key for the current page from TanStack Router * matches, skipping pathless shell/layout routes. * Returns null if no content route is active. */ -export function useVideoSupportRouteKey(): string | null { +export function useVideoTutorialsRouteKey(): string | null { const matches = useMatches(); // Find the deepest match that belongs to the authorized default shell const contentMatch = [...matches] @@ -28,14 +28,14 @@ export function useVideoSupportRouteKey(): string | null { } /** - * Returns the resolved video support list for the current page and app version. + * Returns the resolved video tutorials list for the current page and app version. * Returns an empty array when data is loading, errored, or no videos match. */ -export function useResolvedVideoSupport(): VideoSupport[] { - const { data } = useQuery(videoSupportQueryOptions); +export function useResolvedVideoTutorials(): VideoTutorial[] { + const { data } = useQuery(videoTutorialsQueryOptions); const appVersion = useApp((s) => s.appInfo.version); - const routeKey = useVideoSupportRouteKey(); + const routeKey = useVideoTutorialsRouteKey(); - if (!data || !appVersion || !routeKey) return EMPTY_VIDEO_SUPPORT; - return resolveVideoSupport(data, appVersion, routeKey); + if (!data || !appVersion || !routeKey) return EMPTY_VIDEO_TUTORIALS; + return resolveVideoTutorials(data, appVersion, routeKey); } diff --git a/web/src/shared/video-support/resolver.ts b/web/src/shared/video-tutorials/resolver.ts similarity index 81% rename from web/src/shared/video-support/resolver.ts rename to web/src/shared/video-tutorials/resolver.ts index e0d7ec96ff..b42ba92bf5 100644 --- a/web/src/shared/video-support/resolver.ts +++ b/web/src/shared/video-tutorials/resolver.ts @@ -1,8 +1,8 @@ -import type { VideoSupport, VideoSupportMappings } from './types'; +import type { VideoTutorial, VideoTutorialsMappings } from './types'; import { compareVersions, parseVersion } from './version'; /** - * Given the parsed video support mappings, the current app version string, and the + * Given the parsed video tutorials mappings, the current app version string, and the * current normalized route key, returns the best matching video list. * * Resolution rules: @@ -12,11 +12,11 @@ import { compareVersions, parseVersion } from './version'; * - If no version defines the route key, returns []. * - If the app version or route key is invalid/missing, returns []. */ -export function resolveVideoSupport( - mappings: VideoSupportMappings, +export function resolveVideoTutorials( + mappings: VideoTutorialsMappings, appVersionRaw: string, routeKey: string, -): VideoSupport[] { +): VideoTutorial[] { const appVersion = parseVersion(appVersionRaw); if (!appVersion) return []; diff --git a/web/src/shared/video-support/route-key.ts b/web/src/shared/video-tutorials/route-key.ts similarity index 100% rename from web/src/shared/video-support/route-key.ts rename to web/src/shared/video-tutorials/route-key.ts diff --git a/web/src/shared/video-support/style.scss b/web/src/shared/video-tutorials/style.scss similarity index 92% rename from web/src/shared/video-support/style.scss rename to web/src/shared/video-tutorials/style.scss index fa1559cc90..89dca18ad4 100644 --- a/web/src/shared/video-support/style.scss +++ b/web/src/shared/video-tutorials/style.scss @@ -1,9 +1,9 @@ // --------------------------------------------------------------------------- -// Video Support Widget +// Video Tutorials Widget // Floating launcher button + expandable panel (bottom-right corner) // --------------------------------------------------------------------------- -.video-support-widget { +.video-tutorials-widget { position: fixed; bottom: var(--spacing-xl); right: var(--spacing-xl); @@ -16,7 +16,7 @@ // Launcher pill button -.video-support-launcher { +.video-tutorials-launcher { display: flex; align-items: center; gap: var(--spacing-md); @@ -47,7 +47,7 @@ // Floating panel — no background, just stacks the cards -.video-support-list { +.video-tutorials-list { list-style: none; display: flex; flex-direction: column; @@ -60,7 +60,7 @@ // Close button — replaces launcher when panel is open. // Uses IconButton; overrides border-radius token to circular and adds background/border. -.video-support-close-btn { +.video-tutorials-close-btn { --button-border-radius-sm: var(--radius-full); background-color: var(--bg-default); diff --git a/web/src/shared/video-support/types.ts b/web/src/shared/video-tutorials/types.ts similarity index 54% rename from web/src/shared/video-support/types.ts rename to web/src/shared/video-tutorials/types.ts index b72125c0d5..d2ae978e81 100644 --- a/web/src/shared/video-support/types.ts +++ b/web/src/shared/video-tutorials/types.ts @@ -1,7 +1,7 @@ -export interface VideoSupport { +export interface VideoTutorial { youtubeVideoId: string; title: string; } // outer key = version string (e.g. "2.2"), inner key = canonicalized route key (e.g. "/users") -export type VideoSupportMappings = Record>; +export type VideoTutorialsMappings = Record>; diff --git a/web/src/shared/video-support/version.ts b/web/src/shared/video-tutorials/version.ts similarity index 100% rename from web/src/shared/video-support/version.ts rename to web/src/shared/video-tutorials/version.ts diff --git a/web/tests/video-support.test.ts b/web/tests/video-tutorials.test.ts similarity index 79% rename from web/tests/video-support.test.ts rename to web/tests/video-tutorials.test.ts index 8e81b5a8bb..83d0315156 100644 --- a/web/tests/video-support.test.ts +++ b/web/tests/video-tutorials.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { parseVideoSupport } from '../src/shared/video-support/data'; -import { resolveVideoSupport } from '../src/shared/video-support/resolver'; -import { canonicalizeRouteKey } from '../src/shared/video-support/route-key'; -import { parseVersion } from '../src/shared/video-support/version'; -import type { VideoSupportMappings } from '../src/shared/video-support/types'; +import { parseVideoTutorials } from '../src/shared/video-tutorials/data'; +import { resolveVideoTutorials } from '../src/shared/video-tutorials/resolver'; +import { canonicalizeRouteKey } from '../src/shared/video-tutorials/route-key'; +import { parseVersion } from '../src/shared/video-tutorials/version'; +import type { VideoTutorialsMappings } from '../src/shared/video-tutorials/types'; // --------------------------------------------------------------------------- // canonicalizeRouteKey @@ -82,10 +82,10 @@ describe('parseVersion', () => { }); // --------------------------------------------------------------------------- -// resolveVideoSupport +// resolveVideoTutorials // --------------------------------------------------------------------------- -const makeMappings = (): VideoSupportMappings => ({ +const makeMappings = (): VideoTutorialsMappings => ({ '2.0': { '/users': [{ youtubeVideoId: 'usrGuide200', title: 'Users 2.0' }], }, @@ -95,60 +95,60 @@ const makeMappings = (): VideoSupportMappings => ({ }, }); -describe('resolveVideoSupport', () => { +describe('resolveVideoTutorials', () => { it('should return videos for an exact version match', () => { - const result = resolveVideoSupport(makeMappings(), '2.2', '/users'); + const result = resolveVideoTutorials(makeMappings(), '2.2', '/users'); expect(result).toHaveLength(1); expect(result[0].youtubeVideoId).toBe('usrGuide220'); }); it('should return the most recent eligible version when the exact version defines the route', () => { - const result = resolveVideoSupport(makeMappings(), '2.2', '/users'); + const result = resolveVideoTutorials(makeMappings(), '2.2', '/users'); // 2.2 defines /users, so we get the 2.2 entry (not the older 2.0 entry) expect(result[0].youtubeVideoId).toBe('usrGuide220'); }); it('should fall back to 2.0 for a route only defined there when running 2.2', () => { - const mappings: VideoSupportMappings = { + const mappings: VideoTutorialsMappings = { '2.0': { '/users': [{ youtubeVideoId: 'usrGuide200', title: 'Users 2.0' }] }, '2.2': { '/settings': [{ youtubeVideoId: 'setGuide220', title: 'Settings 2.2' }] }, }; - const result = resolveVideoSupport(mappings, '2.2', '/users'); + const result = resolveVideoTutorials(mappings, '2.2', '/users'); expect(result[0].youtubeVideoId).toBe('usrGuide200'); }); it('should not use a version newer than the runtime version', () => { - const result = resolveVideoSupport(makeMappings(), '2.0', '/settings'); + const result = resolveVideoTutorials(makeMappings(), '2.0', '/settings'); expect(result).toHaveLength(0); }); it('should preserve an explicit empty array without falling back', () => { - const mappings: VideoSupportMappings = { + const mappings: VideoTutorialsMappings = { '2.0': { '/users': [{ youtubeVideoId: 'usrGuide200', title: 'Users 2.0' }] }, '2.2': { '/users': [] }, }; - const result = resolveVideoSupport(mappings, '2.2', '/users'); + const result = resolveVideoTutorials(mappings, '2.2', '/users'); expect(result).toHaveLength(0); }); it('should return empty array when no version defines the route', () => { - const result = resolveVideoSupport(makeMappings(), '2.2', '/nonexistent'); + const result = resolveVideoTutorials(makeMappings(), '2.2', '/nonexistent'); expect(result).toHaveLength(0); }); it('should return empty array for an unparseable app version', () => { - const result = resolveVideoSupport(makeMappings(), '', '/users'); + const result = resolveVideoTutorials(makeMappings(), '', '/users'); expect(result).toHaveLength(0); }); it('should strip prerelease from runtime version before resolving', () => { - const result = resolveVideoSupport(makeMappings(), '2.2.0-beta', '/users'); + const result = resolveVideoTutorials(makeMappings(), '2.2.0-beta', '/users'); expect(result[0].youtubeVideoId).toBe('usrGuide220'); }); }); // --------------------------------------------------------------------------- -// parseVideoSupport +// parseVideoTutorials // --------------------------------------------------------------------------- const validRaw = { @@ -164,9 +164,9 @@ const validRaw = { }, }; -describe('parseVideoSupport', () => { +describe('parseVideoTutorials', () => { it('should accept a valid contract', () => { - const result = parseVideoSupport(validRaw); + const result = parseVideoTutorials(validRaw); expect(result['2.2']['/users']).toHaveLength(1); expect(result['2.2']['/users'][0].youtubeVideoId).toBe('abcDEFghiJK'); }); @@ -179,7 +179,7 @@ describe('parseVideoSupport', () => { }, }, }; - const result = parseVideoSupport(raw); + const result = parseVideoTutorials(raw); expect(result['2.0']['/settings']).toBeDefined(); expect(result['2.0']['/settings/']).toBeUndefined(); }); @@ -192,7 +192,7 @@ describe('parseVideoSupport', () => { }, }, }; - expect(() => parseVideoSupport(raw)).toThrow(); + expect(() => parseVideoTutorials(raw)).toThrow(); }); it('should reject an empty title', () => { @@ -203,7 +203,7 @@ describe('parseVideoSupport', () => { }, }, }; - expect(() => parseVideoSupport(raw)).toThrow(); + expect(() => parseVideoTutorials(raw)).toThrow(); }); it('should reject duplicate route keys after canonicalization', () => { @@ -215,7 +215,7 @@ describe('parseVideoSupport', () => { }, }, }; - expect(() => parseVideoSupport(raw)).toThrow(/[Dd]uplicate/); + expect(() => parseVideoTutorials(raw)).toThrow(/[Dd]uplicate/); }); it('should reject a route key missing a leading slash', () => { @@ -226,7 +226,7 @@ describe('parseVideoSupport', () => { }, }, }; - expect(() => parseVideoSupport(raw)).toThrow(); + expect(() => parseVideoTutorials(raw)).toThrow(); }); it('should reject an invalid version key format', () => { @@ -237,7 +237,7 @@ describe('parseVideoSupport', () => { }, }, }; - expect(() => parseVideoSupport(raw)).toThrow(); + expect(() => parseVideoTutorials(raw)).toThrow(); }); it('should strip unknown fields from videos', () => { @@ -248,15 +248,15 @@ describe('parseVideoSupport', () => { }, }, }; - const result = parseVideoSupport(raw); + const result = parseVideoTutorials(raw); expect((result['2.2']['/users'][0] as Record)['unknownField']).toBeUndefined(); }); it('should reject null input', () => { - expect(() => parseVideoSupport(null)).toThrow(); + expect(() => parseVideoTutorials(null)).toThrow(); }); it('should reject missing versions key', () => { - expect(() => parseVideoSupport({})).toThrow(); + expect(() => parseVideoTutorials({})).toThrow(); }); }); From ac93f14397c78b71d974bdf711fc77cc485f7f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 31 Mar 2026 22:42:19 +0200 Subject: [PATCH 04/35] update json schema --- web/src/shared/video-tutorials/types.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/web/src/shared/video-tutorials/types.ts b/web/src/shared/video-tutorials/types.ts index d2ae978e81..10f7e4d644 100644 --- a/web/src/shared/video-tutorials/types.ts +++ b/web/src/shared/video-tutorials/types.ts @@ -1,7 +1,17 @@ export interface VideoTutorial { youtubeVideoId: string; title: string; + description: string; + /** In-app route this video is associated with (must start with "/"). */ + appRoute: string; + /** External documentation URL. */ + docsUrl: string; } -// outer key = version string (e.g. "2.2"), inner key = canonicalized route key (e.g. "/users") -export type VideoTutorialsMappings = Record>; +export interface VideoTutorialsSection { + name: string; + videos: VideoTutorial[]; +} + +// outer key = version string (e.g. "2.0"), value = ordered list of sections +export type VideoTutorialsMappings = Record; From 187a6fec3226f0fd152fdbde71b40c3cd9cb16d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 1 Apr 2026 07:46:12 +0200 Subject: [PATCH 05/35] add error log if parsing JSON fails --- web/src/shared/query.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/src/shared/query.ts b/web/src/shared/query.ts index f2a6b6d84a..627f6916f5 100644 --- a/web/src/shared/query.ts +++ b/web/src/shared/query.ts @@ -120,7 +120,17 @@ export const clientArtifactsQueryOptions = queryOptions({ export const videoTutorialsQueryOptions = queryOptions({ queryKey: ['update-service', 'video-tutorials'], queryFn: () => updateServiceClient.get(videoTutorialsPath), - select: (resp) => parseVideoTutorials(resp.data), + select: (resp) => { + try { + return parseVideoTutorials(resp.data); + } catch (err) { + console.error( + '[video-tutorials] Fetched successfully but failed to parse response:', + err, + ); + throw err; + } + }, // Mappings are version-tied and won't meaningfully change within a session. staleTime: Infinity, // Silent failure: if the fetch or parse fails, the widget simply won't appear. From 8fe2dc73d6063e27a8a6279e61c75f5b998215f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 1 Apr 2026 11:50:52 +0200 Subject: [PATCH 06/35] update source file validation --- web/src/shared/video-tutorials/data.ts | 44 ++++++++------------------ 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/web/src/shared/video-tutorials/data.ts b/web/src/shared/video-tutorials/data.ts index 3e30d9c9b3..ffc2d45455 100644 --- a/web/src/shared/video-tutorials/data.ts +++ b/web/src/shared/video-tutorials/data.ts @@ -1,5 +1,4 @@ import { z } from 'zod'; -import { canonicalizeRouteKey } from './route-key'; import type { VideoTutorialsMappings } from './types'; // --------------------------------------------------------------------------- @@ -25,7 +24,7 @@ export const videoTutorialsPath: string = // Zod schema + parser // --------------------------------------------------------------------------- -const videoTutorialsSchema = z +const videoTutorialSchema = z .object({ youtubeVideoId: z .string() @@ -34,16 +33,18 @@ const videoTutorialsSchema = z 'youtubeVideoId must be exactly 11 alphanumeric/-/_ chars', ), title: z.string().min(1, 'title must be non-empty'), + description: z.string().min(1, 'description must be non-empty'), + appRoute: z.string().regex(/^\//, 'appRoute must start with "/"'), + docsUrl: z.string().url('docsUrl must be a valid URL'), }) .strip(); -const routeMapSchema = z.record( - // The schema enforces leading "/" as a contract requirement for JSON authors. - // canonicalizeRouteKey() adds it at runtime for widget use, but the JSON - // must supply it explicitly — keeping authoring intent unambiguous. - z.string().regex(/^\//, 'route key must start with "/"'), - z.array(videoTutorialsSchema), -); +const sectionSchema = z + .object({ + name: z.string().min(1, 'section name must be non-empty'), + videos: z.array(videoTutorialSchema), + }) + .strip(); const mappingsSchema = z.object({ versions: z.record( @@ -53,35 +54,16 @@ const mappingsSchema = z.object({ /^\d+\.\d+(\.\d+)?$/, 'version key must be major.minor or major.minor.patch', ), - routeMapSchema, + z.array(sectionSchema), ), }); /** * Validates raw JSON against the video tutorials mapping contract and returns a - * trusted VideoTutorialsMappings object with canonicalized route keys. + * trusted VideoTutorialsMappings object. * Throws a ZodError if the contract is violated. */ export function parseVideoTutorials(raw: unknown): VideoTutorialsMappings { const parsed = mappingsSchema.parse(raw); - - const result: VideoTutorialsMappings = {}; - - for (const [versionKey, routeMap] of Object.entries(parsed.versions)) { - const canonicalRouteMap: Record = {}; - - for (const [routeKey, videos] of Object.entries(routeMap)) { - const canonical = canonicalizeRouteKey(routeKey); - if (canonical in canonicalRouteMap) { - throw new Error( - `Duplicate route key "${canonical}" in version "${versionKey}" after canonicalization`, - ); - } - canonicalRouteMap[canonical] = videos; - } - - result[versionKey] = canonicalRouteMap; - } - - return result; + return parsed.versions; } From c64a7730c07afef92c0388639bdfe04659acac96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 1 Apr 2026 11:56:12 +0200 Subject: [PATCH 07/35] update video to page mapping --- web/src/shared/video-tutorials/resolver.ts | 67 ++++++++++++++++------ 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/web/src/shared/video-tutorials/resolver.ts b/web/src/shared/video-tutorials/resolver.ts index b42ba92bf5..39633adba3 100644 --- a/web/src/shared/video-tutorials/resolver.ts +++ b/web/src/shared/video-tutorials/resolver.ts @@ -1,15 +1,35 @@ -import type { VideoTutorial, VideoTutorialsMappings } from './types'; +import type { VideoTutorial, VideoTutorialsMappings, VideoTutorialsSection } from './types'; +import { canonicalizeRouteKey } from './route-key'; import { compareVersions, parseVersion } from './version'; +/** + * Returns the sorted list of version keys that are eligible for the given app + * version (i.e. version key <= app version), ordered newest-to-oldest. + */ +function eligibleVersionsSorted( + mappings: VideoTutorialsMappings, + appVersion: ReturnType, +): string[] { + return Object.keys(mappings) + .flatMap((key) => { + const parsed = parseVersion(key); + return parsed && compareVersions(parsed, appVersion!) <= 0 ? [{ key, parsed }] : []; + }) + .sort((a, b) => compareVersions(b.parsed, a.parsed)) + .map(({ key }) => key); +} + /** * Given the parsed video tutorials mappings, the current app version string, and the - * current normalized route key, returns the best matching video list. + * current normalized route key, returns the videos from the newest eligible version + * that has at least one video matching the route. * * Resolution rules: * - Only version keys that are <= the runtime app version are eligible. * - Eligible versions are walked newest-to-oldest. - * - The first version that defines the route key wins (even if its value is an empty array). - * - If no version defines the route key, returns []. + * - The first version that has any video whose canonicalized appRoute matches the + * route key wins; those matching videos are returned. + * - If no version has a matching video, returns []. * - If the app version or route key is invalid/missing, returns []. */ export function resolveVideoTutorials( @@ -20,20 +40,35 @@ export function resolveVideoTutorials( const appVersion = parseVersion(appVersionRaw); if (!appVersion) return []; - // Collect and sort eligible version keys (newest first) - const eligibleVersions = Object.keys(mappings) - .flatMap((key) => { - const parsed = parseVersion(key); - return parsed && compareVersions(parsed, appVersion) <= 0 ? [{ key, parsed }] : []; - }) - .sort((a, b) => compareVersions(b.parsed, a.parsed)); - - for (const { key } of eligibleVersions) { - const routeMap = mappings[key]; - if (routeKey in routeMap) { - return routeMap[routeKey]; + for (const versionKey of eligibleVersionsSorted(mappings, appVersion)) { + const matched: VideoTutorial[] = []; + for (const section of mappings[versionKey]) { + for (const video of section.videos) { + if (canonicalizeRouteKey(video.appRoute) === routeKey) { + matched.push(video); + } + } } + if (matched.length > 0) return matched; } return []; } + +/** + * Returns all sections from the newest eligible version (version key <= app version). + * Used by the VideoTutorialsModal to display all available content. + * Returns [] if no eligible version exists or the app version is invalid. + */ +export function resolveAllSections( + mappings: VideoTutorialsMappings, + appVersionRaw: string, +): VideoTutorialsSection[] { + const appVersion = parseVersion(appVersionRaw); + if (!appVersion) return []; + + const versions = eligibleVersionsSorted(mappings, appVersion); + if (versions.length === 0) return []; + + return mappings[versions[0]]; +} From 8832b398133bdd6effe9d2b6d01be719608c46d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 1 Apr 2026 12:03:46 +0200 Subject: [PATCH 08/35] update tests --- web/tests/video-tutorials.test.ts | 317 ++++++++++++++++++++++++------ 1 file changed, 255 insertions(+), 62 deletions(-) diff --git a/web/tests/video-tutorials.test.ts b/web/tests/video-tutorials.test.ts index 83d0315156..b6a83f9c2d 100644 --- a/web/tests/video-tutorials.test.ts +++ b/web/tests/video-tutorials.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { parseVideoTutorials } from '../src/shared/video-tutorials/data'; -import { resolveVideoTutorials } from '../src/shared/video-tutorials/resolver'; +import { resolveAllSections, resolveVideoTutorials } from '../src/shared/video-tutorials/resolver'; import { canonicalizeRouteKey } from '../src/shared/video-tutorials/route-key'; import { parseVersion } from '../src/shared/video-tutorials/version'; import type { VideoTutorialsMappings } from '../src/shared/video-tutorials/types'; @@ -82,19 +82,40 @@ describe('parseVersion', () => { }); // --------------------------------------------------------------------------- -// resolveVideoTutorials +// Shared fixture helpers (new schema) // --------------------------------------------------------------------------- +const makeVideo = (id: string, appRoute: string) => ({ + youtubeVideoId: id, + title: `Video ${id}`, + description: `Description for ${id}`, + appRoute, + docsUrl: 'https://docs.defguard.net/test', +}); + const makeMappings = (): VideoTutorialsMappings => ({ - '2.0': { - '/users': [{ youtubeVideoId: 'usrGuide200', title: 'Users 2.0' }], - }, - '2.2': { - '/users': [{ youtubeVideoId: 'usrGuide220', title: 'Users 2.2' }], - '/settings': [{ youtubeVideoId: 'setGuide220', title: 'Settings 2.2' }], - }, + '2.0': [ + { + name: 'Identity', + videos: [makeVideo('usrGuide200', '/users')], + }, + ], + '2.2': [ + { + name: 'Identity', + videos: [makeVideo('usrGuide220', '/users')], + }, + { + name: 'Admin', + videos: [makeVideo('setGuide220', '/settings')], + }, + ], }); +// --------------------------------------------------------------------------- +// resolveVideoTutorials +// --------------------------------------------------------------------------- + describe('resolveVideoTutorials', () => { it('should return videos for an exact version match', () => { const result = resolveVideoTutorials(makeMappings(), '2.2', '/users'); @@ -110,8 +131,8 @@ describe('resolveVideoTutorials', () => { it('should fall back to 2.0 for a route only defined there when running 2.2', () => { const mappings: VideoTutorialsMappings = { - '2.0': { '/users': [{ youtubeVideoId: 'usrGuide200', title: 'Users 2.0' }] }, - '2.2': { '/settings': [{ youtubeVideoId: 'setGuide220', title: 'Settings 2.2' }] }, + '2.0': [{ name: 'Identity', videos: [makeVideo('usrGuide200', '/users')] }], + '2.2': [{ name: 'Admin', videos: [makeVideo('setGuide220', '/settings')] }], }; const result = resolveVideoTutorials(mappings, '2.2', '/users'); expect(result[0].youtubeVideoId).toBe('usrGuide200'); @@ -122,15 +143,6 @@ describe('resolveVideoTutorials', () => { expect(result).toHaveLength(0); }); - it('should preserve an explicit empty array without falling back', () => { - const mappings: VideoTutorialsMappings = { - '2.0': { '/users': [{ youtubeVideoId: 'usrGuide200', title: 'Users 2.0' }] }, - '2.2': { '/users': [] }, - }; - const result = resolveVideoTutorials(mappings, '2.2', '/users'); - expect(result).toHaveLength(0); - }); - it('should return empty array when no version defines the route', () => { const result = resolveVideoTutorials(makeMappings(), '2.2', '/nonexistent'); expect(result).toHaveLength(0); @@ -145,6 +157,65 @@ describe('resolveVideoTutorials', () => { const result = resolveVideoTutorials(makeMappings(), '2.2.0-beta', '/users'); expect(result[0].youtubeVideoId).toBe('usrGuide220'); }); + + it('should collect matching videos from multiple sections in the same version', () => { + const mappings: VideoTutorialsMappings = { + '2.2': [ + { name: 'Section A', videos: [makeVideo('videoA001', '/users')] }, + { name: 'Section B', videos: [makeVideo('videoB001', '/users')] }, + ], + }; + const result = resolveVideoTutorials(mappings, '2.2', '/users'); + expect(result).toHaveLength(2); + expect(result.map((v) => v.youtubeVideoId)).toContain('videoA001'); + expect(result.map((v) => v.youtubeVideoId)).toContain('videoB001'); + }); + + it('should canonicalize appRoute trailing slash when matching', () => { + // /locations/ in the JSON should match the /locations route key + const mappings: VideoTutorialsMappings = { + '2.2': [{ name: 'VPN', videos: [makeVideo('loc001xxxxx', '/locations/')] }], + }; + const result = resolveVideoTutorials(mappings, '2.2', '/locations'); + expect(result).toHaveLength(1); + expect(result[0].youtubeVideoId).toBe('loc001xxxxx'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveAllSections +// --------------------------------------------------------------------------- + +describe('resolveAllSections', () => { + it('should return all sections from the newest eligible version', () => { + const result = resolveAllSections(makeMappings(), '2.2'); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('Identity'); + expect(result[1].name).toBe('Admin'); + }); + + it('should respect the app version ceiling (not use versions newer than app)', () => { + const result = resolveAllSections(makeMappings(), '2.0'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Identity'); + }); + + it('should return empty array for an unparseable app version', () => { + const result = resolveAllSections(makeMappings(), ''); + expect(result).toHaveLength(0); + }); + + it('should return empty array when no eligible version exists', () => { + const result = resolveAllSections(makeMappings(), '1.0'); + expect(result).toHaveLength(0); + }); + + it('should pick the newest when multiple versions are eligible', () => { + const result = resolveAllSections(makeMappings(), '3.0'); + // 2.2 is newest eligible + expect(result).toHaveLength(2); + expect(result[1].name).toBe('Admin'); + }); }); // --------------------------------------------------------------------------- @@ -153,77 +224,150 @@ describe('resolveVideoTutorials', () => { const validRaw = { versions: { - '2.2': { - '/users': [ - { - youtubeVideoId: 'abcDEFghiJK', - title: 'Test video', - }, - ], - }, + '2.2': [ + { + name: 'Identity', + videos: [ + { + youtubeVideoId: 'abcDEFghiJK', + title: 'Test video', + description: 'A test description', + appRoute: '/users', + docsUrl: 'https://docs.defguard.net/users', + }, + ], + }, + ], }, }; describe('parseVideoTutorials', () => { it('should accept a valid contract', () => { const result = parseVideoTutorials(validRaw); - expect(result['2.2']['/users']).toHaveLength(1); - expect(result['2.2']['/users'][0].youtubeVideoId).toBe('abcDEFghiJK'); + expect(result['2.2']).toHaveLength(1); + expect(result['2.2'][0].name).toBe('Identity'); + expect(result['2.2'][0].videos[0].youtubeVideoId).toBe('abcDEFghiJK'); }); - it('should canonicalize route keys (strip trailing slash)', () => { + it('should reject an invalid youtubeVideoId (not 11 chars)', () => { const raw = { versions: { - '2.0': { - '/settings/': [{ youtubeVideoId: 'abcDEFghiJK', title: 'Settings' }], - }, + '2.2': [ + { + name: 'Test', + videos: [ + { + youtubeVideoId: 'tooshort', + title: 'Test', + description: 'Desc', + appRoute: '/users', + docsUrl: 'https://docs.defguard.net', + }, + ], + }, + ], }, }; - const result = parseVideoTutorials(raw); - expect(result['2.0']['/settings']).toBeDefined(); - expect(result['2.0']['/settings/']).toBeUndefined(); + expect(() => parseVideoTutorials(raw)).toThrow(); }); - it('should reject an invalid youtubeVideoId (not 11 chars)', () => { + it('should reject an empty title', () => { const raw = { versions: { - '2.2': { - '/users': [{ youtubeVideoId: 'tooshort', title: 'Test' }], - }, + '2.2': [ + { + name: 'Test', + videos: [ + { + youtubeVideoId: 'abcDEFghiJK', + title: '', + description: 'Desc', + appRoute: '/users', + docsUrl: 'https://docs.defguard.net', + }, + ], + }, + ], }, }; expect(() => parseVideoTutorials(raw)).toThrow(); }); - it('should reject an empty title', () => { + it('should reject an empty description', () => { + const raw = { + versions: { + '2.2': [ + { + name: 'Test', + videos: [ + { + youtubeVideoId: 'abcDEFghiJK', + title: 'Title', + description: '', + appRoute: '/users', + docsUrl: 'https://docs.defguard.net', + }, + ], + }, + ], + }, + }; + expect(() => parseVideoTutorials(raw)).toThrow(); + }); + + it('should reject an appRoute missing a leading slash', () => { const raw = { versions: { - '2.2': { - '/users': [{ youtubeVideoId: 'abcDEFghiJK', title: '' }], - }, + '2.2': [ + { + name: 'Test', + videos: [ + { + youtubeVideoId: 'abcDEFghiJK', + title: 'Title', + description: 'Desc', + appRoute: 'users', + docsUrl: 'https://docs.defguard.net', + }, + ], + }, + ], }, }; expect(() => parseVideoTutorials(raw)).toThrow(); }); - it('should reject duplicate route keys after canonicalization', () => { + it('should reject an invalid docsUrl', () => { const raw = { versions: { - '2.2': { - '/settings': [{ youtubeVideoId: 'abcDEFghiJK', title: 'A' }], - '/settings/': [{ youtubeVideoId: 'abcDEFghiJK', title: 'B' }], - }, + '2.2': [ + { + name: 'Test', + videos: [ + { + youtubeVideoId: 'abcDEFghiJK', + title: 'Title', + description: 'Desc', + appRoute: '/users', + docsUrl: 'not-a-url', + }, + ], + }, + ], }, }; - expect(() => parseVideoTutorials(raw)).toThrow(/[Dd]uplicate/); + expect(() => parseVideoTutorials(raw)).toThrow(); }); - it('should reject a route key missing a leading slash', () => { + it('should reject a section with an empty name', () => { const raw = { versions: { - '2.2': { - 'settings': [{ youtubeVideoId: 'abcDEFghiJK', title: 'Test' }], - }, + '2.2': [ + { + name: '', + videos: [], + }, + ], }, }; expect(() => parseVideoTutorials(raw)).toThrow(); @@ -232,9 +376,12 @@ describe('parseVideoTutorials', () => { it('should reject an invalid version key format', () => { const raw = { versions: { - 'v2.2': { - '/users': [{ youtubeVideoId: 'abcDEFghiJK', title: 'Test' }], - }, + 'v2.2': [ + { + name: 'Test', + videos: [], + }, + ], }, }; expect(() => parseVideoTutorials(raw)).toThrow(); @@ -243,13 +390,43 @@ describe('parseVideoTutorials', () => { it('should strip unknown fields from videos', () => { const raw = { versions: { - '2.2': { - '/users': [{ youtubeVideoId: 'abcDEFghiJK', title: 'Test', unknownField: 'ignored' }], - }, + '2.2': [ + { + name: 'Test', + videos: [ + { + youtubeVideoId: 'abcDEFghiJK', + title: 'Test', + description: 'Desc', + appRoute: '/users', + docsUrl: 'https://docs.defguard.net', + unknownField: 'ignored', + }, + ], + }, + ], + }, + }; + const result = parseVideoTutorials(raw); + expect( + (result['2.2'][0].videos[0] as Record)['unknownField'], + ).toBeUndefined(); + }); + + it('should strip unknown fields from sections', () => { + const raw = { + versions: { + '2.2': [ + { + name: 'Test', + videos: [], + extraSectionField: 'ignored', + }, + ], }, }; const result = parseVideoTutorials(raw); - expect((result['2.2']['/users'][0] as Record)['unknownField']).toBeUndefined(); + expect((result['2.2'][0] as Record)['extraSectionField']).toBeUndefined(); }); it('should reject null input', () => { @@ -259,4 +436,20 @@ describe('parseVideoTutorials', () => { it('should reject missing versions key', () => { expect(() => parseVideoTutorials({})).toThrow(); }); + + it('should accept versions with an empty sections array', () => { + const raw = { versions: { '2.2': [] } }; + const result = parseVideoTutorials(raw); + expect(result['2.2']).toHaveLength(0); + }); + + it('should accept a section with an empty videos array', () => { + const raw = { + versions: { + '2.2': [{ name: 'Empty Section', videos: [] }], + }, + }; + const result = parseVideoTutorials(raw); + expect(result['2.2'][0].videos).toHaveLength(0); + }); }); From 1b442293f3f0d36cf73060010c027e242610f7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 1 Apr 2026 12:06:47 +0200 Subject: [PATCH 09/35] setup tutorials modal infrastructure --- web/src/shared/hooks/useApp.tsx | 2 + web/src/shared/video-tutorials/resolved.tsx | 20 ++++++- web/src/shared/video-tutorials/route-label.ts | 56 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 web/src/shared/video-tutorials/route-label.ts diff --git a/web/src/shared/hooks/useApp.tsx b/web/src/shared/hooks/useApp.tsx index 8863d4c009..daef51003f 100644 --- a/web/src/shared/hooks/useApp.tsx +++ b/web/src/shared/hooks/useApp.tsx @@ -3,6 +3,7 @@ import type { ApplicationInfo, SettingsEssentials, WizardState } from '../api/ty type StoreValues = { navigationOpen: boolean; + tutorialsModalOpen: boolean; appInfo: ApplicationInfo; settingsEssentials?: SettingsEssentials; wizardState?: WizardState; @@ -12,6 +13,7 @@ type Store = StoreValues; const defaults: StoreValues = { navigationOpen: true, + tutorialsModalOpen: false, appInfo: { external_openid_enabled: false, ldap_info: { diff --git a/web/src/shared/video-tutorials/resolved.tsx b/web/src/shared/video-tutorials/resolved.tsx index 91e09d113a..8942563a33 100644 --- a/web/src/shared/video-tutorials/resolved.tsx +++ b/web/src/shared/video-tutorials/resolved.tsx @@ -2,15 +2,16 @@ import { useQuery } from '@tanstack/react-query'; import { useMatches } from '@tanstack/react-router'; import { useApp } from '../hooks/useApp'; import { videoTutorialsQueryOptions } from '../query'; -import { resolveVideoTutorials } from './resolver'; +import { resolveAllSections, resolveVideoTutorials } from './resolver'; import { canonicalizeRouteKey } from './route-key'; -import type { VideoTutorial } from './types'; +import type { VideoTutorial, VideoTutorialsSection } from './types'; // Matches routes defined under src/routes/_authorized/_default.tsx const CONTENT_ROUTE_PREFIX = '/_authorized/_default/'; -// Stable empty reference — avoids triggering effects/memos that depend on this value. +// Stable empty references — avoids triggering effects/memos that depend on these values. const EMPTY_VIDEO_TUTORIALS: VideoTutorial[] = []; +const EMPTY_SECTIONS: VideoTutorialsSection[] = []; /** * Derives the canonical route key for the current page from TanStack Router @@ -39,3 +40,16 @@ export function useResolvedVideoTutorials(): VideoTutorial[] { if (!data || !appVersion || !routeKey) return EMPTY_VIDEO_TUTORIALS; return resolveVideoTutorials(data, appVersion, routeKey); } + +/** + * Returns all sections from the newest eligible version for the current app version. + * Used by VideoTutorialsModal to display the full content list. + * Returns an empty array when data is loading, errored, or no eligible version exists. + */ +export function useAllVideoTutorialsSections(): VideoTutorialsSection[] { + const { data } = useQuery(videoTutorialsQueryOptions); + const appVersion = useApp((s) => s.appInfo.version); + + if (!data || !appVersion) return EMPTY_SECTIONS; + return resolveAllSections(data, appVersion); +} diff --git a/web/src/shared/video-tutorials/route-label.ts b/web/src/shared/video-tutorials/route-label.ts new file mode 100644 index 0000000000..e1e5eb90f6 --- /dev/null +++ b/web/src/shared/video-tutorials/route-label.ts @@ -0,0 +1,56 @@ +import { m } from '../../paraglide/messages'; +import { canonicalizeRouteKey } from './route-key'; + +/** + * Maps a canonicalized app route to a human-readable navigation label. + * + * Uses the same m.cmp_nav_item_*() calls as the Navigation component but + * defined inline here to avoid a circular import with Navigation.tsx. + * + * The map is built lazily on first call (labels are functions, so they are + * safe to call at module init time, but we defer to keep import side-effects + * minimal). + */ +let _labelMap: Map string> | undefined; + +function getLabelMap(): Map string> { + if (_labelMap) return _labelMap; + + _labelMap = new Map([ + ['/vpn-overview', () => m.cmp_nav_item_overview()], + ['/locations', () => m.cmp_nav_item_locations()], + ['/users', () => m.cmp_nav_item_users()], + ['/groups', () => m.cmp_nav_item_groups()], + ['/enrollment', () => m.cmp_nav_item_enrollment()], + ['/acl/rules', () => m.cmp_nav_item_rules()], + ['/acl/destinations', () => m.cmp_nav_item_destinations()], + ['/acl/aliases', () => m.cmp_nav_item_aliases()], + ['/activity', () => m.cmp_nav_item_activity_log()], + ['/network-devices', () => m.cmp_nav_item_network_devices()], + ['/openid', () => m.cmp_nav_item_openid()], + ['/webhooks', () => m.cmp_nav_item_webhooks()], + ['/settings', () => m.cmp_nav_item_settings()], + ['/support', () => m.cmp_nav_item_support()], + ['/edges', () => m.cmp_nav_item_edges()], + ]); + + return _labelMap; +} + +/** + * Returns the translated navigation label for a given app route, or undefined + * if the route does not correspond to a known navigation item. + * + * The route is canonicalized before lookup, so trailing slashes and leading + * slash omissions are tolerated. + * + * @example + * getRouteLabel('/locations') // → "Locations" + * getRouteLabel('/settings/') // → "Settings" + * getRouteLabel('/unknown') // → undefined + */ +export function getRouteLabel(route: string): string | undefined { + const key = canonicalizeRouteKey(route); + const labelFn = getLabelMap().get(key); + return labelFn?.(); +} From 522c1cf25c7ec071e385c37254648373a4746369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 1 Apr 2026 12:14:51 +0200 Subject: [PATCH 10/35] show basic tutorials modal --- web/messages/en/components.json | 6 +- web/src/routes/_authorized/_default.tsx | 2 + .../NavTutorialsButton/NavTutorialsButton.tsx | 7 +- .../VideoTutorialsModal.tsx | 239 +++++++++++++++ .../components/VideoTutorialsModal/style.scss | 280 ++++++++++++++++++ 5 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 web/src/shared/video-tutorials/components/VideoTutorialsModal/VideoTutorialsModal.tsx create mode 100644 web/src/shared/video-tutorials/components/VideoTutorialsModal/style.scss diff --git a/web/messages/en/components.json b/web/messages/en/components.json index ae876b5d25..ce102b69f9 100644 --- a/web/messages/en/components.json +++ b/web/messages/en/components.json @@ -179,5 +179,9 @@ "cmp_video_tutorials_list_label": "Video support", "cmp_video_tutorials_overlay_error": "Video unavailable", "cmp_video_tutorials_overlay_watch_on_youtube": "Watch on YouTube:", - "cmp_nav_item_tutorials": "Tutorials" + "cmp_nav_item_tutorials": "Tutorials", + "cmp_video_tutorials_modal_title": "Video tutorials", + "cmp_video_tutorials_modal_search_placeholder": "Search", + "cmp_video_tutorials_modal_go_to": "Go to {page}", + "cmp_video_tutorials_modal_learn_more": "Learn more in Documentation" } diff --git a/web/src/routes/_authorized/_default.tsx b/web/src/routes/_authorized/_default.tsx index 51c3f5c20f..929122d405 100644 --- a/web/src/routes/_authorized/_default.tsx +++ b/web/src/routes/_authorized/_default.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Outlet } from '@tanstack/react-router'; import { Navigation } from '../../shared/components/Navigation/Navigation'; +import { VideoTutorialsModal } from '../../shared/video-tutorials/components/VideoTutorialsModal/VideoTutorialsModal'; import { VideoTutorialsWidget } from '../../shared/video-tutorials/VideoTutorialsWidget'; export const Route = createFileRoute('/_authorized/_default')({ @@ -12,6 +13,7 @@ function RouteComponent() { + ); } diff --git a/web/src/shared/video-tutorials/components/NavTutorialsButton/NavTutorialsButton.tsx b/web/src/shared/video-tutorials/components/NavTutorialsButton/NavTutorialsButton.tsx index d341baa901..484d4afe15 100644 --- a/web/src/shared/video-tutorials/components/NavTutorialsButton/NavTutorialsButton.tsx +++ b/web/src/shared/video-tutorials/components/NavTutorialsButton/NavTutorialsButton.tsx @@ -1,9 +1,14 @@ import { m } from '../../../../paraglide/messages'; +import { useApp } from '../../../hooks/useApp'; import { Icon } from '../../../defguard-ui/components/Icon/Icon'; export const NavTutorialsButton = () => { return ( - diff --git a/web/src/shared/video-tutorials/components/VideoTutorialsModal/VideoTutorialsModal.tsx b/web/src/shared/video-tutorials/components/VideoTutorialsModal/VideoTutorialsModal.tsx new file mode 100644 index 0000000000..78d344243e --- /dev/null +++ b/web/src/shared/video-tutorials/components/VideoTutorialsModal/VideoTutorialsModal.tsx @@ -0,0 +1,239 @@ +import './style.scss'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import Skeleton from 'react-loading-skeleton'; +import { Link } from '@tanstack/react-router'; +import { m } from '../../../../paraglide/messages'; +import { useApp } from '../../../hooks/useApp'; +import { Icon } from '../../../defguard-ui/components/Icon/Icon'; +import { IconButton } from '../../../defguard-ui/components/IconButton/IconButton'; +import { ModalFoundation } from '../../../defguard-ui/components/ModalFoundation/ModalFoundation'; +import { Direction } from '../../../defguard-ui/types'; +import { useAllVideoTutorialsSections, useVideoTutorialsRouteKey } from '../../resolved'; +import { getRouteLabel } from '../../route-label'; +import type { VideoTutorial, VideoTutorialsSection } from '../../types'; + +const LOAD_TIMEOUT_MS = 8_000; + +// --------------------------------------------------------------------------- +// Right panel: inline video player + metadata +// --------------------------------------------------------------------------- + +interface VideoPlayerProps { + video: VideoTutorial; +} + +const VideoPlayer = ({ video }: VideoPlayerProps) => { + const [loaded, setLoaded] = useState(false); + const [errored, setErrored] = useState(false); + const timeoutRef = useRef | null>(null); + + // Reset state whenever the selected video changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: resetting on ID change is intentional; full object ref changes every render + useEffect(() => { + setLoaded(false); + setErrored(false); + + timeoutRef.current = setTimeout(() => { + setErrored(true); + }, LOAD_TIMEOUT_MS); + + return () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [video.youtubeVideoId]); + + const handleLoad = () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setLoaded(true); + }; + + const pageLabel = getRouteLabel(video.appRoute) ?? video.appRoute; + + return ( +
+
+ {errored ? ( + + ) : ( + <> + {!loaded && ( +
+ +
+ )} +