perf: optimize launcher boot and media loading
This commit is contained in:
parent
3799483638
commit
3094464f62
|
|
@ -1543,7 +1543,19 @@ if (process.env.NODE_ENV === "production") {
|
||||||
throw new Error("Launcher production build is missing. Run npm run build before starting the server.");
|
throw new Error("Launcher production build is missing. Run npm run build before starting the server.");
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(express.static(distRoot, { index: false }));
|
app.use(express.static(distRoot, {
|
||||||
|
index: false,
|
||||||
|
immutable: true,
|
||||||
|
maxAge: "1y",
|
||||||
|
setHeaders(res, assetPath) {
|
||||||
|
if (assetPath === indexHtmlPath) {
|
||||||
|
res.setHeader("Cache-Control", noStoreCacheControl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
},
|
||||||
|
}));
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||||
next();
|
next();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { Client } from "../entities/client/types";
|
import type { Client } from "../entities/client/types";
|
||||||
import type { Invite } from "../entities/invite/types";
|
import type { Invite } from "../entities/invite/types";
|
||||||
import { syncServiceLaunchLink } from "../entities/service/links";
|
import { syncServiceLaunchLink } from "../entities/service/links";
|
||||||
|
|
@ -21,7 +21,6 @@ import {
|
||||||
ensureAdminTaskManagerProjectMembership,
|
ensureAdminTaskManagerProjectMembership,
|
||||||
ensureAdminTaskManagerWorkspaceMembership,
|
ensureAdminTaskManagerWorkspaceMembership,
|
||||||
fetchAdminTaskManagerWorkspaces,
|
fetchAdminTaskManagerWorkspaces,
|
||||||
fetchControlPlaneSnapshot,
|
|
||||||
reorderAdminServices,
|
reorderAdminServices,
|
||||||
retryAdminSync,
|
retryAdminSync,
|
||||||
rejectAdminAccessRequest,
|
rejectAdminAccessRequest,
|
||||||
|
|
@ -64,21 +63,24 @@ import { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteRespo
|
||||||
import type { AccessRequest, CreateAccessRequestCommand } from "../entities/access-request/types";
|
import type { AccessRequest, CreateAccessRequestCommand } from "../entities/access-request/types";
|
||||||
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
|
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
|
||||||
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
||||||
import {
|
import type {
|
||||||
AdminOverlay,
|
AccessAssignmentValue,
|
||||||
type AccessAssignmentValue,
|
CreateUserCommand,
|
||||||
type CreateUserCommand,
|
EnsureTaskManagerProjectMemberCommand,
|
||||||
type EnsureTaskManagerProjectMemberCommand,
|
SetServiceModuleEntitlementCommand,
|
||||||
type SetServiceModuleEntitlementCommand,
|
SetUserServiceAccessCommand,
|
||||||
type SetUserServiceAccessCommand,
|
|
||||||
} from "../widgets/admin-overlay/AdminOverlay";
|
} from "../widgets/admin-overlay/AdminOverlay";
|
||||||
import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel";
|
|
||||||
import { ServiceRail } from "../widgets/service-rail/ServiceRail";
|
import { ServiceRail } from "../widgets/service-rail/ServiceRail";
|
||||||
import { ServiceStage } from "../widgets/service-stage/ServiceStage";
|
import { ServiceStage } from "../widgets/service-stage/ServiceStage";
|
||||||
import { TopBar, type LauncherAdminMode } from "../widgets/top-bar/TopBar";
|
import { TopBar, type LauncherAdminMode } from "../widgets/top-bar/TopBar";
|
||||||
|
|
||||||
let lastAuthRedirect: { url: string; startedAt: number } | null = null;
|
let lastAuthRedirect: { url: string; startedAt: number } | null = null;
|
||||||
|
|
||||||
|
const AdminOverlay = lazy(() => import("../widgets/admin-overlay/AdminOverlay").then((module) => ({ default: module.AdminOverlay })));
|
||||||
|
const ProfileSettingsPanel = lazy(() =>
|
||||||
|
import("../widgets/profile-settings-panel/ProfileSettingsPanel").then((module) => ({ default: module.ProfileSettingsPanel }))
|
||||||
|
);
|
||||||
|
|
||||||
type InviteFlowState =
|
type InviteFlowState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "ready"; payload: PublicInviteResponse }
|
| { status: "ready"; payload: PublicInviteResponse }
|
||||||
|
|
@ -101,6 +103,7 @@ export function LauncherApp() {
|
||||||
const [adminMode, setAdminMode] = useState<LauncherAdminMode>("admin");
|
const [adminMode, setAdminMode] = useState<LauncherAdminMode>("admin");
|
||||||
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
|
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
|
||||||
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
|
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
|
||||||
|
const [runtimeReady, setRuntimeReady] = useState(false);
|
||||||
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
|
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
|
||||||
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
|
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
|
||||||
const [pendingServiceModuleEntitlements, setPendingServiceModuleEntitlements] = useState<Record<string, boolean>>({});
|
const [pendingServiceModuleEntitlements, setPendingServiceModuleEntitlements] = useState<Record<string, boolean>>({});
|
||||||
|
|
@ -113,6 +116,7 @@ export function LauncherApp() {
|
||||||
const runtimeDataRef = useRef(data);
|
const runtimeDataRef = useRef(data);
|
||||||
const runtimeProfileIdRef = useRef(activeProfileId);
|
const runtimeProfileIdRef = useRef(activeProfileId);
|
||||||
const runtimeClientIdRef = useRef(activeClientId);
|
const runtimeClientIdRef = useRef(activeClientId);
|
||||||
|
const runtimeRefreshInFlightRef = useRef<Promise<void> | null>(null);
|
||||||
const resolvedProfileId = useMemo(
|
const resolvedProfileId = useMemo(
|
||||||
() => resolveRuntimeProfileId(data, authSession, activeProfileId),
|
() => resolveRuntimeProfileId(data, authSession, activeProfileId),
|
||||||
[activeProfileId, authSession, data]
|
[activeProfileId, authSession, data]
|
||||||
|
|
@ -219,38 +223,63 @@ export function LauncherApp() {
|
||||||
|
|
||||||
const selectedService = launcherServices.find((service) => service.id === selectedServiceId);
|
const selectedService = launcherServices.find((service) => service.id === selectedServiceId);
|
||||||
|
|
||||||
useEffect(() => {
|
const refreshRuntimeState = useCallback(async () => {
|
||||||
let isMounted = true;
|
if (runtimeRefreshInFlightRef.current) {
|
||||||
|
return runtimeRefreshInFlightRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
fetchAuthSession()
|
const request = (async () => {
|
||||||
.then(async (session) => {
|
try {
|
||||||
if (!isMounted) return;
|
const nextSession = await fetchAuthSession();
|
||||||
|
|
||||||
setAuthSession(session);
|
setAuthSession(nextSession);
|
||||||
|
|
||||||
if (!session.authenticated) {
|
if (!nextSession.authenticated) {
|
||||||
setAuthApps([]);
|
setAuthApps([]);
|
||||||
|
setRuntimeReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apps = await fetchAvailableApps();
|
const [persistedData, apps] = await Promise.all([
|
||||||
|
loadPersistedLauncherData(),
|
||||||
|
fetchAvailableApps(),
|
||||||
|
]);
|
||||||
|
|
||||||
if (isMounted) {
|
if (persistedData) {
|
||||||
setAuthApps(apps);
|
setData(syncLauncherServiceLinks(persistedData));
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
setAuthApps(apps);
|
||||||
|
setRuntimeReady(true);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setRuntimeReady(true);
|
||||||
|
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
runtimeRefreshInFlightRef.current = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
runtimeRefreshInFlightRef.current = request;
|
||||||
|
return request;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
refreshRuntimeState()
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
|
|
||||||
setAuthSession({ authenticated: false, loginUrl: "/auth/login" });
|
setAuthSession({ authenticated: false, loginUrl: "/auth/login" });
|
||||||
setAuthApps([]);
|
setAuthApps([]);
|
||||||
|
setRuntimeReady(true);
|
||||||
console.warn(error instanceof Error ? error.message : "Не удалось проверить сессию платформы");
|
console.warn(error instanceof Error ? error.message : "Не удалось проверить сессию платформы");
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [refreshRuntimeState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authSession || authSession.authenticated) return;
|
if (!authSession || authSession.authenticated) return;
|
||||||
|
|
@ -338,41 +367,6 @@ export function LauncherApp() {
|
||||||
}
|
}
|
||||||
}, [activeClientId, activeProfileId, authSession, data]);
|
}, [activeClientId, activeProfileId, authSession, data]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
loadPersistedLauncherData()
|
|
||||||
.then((persistedData) => {
|
|
||||||
if (isMounted && persistedData) {
|
|
||||||
setData(syncLauncherServiceLinks(persistedData));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canOpenAdminApi) return;
|
|
||||||
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
fetchControlPlaneSnapshot()
|
|
||||||
.then((snapshot) => {
|
|
||||||
if (isMounted) {
|
|
||||||
setData(syncLauncherServiceLinks(snapshot.data));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
console.warn(error instanceof Error ? error.message : "Не удалось загрузить control-plane snapshot");
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, [canOpenAdminApi]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!adminOpen || !canOpenAdminApi) return;
|
if (!adminOpen || !canOpenAdminApi) return;
|
||||||
void refreshTaskManagerWorkspaces();
|
void refreshTaskManagerWorkspaces();
|
||||||
|
|
@ -385,32 +379,6 @@ export function LauncherApp() {
|
||||||
setAdminMode("admin");
|
setAdminMode("admin");
|
||||||
}, [runtimeMe.permissions.canOpenAdmin]);
|
}, [runtimeMe.permissions.canOpenAdmin]);
|
||||||
|
|
||||||
const refreshRuntimeState = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const nextSession = await fetchAuthSession();
|
|
||||||
|
|
||||||
setAuthSession(nextSession);
|
|
||||||
|
|
||||||
if (!nextSession.authenticated) {
|
|
||||||
setAuthApps([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [persistedData, apps] = await Promise.all([
|
|
||||||
loadPersistedLauncherData(),
|
|
||||||
fetchAvailableApps(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (persistedData) {
|
|
||||||
setData(syncLauncherServiceLinks(persistedData));
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthApps(apps);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authSession?.authenticated) return;
|
if (!authSession?.authenticated) return;
|
||||||
|
|
||||||
|
|
@ -423,10 +391,6 @@ export function LauncherApp() {
|
||||||
|
|
||||||
const eventSource = new EventSource("/api/events");
|
const eventSource = new EventSource("/api/events");
|
||||||
|
|
||||||
eventSource.addEventListener("nodedc-ready", () => {
|
|
||||||
void refreshMountedRuntimeState();
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener("nodedc-runtime", () => {
|
eventSource.addEventListener("nodedc-runtime", () => {
|
||||||
void refreshMountedRuntimeState();
|
void refreshMountedRuntimeState();
|
||||||
});
|
});
|
||||||
|
|
@ -466,7 +430,7 @@ export function LauncherApp() {
|
||||||
if (document.visibilityState === "visible") {
|
if (document.visibilityState === "visible") {
|
||||||
void refreshRuntimeState();
|
void refreshRuntimeState();
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 60000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearInterval(intervalId);
|
window.clearInterval(intervalId);
|
||||||
|
|
@ -871,7 +835,7 @@ export function LauncherApp() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authSession) {
|
if (!authSession || (authSession.authenticated && !runtimeReady)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -923,6 +887,7 @@ export function LauncherApp() {
|
||||||
onSelectNext={() => handleStageStep("next")}
|
onSelectNext={() => handleStageStep("next")}
|
||||||
/>
|
/>
|
||||||
{adminOpen && me.permissions.canOpenAdmin ? (
|
{adminOpen && me.permissions.canOpenAdmin ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<AdminOverlay
|
<AdminOverlay
|
||||||
data={data}
|
data={data}
|
||||||
me={runtimeMe}
|
me={runtimeMe}
|
||||||
|
|
@ -967,14 +932,17 @@ export function LauncherApp() {
|
||||||
onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole}
|
onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole}
|
||||||
onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement}
|
onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
{profileSettingsOpen && activeProfileUser ? (
|
{profileSettingsOpen && activeProfileUser ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<ProfileSettingsPanel
|
<ProfileSettingsPanel
|
||||||
user={activeProfileUser}
|
user={activeProfileUser}
|
||||||
onClose={() => setProfileSettingsOpen(false)}
|
onClose={() => setProfileSettingsOpen(false)}
|
||||||
onSaveProfile={handleUpdateOwnProfile}
|
onSaveProfile={handleUpdateOwnProfile}
|
||||||
onChangePassword={handleUpdateOwnPassword}
|
onChangePassword={handleUpdateOwnPassword}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
<ServiceRail services={launcherServices} selectedServiceId={selectedServiceId} onSelect={handleServiceSelect} />
|
<ServiceRail services={launcherServices} selectedServiceId={selectedServiceId} onSelect={handleServiceSelect} />
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -754,7 +754,6 @@ code {
|
||||||
content: "";
|
content: "";
|
||||||
opacity: 0.34;
|
opacity: 0.34;
|
||||||
transform: rotate(-2deg);
|
transform: rotate(-2deg);
|
||||||
animation: stageVideoDrift 16s linear infinite;
|
|
||||||
mix-blend-mode: screen;
|
mix-blend-mode: screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1045,15 +1044,6 @@ code {
|
||||||
background: color-mix(in srgb, var(--service-accent) 45%, rgba(255, 255, 255, 0.16));
|
background: color-mix(in srgb, var(--service-accent) 45%, rgba(255, 255, 255, 0.16));
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes stageVideoDrift {
|
|
||||||
from {
|
|
||||||
transform: translate3d(0, 0, 0) rotate(-2deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translate3d(-7rem, -7rem, 0) rotate(-2deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes stageFigureFloat {
|
@keyframes stageFigureFloat {
|
||||||
from {
|
from {
|
||||||
transform: translate3d(-0.8rem, 0, 0) rotate(-9deg) scale(1);
|
transform: translate3d(-0.8rem, 0, 0) rotate(-9deg) scale(1);
|
||||||
|
|
@ -1201,7 +1191,7 @@ code {
|
||||||
flex: 0 0 var(--service-rail-card-size);
|
flex: 0 0 var(--service-rail-card-size);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: color-mix(in srgb, var(--tile-accent) 36%, rgba(255, 255, 255, 0.12));
|
background: #050506;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-tile__media-asset {
|
.service-tile__media-asset {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { DEFAULT_AMBIENT_MEDIA, resolveAmbientMedia } from "../../entities/service/media";
|
import { resolveAmbientMedia } from "../../entities/service/media";
|
||||||
import type { LauncherServiceView } from "../../entities/service/types";
|
import type { LauncherServiceView } from "../../entities/service/types";
|
||||||
import { cn } from "../../shared/lib/cn";
|
import { cn } from "../../shared/lib/cn";
|
||||||
|
|
||||||
|
|
@ -34,8 +34,8 @@ export function ServiceRail({
|
||||||
>
|
>
|
||||||
<RailMedia
|
<RailMedia
|
||||||
className="service-tile__media-asset"
|
className="service-tile__media-asset"
|
||||||
src={service.media.coverImage ?? service.media.ambientVideo ?? DEFAULT_AMBIENT_MEDIA}
|
src={service.media.coverImage ?? service.media.thumbnail ?? null}
|
||||||
kind={service.media.coverKind ?? service.media.ambientKind}
|
kind={service.media.coverKind ?? "image"}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="service-tile__content">
|
<span className="service-tile__content">
|
||||||
|
|
@ -57,15 +57,17 @@ function RailMedia({
|
||||||
kind,
|
kind,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
src: string;
|
src?: string | null;
|
||||||
kind?: LauncherServiceView["media"]["coverKind"];
|
kind?: LauncherServiceView["media"]["coverKind"];
|
||||||
className: string;
|
className: string;
|
||||||
}) {
|
}) {
|
||||||
|
if (!src) return null;
|
||||||
|
|
||||||
if (kind === "video" || isVideoSource(src)) {
|
if (kind === "video" || isVideoSource(src)) {
|
||||||
return <video className={className} src={src} autoPlay loop muted playsInline />;
|
return <video className={className} src={src} autoPlay loop muted playsInline preload="metadata" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <img className={className} src={src} alt="" />;
|
return <img className={className} src={src} alt="" loading="lazy" decoding="async" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVideoSource(src: string) {
|
function isVideoSource(src: string) {
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export function ServiceStage({
|
||||||
<section className="service-stage" style={style}>
|
<section className="service-stage" style={style}>
|
||||||
<div className="stage-video-shell">
|
<div className="stage-video-shell">
|
||||||
<div className="stage-video-stream" aria-hidden="true">
|
<div className="stage-video-stream" aria-hidden="true">
|
||||||
<StageMedia className="stage-video-gif" src={ambientMedia.src} kind={ambientMedia.kind} />
|
<StageMedia className="stage-video-gif" src={ambientMedia.src} kind={ambientMedia.kind} priority={!service} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{service ? (
|
{service ? (
|
||||||
|
|
@ -155,12 +155,22 @@ function ServiceIcon({ slug }: { slug: string }) {
|
||||||
return <Bot size={18} />;
|
return <Bot size={18} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StageMedia({ src, kind, className }: { src: string; kind?: LauncherServiceView["media"]["coverKind"]; className: string }) {
|
function StageMedia({
|
||||||
|
src,
|
||||||
|
kind,
|
||||||
|
className,
|
||||||
|
priority = false,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
kind?: LauncherServiceView["media"]["coverKind"];
|
||||||
|
className: string;
|
||||||
|
priority?: boolean;
|
||||||
|
}) {
|
||||||
if (kind === "video" || isVideoSource(src)) {
|
if (kind === "video" || isVideoSource(src)) {
|
||||||
return <video className={className} src={src} autoPlay loop muted playsInline />;
|
return <video className={className} src={src} autoPlay loop muted playsInline preload="metadata" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <img className={className} src={src} alt="" />;
|
return <img className={className} src={src} alt="" loading={priority ? "eager" : "lazy"} decoding="async" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RichDescription({ text }: { text: string }) {
|
function RichDescription({ text }: { text: string }) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue