diff --git a/web/messages/en/components.json b/web/messages/en/components.json index 62b735fa2..240fdbc2b 100644 --- a/web/messages/en/components.json +++ b/web/messages/en/components.json @@ -176,9 +176,14 @@ "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_video_support_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_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", "cmp_helper_overview_period_select": "", "cmp_helper_search": "" } diff --git a/web/src/routes/_authorized/_default.tsx b/web/src/routes/_authorized/_default.tsx index e31d25ddc..e58c9b5ba 100644 --- a/web/src/routes/_authorized/_default.tsx +++ b/web/src/routes/_authorized/_default.tsx @@ -1,6 +1,7 @@ import { createFileRoute, Outlet } from '@tanstack/react-router'; import { Navigation } from '../../shared/components/Navigation/Navigation'; -import { VideoSupportWidget } from '../../shared/video-support/VideoSupportWidget'; +import { VideoSupportWidget } from '../../shared/video-tutorials/VideoSupportWidget'; +import { VideoTutorialsModal } from '../../shared/video-tutorials/VideoTutorialsModal'; export const Route = createFileRoute('/_authorized/_default')({ component: RouteComponent, @@ -12,6 +13,7 @@ function RouteComponent() { + ); } diff --git a/web/src/shared/components/Navigation/Navigation.tsx b/web/src/shared/components/Navigation/Navigation.tsx index 14fe54e30..c66fe8b93 100644 --- a/web/src/shared/components/Navigation/Navigation.tsx +++ b/web/src/shared/components/Navigation/Navigation.tsx @@ -10,22 +10,21 @@ 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, getLicenseInfoQueryOptions, getRulesCountQueryOptions, + videoTutorialsQueryOptions, } from '../../query'; import { canUseBusinessFeature } from '../../utils/license'; +import { NavTutorialsButton } from '../../video-tutorials/components/widget/NavTutorialsButton/NavTutorialsButton'; interface NavGroupProps { id: string; @@ -196,6 +195,8 @@ export const Navigation = () => { enabled: isAdmin, }); + const { data: videoTutorialsData } = useQuery(videoTutorialsQueryOptions); + const navigationGroups = useMemo(() => { const pendingCounts = { rules: rulesCount?.pending, @@ -234,7 +235,11 @@ export const Navigation = () => { ))}
- + {videoTutorialsData && ( +
+ +
+ )}
); @@ -314,35 +319,3 @@ const NavItem = ({ ); }; - -const NavControls = () => { - const { changeTheme, theme } = useTheme(); - - return ( -
- { - changeTheme('light'); - }} - /> - { - changeTheme('dark'); - }} - /> -
- - - -
-
- ); -}; diff --git a/web/src/shared/components/Navigation/style.scss b/web/src/shared/components/Navigation/style.scss index fdde1c77c..8b4a812b2 100644 --- a/web/src/shared/components/Navigation/style.scss +++ b/web/src/shared/components/Navigation/style.scss @@ -45,7 +45,7 @@ width: 100%; margin-top: auto; box-sizing: border-box; - padding: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-lg); display: flex; flex-flow: row nowrap; align-items: center; @@ -58,6 +58,7 @@ .navigation .nav-group { user-select: none; + width: 100%; & > .track { display: flex; @@ -103,6 +104,8 @@ width: 100%; user-select: none; border-radius: var(--radius-md); + border: none; + cursor: pointer; @include animate(color, background-color); @@ -135,17 +138,3 @@ row-gap: var(--spacing-xxs); } } - -.navigation .nav-controls { - display: flex; - flex-flow: row nowrap; - column-gap: var(--spacing-md); - flex-basis: 100%; - - .right { - display: flex; - flex-flow: row nowrap; - column-gap: var(--spacing-md); - margin-left: auto; - } -} diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index eac632a31..2e18ae13b 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit eac632a3192289c7d9963c20e3a34f0385205109 +Subproject commit 2e18ae13bd9e96a5574042eb9323171ca02d0943 diff --git a/web/src/shared/query.ts b/web/src/shared/query.ts index e20bc69ab..627f6916f 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,20 @@ 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) => { + 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. diff --git a/web/src/shared/video-support/README.md b/web/src/shared/video-support/README.md deleted file mode 100644 index feb84032f..000000000 --- a/web/src/shared/video-support/README.md +++ /dev/null @@ -1,191 +0,0 @@ -# Video Support - -The video support widget displays route-specific YouTube video tutorials inside -the authenticated app shell. A floating "Video support" 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. - -The widget is mounted in `src/routes/_authorized/_default.tsx` and is therefore -available across the entire authenticated layout. While a video is loading a -skeleton placeholder is shown; if the video fails to load within 8 seconds, a -"Video unavailable" message is displayed instead. - ---- - -## 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 -├── route-key.ts — canonicalizeRouteKey() -├── version.ts — parseVersion(), compareVersions() -├── types.ts — VideoSupport, VideoSupportMappings types -├── style.scss — widget, launcher, and panel styles -└── components/ - ├── Thumbnail/ — thumbnail with skeleton while loading and icon on error - ├── VideoCard/ — clickable card shown in the panel list - └── VideoOverlay/ — modal with iframe, skeleton while loading, and error placeholder -``` - ---- - -## Testing without remote API access - -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 -``` - -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`). - -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 -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. - -1. Create the directory and file: - - ```bash - mkdir -p web/public/content - # create web/public/content/video-support 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: - - ``` - # web/.env.local - VITE_VIDEO_SUPPORT_URL=/content/video-support.json - ``` - -3. Start the dev server as usual (`pnpm dev`). The widget will pick up the file - on page load. - -> Do not commit the test file — `web/public/content/` is not git-ignored by -> default, so add it to your local `.git/info/exclude` or a global gitignore -> if you want to keep it permanently. - -### 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: - -``` -# web/.env.local -VITE_UPDATE_BASE_URL=http://localhost:4000 -``` - -Then serve a JSON file at `http://localhost:4000/content/video-support`: - -```bash -# example using Python's built-in server from the directory containing your file -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 - -To redirect just the video support fetch without affecting other update-service -calls, override the path only: - -``` -# web/.env.local -VITE_VIDEO_SUPPORT_URL=/content/my-test-config -``` - -The path is still resolved against `VITE_UPDATE_BASE_URL` (or the default -`https://pkgs.defguard.net/api`), so this is most useful when you have a test -endpoint on the same server. - ---- - -## JSON structure - -```jsonc -{ - "versions": { - // Version key: "major.minor" or "major.minor.patch" - "2.2": { - // Route key: must start with "/". Use route templates, not runtime URLs. - // Trailing slashes are stripped (except for the root "/"). - "/settings": [ - { - // Required. Exactly 11-character YouTube video ID (from the video URL). - "youtubeVideoId": "abc123DEF45", - - // Required. Non-empty display title shown on the card. - "title": "Configuring settings" - } - ], - - // A route can have multiple videos. - "/users": [ - { "youtubeVideoId": "xyz987GHI12", "title": "Managing users" }, - { "youtubeVideoId": "lmn456JKL78", "title": "User roles overview" } - ], - - // Dynamic route segments use TanStack Router template syntax. - "/vpn-overview/$locationId": [ - { "youtubeVideoId": "pqr321MNO65", "title": "VPN location walkthrough" } - ], - - // An explicit empty array suppresses fallback to older versions for this - // route — the widget will not appear on this route for version 2.2 users. - "/legacy-page": [] - }, - - // Older version. Only consulted when the current app version is older than - // 2.2, or when 2.2 does not define the route being looked up. - "2.0": { - "/settings": [ - { "youtubeVideoId": "old000SET00", "title": "Settings (legacy)" } - ] - } - } -} -``` - -### Field reference - -| Field | Required | Description | -|---|---|---| -| `youtubeVideoId` | Yes | Exactly 11 characters: letters, digits, `-`, `_`. Found in any YouTube video URL after `?v=` or in the short URL path. | -| `title` | Yes | Non-empty string. Displayed on the video card. | - -### Route key rules - -- Must start with `/`. -- Use **route template paths** (e.g. `/vpn-overview/$locationId`), not runtime - URLs with actual parameter values. The widget matches against TanStack Router's - `fullPath` template, not the resolved pathname. -- Trailing slashes are stripped during parsing, so `/settings/` and `/settings` - are treated as the same key. Duplicates after normalisation are rejected. - ---- - -## Version resolution - -When looking up videos for the current route, the resolver iterates mapped -versions from **newest to oldest** and returns the video list from the first -version that defines the current route key. - -- If the runtime app version has a prerelease or build suffix (e.g. `2.2.0-rc.1`) - the suffix is stripped before matching, so `2.2.0-rc.1` resolves as `2.2.0`. -- If a version defines a route with an **explicit empty array**, that empty result - is returned and older versions are **not** consulted — this is intentional and - allows suppressing the widget on a specific route for a given version. -- If no version defines the current route, the widget does not appear. diff --git a/web/src/shared/video-support/components/VideoOverlay/VideoOverlay.tsx b/web/src/shared/video-support/components/VideoOverlay/VideoOverlay.tsx deleted file mode 100644 index 104d1db57..000000000 --- a/web/src/shared/video-support/components/VideoOverlay/VideoOverlay.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import './style.scss'; -import { useEffect, useRef, useState } from 'react'; -import Skeleton from 'react-loading-skeleton'; -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'; - -const LOAD_TIMEOUT_MS = 8_000; - -export interface VideoOverlayProps { - video: VideoSupport | null; - isOpen: boolean; - onClose: () => void; - afterClose: () => void; -} - -interface VideoOverlayContentProps { - video: VideoSupport; - onClose: () => void; -} - -const VideoOverlayContent = ({ video, onClose }: VideoOverlayContentProps) => { - const [loaded, setLoaded] = useState(false); - const [errored, setErrored] = useState(false); - const timeoutRef = useRef | null>(null); - - useEffect(() => { - timeoutRef.current = setTimeout(() => { - setErrored(true); - }, LOAD_TIMEOUT_MS); - - return () => { - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - }; - }, []); - - const handleLoad = () => { - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - setLoaded(true); - }; - - return ( - <> - -
- {errored ? ( -
-
-
- -
-

- {m.cmp_video_support_overlay_error()} -

-
-
-

- {m.cmp_video_support_overlay_watch_on_youtube()} -

- - {`https://www.youtube.com/watch?v=${video.youtubeVideoId}`} - -
-
- ) : ( - <> - {!loaded && ( -
- -
- )} -