perf(video): perf

This commit is contained in:
2026-03-09 10:01:31 +08:00
parent 2402d685d7
commit 3b118e3304
12 changed files with 1284 additions and 328 deletions

View File

@@ -11,8 +11,8 @@ import {
import ScaleComponent from "./components/ScaleComponent"
import { processVideo } from "@/app/component/label/video2image/processVideo"
import { Box, Flex, LoadingOverlay, Stack } from "@mantine/core"
import { showNotification } from "@mantine/notifications"
import { Box, Button, Flex, LoadingOverlay, Stack } from "@mantine/core"
import { notifications, showNotification } from "@mantine/notifications"
import { getLabelResult, getServerImage } from "./api/label"
import {
Comment,
@@ -94,6 +94,37 @@ const getSegmentPoints = (arr: Array<number[]>) => {
}
const videoExtPattern = /\.(mp4|mov|avi|mkv|webm|h264|264|ts|m4v)$/i
const GLOBAL_LOADING_Z_INDEX = 900
const videoMimeByExt: Record<string, string> = {
mp4: "video/mp4",
mov: "video/quicktime",
webm: "video/webm",
mkv: "video/x-matroska",
avi: "video/x-msvideo",
h264: "video/h264",
"264": "video/h264",
ts: "video/mp2t",
m4v: "video/x-m4v",
}
const streamingFrameArgs = [
"-vf",
"fps=1,scale='min(640,iw)':-2:flags=bilinear,format=yuv420p",
"-q:v",
"5",
"frame_%03d.jpg",
]
const getFileExt = (fileName: string) => {
const segments = fileName.split(".")
if (segments.length <= 1) return ""
return segments[segments.length - 1].toLowerCase()
}
const normalizeBase64 = (text: string) => {
if (!text) return ""
const raw = text.includes(",") ? text.split(",").slice(1).join(",") : text
return raw.replace(/\s+/g, "")
}
const normalizeNamePrefix = (name: string) => {
const value = name
@@ -103,6 +134,23 @@ const normalizeNamePrefix = (name: string) => {
return value || "video"
}
const isVideoTaskDetail = (detail: Task.DataProps) => {
const sourceNames = detail.data_name
.map((item) => item?.name?.[0] || "")
.filter((name) => !!name)
if (!sourceNames.length) return false
const taskDataType = Number(
(detail as { [key: string]: any })?.data_type ??
(detail as { [key: string]: any })?.task_data_type ??
-1
)
return (
taskDataType === 2 ||
taskDataType === 7 ||
sourceNames.some((name) => videoExtPattern.test(name))
)
}
interface ResultImageEntry {
frameName: string
imageObjects: ImageObjects
@@ -168,6 +216,12 @@ const LabelPage = ({
const [isConfirmSave, setIsConfirmSave] = useState(false)
const [loading, setLoading] = useState(false)
const [sizes, setSizes] = useState<number[]>([])
const [videoFetchPendingCount, setVideoFetchPendingCount] = useState(0)
const [hasReadyVideoFrame, setHasReadyVideoFrame] = useState(false)
const [sourceVideoNames, setSourceVideoNames] = useState<string[]>([])
const [downloadingVideoName, setDownloadingVideoName] = useState<
string | null
>(null)
const labelData = useLabelStore((state) => state.label)
const setLabel = useLabelStore((state) => state.setLabel)
@@ -281,22 +335,60 @@ const LabelPage = ({
])
const normalizeVideoTaskData = useCallback(
async (detail: Task.DataProps) => {
async (detail: Task.DataProps, requestSeq: number) => {
const isStaleRequest = () => requestSeq !== latestTaskDetailRequestSeq
// 后端任务可能是视频(单条 data_name这里统一展开为可渲染的“帧列表任务”。
const sourceNames = detail.data_name
.map((item) => item?.name?.[0] || "")
.filter((name) => !!name)
if (!sourceNames.length || !projectId || !taskId) return detail
const taskDataType = Number(
(detail as { [key: string]: any })?.data_type ??
(detail as { [key: string]: any })?.task_data_type ??
-1
)
const isVideoTask =
taskDataType === 2 ||
taskDataType === 7 ||
sourceNames.some((name) => videoExtPattern.test(name))
if (!isVideoTask) return detail
const isVideoTask = isVideoTaskDetail(detail)
if (!isVideoTask) {
setSourceVideoNames([])
setHasReadyVideoFrame(true)
return detail
}
setSourceVideoNames(sourceNames)
const appendFramesToPage = (frameNames: string[]) => {
if (isStaleRequest() || !frameNames.length) return
const bottomStore = useBottomToolsStore.getState()
const nextAllItems = [...bottomStore.allItems]
const existedItems = new Set(nextAllItems)
const appendNames: string[] = []
frameNames.forEach((name) => {
if (!name || existedItems.has(name)) return
existedItems.add(name)
nextAllItems.push(name)
appendNames.push(name)
})
if (!appendNames.length) return
bottomStore.setAllItems(nextAllItems)
setTaskDetail((prev) => {
const baseDetail = prev ?? { ...detail, data_name: [] }
const existedFrameNames = new Set(
baseDetail.data_name.map((item) => item?.name?.[0] || "")
)
const additions = appendNames
.filter((name) => !existedFrameNames.has(name))
.map((name) => ({
name: [name] as [string],
related_images: [] as [],
}))
if (!additions.length) {
return baseDetail === prev ? prev : baseDetail
}
return {
...baseDetail,
data_name: [...baseDetail.data_name, ...additions],
}
})
}
const imagesMap = new Map(useImagesStore.getState().images)
const frameMapUpdates = new Map<string, string[]>()
@@ -304,6 +396,7 @@ const LabelPage = ({
const normalizedDataName: Task.DataProps["data_name"] = []
for (const videoName of sourceNames) {
// 任务级缓存键:相同 project/task/video 可复用历史抽帧结果,避免重复解码。
const cacheKey = `${projectId}:${taskId}:${videoName}`
let frameNames =
useVideoFrameStore.getState().videoFrames.get(cacheKey) ?? []
@@ -317,32 +410,201 @@ const LabelPage = ({
if (!hasAllCachedFrames) {
let frameData: Awaited<ReturnType<typeof processVideo>> = []
const noticeId = `video-process-${cacheKey}`
const fetchStartedAt = Date.now()
let decodeStartedAt: number | null = null
let fetchNoticeTicker: ReturnType<typeof setInterval> | null = null
let progress = 0
let firstFrameDelaySec: string | null = null
const streamFrameNames: string[] = []
const streamFrameNameSet = new Set<string>()
let lastNoticeUpdateAt = 0
let noticeTicker: ReturnType<typeof setInterval> | null = null
const getFetchSec = (anchorTs?: number) => {
const endTs = decodeStartedAt ?? anchorTs ?? Date.now()
return ((endTs - fetchStartedAt) / 1000).toFixed(1)
}
const updateProgressNotice = (force = false) => {
if (isStaleRequest()) return
const now = Date.now()
if (!force && now - lastNoticeUpdateAt < 500) return
lastNoticeUpdateAt = now
const decodeElapsedSec = decodeStartedAt
? ((now - decodeStartedAt) / 1000).toFixed(1)
: "0.0"
const fetchSec = getFetchSec(now)
const progressPct = Math.max(0, Math.min(100, progress * 100))
const progressText =
progressPct < 1
? `${progressPct.toFixed(1)}%`
: `${Math.round(progressPct)}%`
const firstFrameText = firstFrameDelaySec
? `,首帧解码 ${firstFrameDelaySec}s`
: decodeStartedAt && now - decodeStartedAt >= 8000
? ",首帧生成中"
: ""
notifications.update({
id: noticeId,
loading: true,
autoClose: false,
withCloseButton: false,
title: "视频抽帧中",
message: `${videoName} ${progressText},已生成 ${streamFrameNames.length} 帧,解码用时 ${decodeElapsedSec}s拉取 ${fetchSec}s${firstFrameText}`,
})
}
const applyFrameBatch = (
batchFrames: Awaited<ReturnType<typeof processVideo>>
) => {
if (isStaleRequest() || !batchFrames.length) return
const appendNames: string[] = []
batchFrames.forEach((frame) => {
if (!frame?.name || streamFrameNameSet.has(frame.name)) return
streamFrameNameSet.add(frame.name)
streamFrameNames.push(frame.name)
appendNames.push(frame.name)
imagesMap.set(frame.name, frame.dataUrl)
})
if (!appendNames.length) return
if (!firstFrameDelaySec) {
const anchor = decodeStartedAt ?? Date.now()
const firstDelayMs = Date.now() - anchor
firstFrameDelaySec = (firstDelayMs / 1000).toFixed(1)
progress = Math.max(progress, 0.01)
setHasReadyVideoFrame(true)
}
imageMapUpdated = true
useImagesStore.getState().setImages(new Map(imagesMap))
useVideoFrameStore
.getState()
.setVideoFrames(cacheKey, [...streamFrameNames])
appendFramesToPage(appendNames)
updateProgressNotice(true)
}
// 用计数器驱动全局 LoadingOverlay仅在首帧未到达前阻塞页面。
setVideoFetchPendingCount((prev) => prev + 1)
notifications.show({
id: noticeId,
loading: true,
autoClose: false,
withCloseButton: false,
title: "视频加载中",
message: `正在获取 ${videoName},拉取用时 0.0s`,
})
fetchNoticeTicker = setInterval(() => {
if (isStaleRequest()) return
notifications.update({
id: noticeId,
loading: true,
autoClose: false,
withCloseButton: false,
title: "视频加载中",
message: `正在获取 ${videoName},拉取用时 ${getFetchSec()}s`,
})
}, 1000)
try {
const base64Video = await getServerImage({
data_names: [videoName],
data_type: 2,
project_id: projectId,
})
if (isStaleRequest()) return detail
decodeStartedAt = Date.now()
const fetchSecAtDecode = getFetchSec(decodeStartedAt)
if (fetchNoticeTicker) {
clearInterval(fetchNoticeTicker)
fetchNoticeTicker = null
}
notifications.update({
id: noticeId,
loading: true,
autoClose: false,
withCloseButton: false,
title: "视频抽帧中",
message: `正在快速提取并持续抽帧 ${videoName},拉取 ${fetchSecAtDecode}s`,
})
noticeTicker = setInterval(() => {
updateProgressNotice(true)
}, 1000)
frameData = await processVideo({
base64Data: String(base64Video || ""),
fileName: videoName,
// 输出帧名带 project/task 前缀,降低跨任务重名覆盖风险。
namePrefix: normalizeNamePrefix(
`${projectId}_${taskId}_${videoName.replace(/\.[^.]+$/, "")}`
),
args: streamingFrameArgs,
quickFirstFrame: true,
continueAfterQuickFirstFrame: true,
onProgress: (value) => {
if (isStaleRequest()) return
progress = Math.max(0, Math.min(1, Number(value) || 0))
updateProgressNotice(false)
},
onFrames: (batchFrames) => {
applyFrameBatch(batchFrames)
},
})
if (isStaleRequest()) return detail
const totalSec = ((Date.now() - fetchStartedAt) / 1000).toFixed(1)
const fetchSecFinal = decodeStartedAt
? ((decodeStartedAt - fetchStartedAt) / 1000).toFixed(1)
: "未统计"
notifications.update({
id: noticeId,
loading: false,
color: "teal",
title: "视频处理完成",
message: `${videoName} 已生成 ${frameData.length} 帧(首帧解码 ${firstFrameDelaySec || "未统计"}s总耗时 ${totalSec}s拉取 ${fetchSecFinal}s`,
autoClose: 2600,
withCloseButton: true,
})
} catch (error) {
const message = error instanceof Error ? error.message : "未知错误"
const fetchSec = getFetchSec()
notifications.update({
id: noticeId,
loading: false,
color: "red",
title: "视频处理失败",
message: `${videoName}${message},拉取 ${fetchSec}s`,
autoClose: 5000,
withCloseButton: true,
})
throw new Error(`视频帧生成失败:${videoName}${message}`)
} finally {
if (fetchNoticeTicker) {
clearInterval(fetchNoticeTicker)
fetchNoticeTicker = null
}
if (noticeTicker) {
clearInterval(noticeTicker)
noticeTicker = null
}
setVideoFetchPendingCount((prev) => Math.max(prev - 1, 0))
}
frameNames = frameData.map((frame) => frame.name)
if (!frameNames.length) {
throw new Error(`视频帧生成失败:${videoName}`)
}
frameData.forEach((frame) => {
imagesMap.set(frame.name, frame.dataUrl)
if (!imagesMap.has(frame.name)) {
imagesMap.set(frame.name, frame.dataUrl)
}
})
imageMapUpdated = true
frameMapUpdates.set(cacheKey, frameNames)
appendFramesToPage(frameNames)
} else {
// 已命中缓存时也立即解锁页面并展示帧列表。
setHasReadyVideoFrame(true)
appendFramesToPage(frameNames)
}
if (!frameNames.length) {
@@ -350,6 +612,7 @@ const LabelPage = ({
}
frameNames.forEach((frameName) => {
// 将视频帧映射回 Task.DataProps 结构,复用现有图片标注渲染逻辑。
normalizedDataName.push({
name: [frameName] as [string],
related_images: [],
@@ -362,11 +625,13 @@ const LabelPage = ({
}
if (imageMapUpdated) {
// 批量写入,避免每帧 setState 触发频繁重渲染。
useImagesStore.getState().setImages(imagesMap)
}
frameMapUpdates.forEach((frameNames, cacheKey) => {
useVideoFrameStore.getState().setVideoFrames(cacheKey, frameNames)
})
setHasReadyVideoFrame(true)
return {
...detail,
@@ -376,11 +641,112 @@ const LabelPage = ({
[projectId, taskId]
)
const downloadVideoToLocal = useCallback(
(base64Video: string, name: string) => {
// 调试入口:把服务端 base64 视频还原成本地文件,便于和抽帧结果对比。
const payload = normalizeBase64(String(base64Video || ""))
if (!payload) throw new Error("视频数据为空")
const binary = atob(payload)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
const ext = getFileExt(name)
const blob = new Blob([bytes], {
type: videoMimeByExt[ext] || "video/mp4",
})
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = name
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
},
[]
)
const handleDownloadSourceVideo = useCallback(async () => {
const videoName = sourceVideoNames[0]
if (!videoName || !projectId) return
const noticeId = `video-download-${projectId}-${taskId || 0}-${videoName}`
const fetchStartedAt = Date.now()
let fetchNoticeTicker: ReturnType<typeof setInterval> | null = null
setDownloadingVideoName(videoName)
setVideoFetchPendingCount((prev) => prev + 1)
notifications.show({
id: noticeId,
loading: true,
autoClose: false,
withCloseButton: false,
title: "准备下载视频",
message: `正在获取 ${videoName},拉取用时 0.0s`,
})
fetchNoticeTicker = setInterval(() => {
const fetchSec = ((Date.now() - fetchStartedAt) / 1000).toFixed(1)
notifications.update({
id: noticeId,
loading: true,
autoClose: false,
withCloseButton: false,
title: "准备下载视频",
message: `正在获取 ${videoName},拉取用时 ${fetchSec}s`,
})
}, 1000)
try {
const base64Video = await getServerImage({
data_names: [videoName],
data_type: 2,
project_id: projectId,
})
if (fetchNoticeTicker) {
clearInterval(fetchNoticeTicker)
fetchNoticeTicker = null
}
const fetchSec = ((Date.now() - fetchStartedAt) / 1000).toFixed(1)
downloadVideoToLocal(String(base64Video || ""), videoName)
notifications.update({
id: noticeId,
loading: false,
color: "teal",
title: "下载已开始",
message:
sourceVideoNames.length > 1
? `${videoName}(当前任务共 ${sourceVideoNames.length} 个视频,拉取 ${fetchSec}s`
: `${videoName}(拉取 ${fetchSec}s`,
autoClose: 2500,
withCloseButton: true,
})
} catch (error) {
const message = error instanceof Error ? error.message : "未知错误"
const fetchSec = ((Date.now() - fetchStartedAt) / 1000).toFixed(1)
notifications.update({
id: noticeId,
loading: false,
color: "red",
title: "下载失败",
message: `${videoName}${message},拉取 ${fetchSec}s`,
autoClose: 5000,
withCloseButton: true,
})
} finally {
if (fetchNoticeTicker) {
clearInterval(fetchNoticeTicker)
fetchNoticeTicker = null
}
setDownloadingVideoName(null)
setVideoFetchPendingCount((prev) => Math.max(prev - 1, 0))
}
}, [downloadVideoToLocal, projectId, sourceVideoNames, taskId])
const asyncGetTaskDetail = useCallback(async () => {
if (taskId) {
const requestSeq = ++latestTaskDetailRequestSeq
try {
usePaperStore.getState().setLoadingData(true)
setHasReadyVideoFrame(false)
setSourceVideoNames([])
// 获取当前任务全量数据
const res = await getTaskList({
id: [taskId],
@@ -397,7 +763,17 @@ const LabelPage = ({
return
}
let detail = res.task_list[0]
detail = await normalizeVideoTaskData(detail)
if (isVideoTaskDetail(detail)) {
// 视频任务先清空帧列表占位,首帧到达后再逐步追加并可立即开始操作。
setTaskDetail({
...detail,
data_name: [],
})
useBottomToolsStore.getState().setAllItems([])
} else {
setHasReadyVideoFrame(true)
}
detail = await normalizeVideoTaskData(detail, requestSeq)
if (requestSeq !== latestTaskDetailRequestSeq) {
console.warn("[LabelNossr] ignore stale normalized task response", {
taskId,
@@ -765,6 +1141,7 @@ const LabelPage = ({
} catch (err) {
if (requestSeq !== latestTaskDetailRequestSeq) return
console.log(err)
setSourceVideoNames([])
setOldWorkLoad(null)
setTaskDetail(undefined)
useBottomToolsStore.getState().setAllItems([])
@@ -1376,11 +1753,12 @@ const LabelPage = ({
return (
<Stack w="100%" h="100vh" pos="relative" ref={fullscreenRef} gap={0}>
<LoadingOverlay
visible={loading}
zIndex={2000}
// 视频场景仅阻塞到首帧可见;后续抽帧在后台继续,页面保持可操作。
visible={loading || (videoFetchPendingCount > 0 && !hasReadyVideoFrame)}
zIndex={GLOBAL_LOADING_Z_INDEX}
overlayProps={{ radius: "sm", blur: 2 }}
/>
<Box w="100%" h={80}>
<Box w="100%" h={80} pos="relative">
<TopTools
ref={topRef}
taskDetail={taskDetail}
@@ -1394,6 +1772,25 @@ const LabelPage = ({
: null
}
/>
{sourceVideoNames.length > 0 && (
<Flex
pos="absolute"
top={8}
right={12}
style={{ zIndex: 2101 }}
gap="xs">
<Button
size="xs"
variant="light"
// 当前仅下载第一个视频,便于快速核对源视频与抽帧质量。
loading={downloadingVideoName === sourceVideoNames[0]}
onClick={handleDownloadSourceVideo}>
{sourceVideoNames.length > 1
? `下载源视频(1/${sourceVideoNames.length})`
: "下载源视频"}
</Button>
</Flex>
)}
</Box>
<Flex w="calc(100% - 4px)" h={mainBoxHeight}>
{/* <Flex h="100%" w={"100%"}> */}

View File

@@ -37,32 +37,42 @@ export const getServerImage = async (data: {
flag?: number
project_id: number
}) => {
let data_type = data.data_type
return new Promise((resolve, reject) => {
httpFetch<ServerResponse>({
url: BASE_LABEL_API + "/api/v1/label_server/data/list",
method: "POST",
body: JSON.stringify(data),
}).then((res) => {
if (res.proxy) {
reject({ proxy: res.proxy, params: data })
} else {
if (res.data_list && res.data_list.length) {
const { image_data, video_data, pointcloud_data } = res.data_list[0]
resolve({
base64data:
data_type === 0
? image_data
: data_type === 2
? video_data
: pointcloud_data,
})
}
}
})
}).then(({ base64data }: any) => {
return base64data
const res = await httpFetch<ServerResponse>({
url: BASE_LABEL_API + "/api/v1/label_server/data/list",
method: "POST",
body: JSON.stringify(data),
})
if (res.proxy) {
throw new Error(`get_data_list requires proxy=${res.proxy}`)
}
const first = res.data_list?.[0]
if (!first) {
throw new Error(
`get_data_list empty data_list, data_name=${data.data_names?.[0] || ""}`
)
}
const payload =
data.data_type === 0
? first.image_data
: data.data_type === 2
? first.video_data
: first.pointcloud_data
if (payload == null) {
throw new Error(
`get_data_list missing payload type=${data.data_type}, data_name=${first.data_name || data.data_names?.[0] || ""}`
)
}
if (typeof payload === "string" && !payload.trim()) {
throw new Error(
`get_data_list empty payload type=${data.data_type}, data_name=${first.data_name || data.data_names?.[0] || ""}`
)
}
return payload
// .catch(async ({ proxy, params }) => {
// try {
// const res = await httpFetch<ServerResponse>({

View File

@@ -99,11 +99,34 @@ const BottomTools: React.FC<BottomToolsComponentProps> = (props) => {
}, [activeImage, isShowKeyFrame, keyFrameData, labelState])
useEffect(() => {
const [firstKey] = labelState.keys()
const keys = Array.from(labelState.keys())
if (!keys.length) {
setActiveImage("")
setInputNumber("")
setActiveImageId(0)
return
}
const currentIndex = activeImage
? keys.findIndex((k) => k === activeImage)
: -1
if (currentIndex >= 0) {
setInputNumber(String(currentIndex + 1))
setActiveImageId(currentIndex + 1)
return
}
const firstKey = keys[0]
setActiveImage(firstKey)
setInputNumber("1")
setActiveImageId(1)
}, [labelState, setActiveImage, setActiveImageId, setInputNumber])
}, [
activeImage,
labelState,
setActiveImage,
setActiveImageId,
setInputNumber,
])
const scrollToIndex = (index: number) => {
const key = currentKeys[index]

View File

@@ -1472,7 +1472,8 @@ const PaperContainer = (
)
useEffect(() => {
if (loadingData) return
const hasCachedImage = !!useImagesStore.getState().images.get(activeImage)
if (loadingData && !hasCachedImage) return
if (!imgSize?.clientWidth || !imgSize?.clientHeight) return
const currentGroup = usePaperStore.getState().group
if (currentGroup && projectDetail) {

View File

@@ -157,7 +157,7 @@ const TopTools = (
renderPolygons,
} = props
const { backUrl } = useBackUrlStore()
const { mode } = usePaperStore()
const { mode, loadingData } = usePaperStore()
const router = useRouter()
const user_id = usePermissionStore.getState().user_id
@@ -418,6 +418,10 @@ const TopTools = (
)
const handleSave = useCallback(async () => {
if (loadingData) {
return
}
if (!oldWorkLoad) {
notifications.hide("save")
notifications.show({
@@ -869,6 +873,7 @@ const TopTools = (
transferPath,
updateOldWorkLoad,
user_id,
loadingData,
])
const handleBackup = useCallback(
@@ -1029,6 +1034,13 @@ const TopTools = (
// 任务跳转
const getNextTaskData = async (needSave = false) => {
if (loadingData) {
notifications.show({
color: "yellow",
message: "视频帧仍在同步,请稍后再切换任务",
})
return
}
if (needSave) {
notifications.show({
id: "save",
@@ -1066,6 +1078,13 @@ const TopTools = (
}
const commitTaskByActionKey = async (key?: number) => {
if (loadingData) {
notifications.show({
color: "yellow",
message: "视频帧仍在同步,请稍后再提交",
})
return
}
if (!key) {
// 提交时对数据进行校验
let imgBool = taskDetail!.data_name.some((item) => {
@@ -2079,8 +2098,16 @@ const TopTools = (
<ActionIcon
variant="transparent"
c="var(--mantine-color-text)"
style={{ width: "auto", display: "flex", gap: 4 }}
onClick={handleSave}>
style={{
width: "auto",
display: "flex",
gap: 4,
opacity: loadingData ? 0.5 : 1,
cursor: loadingData ? "not-allowed" : "pointer",
}}
onClick={() => {
if (!loadingData) handleSave()
}}>
<Cloud style={{ width: 20, height: 20 }} />
<Text size="xs"></Text>
</ActionIcon>

View File

@@ -8,6 +8,7 @@ import {
Flex,
Menu,
NavLink,
ScrollArea,
Space,
Stack,
Tooltip,
@@ -227,101 +228,103 @@ export default function AppLayout({
</AppShell.Header>
<AppShell.Navbar style={{ transition: "width 0.2s ease" }}>
<Stack justify="start" gap={4} flex={1}>
{activeHeaderMenu?.items?.map((item) => {
if (item.items?.length) {
if (matches) {
if (isOpen) {
return (
<LinksGroup
key={item.url}
item={item}
toggleMobile={toggleMobile}
routerUrl={activeHeaderMenu.url}
initiallyOpened={true}
/>
)
<ScrollArea h="calc(100vh - 95px)">
{activeHeaderMenu?.items?.map((item) => {
if (item.items?.length) {
if (matches) {
if (isOpen) {
return (
<LinksGroup
key={item.url}
item={item}
toggleMobile={toggleMobile}
routerUrl={activeHeaderMenu.url}
initiallyOpened={true}
/>
)
} else {
return (
<Menu
width={"auto"}
key={item.url + "desktop"}
position="right-start"
trigger="hover"
openDelay={100}
closeDelay={200}>
<Menu.Target>
<NavLink
noWrap={true}
leftSection={
item.icon && (
<ClientIcon
icon={item.icon}
style={{ width: "1rem", height: "1rem" }}
/>
)
}
label={item.title}
/>
</Menu.Target>
<Menu.Dropdown>
{item.items?.map((sub) =>
renderMenuItem(
sub,
`${activeHeaderMenu.url}/${item.url}`
)
)}
</Menu.Dropdown>
</Menu>
)
}
} else {
return (
<Menu
width={"auto"}
key={item.url + "desktop"}
position="right-start"
trigger="hover"
openDelay={100}
closeDelay={200}>
<Menu.Target>
<NavLink
noWrap={true}
leftSection={
item.icon && (
<ClientIcon
icon={item.icon}
style={{ width: "1rem", height: "1rem" }}
/>
)
}
label={item.title}
/>
</Menu.Target>
<Menu.Dropdown>
{item.items?.map((sub) =>
renderMenuItem(
sub,
`${activeHeaderMenu.url}/${item.url}`
)
)}
</Menu.Dropdown>
</Menu>
<Box key={item.url + "mobile"} hiddenFrom="sm">
<LinksGroup
item={item}
toggleMobile={toggleMobile}
routerUrl={activeHeaderMenu.url}
initiallyOpened={true}
/>
</Box>
)
}
} else {
return (
<Box key={item.url + "mobile"} hiddenFrom="sm">
<LinksGroup
item={item}
toggleMobile={toggleMobile}
routerUrl={activeHeaderMenu.url}
initiallyOpened={true}
<Tooltip
key={item.url}
events={{
hover: !isOpen && !mobileOpened,
focus: false,
touch: false,
}}
label={item.title || ""}
position="right"
openDelay={100}
closeDelay={200}
transitionProps={{ duration: 100 }}>
<NavLink
// href={`/${activeHeaderMenu.url}/${item.url}`}
onClick={() => {
toggleMobile()
router.push(`/${activeHeaderMenu.url}/${item.url}`)
}}
noWrap={true}
active={pathname.startsWith(
`/${activeHeaderMenu.url}/${item.url}`
)}
leftSection={
<ClientIcon
icon={item.icon}
style={{ width: "1rem", height: "1rem" }}
/>
}
label={item.title}
/>
</Box>
</Tooltip>
)
}
} else {
return (
<Tooltip
key={item.url}
events={{
hover: !isOpen && !mobileOpened,
focus: false,
touch: false,
}}
label={item.title || ""}
position="right"
openDelay={100}
closeDelay={200}
transitionProps={{ duration: 100 }}>
<NavLink
// href={`/${activeHeaderMenu.url}/${item.url}`}
onClick={() => {
toggleMobile()
router.push(`/${activeHeaderMenu.url}/${item.url}`)
}}
noWrap={true}
active={pathname.startsWith(
`/${activeHeaderMenu.url}/${item.url}`
)}
leftSection={
<ClientIcon
icon={item.icon}
style={{ width: "1rem", height: "1rem" }}
/>
}
label={item.title}
/>
</Tooltip>
)
}
})}
})}
</ScrollArea>
</Stack>
<Stack justify="center" gap={0}>
<Flex