diff --git a/public/media-fallback-patient-tv.svg b/public/media-fallback-patient-tv.svg new file mode 100644 index 0000000..68a64e9 --- /dev/null +++ b/public/media-fallback-patient-tv.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + Media is currently loading... + If media did not load correctly just reload a page again.. + \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index ed05867..8942580 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,7 +2,6 @@ import { useEffect } from 'react'; import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext'; import { ThemeProvider } from './contexts/ThemeContext'; import { DateTimePreferenceProvider } from './contexts/DateTimePreferenceContext'; -import { CurrencyPreferenceProvider } from './contexts/CurrencyPreferenceContext'; import { Helmet, HelmetProvider } from "react-helmet-async"; import { TooltipProvider } from './components/ui/tooltip'; import { setAuthInterceptor } from './utils/api.util'; @@ -82,15 +81,13 @@ export default function App() { - + - - - - - - - + + + + + diff --git a/src/components/generic/AssetPickerSheet.jsx b/src/components/generic/AssetPickerSheet.jsx index 4daf3ed..5d51545 100644 --- a/src/components/generic/AssetPickerSheet.jsx +++ b/src/components/generic/AssetPickerSheet.jsx @@ -9,7 +9,6 @@ import { Spinner } from "@/components/ui/spinner"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useAssets } from "@/contexts/AdminAssetsContext"; -import api from "@/utils/api.util"; const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`; const DEBOUNCE_MS = 400; @@ -73,23 +72,34 @@ function EmptyState({ fileType }) { // ─── Main Sheet ─────────────────────────────────────────────────────────────── export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { - if (!open) return null; + // NOTE: previously this returned null before any hooks ran when `open` was + // false. Since the parent renders this component unconditionally (only the + // `open` prop toggles), that meant React remounted every hook from scratch + // on each open — wiping local state and forcing a full refetch every time, + // plus skipping the close transition. Visibility is controlled by + // below instead, so state (and the caches in + // AdminAssetsContext) survive across open/close toggles. - const { fetchAssets, assets, pagination, loading } = useAssets(); + const { fetchAssets, assets, pagination, loading, mediaTokens, getMediaTokens } = useAssets(); const [search, setSearch] = useState(""); const [activeExts, setActiveExts] = useState(new Set()); const [page, setPage] = useState(1); const [selected, setSelected] = useState(null); const [filterOpen, setFilterOpen] = useState(false); - // { [asset_id]: streamUrl } — resolved once per asset list via batch token request - const [streamUrls, setStreamUrls] = useState({}); const debounceRef = useRef(null); + const isFirstSearchRun = useRef(true); const LIMIT = 12; const extOptions = EXT_OPTIONS[fileType] ?? []; + const resolveStreamSrc = useCallback((assetId) => { + const entry = mediaTokens[String(assetId)]; + if (!entry) return null; + return entry.thumbnail_url ?? `${STREAM_BASE}/${entry.token}`; + }, [mediaTokens]); + // ── Build and fire fetch ────────────────────────────────────────────────── const doFetch = useCallback((searchVal, extSet, pg) => { const filters = [ @@ -100,8 +110,19 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { fetchAssets({ page: pg, limit: LIMIT, filters }); }, [fileType, fetchAssets]); - // ── Auto-search: debounce on search input change ────────────────────────── + // ── Immediate fetch: on open, or when filters/page change while open ────── + // (fetchAssets itself is TTL-cached in AdminAssetsContext, so reopening + // with the same query within the cache window costs no network round-trip.) useEffect(() => { + if (!open) return; + doFetch(search, activeExts, page); + }, [open, activeExts, page]); + + // ── Debounced fetch: only when the user edits the search box ────────────── + // Deliberately does NOT depend on `open` — otherwise this and the effect + // above both fire on every sheet open, doubling the request. + useEffect(() => { + if (isFirstSearchRun.current) { isFirstSearchRun.current = false; return; } if (!open) return; clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { @@ -109,51 +130,26 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { doFetch(search, activeExts, 1); }, DEBOUNCE_MS); return () => clearTimeout(debounceRef.current); - }, [search, open]); - - // ── Immediate fetch on ext filter or page change ────────────────────────── - useEffect(() => { - if (!open) return; - doFetch(search, activeExts, page); - }, [activeExts, page, open]); + }, [search]); // ── Batch token fetch after assets load ─────────────────────────────────── - // One request for all S3 assets on the current page instead of N per-card requests. - // This eliminates the thundering-herd / auth-refresh race that caused some cards to - // silently show "No preview" after a page reload (multiple 401s queuing simultaneously - // while the interceptor refreshes, some dropping if cancelled mid-flight). + // One request for all S3 assets on the current page instead of N per-card + // requests. getMediaTokens (AdminAssetsContext) already skips any asset_id + // whose cached token is still valid, so reopening the sheet within the + // token's ~30min TTL issues no request at all for previously-seen assets. useEffect(() => { - if (!assets.length) return; + if (!open || !assets.length) return; - // Only request tokens for S3 assets we don't already have a URL for — - // this prevents duplicate POST /tokens when the assets list triggers - // this effect more than once per sheet open (e.g., two fetch effects - // both reacting to open mounting, producing two assets updates). const s3Ids = assets - .filter((a) => a.storage_provider === "s3" && !streamUrls[String(a.asset_id)]) + .filter((a) => a.storage_provider === "s3") .map((a) => a.asset_id); if (!s3Ids.length) return; - let cancelled = false; - api.post("/admin/media/tokens", { asset_ids: s3Ids }) - .then(({ data }) => { - if (cancelled) return; - const tokens = data.data?.tokens ?? {}; - const thumbnails = data.data?.thumbnails ?? {}; - const urls = {}; - for (const [id, token] of Object.entries(tokens)) { - // Prefer presigned thumbnail URL (faster, direct); fall back to stream proxy - urls[id] = thumbnails[id] ?? `${STREAM_BASE}/${token}`; - } - setStreamUrls((prev) => ({ ...prev, ...urls })); - }) - .catch((err) => { - console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message); - }); - - return () => { cancelled = true; }; - }, [assets]); + getMediaTokens(s3Ids).catch((err) => { + console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message); + }); + }, [assets, open, getMediaTokens]); // ── Reset on close ──────────────────────────────────────────────────────── useEffect(() => { @@ -163,7 +159,6 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { setPage(1); setSelected(null); setFilterOpen(false); - setStreamUrls({}); } }, [open]); @@ -187,7 +182,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { // (e.g. badge image picker) can use the authenticated URL directly // rather than falling back to asset.file_url which is a private CDN // key that the browser cannot load without S3 credentials. - const resolvedUrl = streamUrls[String(asset.asset_id)] + const resolvedUrl = resolveStreamSrc(asset.asset_id) ?? asset.thumbnail_url ?? asset.file_url ?? null; @@ -304,7 +299,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { diff --git a/src/components/generic/Blocks/Admin/AudioBlock.jsx b/src/components/generic/Blocks/Admin/AudioBlock.jsx index 19a53c6..fbe93fb 100644 --- a/src/components/generic/Blocks/Admin/AudioBlock.jsx +++ b/src/components/generic/Blocks/Admin/AudioBlock.jsx @@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { AssetPickerSheet } from "../../AssetPickerSheet"; import api from "@/utils/api.util"; +import { MediaFallback } from "@/components/generic/MediaFallback"; const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, ""); @@ -173,9 +174,7 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) { {!readOnly && } {isS3 && tokenLoading ? ( -
-
-
+ ) : src ? (