mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
Plural voiceover carve now targets a group instead of naming each clip: `resolveCarveSourceIds` (core `audioGroups.ts`) expands a group id to its current members at analysis time, so a clip added to the group later is covered without touching `sources`. The picker (`useFxCarve.ts`) offers a grouped voice as one option instead of one row per member, tests overlap as a union of member spans (a group overlaps the bed if ANY member does), and prefers a qualifying group over its individual members in `autoSourceIds`. Picking two or more ungrouped voice clips in the carve flow now mints a group behind them (`mintGroupId`, de-duped against every id in the document) and writes `data-audio-group` on each picked clip atomically, one undo entry — `createAudioGroupAndAssignMembers` in `timelineTrackVisibility.ts` copies `setElementsHidden`'s multi-target write shape. The DSP is untouched: `mixCarveSources` already sums multiple sources correctly (verified in the design doc's own investigation) — this only fixes the picker. New lint rule `audio_carve_ungrouped_sources` (`packages/lint/src/rules/ media.ts`, alongside `audio_volume_double_automation`) warns when a `data-fx-carve`'s `sources` names two or more plain clip ids instead of a group — the shape that silently rots when a clip is added. `/hyperframes- audio` states the same rule as an invariant, not a tip, with the grouped- narration HTML example from the design doc. The group-matching and auto-group logic (`withAutoGroupedSources`, `collectCarveCandidates`) is split into `useFxCarveGrouping.ts` — `useFxCarve.ts` was pushing past the 600-line cap. `resolveNextCarveSettings` is deliberately NOT an `async function`: wrapping it in one would force a microtask on every call, including the synchronous branch — the exact bug `withAutoGroupedSources`'s own sync-when-possible contract exists to avoid, and one caught via `propertyPanelAudioFxGroup.test.tsx` (10 failures) before fixing it back to a plain function the caller conditionally awaits. Also extracted `useEffectiveTimelineDuration` out of `App.tsx` and `useRemoveBackground` out of `StudioRightPanel.tsx` (both pushed past 600 lines from an added prop wire), and decomposed `useFxCarve.ts`'s picker IIFE to clear fallow's complexity gate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
485 lines
19 KiB
TypeScript
485 lines
19 KiB
TypeScript
import { useCallback } from "react";
|
|
import type { StudioRightPanelProps } from "./StudioRightPanel.types";
|
|
|
|
export type { StudioRightPanelProps };
|
|
import { PropertyPanel } from "./editor/PropertyPanel";
|
|
import { LayersPanel } from "./editor/LayersPanel";
|
|
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
|
|
import { BlockParamsPanel } from "./editor/BlockParamsPanel";
|
|
import { RenderQueuePanel } from "./renders/RenderQueuePanel";
|
|
import { SlideshowPanel } from "./panels/SlideshowPanel";
|
|
import { VariablesPanel } from "./panels/VariablesPanel";
|
|
import { PanelTabButton } from "./PanelTabButton";
|
|
import type { RenderJob } from "./renders/useRenderQueue";
|
|
import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./editor/manualEditingAvailability";
|
|
import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
|
|
import { useSlideshowTabState } from "../hooks/useSlideshowTabState";
|
|
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";
|
|
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
|
|
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
|
import { useFileManagerContext } from "../contexts/FileManagerContext";
|
|
import { useDomEditContext } from "../contexts/DomEditContext";
|
|
import { usePlayerStore } from "../player";
|
|
import {
|
|
applyColorGradingScopeUpdate,
|
|
EMPTY_COLOR_GRADING_SCOPE_RESULT,
|
|
type ColorGradingScope,
|
|
} from "./studioColorGradingScope";
|
|
import { timelineKeysForSelections } from "../utils/studioHelpers";
|
|
import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
|
|
import { useRemoveBackground } from "../hooks/useRemoveBackground";
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export function StudioRightPanel({
|
|
designPanelActive,
|
|
activeBlockParams,
|
|
onCloseBlockParams,
|
|
recordingState,
|
|
recordingDuration,
|
|
onToggleRecording,
|
|
sdkSession,
|
|
publishSdkSession,
|
|
forceReloadSdkSession,
|
|
reloadPreview,
|
|
domEditSaveTimestampRef,
|
|
recordEdit,
|
|
onToggleElementHidden,
|
|
onAutoGroupCarveSources,
|
|
onAddMediaOverlay,
|
|
}: StudioRightPanelProps) {
|
|
const {
|
|
rightWidth,
|
|
adjustPanelWidth,
|
|
rightPanelTab,
|
|
setRightPanelTab,
|
|
rightInspectorPanes,
|
|
toggleRightInspectorPane,
|
|
setExclusiveRightInspectorPane,
|
|
handlePanelResizeStart,
|
|
handlePanelResizeMove,
|
|
handlePanelResizeEnd,
|
|
} = usePanelLayoutContext();
|
|
|
|
const {
|
|
previewIframeRef,
|
|
projectId,
|
|
activeCompPath,
|
|
showToast,
|
|
waitForPendingDomEditSaves,
|
|
renderQueue,
|
|
} = useStudioShellContext();
|
|
const { captionEditMode, refreshKey } = useStudioPlaybackContext();
|
|
|
|
const {
|
|
domEditSelection,
|
|
domEditGroupSelections,
|
|
copiedAgentPrompt,
|
|
clearDomSelection,
|
|
handleUngroupSelection,
|
|
handleGroupSelection,
|
|
handleDomStyleCommit,
|
|
handleDomAttributeCommit,
|
|
handleDomAttributeLiveCommit,
|
|
handleDomAttributeQuietCommit,
|
|
handleDomHtmlAttributeCommit,
|
|
handleDomAttributesCommit,
|
|
handleDomPathOffsetCommit,
|
|
handleDomBoxSizeCommit,
|
|
handleDomRotationCommit,
|
|
handleDomTextCommit,
|
|
handleDomTextFieldStyleCommit,
|
|
handleDomAddTextField,
|
|
handleDomRemoveTextField,
|
|
handleAskAgent,
|
|
selectedGsapAnimations,
|
|
gsapMultipleTimelines,
|
|
gsapUnsupportedTimelinePattern,
|
|
handleGsapUpdateProperty,
|
|
handleGsapUpdateMeta,
|
|
handleGsapDeleteAnimation,
|
|
handleGsapAddAnimation,
|
|
handleGsapAddProperty,
|
|
handleGsapRemoveProperty,
|
|
handleGsapUpdateFromProperty,
|
|
handleGsapAddFromProperty,
|
|
handleGsapRemoveFromProperty,
|
|
commitAnimatedProperty,
|
|
commitAnimatedProperties,
|
|
handleSetArcPath,
|
|
handleUpdateArcSegment,
|
|
handleUnroll,
|
|
handleUpdateKeyframeEase,
|
|
handleUpdateSegmentEase,
|
|
handleSetAllKeyframeEases,
|
|
handleGsapAddKeyframe,
|
|
handleGsapRemoveKeyframe,
|
|
handleGsapConvertToKeyframes,
|
|
} = useDomEditContext();
|
|
|
|
const {
|
|
assets,
|
|
fontAssets,
|
|
projectDir,
|
|
handleImportFiles,
|
|
handleImportFonts,
|
|
refreshFileTree,
|
|
readProjectFile,
|
|
writeProjectFile,
|
|
fileTree,
|
|
editingFile,
|
|
} = useFileManagerContext();
|
|
|
|
// Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately,
|
|
// no coalescing — each is a distinct user action that deserves its own undo entry.
|
|
const onPersistSlideshow = useSlideshowPersist({
|
|
sdkSession,
|
|
activeCompPath,
|
|
readProjectFile,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
reloadPreview,
|
|
domEditSaveTimestampRef,
|
|
publishSdkSession,
|
|
});
|
|
|
|
// Notes path: persists are debounced in SlideshowPanel; coalesceKey ensures
|
|
// rapid writes collapse into a single undo entry via the save-queue infra.
|
|
const onPersistSlideshowNotes = useSlideshowPersist({
|
|
sdkSession,
|
|
activeCompPath,
|
|
readProjectFile,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
reloadPreview,
|
|
domEditSaveTimestampRef,
|
|
publishSdkSession,
|
|
coalesceKey: activeCompPath ? `slideshow-notes:${activeCompPath}` : "slideshow-notes",
|
|
});
|
|
|
|
const {
|
|
layersPanePercent,
|
|
splitContainerRef,
|
|
handleInspectorSplitResizeStart,
|
|
handleInspectorSplitResizeMove,
|
|
handleInspectorSplitResizeEnd,
|
|
} = useInspectorSplitResize();
|
|
|
|
const renderJobs = renderQueue.jobs as RenderJob[];
|
|
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
|
|
|
|
const { isSlideshowComposition, slideshowScenes } = useSlideshowTabState({
|
|
editingFileContent: editingFile?.content,
|
|
previewIframeRef,
|
|
refreshKey,
|
|
rightPanelTab,
|
|
setRightPanelTab,
|
|
});
|
|
const designPaneOpen = inspectorTabActive && rightInspectorPanes.design && designPanelActive;
|
|
const layersPaneOpen = inspectorTabActive && rightInspectorPanes.layers;
|
|
|
|
const handleInspectorPaneButtonClick = (pane: "design" | "layers") => {
|
|
if (!inspectorTabActive) {
|
|
setRightPanelTab(pane);
|
|
return;
|
|
}
|
|
// Flat inspector: Layers always renders full-height by itself (see the
|
|
// render branch below), so the two panes are mutually exclusive here —
|
|
// otherwise both tabs could show "active" while only one actually shows.
|
|
if (STUDIO_FLAT_INSPECTOR_ENABLED) {
|
|
setExclusiveRightInspectorPane(pane);
|
|
return;
|
|
}
|
|
toggleRightInspectorPane(pane);
|
|
};
|
|
|
|
const handleApplyColorGradingScope = useCallback(
|
|
async (scope: ColorGradingScope, value: string | null) =>
|
|
applyColorGradingScopeUpdate({
|
|
scope,
|
|
value,
|
|
selectedSourceFile: domEditSelection?.sourceFile || activeCompPath || "index.html",
|
|
fileTree,
|
|
projectId,
|
|
domEditSaveTimestampRef,
|
|
waitForPendingDomEditSaves,
|
|
readProjectFile,
|
|
writeProjectFile,
|
|
recordEdit,
|
|
reloadPreview,
|
|
showToast,
|
|
}).catch((error) => {
|
|
showToast(
|
|
`Couldn't apply color grading: ${error instanceof Error ? error.message : String(error)}`,
|
|
"error",
|
|
);
|
|
return EMPTY_COLOR_GRADING_SCOPE_RESULT;
|
|
}),
|
|
[
|
|
activeCompPath,
|
|
domEditSaveTimestampRef,
|
|
domEditSelection?.sourceFile,
|
|
fileTree,
|
|
projectId,
|
|
readProjectFile,
|
|
recordEdit,
|
|
reloadPreview,
|
|
showToast,
|
|
waitForPendingDomEditSaves,
|
|
writeProjectFile,
|
|
],
|
|
);
|
|
|
|
const handleRemoveBackground = useRemoveBackground(projectId, refreshFileTree, showToast);
|
|
|
|
/**
|
|
* A dial being dragged writes to the preview and stops there.
|
|
*
|
|
* Every one of these panels previews on each pointermove and commits on
|
|
* release. Persisting the moves too put a fragment of the drag in the undo
|
|
* stack — and since those writes race, history could not coalesce them
|
|
* reliably, so undo took back a sliver of the gesture rather than the gesture.
|
|
* The release's own commit is what reaches the file and the undo stack.
|
|
*/
|
|
const setAttributeWhileDragging = useCallback(
|
|
(attr: string, value: string | null) =>
|
|
handleDomAttributeLiveCommit(attr, value, undefined, { previewOnly: true }),
|
|
[handleDomAttributeLiveCommit],
|
|
);
|
|
const handleHideAllSelected = () => {
|
|
const { elements } = usePlayerStore.getState();
|
|
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
|
|
if (keys.length > 0) void onToggleElementHidden?.(keys, true);
|
|
};
|
|
const propertyPanel = (
|
|
<DesignPanelPromoteProvider
|
|
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
|
projectId={projectId}
|
|
activeCompPath={activeCompPath}
|
|
showToast={showToast}
|
|
readProjectFile={readProjectFile}
|
|
writeProjectFile={writeProjectFile}
|
|
recordEdit={recordEdit}
|
|
reloadPreview={reloadPreview}
|
|
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
|
forceReloadSharedSdkSession={forceReloadSdkSession}
|
|
>
|
|
<PropertyPanel
|
|
projectId={projectId}
|
|
projectDir={projectDir}
|
|
assets={assets}
|
|
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
|
multiSelectCount={domEditGroupSelections.length}
|
|
multiSelectedElements={domEditGroupSelections}
|
|
onGroupSelection={handleGroupSelection}
|
|
onHideAllSelected={handleHideAllSelected}
|
|
copiedAgentPrompt={copiedAgentPrompt}
|
|
onClearSelection={clearDomSelection}
|
|
onToggleElementHidden={onToggleElementHidden}
|
|
onAutoGroupCarveSources={onAutoGroupCarveSources}
|
|
onUngroup={handleUngroupSelection}
|
|
onSetStyle={handleDomStyleCommit}
|
|
onSetAttribute={handleDomAttributeCommit}
|
|
onSetAttributes={handleDomAttributesCommit}
|
|
onSetAttributeLive={setAttributeWhileDragging}
|
|
onSetAttributeQuiet={handleDomAttributeQuietCommit}
|
|
onApplyColorGradingScope={handleApplyColorGradingScope}
|
|
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
|
|
onRemoveBackground={handleRemoveBackground}
|
|
onSetManualOffset={handleDomPathOffsetCommit}
|
|
onSetManualSize={handleDomBoxSizeCommit}
|
|
onSetManualRotation={handleDomRotationCommit}
|
|
onSetText={handleDomTextCommit}
|
|
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
|
|
onAddTextField={handleDomAddTextField}
|
|
onRemoveTextField={handleDomRemoveTextField}
|
|
onAskAgent={handleAskAgent}
|
|
onImportAssets={handleImportFiles}
|
|
onAddMediaOverlay={onAddMediaOverlay}
|
|
fontAssets={fontAssets}
|
|
onImportFonts={handleImportFonts}
|
|
previewIframeRef={previewIframeRef}
|
|
gsapAnimations={selectedGsapAnimations}
|
|
gsapMultipleTimelines={gsapMultipleTimelines}
|
|
gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
|
|
onUpdateGsapProperty={handleGsapUpdateProperty}
|
|
onUpdateGsapMeta={handleGsapUpdateMeta}
|
|
onDeleteGsapAnimation={handleGsapDeleteAnimation}
|
|
onAddGsapProperty={handleGsapAddProperty}
|
|
onRemoveGsapProperty={handleGsapRemoveProperty}
|
|
onUpdateGsapFromProperty={handleGsapUpdateFromProperty}
|
|
onAddGsapFromProperty={handleGsapAddFromProperty}
|
|
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
|
|
onAddGsapAnimation={handleGsapAddAnimation}
|
|
onCommitAnimatedProperty={commitAnimatedProperty}
|
|
onCommitAnimatedProperties={commitAnimatedProperties}
|
|
onAddKeyframe={handleGsapAddKeyframe}
|
|
onRemoveKeyframe={handleGsapRemoveKeyframe}
|
|
onConvertToKeyframes={(animId, duration) =>
|
|
handleGsapConvertToKeyframes(animId, undefined, duration)
|
|
}
|
|
onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
|
|
onSetArcPath={handleSetArcPath}
|
|
onUpdateArcSegment={handleUpdateArcSegment}
|
|
onUnroll={handleUnroll}
|
|
onUpdateKeyframeEase={handleUpdateKeyframeEase}
|
|
onUpdateSegmentEase={handleUpdateSegmentEase}
|
|
onSetAllKeyframeEases={handleSetAllKeyframeEases}
|
|
recordingState={recordingState}
|
|
recordingDuration={recordingDuration}
|
|
onToggleRecording={onToggleRecording}
|
|
/>
|
|
</DesignPanelPromoteProvider>
|
|
);
|
|
|
|
const renderQueuePanel = <RenderQueuePanel />;
|
|
|
|
return (
|
|
<>
|
|
{/* Vertical resize divider: 3px visible seam, 13px hit zone via the inner div. */}
|
|
<div
|
|
role="separator"
|
|
aria-label="Resize inspector panel"
|
|
aria-orientation="vertical"
|
|
tabIndex={0}
|
|
className="group relative w-[3px] flex-shrink-0 cursor-col-resize outline-none focus-visible:bg-studio-accent/20"
|
|
style={{ touchAction: "none" }}
|
|
onPointerDown={(e) => handlePanelResizeStart("right", e)}
|
|
onPointerMove={handlePanelResizeMove}
|
|
onPointerUp={handlePanelResizeEnd}
|
|
onPointerCancel={handlePanelResizeEnd}
|
|
onKeyDown={(e) => {
|
|
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
e.preventDefault();
|
|
// Panel is right-anchored: ArrowLeft grows it, ArrowRight shrinks it.
|
|
const delta = e.key === "ArrowLeft" ? 16 : -16;
|
|
adjustPanelWidth("right", delta);
|
|
}}
|
|
>
|
|
{/* Asymmetric hit zone: 8px into the preview's p-2 gutter (the only dead
|
|
space), the 3px seam, 2px into the card. Stops short of the 24px WCAG
|
|
2.5.8 target because the next pixel each way is live. */}
|
|
<div className="absolute inset-y-0 -left-[8px] w-[13px]" />
|
|
{/* Visible hairline */}
|
|
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
|
</div>
|
|
<div
|
|
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-950"
|
|
style={{ width: rightWidth }}
|
|
>
|
|
{captionEditMode ? (
|
|
<CaptionPropertyPanel iframeRef={previewIframeRef} />
|
|
) : (
|
|
<>
|
|
<div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
|
|
<PanelTabButton
|
|
label="Design"
|
|
tooltip="Element styles and properties"
|
|
active={designPaneOpen}
|
|
onClick={() => handleInspectorPaneButtonClick("design")}
|
|
/>
|
|
<PanelTabButton
|
|
label="Layers"
|
|
tooltip="Composition layer stack"
|
|
active={layersPaneOpen}
|
|
onClick={() => handleInspectorPaneButtonClick("layers")}
|
|
/>
|
|
<PanelTabButton
|
|
label={renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
|
|
tooltip="Render queue and exports"
|
|
active={rightPanelTab === "renders"}
|
|
onClick={() => setRightPanelTab("renders")}
|
|
/>
|
|
{isSlideshowComposition && (
|
|
<PanelTabButton
|
|
label="Slideshow"
|
|
tooltip="Slideshow branching editor"
|
|
active={rightPanelTab === "slideshow"}
|
|
onClick={() => setRightPanelTab("slideshow")}
|
|
/>
|
|
)}
|
|
<PanelTabButton
|
|
label="Variables"
|
|
tooltip="Template variables — declare, preview with values"
|
|
active={rightPanelTab === "variables"}
|
|
onClick={() => setRightPanelTab("variables")}
|
|
/>
|
|
</div>
|
|
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
|
{rightPanelTab === "block-params" && activeBlockParams ? (
|
|
<BlockParamsPanel
|
|
blockName={activeBlockParams.blockName}
|
|
blockTitle={activeBlockParams.blockTitle}
|
|
params={activeBlockParams.params}
|
|
compositionPath={activeBlockParams.compositionPath}
|
|
onClose={onCloseBlockParams ?? (() => {})}
|
|
/>
|
|
) : rightPanelTab === "slideshow" && isSlideshowComposition ? (
|
|
<SlideshowPanel
|
|
scenes={slideshowScenes}
|
|
onPersist={onPersistSlideshow}
|
|
onPersistNotes={onPersistSlideshowNotes}
|
|
/>
|
|
) : rightPanelTab === "variables" ? (
|
|
<VariablesPanel
|
|
sdkSession={sdkSession}
|
|
publishSdkSession={publishSdkSession}
|
|
reloadPreview={reloadPreview}
|
|
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
|
recordEdit={recordEdit}
|
|
/>
|
|
) : layersPaneOpen && designPaneOpen && !STUDIO_FLAT_INSPECTOR_ENABLED ? (
|
|
<div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
|
|
<div
|
|
className="min-h-[120px] overflow-hidden"
|
|
style={{ flexBasis: `${layersPanePercent}%`, flexShrink: 0 }}
|
|
>
|
|
<LayersPanel />
|
|
</div>
|
|
<div
|
|
role="separator"
|
|
aria-label="Resize Layers and Design panes"
|
|
aria-orientation="horizontal"
|
|
className="group flex h-2 flex-shrink-0 cursor-row-resize items-center justify-center border-y border-neutral-800 bg-neutral-900"
|
|
style={{ touchAction: "none" }}
|
|
onPointerDown={handleInspectorSplitResizeStart}
|
|
onPointerMove={handleInspectorSplitResizeMove}
|
|
onPointerUp={handleInspectorSplitResizeEnd}
|
|
onPointerCancel={handleInspectorSplitResizeEnd}
|
|
>
|
|
<div className="h-px w-10 rounded-full bg-white/12 transition-colors group-hover:bg-white/24 group-active:bg-studio-accent/70" />
|
|
</div>
|
|
<div className="min-h-0 flex-1 overflow-hidden">{propertyPanel}</div>
|
|
</div>
|
|
) : layersPaneOpen ? (
|
|
<LayersPanel />
|
|
) : designPaneOpen ? (
|
|
propertyPanel
|
|
) : inspectorTabActive ? (
|
|
// Inspector tab selected but no pane can render (panes toggled
|
|
// off, or inspector inactive during playback/recording): show an
|
|
// explanation instead of silently rendering the render queue
|
|
// under a highlighted inspector tab.
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
|
<p className="text-xs text-neutral-500">
|
|
Inspector is unavailable right now — select the Design or Layers pane above, or
|
|
pause playback/recording to inspect elements.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => setRightPanelTab("renders")}
|
|
className="h-7 rounded-md border border-neutral-800 px-3 text-[11px] font-medium text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200 active:scale-[0.98]"
|
|
>
|
|
Show Renders
|
|
</button>
|
|
</div>
|
|
) : (
|
|
renderQueuePanel
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|