mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-04-30 11:23:07 -04:00
Add fullscreen quick preview via portal layer
This commit is contained in:
@@ -182,8 +182,9 @@ impl crate::domain::resource::Identifiable for File {
|
||||
})?;
|
||||
|
||||
// Find entries with matching content_identity UUID
|
||||
// Note: content_identity.uuid is Option<Uuid>, must wrap in Some()
|
||||
let ci_opt = content_identity::Entity::find()
|
||||
.filter(content_identity::Column::Uuid.eq(sc.content_uuid))
|
||||
.filter(content_identity::Column::Uuid.eq(Some(sc.content_uuid)))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { KeyboardHandler } from "./components/Explorer/KeyboardHandler";
|
||||
import { TagAssignmentMode } from "./components/Explorer/TagAssignmentMode";
|
||||
import { SpacesSidebar } from "./components/SpacesSidebar";
|
||||
import { QuickPreviewModal } from "./components/QuickPreview";
|
||||
import { QuickPreviewFullscreen, PREVIEW_LAYER_ID } from "./components/QuickPreview";
|
||||
import { createExplorerRouter } from "./router";
|
||||
import { useNormalizedCache } from "./context";
|
||||
import { usePlatform } from "./platform";
|
||||
@@ -110,11 +110,20 @@ export function ExplorerLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
const isPreviewActive = !!quickPreviewFileId;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen select-none overflow-hidden text-sidebar-ink bg-app rounded-[10px] border border-transparent frame">
|
||||
{/* Preview layer - portal target for fullscreen preview, sits between content and sidebar/inspector */}
|
||||
<div
|
||||
id={PREVIEW_LAYER_ID}
|
||||
className="absolute inset-0 z-40 pointer-events-none [&>*]:pointer-events-auto"
|
||||
/>
|
||||
|
||||
<TopBar
|
||||
sidebarWidth={sidebarVisible ? 224 : 0}
|
||||
inspectorWidth={inspectorVisible && !isOverview && !isKnowledgeView ? 284 : 0}
|
||||
isPreviewActive={isPreviewActive}
|
||||
/>
|
||||
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
@@ -124,15 +133,14 @@ export function ExplorerLayout() {
|
||||
animate={{ x: 0, width: 220 }}
|
||||
exit={{ x: -220, width: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.25, 1, 0.5, 1] }}
|
||||
className="overflow-hidden"
|
||||
className="relative z-50 overflow-hidden"
|
||||
>
|
||||
<SpacesSidebar />
|
||||
{/*<Sidebar />*/}
|
||||
<SpacesSidebar isPreviewActive={isPreviewActive} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="relative flex-1 overflow-hidden z-30">
|
||||
{/* Router content renders here */}
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -154,21 +162,22 @@ export function ExplorerLayout() {
|
||||
animate={{ width: 280 }}
|
||||
exit={{ width: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.25, 1, 0.5, 1] }}
|
||||
className="overflow-hidden"
|
||||
className="relative z-50 overflow-hidden"
|
||||
>
|
||||
<div className="w-[280px] min-w-[280px] flex flex-col h-full p-2 bg-app">
|
||||
<div className="w-[280px] min-w-[280px] flex flex-col h-full p-2 bg-transparent">
|
||||
<Inspector
|
||||
currentLocation={currentLocation}
|
||||
onPopOut={handlePopOutInspector}
|
||||
isPreviewActive={isPreviewActive}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Quick Preview Modal - TODO: Fix files reference */}
|
||||
{/* Quick Preview - renders via portal into preview layer */}
|
||||
{quickPreviewFileId && (
|
||||
<QuickPreviewModal
|
||||
<QuickPreviewFullscreen
|
||||
fileId={quickPreviewFileId}
|
||||
isOpen={!!quickPreviewFileId}
|
||||
onClose={closeQuickPreview}
|
||||
@@ -176,6 +185,8 @@ export function ExplorerLayout() {
|
||||
onPrevious={() => goToPreviousPreview([])}
|
||||
hasPrevious={false}
|
||||
hasNext={false}
|
||||
sidebarWidth={sidebarVisible ? 220 : 0}
|
||||
inspectorWidth={inspectorVisible && !isOverview && !isKnowledgeView ? 280 : 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePlatform } from "./platform";
|
||||
import { useSelection } from "./components/Explorer/SelectionContext";
|
||||
import { FileInspector } from "./inspectors/FileInspector";
|
||||
import { LocationInspector } from "./inspectors/LocationInspector";
|
||||
import clsx from "clsx";
|
||||
|
||||
export type InspectorVariant =
|
||||
| { type: "file"; file: File }
|
||||
@@ -18,12 +19,14 @@ interface InspectorProps {
|
||||
onPopOut?: () => void;
|
||||
showPopOutButton?: boolean;
|
||||
currentLocation?: LocationInfo | null;
|
||||
isPreviewActive?: boolean;
|
||||
}
|
||||
|
||||
export function Inspector({
|
||||
onPopOut,
|
||||
showPopOutButton = true,
|
||||
currentLocation,
|
||||
isPreviewActive = false,
|
||||
}: InspectorProps) {
|
||||
const { selectedFiles } = useSelection();
|
||||
|
||||
@@ -41,7 +44,12 @@ export function Inspector({
|
||||
// No need for interface package to call platform-specific commands
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full rounded-2xl overflow-hidden bg-sidebar/65">
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col h-full rounded-2xl overflow-hidden",
|
||||
isPreviewActive ? "backdrop-blur-2xl bg-sidebar/80" : "bg-sidebar/65",
|
||||
)}
|
||||
>
|
||||
<div className="relative z-[51] flex h-full flex-col p-2.5 pb-2">
|
||||
{/* Variant-specific content */}
|
||||
{!variant || variant.type === "empty" ? (
|
||||
|
||||
@@ -5,9 +5,10 @@ import clsx from "clsx";
|
||||
interface TopBarProps {
|
||||
sidebarWidth?: number;
|
||||
inspectorWidth?: number;
|
||||
isPreviewActive?: boolean;
|
||||
}
|
||||
|
||||
export const TopBar = memo(function TopBar({ sidebarWidth = 0, inspectorWidth = 0 }: TopBarProps) {
|
||||
export const TopBar = memo(function TopBar({ sidebarWidth = 0, inspectorWidth = 0, isPreviewActive = false }: TopBarProps) {
|
||||
const { setLeftRef, setCenterRef, setRightRef } = useTopBar();
|
||||
const leftRef = useRef<HTMLDivElement>(null);
|
||||
const centerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -21,7 +22,7 @@ export const TopBar = memo(function TopBar({ sidebarWidth = 0, inspectorWidth =
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-50 h-12"
|
||||
className="absolute inset-x-0 top-0 z-[60] h-12"
|
||||
data-tauri-drag-region
|
||||
style={{
|
||||
paddingLeft: sidebarWidth,
|
||||
@@ -33,8 +34,10 @@ export const TopBar = memo(function TopBar({ sidebarWidth = 0, inspectorWidth =
|
||||
<div ref={centerRef} className="flex-1 flex items-center justify-center gap-2" />
|
||||
<div ref={rightRef} className="flex items-center gap-2" />
|
||||
|
||||
{/* Right fade mask */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-app to-transparent pointer-events-none" />
|
||||
{/* Right fade mask - hide when preview active */}
|
||||
{!isPreviewActive && (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-app to-transparent pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -44,8 +44,11 @@ export function ExplorerView() {
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
devices,
|
||||
quickPreviewFileId,
|
||||
} = useExplorer();
|
||||
|
||||
const isPreviewActive = !!quickPreviewFileId;
|
||||
|
||||
// Fetch locations to get the SdPath for this locationId
|
||||
const locationsQuery = useNormalizedCache({
|
||||
wireMethod: "query:locations.list",
|
||||
@@ -99,59 +102,61 @@ export function ExplorerView() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBarPortal
|
||||
left={
|
||||
<div className="flex items-center gap-2">
|
||||
<TopBarButton
|
||||
icon={SidebarSimple}
|
||||
onClick={() => setSidebarVisible(!sidebarVisible)}
|
||||
active={sidebarVisible}
|
||||
/>
|
||||
<TopBarButtonGroup>
|
||||
{!isPreviewActive && (
|
||||
<TopBarPortal
|
||||
left={
|
||||
<div className="flex items-center gap-2">
|
||||
<TopBarButton
|
||||
icon={ArrowLeft}
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
icon={SidebarSimple}
|
||||
onClick={() => setSidebarVisible(!sidebarVisible)}
|
||||
active={sidebarVisible}
|
||||
/>
|
||||
<TopBarButtonGroup>
|
||||
<TopBarButton
|
||||
icon={ArrowLeft}
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
/>
|
||||
<TopBarButton
|
||||
icon={ArrowRight}
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward}
|
||||
/>
|
||||
</TopBarButtonGroup>
|
||||
{currentPath && (
|
||||
<PathBar
|
||||
path={currentPath}
|
||||
devices={devices}
|
||||
onNavigate={setCurrentPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchBar className="w-64" placeholder="Search..." />
|
||||
<TopBarButton
|
||||
icon={TagIcon}
|
||||
onClick={() => setTagModeActive(!tagModeActive)}
|
||||
active={tagModeActive}
|
||||
tooltip="Tag Mode (T)"
|
||||
/>
|
||||
<ViewModeMenu viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||
<ViewSettings />
|
||||
<SortMenu
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
<TopBarButton
|
||||
icon={ArrowRight}
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward}
|
||||
icon={Info}
|
||||
onClick={() => setInspectorVisible(!inspectorVisible)}
|
||||
active={inspectorVisible}
|
||||
/>
|
||||
</TopBarButtonGroup>
|
||||
{currentPath && (
|
||||
<PathBar
|
||||
path={currentPath}
|
||||
devices={devices}
|
||||
onNavigate={setCurrentPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchBar className="w-64" placeholder="Search..." />
|
||||
<TopBarButton
|
||||
icon={TagIcon}
|
||||
onClick={() => setTagModeActive(!tagModeActive)}
|
||||
active={tagModeActive}
|
||||
tooltip="Tag Mode (T)"
|
||||
/>
|
||||
<ViewModeMenu viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||
<ViewSettings />
|
||||
<SortMenu
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
<TopBarButton
|
||||
icon={Info}
|
||||
onClick={() => setInspectorVisible(!inspectorVisible)}
|
||||
active={inspectorVisible}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative flex w-full flex-col h-full overflow-hidden bg-app/80">
|
||||
<div className="flex-1 overflow-auto pt-[52px]">
|
||||
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
} from "@phosphor-icons/react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import type { File } from "@sd/ts-client";
|
||||
import { File as FileComponent } from "../Explorer/File";
|
||||
import { formatBytes } from "../Explorer/utils";
|
||||
|
||||
interface SubtitleCue {
|
||||
index: number;
|
||||
@@ -240,7 +238,7 @@ export function AudioPlayer({ src, file }: AudioPlayerProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col bg-gradient-to-br from-app via-app-box to-app">
|
||||
<div className="relative flex h-full w-full flex-col">
|
||||
{/* Hidden audio element */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
@@ -255,26 +253,8 @@ export function AudioPlayer({ src, file }: AudioPlayerProps) {
|
||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
{/* Main content area - Lyrics only */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left: Artwork */}
|
||||
<div className="flex w-[320px] flex-col items-center justify-center border-r border-app-line bg-sidebar-box/40 p-8 backdrop-blur-xl">
|
||||
<div className="mb-6">
|
||||
<FileComponent.Thumb file={file} size={240} />
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 text-center text-xl font-bold text-ink">
|
||||
{file.name}
|
||||
</h2>
|
||||
|
||||
{file.extension && (
|
||||
<p className="text-xs uppercase tracking-wider text-ink-dull">
|
||||
{file.extension} Audio
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Lyrics */}
|
||||
<div className="relative flex min-w-0 flex-1 items-center justify-center p-8">
|
||||
<div className="absolute inset-0 flex w-full items-center justify-center p-8">
|
||||
{cues.length > 0 ? (
|
||||
@@ -303,14 +283,14 @@ export function AudioPlayer({ src, file }: AudioPlayerProps) {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className={`cursor-pointer whitespace-nowrap transition-all duration-300 ${
|
||||
className={`cursor-pointer text-center text-2xl transition-all duration-300 ${
|
||||
isActive
|
||||
? "text-3xl font-bold text-ink"
|
||||
: "text-2xl text-ink-faint hover:text-ink-dull"
|
||||
? "font-bold text-white"
|
||||
: "text-white/40 hover:text-white/60"
|
||||
}`}
|
||||
style={{
|
||||
transform: isActive
|
||||
? "scale(1.05)"
|
||||
? "scale(1.15)"
|
||||
: "scale(1)",
|
||||
transformOrigin: "center",
|
||||
}}
|
||||
@@ -323,10 +303,10 @@ export function AudioPlayer({ src, file }: AudioPlayerProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="mb-4 text-6xl font-bold text-ink-faint">
|
||||
<div className="mb-4 text-6xl font-bold text-white/30">
|
||||
♪
|
||||
</div>
|
||||
<p className="text-ink-dull">No lyrics available</p>
|
||||
<p className="text-white/50">No lyrics available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -334,7 +314,7 @@ export function AudioPlayer({ src, file }: AudioPlayerProps) {
|
||||
</div>
|
||||
|
||||
{/* Bottom: Audio Controls */}
|
||||
<div className="border-t border-app-line bg-sidebar-box/95 px-6 py-4 backdrop-blur-xl">
|
||||
<div className="px-6 py-4">
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
className="group mb-4 cursor-pointer"
|
||||
@@ -346,7 +326,7 @@ export function AudioPlayer({ src, file }: AudioPlayerProps) {
|
||||
onMouseUp={() => setSeeking(false)}
|
||||
onMouseLeave={() => setSeeking(false)}
|
||||
>
|
||||
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-sidebar-line transition-all group-hover:h-2">
|
||||
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-white/20 transition-all group-hover:h-2">
|
||||
{/* Progress */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full bg-accent transition-all"
|
||||
@@ -372,7 +352,7 @@ export function AudioPlayer({ src, file }: AudioPlayerProps) {
|
||||
{/* Left side - Fixed width */}
|
||||
<div className="flex w-[200px] items-center gap-3">
|
||||
{/* Time */}
|
||||
<div className="text-sm font-medium text-ink-dull tabular-nums">
|
||||
<div className="text-sm font-medium text-white/70 tabular-nums">
|
||||
{formatTime(currentTime)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,7 +361,7 @@ export function AudioPlayer({ src, file }: AudioPlayerProps) {
|
||||
<div className="flex flex-1 items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={skipBack}
|
||||
className="rounded-full p-2 text-ink transition-colors hover:bg-app-hover"
|
||||
className="rounded-full p-2 text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||
title="Skip back 10s"
|
||||
>
|
||||
<SkipBack size={24} weight="fill" />
|
||||
@@ -404,7 +384,7 @@ export function AudioPlayer({ src, file }: AudioPlayerProps) {
|
||||
|
||||
<button
|
||||
onClick={skipForward}
|
||||
className="rounded-full p-2 text-ink transition-colors hover:bg-app-hover"
|
||||
className="rounded-full p-2 text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||
title="Skip forward 10s"
|
||||
>
|
||||
<SkipForward size={24} weight="fill" />
|
||||
@@ -414,7 +394,7 @@ export function AudioPlayer({ src, file }: AudioPlayerProps) {
|
||||
{/* Right side - Fixed width matching left */}
|
||||
<div className="flex w-[200px] items-center justify-end gap-3">
|
||||
{/* Time remaining */}
|
||||
<div className="text-sm font-medium text-ink-dull tabular-nums">
|
||||
<div className="text-sm font-medium text-white/70 tabular-nums">
|
||||
-{formatTime(duration - currentTime)}
|
||||
</div>
|
||||
|
||||
@@ -422,7 +402,7 @@ export function AudioPlayer({ src, file }: AudioPlayerProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setMuted(!muted)}
|
||||
className="rounded-md p-2 text-ink transition-colors hover:bg-app-hover"
|
||||
className="rounded-md p-2 text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{muted || volume === 0 ? (
|
||||
<SpeakerSlash size={20} weight="fill" />
|
||||
@@ -442,7 +422,7 @@ export function AudioPlayer({ src, file }: AudioPlayerProps) {
|
||||
onChange={(e) =>
|
||||
setVolume(parseFloat(e.target.value))
|
||||
}
|
||||
className="h-1 w-full cursor-pointer appearance-none rounded-full bg-sidebar-line [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:shadow-lg [&::-webkit-slider-thumb]:transition-all [&::-webkit-slider-thumb]:hover:scale-110"
|
||||
className="h-1 w-full cursor-pointer appearance-none rounded-full bg-white/20 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:shadow-lg [&::-webkit-slider-thumb]:transition-all [&::-webkit-slider-thumb]:hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,14 +15,20 @@ import { Folder } from "@sd/assets/icons";
|
||||
|
||||
interface ContentRendererProps {
|
||||
file: File;
|
||||
onZoomChange?: (isZoomed: boolean) => void;
|
||||
}
|
||||
|
||||
function ImageRenderer({ file }: ContentRendererProps) {
|
||||
function ImageRenderer({ file, onZoomChange }: ContentRendererProps) {
|
||||
const platform = usePlatform();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [originalLoaded, setOriginalLoaded] = useState(false);
|
||||
const [originalUrl, setOriginalUrl] = useState<string | null>(null);
|
||||
const { zoom, zoomIn, zoomOut, reset, transform } = useZoomPan(containerRef);
|
||||
const { zoom, zoomIn, zoomOut, reset, isZoomed, transform } = useZoomPan(containerRef);
|
||||
|
||||
// Notify parent of zoom state changes
|
||||
useEffect(() => {
|
||||
onZoomChange?.(isZoomed);
|
||||
}, [isZoomed, onZoomChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platform.convertFileSrc) {
|
||||
@@ -79,7 +85,7 @@ function ImageRenderer({ file }: ContentRendererProps) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-full overflow-hidden flex items-center justify-center"
|
||||
className={`relative w-full h-full flex items-center justify-center ${isZoomed ? 'overflow-visible' : 'overflow-hidden'}`}
|
||||
>
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2">
|
||||
@@ -156,7 +162,7 @@ function ImageRenderer({ file }: ContentRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function VideoRenderer({ file }: ContentRendererProps) {
|
||||
function VideoRenderer({ file, onZoomChange }: ContentRendererProps) {
|
||||
const platform = usePlatform();
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
|
||||
@@ -195,7 +201,7 @@ function VideoRenderer({ file }: ContentRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
return <VideoPlayer src={videoUrl} file={file} />;
|
||||
return <VideoPlayer src={videoUrl} file={file} onZoomChange={onZoomChange} />;
|
||||
}
|
||||
|
||||
function AudioRenderer({ file }: ContentRendererProps) {
|
||||
@@ -247,7 +253,7 @@ function DocumentRenderer({ file }: ContentRendererProps) {
|
||||
<FileComponent.Thumb file={file} size={200} />
|
||||
<div className="mt-6 text-ink text-lg font-medium">{file.name}</div>
|
||||
<div className="text-ink-dull text-sm mt-2 capitalize">
|
||||
{file.content_kind}
|
||||
{file.content_identity?.kind ?? "unknown"}
|
||||
</div>
|
||||
<div className="text-ink-dull text-xs mt-1">
|
||||
{formatBytes(file.size || 0)}
|
||||
@@ -283,7 +289,7 @@ function DefaultRenderer({ file }: ContentRendererProps) {
|
||||
<FileComponent.Thumb file={file} size={200} />
|
||||
<div className="mt-6 text-ink text-lg font-medium">{file.name}</div>
|
||||
<div className="text-ink-dull text-sm mt-2 capitalize">
|
||||
{file.content_kind}
|
||||
{file.content_identity?.kind ?? "unknown"}
|
||||
</div>
|
||||
<div className="text-ink-dull text-xs mt-1">
|
||||
{formatBytes(file.size || 0)}
|
||||
@@ -293,7 +299,7 @@ function DefaultRenderer({ file }: ContentRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ContentRenderer({ file }: ContentRendererProps) {
|
||||
export function ContentRenderer({ file, onZoomChange }: ContentRendererProps) {
|
||||
// Handle directories first
|
||||
if (file.kind.type === "Directory") {
|
||||
return (
|
||||
@@ -308,13 +314,13 @@ export function ContentRenderer({ file }: ContentRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const kind = file.content_kind;
|
||||
const kind = file.content_identity?.kind;
|
||||
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return <ImageRenderer file={file} />;
|
||||
return <ImageRenderer file={file} onZoomChange={onZoomChange} />;
|
||||
case "video":
|
||||
return <VideoRenderer file={file} />;
|
||||
return <VideoRenderer file={file} onZoomChange={onZoomChange} />;
|
||||
case "audio":
|
||||
return <AudioRenderer file={file} />;
|
||||
case "document":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLibraryQuery } from "../../context";
|
||||
import { useNormalizedCache } from "../../context";
|
||||
import { usePlatform } from "../../platform";
|
||||
import type { File } from "@sd/ts-client";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -17,7 +17,7 @@ function MetadataPanel({ file }: { file: File }) {
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-ink-dull mb-1">Kind</div>
|
||||
<div className="text-sm text-ink capitalize">{file.content_kind}</div>
|
||||
<div className="text-sm text-ink capitalize">{file.content_identity?.kind ?? "unknown"}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -71,15 +71,13 @@ export function QuickPreview() {
|
||||
}
|
||||
}, [platform]);
|
||||
|
||||
const { data: file, isLoading, error } = useLibraryQuery(
|
||||
{
|
||||
type: "files.by_id",
|
||||
input: { file_id: fileId! },
|
||||
},
|
||||
{
|
||||
enabled: !!fileId,
|
||||
}
|
||||
);
|
||||
const { data: file, isLoading, error } = useNormalizedCache<{ file_id: string }, File>({
|
||||
wireMethod: "query:files.by_id",
|
||||
input: { file_id: fileId! },
|
||||
resourceType: "file",
|
||||
resourceId: fileId!,
|
||||
enabled: !!fileId,
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
if (platform.closeCurrentWindow) {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ArrowLeft, ArrowRight } from '@phosphor-icons/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { File } from '@sd/ts-client';
|
||||
import { useNormalizedCache } from '../../context';
|
||||
import { ContentRenderer } from './ContentRenderer';
|
||||
import { TopBarPortal } from '../../TopBar';
|
||||
|
||||
interface QuickPreviewFullscreenProps {
|
||||
fileId: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
hasPrevious?: boolean;
|
||||
hasNext?: boolean;
|
||||
sidebarWidth?: number;
|
||||
inspectorWidth?: number;
|
||||
}
|
||||
|
||||
const PREVIEW_LAYER_ID = 'quick-preview-layer';
|
||||
|
||||
export function QuickPreviewFullscreen({
|
||||
fileId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onNext,
|
||||
onPrevious,
|
||||
hasPrevious,
|
||||
hasNext,
|
||||
sidebarWidth = 0,
|
||||
inspectorWidth = 0
|
||||
}: QuickPreviewFullscreenProps) {
|
||||
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
|
||||
// Reset zoom when file changes
|
||||
useEffect(() => {
|
||||
setIsZoomed(false);
|
||||
}, [fileId]);
|
||||
|
||||
const { data: file, isLoading, error } = useNormalizedCache<{ file_id: string }, File>({
|
||||
wireMethod: 'query:files.by_id',
|
||||
input: { file_id: fileId },
|
||||
resourceType: 'file',
|
||||
resourceId: fileId,
|
||||
enabled: !!fileId && isOpen,
|
||||
});
|
||||
|
||||
// Find portal target on mount
|
||||
useEffect(() => {
|
||||
const target = document.getElementById(PREVIEW_LAYER_ID);
|
||||
setPortalTarget(target);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Escape' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
if (e.code === 'ArrowLeft' && hasPrevious && onPrevious) {
|
||||
e.preventDefault();
|
||||
onPrevious();
|
||||
}
|
||||
if (e.code === 'ArrowRight' && hasNext && onNext) {
|
||||
e.preventDefault();
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, onNext, onPrevious, hasPrevious, hasNext]);
|
||||
|
||||
// Get background style based on content type
|
||||
const getBackgroundClass = () => {
|
||||
if (!file) return 'bg-black/90';
|
||||
|
||||
switch (file.content_identity?.kind) {
|
||||
case 'video':
|
||||
return 'bg-black';
|
||||
case 'audio':
|
||||
return 'audio-gradient';
|
||||
case 'image':
|
||||
return 'bg-black/95';
|
||||
default:
|
||||
return 'bg-black/90';
|
||||
}
|
||||
};
|
||||
|
||||
if (!portalTarget) return null;
|
||||
|
||||
const content = (
|
||||
<AnimatePresence mode="wait">
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
key="fullscreen-preview"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`absolute inset-0 flex flex-col ${getBackgroundClass()}`}
|
||||
>
|
||||
{isLoading || !file ? (
|
||||
<div className="flex h-full items-center justify-center text-ink">
|
||||
<div className="animate-pulse">Loading...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center text-red-400">
|
||||
<div>
|
||||
<div className="mb-2 text-lg font-medium">Error loading file</div>
|
||||
<div className="text-sm">{error.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* TopBar content via portal */}
|
||||
<TopBarPortal
|
||||
left={
|
||||
<div className="flex items-center gap-2">
|
||||
{(hasPrevious || hasNext) && (
|
||||
<>
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
disabled={!hasPrevious}
|
||||
className="rounded-md p-1.5 text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-30"
|
||||
>
|
||||
<ArrowLeft size={16} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!hasNext}
|
||||
className="rounded-md p-1.5 text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-30"
|
||||
>
|
||||
<ArrowRight size={16} weight="bold" />
|
||||
</button>
|
||||
<div className="h-4 w-px bg-white/20 mx-1" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
center={
|
||||
<div className="truncate text-sm font-medium text-white/90">
|
||||
{file.name}
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1.5 text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X size={16} weight="bold" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Content Area - padded to fit between sidebar/inspector, expands on zoom */}
|
||||
<div
|
||||
className={`flex-1 pt-14 pb-10 ${isZoomed ? 'overflow-visible' : 'overflow-hidden'}`}
|
||||
style={{
|
||||
paddingLeft: isZoomed ? 0 : sidebarWidth,
|
||||
paddingRight: isZoomed ? 0 : inspectorWidth,
|
||||
}}
|
||||
>
|
||||
<ContentRenderer file={file} onZoomChange={setIsZoomed} />
|
||||
</div>
|
||||
|
||||
{/* Footer with keyboard hints */}
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 py-3">
|
||||
<div className="text-center text-xs text-white/50">
|
||||
<span className="text-white/70">ESC</span> or{' '}
|
||||
<span className="text-white/70">Space</span> to close
|
||||
{(hasPrevious || hasNext) && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="text-white/70">←</span> /{' '}
|
||||
<span className="text-white/70">→</span> to navigate
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return createPortal(content, portalTarget);
|
||||
}
|
||||
|
||||
export { PREVIEW_LAYER_ID };
|
||||
@@ -0,0 +1,142 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ArrowLeft, ArrowRight } from '@phosphor-icons/react';
|
||||
import { useEffect } from 'react';
|
||||
import type { File } from '@sd/ts-client';
|
||||
import { useNormalizedCache } from '../../context';
|
||||
import { ContentRenderer } from './ContentRenderer';
|
||||
|
||||
interface QuickPreviewOverlayProps {
|
||||
fileId: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
hasPrevious?: boolean;
|
||||
hasNext?: boolean;
|
||||
}
|
||||
|
||||
export function QuickPreviewOverlay({
|
||||
fileId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onNext,
|
||||
onPrevious,
|
||||
hasPrevious,
|
||||
hasNext
|
||||
}: QuickPreviewOverlayProps) {
|
||||
const { data: file, isLoading, error } = useNormalizedCache<{ file_id: string }, File>({
|
||||
wireMethod: 'query:files.by_id',
|
||||
input: { file_id: fileId },
|
||||
resourceType: 'file',
|
||||
resourceId: fileId,
|
||||
enabled: !!fileId && isOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Escape' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
if (e.code === 'ArrowLeft' && hasPrevious && onPrevious) {
|
||||
e.preventDefault();
|
||||
onPrevious();
|
||||
}
|
||||
if (e.code === 'ArrowRight' && hasNext && onNext) {
|
||||
e.preventDefault();
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, onNext, onPrevious, hasPrevious, hasNext]);
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
key="overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 z-50 flex flex-col overflow-hidden rounded-lg bg-app/95 backdrop-blur-xl"
|
||||
>
|
||||
{isLoading || !file ? (
|
||||
<div className="flex h-full items-center justify-center text-ink">
|
||||
<div className="animate-pulse">Loading...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center text-red-400">
|
||||
<div>
|
||||
<div className="mb-2 text-lg font-medium">Error loading file</div>
|
||||
<div className="text-sm">{error.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-app-line/50 bg-app-box/40 px-4 py-2">
|
||||
<div className="flex flex-1 items-center gap-3">
|
||||
{/* Navigation Arrows */}
|
||||
{(hasPrevious || hasNext) && (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
disabled={!hasPrevious}
|
||||
className="rounded-md p-1.5 text-ink-dull transition-colors hover:bg-app-hover hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<ArrowLeft size={16} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!hasNext}
|
||||
className="rounded-md p-1.5 text-ink-dull transition-colors hover:bg-app-hover hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<ArrowRight size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-app-line/50" />
|
||||
</>
|
||||
)}
|
||||
<div className="truncate text-sm font-medium text-ink">{file.name}</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1.5 text-ink-dull transition-colors hover:bg-app-hover hover:text-ink"
|
||||
>
|
||||
<X size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area - full width, no inspector */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ContentRenderer file={file} />
|
||||
</div>
|
||||
|
||||
{/* Footer with keyboard hints */}
|
||||
<div className="border-t border-app-line/50 bg-app-box/40 px-4 py-1.5">
|
||||
<div className="text-center text-xs text-ink-dull">
|
||||
<span className="text-ink">ESC</span> or{' '}
|
||||
<span className="text-ink">Space</span> to close
|
||||
{(hasPrevious || hasNext) && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="text-ink">←</span> /{' '}
|
||||
<span className="text-ink">→</span> to navigate
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,29 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import type { File } from '@sd/ts-client';
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import type { File } from "@sd/ts-client";
|
||||
|
||||
interface SubtitleCue {
|
||||
index: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
index: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SubtitleSettings {
|
||||
fontSize: number; // 0.8 to 2.0
|
||||
position: 'bottom' | 'top';
|
||||
backgroundOpacity: number; // 0 to 1
|
||||
fontSize: number; // 0.8 to 2.0
|
||||
position: "bottom" | "top";
|
||||
backgroundOpacity: number; // 0 to 1
|
||||
}
|
||||
|
||||
interface SubtitlesProps {
|
||||
file: File;
|
||||
videoElement: HTMLVideoElement | null;
|
||||
settings?: SubtitleSettings;
|
||||
file: File;
|
||||
videoElement: HTMLVideoElement | null;
|
||||
settings?: SubtitleSettings;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: SubtitleSettings = {
|
||||
fontSize: 1.5,
|
||||
position: 'bottom',
|
||||
backgroundOpacity: 0.9
|
||||
fontSize: 1.5,
|
||||
position: "bottom",
|
||||
backgroundOpacity: 0.9,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -38,147 +38,168 @@ const DEFAULT_SETTINGS: SubtitleSettings = {
|
||||
* Next subtitle
|
||||
*/
|
||||
function parseSRT(srtContent: string): SubtitleCue[] {
|
||||
const cues: SubtitleCue[] = [];
|
||||
const blocks = srtContent.trim().split(/\n\s*\n/);
|
||||
const cues: SubtitleCue[] = [];
|
||||
const blocks = srtContent.trim().split(/\n\s*\n/);
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.trim().split('\n');
|
||||
if (lines.length < 3) continue;
|
||||
for (const block of blocks) {
|
||||
const lines = block.trim().split("\n");
|
||||
if (lines.length < 3) continue;
|
||||
|
||||
const index = parseInt(lines[0], 10);
|
||||
const timecodeMatch = lines[1].match(/(\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3})/);
|
||||
const index = parseInt(lines[0], 10);
|
||||
const timecodeMatch = lines[1].match(
|
||||
/(\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3})/,
|
||||
);
|
||||
|
||||
if (!timecodeMatch) continue;
|
||||
if (!timecodeMatch) continue;
|
||||
|
||||
const startTime =
|
||||
parseInt(timecodeMatch[1]) * 3600 +
|
||||
parseInt(timecodeMatch[2]) * 60 +
|
||||
parseInt(timecodeMatch[3]) +
|
||||
parseInt(timecodeMatch[4]) / 1000;
|
||||
const startTime =
|
||||
parseInt(timecodeMatch[1]) * 3600 +
|
||||
parseInt(timecodeMatch[2]) * 60 +
|
||||
parseInt(timecodeMatch[3]) +
|
||||
parseInt(timecodeMatch[4]) / 1000;
|
||||
|
||||
const endTime =
|
||||
parseInt(timecodeMatch[5]) * 3600 +
|
||||
parseInt(timecodeMatch[6]) * 60 +
|
||||
parseInt(timecodeMatch[7]) +
|
||||
parseInt(timecodeMatch[8]) / 1000;
|
||||
const endTime =
|
||||
parseInt(timecodeMatch[5]) * 3600 +
|
||||
parseInt(timecodeMatch[6]) * 60 +
|
||||
parseInt(timecodeMatch[7]) +
|
||||
parseInt(timecodeMatch[8]) / 1000;
|
||||
|
||||
const text = lines.slice(2).join('\n');
|
||||
const text = lines.slice(2).join("\n");
|
||||
|
||||
cues.push({ index, startTime, endTime, text });
|
||||
}
|
||||
cues.push({ index, startTime, endTime, text });
|
||||
}
|
||||
|
||||
return cues;
|
||||
return cues;
|
||||
}
|
||||
|
||||
export function Subtitles({ file, videoElement, settings = DEFAULT_SETTINGS }: SubtitlesProps) {
|
||||
const [cues, setCues] = useState<SubtitleCue[]>([]);
|
||||
const [currentCue, setCurrentCue] = useState<SubtitleCue | null>(null);
|
||||
export function Subtitles({
|
||||
file,
|
||||
videoElement,
|
||||
settings = DEFAULT_SETTINGS,
|
||||
}: SubtitlesProps) {
|
||||
const [cues, setCues] = useState<SubtitleCue[]>([]);
|
||||
const [currentCue, setCurrentCue] = useState<SubtitleCue | null>(null);
|
||||
|
||||
// Load SRT sidecar if available
|
||||
useEffect(() => {
|
||||
const srtSidecar = file.sidecars?.find(
|
||||
(s) => s.kind === 'transcript' && s.variant === 'srt'
|
||||
);
|
||||
// Load SRT sidecar if available
|
||||
useEffect(() => {
|
||||
const srtSidecar = file.sidecars?.find(
|
||||
(s) => s.kind === "transcript" && s.variant === "srt",
|
||||
);
|
||||
|
||||
if (!srtSidecar || !file.content_identity?.uuid) {
|
||||
return;
|
||||
}
|
||||
if (!srtSidecar || !file.content_identity?.uuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the SRT file from the sidecar server
|
||||
const serverUrl = (window as any).__SPACEDRIVE_SERVER_URL__;
|
||||
const libraryId = (window as any).__SPACEDRIVE_LIBRARY_ID__;
|
||||
// Fetch the SRT file from the sidecar server
|
||||
const serverUrl = (window as any).__SPACEDRIVE_SERVER_URL__;
|
||||
const libraryId = (window as any).__SPACEDRIVE_LIBRARY_ID__;
|
||||
|
||||
if (!serverUrl || !libraryId) {
|
||||
console.warn('[Subtitles] Server URL or Library ID not available');
|
||||
return;
|
||||
}
|
||||
if (!serverUrl || !libraryId) {
|
||||
console.warn("[Subtitles] Server URL or Library ID not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const contentUuid = file.content_identity.uuid;
|
||||
// Map "text" format to "txt" extension (DB stores "text", file is .txt)
|
||||
const extension = srtSidecar.format === 'text' ? 'txt' : srtSidecar.format;
|
||||
const srtUrl = `${serverUrl}/sidecar/${libraryId}/${contentUuid}/${srtSidecar.kind}/${srtSidecar.variant}.${extension}`;
|
||||
const contentUuid = file.content_identity.uuid;
|
||||
// Map "text" format to "txt" extension (DB stores "text", file is .txt)
|
||||
const extension = srtSidecar.format === "text" ? "txt" : srtSidecar.format;
|
||||
const srtUrl = `${serverUrl}/sidecar/${libraryId}/${contentUuid}/${srtSidecar.kind}/${srtSidecar.variant}.${extension}`;
|
||||
|
||||
console.log('[Subtitles] Loading SRT from:', srtUrl);
|
||||
console.log("[Subtitles] Loading SRT from:", srtUrl);
|
||||
|
||||
fetch(srtUrl)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
console.log('[Subtitles] No subtitle file found (not generated yet)');
|
||||
} else {
|
||||
console.error('[Subtitles] Failed to fetch SRT, status:', res.status);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return res.text();
|
||||
})
|
||||
.then((srtContent) => {
|
||||
if (!srtContent) return;
|
||||
const parsed = parseSRT(srtContent);
|
||||
console.log('[Subtitles] Loaded and parsed', parsed.length, 'subtitle cues');
|
||||
setCues(parsed);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('[Subtitles] Subtitles not available:', err.message);
|
||||
});
|
||||
}, [file]);
|
||||
fetch(srtUrl)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
console.log(
|
||||
"[Subtitles] No subtitle file found (not generated yet)",
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
"[Subtitles] Failed to fetch SRT, status:",
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return res.text();
|
||||
})
|
||||
.then((srtContent) => {
|
||||
if (!srtContent) return;
|
||||
const parsed = parseSRT(srtContent);
|
||||
console.log(
|
||||
"[Subtitles] Loaded and parsed",
|
||||
parsed.length,
|
||||
"subtitle cues",
|
||||
);
|
||||
setCues(parsed);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("[Subtitles] Subtitles not available:", err.message);
|
||||
});
|
||||
}, [file]);
|
||||
|
||||
// Sync with video playback
|
||||
useEffect(() => {
|
||||
if (!videoElement || cues.length === 0) {
|
||||
console.log('[Subtitles] Not setting up sync - videoElement:', !!videoElement, 'cues:', cues.length);
|
||||
return;
|
||||
}
|
||||
// Sync with video playback
|
||||
useEffect(() => {
|
||||
if (!videoElement || cues.length === 0) {
|
||||
console.log(
|
||||
"[Subtitles] Not setting up sync - videoElement:",
|
||||
!!videoElement,
|
||||
"cues:",
|
||||
cues.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Subtitles] Setting up video sync with', cues.length, 'cues');
|
||||
console.log("[Subtitles] Setting up video sync with", cues.length, "cues");
|
||||
|
||||
const updateSubtitle = () => {
|
||||
const currentTime = videoElement.currentTime;
|
||||
const activeCue = cues.find(
|
||||
(cue) => currentTime >= cue.startTime && currentTime <= cue.endTime
|
||||
);
|
||||
const updateSubtitle = () => {
|
||||
const currentTime = videoElement.currentTime;
|
||||
const activeCue = cues.find(
|
||||
(cue) => currentTime >= cue.startTime && currentTime <= cue.endTime,
|
||||
);
|
||||
|
||||
if (activeCue !== currentCue) {
|
||||
console.log('[Subtitles] Cue changed at', currentTime, 's:', activeCue?.text || 'none');
|
||||
setCurrentCue(activeCue || null);
|
||||
}
|
||||
};
|
||||
if (activeCue !== currentCue) {
|
||||
setCurrentCue(activeCue || null);
|
||||
}
|
||||
};
|
||||
|
||||
// Update on time change
|
||||
videoElement.addEventListener('timeupdate', updateSubtitle);
|
||||
// Update on time change
|
||||
videoElement.addEventListener("timeupdate", updateSubtitle);
|
||||
|
||||
// Also update when seeking
|
||||
videoElement.addEventListener('seeked', updateSubtitle);
|
||||
// Also update when seeking
|
||||
videoElement.addEventListener("seeked", updateSubtitle);
|
||||
|
||||
return () => {
|
||||
videoElement.removeEventListener('timeupdate', updateSubtitle);
|
||||
videoElement.removeEventListener('seeked', updateSubtitle);
|
||||
};
|
||||
}, [videoElement, cues, currentCue]);
|
||||
return () => {
|
||||
videoElement.removeEventListener("timeupdate", updateSubtitle);
|
||||
videoElement.removeEventListener("seeked", updateSubtitle);
|
||||
};
|
||||
}, [videoElement, cues, currentCue]);
|
||||
|
||||
if (!currentCue) {
|
||||
return null;
|
||||
}
|
||||
if (!currentCue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const positionClass = settings.position === 'top' ? 'top-16' : 'bottom-16';
|
||||
const positionClass = settings.position === "top" ? "top-16" : "bottom-16";
|
||||
|
||||
return (
|
||||
<div className={`pointer-events-none absolute ${positionClass} left-0 right-0 flex justify-center px-8`}>
|
||||
<div
|
||||
className="max-w-4xl rounded-lg px-6 py-3 text-center backdrop-blur-sm"
|
||||
style={{
|
||||
backgroundColor: `rgba(0, 0, 0, ${settings.backgroundOpacity})`
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="leading-relaxed text-white"
|
||||
style={{
|
||||
fontSize: `${settings.fontSize}rem`
|
||||
}}
|
||||
>
|
||||
{currentCue.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none absolute ${positionClass} left-0 right-0 flex justify-center px-8`}
|
||||
>
|
||||
<div
|
||||
className="max-w-4xl rounded-lg px-6 py-3 text-center backdrop-blur-sm"
|
||||
style={{
|
||||
backgroundColor: `rgba(0, 0, 0, ${settings.backgroundOpacity})`,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="leading-relaxed text-white"
|
||||
style={{
|
||||
fontSize: `${settings.fontSize}rem`,
|
||||
}}
|
||||
>
|
||||
{currentCue.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TimelineScrubber } from './TimelineScrubber';
|
||||
interface VideoPlayerProps {
|
||||
src: string;
|
||||
file: File;
|
||||
onZoomChange?: (isZoomed: boolean) => void;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -23,7 +24,7 @@ function formatTime(seconds: number): string {
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ src, file }: VideoPlayerProps) {
|
||||
export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const videoContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -42,8 +43,13 @@ export function VideoPlayer({ src, file }: VideoPlayerProps) {
|
||||
backgroundOpacity: 0.9
|
||||
});
|
||||
const [timelineHover, setTimelineHover] = useState<{ percent: number; mouseX: number } | null>(null);
|
||||
const hideControlsTimeout = useRef<NodeJS.Timeout>();
|
||||
const { zoom, zoomIn, zoomOut, reset, transform } = useZoomPan(videoContainerRef);
|
||||
const hideControlsTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
const { zoom, zoomIn, zoomOut, reset, isZoomed, transform } = useZoomPan(videoContainerRef);
|
||||
|
||||
// Notify parent of zoom state changes
|
||||
useEffect(() => {
|
||||
onZoomChange?.(isZoomed);
|
||||
}, [isZoomed, onZoomChange]);
|
||||
|
||||
// Show controls on mouse move, hide after 3s of inactivity
|
||||
const handleMouseMove = () => {
|
||||
@@ -167,7 +173,7 @@ export function VideoPlayer({ src, file }: VideoPlayerProps) {
|
||||
{/* Video container with zoom/pan */}
|
||||
<div
|
||||
ref={videoContainerRef}
|
||||
className="relative flex h-full w-full items-center justify-center overflow-hidden"
|
||||
className={`relative flex h-full w-full items-center justify-center ${isZoomed ? 'overflow-visible' : 'overflow-hidden'}`}
|
||||
>
|
||||
<div style={transform} className="flex items-center justify-center">
|
||||
<video
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export { QuickPreview } from './QuickPreview';
|
||||
export { QuickPreviewModal } from './QuickPreviewModal';
|
||||
export { QuickPreviewOverlay } from './QuickPreviewOverlay';
|
||||
export { QuickPreviewFullscreen, PREVIEW_LAYER_ID } from './QuickPreviewFullscreen';
|
||||
|
||||
@@ -148,6 +148,7 @@ export function useZoomPan(
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
reset,
|
||||
isZoomed: zoom > 1,
|
||||
transform: {
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
transition: isDragging ? 'none' : 'transform 0.1s ease-out'
|
||||
|
||||
@@ -14,7 +14,11 @@ import { JobManagerPopover } from "../JobManager/JobManagerPopover";
|
||||
import { SyncMonitorPopover } from "../SyncMonitor";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function SpacesSidebar() {
|
||||
interface SpacesSidebarProps {
|
||||
isPreviewActive?: boolean;
|
||||
}
|
||||
|
||||
export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
|
||||
const client = useSpacedriveClient();
|
||||
const platform = usePlatform();
|
||||
const { data: libraries } = useLibraries();
|
||||
@@ -69,11 +73,11 @@ export function SpacesSidebar() {
|
||||
const { data: layout } = useSpaceLayout(currentSpace?.id ?? null);
|
||||
|
||||
return (
|
||||
<div className="w-[220px] min-w-[176px] max-w-[300px] flex flex-col h-full p-2 bg-app">
|
||||
<div className="w-[220px] min-w-[176px] max-w-[300px] flex flex-col h-full p-2 bg-transparent">
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col h-full rounded-2xl overflow-hidden",
|
||||
"bg-sidebar/65",
|
||||
isPreviewActive ? "backdrop-blur-2xl bg-sidebar/80" : "bg-sidebar/65",
|
||||
)}
|
||||
>
|
||||
<nav className="relative z-[51] flex h-full flex-col gap-2.5 p-2.5 pb-2 pt-[52px]">
|
||||
|
||||
@@ -15,11 +15,9 @@ export {
|
||||
useCoreMutation,
|
||||
useLibraryMutation,
|
||||
useNormalizedQuery,
|
||||
useNormalizedCache, // Alias for useNormalizedQuery
|
||||
} from "@sd/ts-client/hooks";
|
||||
|
||||
// Deprecated - use useNormalizedQuery instead
|
||||
export { useNormalizedQuery as useNormalizedCache } from "@sd/ts-client/hooks";
|
||||
|
||||
// Export client type
|
||||
export type { SpacedriveClient } from "@sd/ts-client";
|
||||
|
||||
|
||||
@@ -644,22 +644,23 @@ function SidecarItem({
|
||||
try {
|
||||
const libraryId = (window as any).__SPACEDRIVE_LIBRARY_ID__;
|
||||
if (!libraryId) {
|
||||
alert("Library ID not found");
|
||||
console.error("Library ID not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert "text" format to "txt" extension (matches actual file on disk)
|
||||
const format = sidecar.format === "text" ? "txt" : sidecar.format;
|
||||
const sidecarPath = await platform.getSidecarPath(
|
||||
libraryId,
|
||||
file.content_identity.uuid,
|
||||
sidecar.kind,
|
||||
sidecar.variant,
|
||||
sidecar.format,
|
||||
format,
|
||||
);
|
||||
|
||||
await platform.revealFile(sidecarPath);
|
||||
} catch (err) {
|
||||
console.error("Failed to reveal sidecar:", err);
|
||||
alert(`Failed to reveal sidecar: ${err}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -87,3 +87,43 @@
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||
}
|
||||
|
||||
/* Animated gradient for audio player background */
|
||||
.audio-gradient {
|
||||
position: relative;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.audio-gradient::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
||||
background-size: 400% 400%;
|
||||
animation: audio-gradient-shift 15s ease infinite;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.audio-gradient::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 0%, transparent 40%, rgba(0, 0, 0, 0.8) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.audio-gradient.paused::before {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes audio-gradient-shift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
export { SpacedriveProvider, useSpacedriveClient, useClient, queryClient } from "./useClient";
|
||||
export { useCoreQuery, useLibraryQuery } from "./useQuery";
|
||||
export { useCoreMutation, useLibraryMutation } from "./useMutation";
|
||||
export { useNormalizedCache } from "./useNormalizedCache";
|
||||
export { useNormalizedQuery } from "./useNormalizedQuery";
|
||||
// Alias for backwards compatibility
|
||||
export { useNormalizedQuery as useNormalizedCache } from "./useNormalizedQuery";
|
||||
|
||||
@@ -1,849 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSpacedriveClient } from "./useClient";
|
||||
|
||||
/**
|
||||
* Deep merge that preserves existing non-null values
|
||||
* Uses metadata from Identifiable trait to determine merge behavior
|
||||
*
|
||||
* @param existing - The current cached value
|
||||
* @param incoming - The new value from the event
|
||||
* @param noMergeFields - Fields to replace (from Identifiable.no_merge_fields)
|
||||
*/
|
||||
function deepMerge(
|
||||
existing: any,
|
||||
incoming: any,
|
||||
noMergeFields: string[] = [],
|
||||
): any {
|
||||
// If incoming is null/undefined, keep existing
|
||||
if (incoming === null || incoming === undefined) {
|
||||
return existing !== null && existing !== undefined
|
||||
? existing
|
||||
: incoming;
|
||||
}
|
||||
|
||||
// If types don't match or not objects, incoming wins
|
||||
if (
|
||||
typeof existing !== "object" ||
|
||||
typeof incoming !== "object" ||
|
||||
Array.isArray(existing) ||
|
||||
Array.isArray(incoming)
|
||||
) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
// Both are objects - deep merge
|
||||
const merged: any = { ...incoming };
|
||||
|
||||
for (const key in existing) {
|
||||
// Check if this field should not be merged (from backend Identifiable trait)
|
||||
if (noMergeFields.includes(key)) {
|
||||
continue; // Use incoming value as-is
|
||||
}
|
||||
|
||||
if (!(key in incoming)) {
|
||||
// Key exists in old but not new - preserve it
|
||||
merged[key] = existing[key];
|
||||
} else if (incoming[key] === null || incoming[key] === undefined) {
|
||||
// Key exists in both but new is null - preserve old
|
||||
if (existing[key] !== null && existing[key] !== undefined) {
|
||||
merged[key] = existing[key];
|
||||
}
|
||||
} else if (
|
||||
typeof existing[key] === "object" &&
|
||||
typeof incoming[key] === "object" &&
|
||||
!Array.isArray(existing[key]) &&
|
||||
!Array.isArray(incoming[key])
|
||||
) {
|
||||
// Both are objects - recurse
|
||||
merged[key] = deepMerge(
|
||||
existing[key],
|
||||
incoming[key],
|
||||
noMergeFields,
|
||||
);
|
||||
}
|
||||
// else: incoming wins (has non-null value)
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a resource matches by ID or alternate IDs
|
||||
* Uses metadata from Identifiable trait for matching
|
||||
*/
|
||||
function resourceMatches(
|
||||
existing: any,
|
||||
incoming: any,
|
||||
alternateIds: string[] = [],
|
||||
): boolean {
|
||||
// Match by primary ID
|
||||
if (existing.id === incoming.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Match by any alternate ID (e.g., content UUID for Files)
|
||||
for (const altId of alternateIds) {
|
||||
if (existing.id === altId || incoming.id === altId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
interface UseNormalizedCacheOptions<I> {
|
||||
/** Wire method to call (e.g., "query:locations.list") */
|
||||
wireMethod: string;
|
||||
/** Input for the query */
|
||||
input: I;
|
||||
/** Resource type for cache indexing (e.g., "location") */
|
||||
resourceType: string;
|
||||
/** Whether the query is enabled (default: true) */
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Optional filter function to check if a resource belongs in this query.
|
||||
* If not provided, all resources that pass the pathScope filter will be added (global list behavior).
|
||||
* Use this for additional filtering beyond path scope (e.g., file type, tags, etc.)
|
||||
*/
|
||||
resourceFilter?: (resource: any) => boolean;
|
||||
/** Resource ID for single-resource queries (filters events to matching ID only) */
|
||||
resourceId?: string;
|
||||
/**
|
||||
* Optional path scope for filtering events to a specific directory/path.
|
||||
* When provided, the backend includes affected_paths in event metadata for efficient filtering.
|
||||
*/
|
||||
pathScope?: import("../types").SdPath;
|
||||
/**
|
||||
* Whether to include descendants (recursive matching) or only direct children (exact matching).
|
||||
* - false (default): Only match files whose parent directory exactly equals pathScope (directory view)
|
||||
* - true: Match all files under pathScope recursively (media view, search results)
|
||||
*/
|
||||
includeDescendants?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook that wraps TanStack Query with event-driven cache updates
|
||||
*
|
||||
* This hook:
|
||||
* 1. Uses TanStack Query normally (all refetching behavior preserved)
|
||||
* 2. Listens for ResourceChanged events for the given resource type
|
||||
* 3. When event arrives, atomically updates TanStack Query's cache
|
||||
* 4. Component re-renders instantly with new data
|
||||
*
|
||||
* TanStack Query continues to refetch based on its normal rules (staleTime, etc.),
|
||||
* but events provide instant updates without waiting for refetch.
|
||||
*
|
||||
* Example:
|
||||
* ```tsx
|
||||
* const { data: locations, isLoading } = useNormalizedCache({
|
||||
* wireMethod: 'query:locations.list',
|
||||
* input: {},
|
||||
* resourceType: 'location',
|
||||
* });
|
||||
*
|
||||
* // When LocationA is created on Device B:
|
||||
* // 1. Backend emits ResourceChanged event
|
||||
* // 2. Event listener updates TanStack Query cache atomically
|
||||
* // 3. This component re-renders
|
||||
* // 4. User sees new location instantly!
|
||||
* // 5. TanStack Query may refetch in background (normal behavior)
|
||||
* ```
|
||||
*/
|
||||
export function useNormalizedCache<I, O>({
|
||||
wireMethod,
|
||||
input,
|
||||
resourceType,
|
||||
enabled = true,
|
||||
resourceFilter,
|
||||
resourceId,
|
||||
pathScope,
|
||||
includeDescendants = false,
|
||||
}: UseNormalizedCacheOptions<I>) {
|
||||
const client = useSpacedriveClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Track library ID reactively so queryKey updates when it changes
|
||||
const [libraryId, setLibraryId] = useState<string | null>(
|
||||
client.getCurrentLibraryId(),
|
||||
);
|
||||
|
||||
// Listen for library ID changes and update our state (causes re-render)
|
||||
useEffect(() => {
|
||||
const handleLibraryChange = (newLibraryId: string) => {
|
||||
setLibraryId(newLibraryId);
|
||||
};
|
||||
|
||||
client.on("library-changed", handleLibraryChange);
|
||||
return () => {
|
||||
client.off("library-changed", handleLibraryChange);
|
||||
};
|
||||
}, [client, wireMethod]);
|
||||
|
||||
// Include library ID in key so switching libraries triggers refetch
|
||||
// useMemo to prevent array recreation on every render
|
||||
const queryKey = useMemo(
|
||||
() => [wireMethod, libraryId, input],
|
||||
[wireMethod, libraryId, JSON.stringify(input)],
|
||||
);
|
||||
|
||||
// Use TanStack Query normally
|
||||
// When libraryId changes, queryKey changes, and TanStack Query automatically fetches new data
|
||||
const query = useQuery<O>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
// Client.execute() automatically adds library_id to the request
|
||||
// as a sibling field to payload
|
||||
return await client.execute<I, O>(wireMethod, input);
|
||||
},
|
||||
enabled: enabled && !!libraryId,
|
||||
});
|
||||
|
||||
// Listen for ResourceChanged events via filtered subscription
|
||||
useEffect(() => {
|
||||
const handleEvent = (event: any) => {
|
||||
// Handle Refresh event - invalidate all queries
|
||||
if ("Refresh" in event) {
|
||||
console.log(
|
||||
"[useNormalizedCache] Refresh event received, invalidating all queries",
|
||||
);
|
||||
queryClient.invalidateQueries();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast path: ignore job/indexing progress events immediately
|
||||
if ("JobProgress" in event || "IndexingProgress" in event) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a ResourceChanged event for our resource type
|
||||
if ("ResourceChanged" in event) {
|
||||
const { resource_type, resource, metadata } =
|
||||
event.ResourceChanged;
|
||||
|
||||
const noMergeFields = metadata?.no_merge_fields || [];
|
||||
|
||||
// Log all events that match our resource type
|
||||
if (resource_type === resourceType)
|
||||
console.log(
|
||||
"targeted ResourceChanged event",
|
||||
resource_type,
|
||||
resourceType,
|
||||
event,
|
||||
);
|
||||
|
||||
if (resource_type === resourceType) {
|
||||
// Atomic update: merge this resource into the query data
|
||||
queryClient.setQueryData<O>(queryKey, (oldData) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
// Handle both array responses and wrapped responses
|
||||
// e.g., LocationsListOutput = { locations: LocationInfo[] }
|
||||
if (Array.isArray(oldData)) {
|
||||
// Direct array response
|
||||
const resourceId = resource.id;
|
||||
const existingIndex = oldData.findIndex(
|
||||
(item: any) => item.id === resourceId,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const newData = [...oldData];
|
||||
newData[existingIndex] = deepMerge(
|
||||
oldData[existingIndex],
|
||||
resource,
|
||||
noMergeFields,
|
||||
);
|
||||
return newData as O;
|
||||
}
|
||||
|
||||
// Append if no filter OR resource passes filter
|
||||
if (!resourceFilter || resourceFilter(resource)) {
|
||||
console.log(
|
||||
"[Cache] Appending new item to array",
|
||||
);
|
||||
return [...oldData, resource] as O;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[Cache] Skipping - filtered out by resourceFilter",
|
||||
);
|
||||
|
||||
return oldData;
|
||||
} else if (oldData && typeof oldData === "object") {
|
||||
// Wrapped response - look for array field
|
||||
// Try common wrapper field names
|
||||
const arrayField = Object.keys(oldData).find(
|
||||
(key) => Array.isArray((oldData as any)[key]),
|
||||
);
|
||||
|
||||
if (arrayField) {
|
||||
const array = (oldData as any)[arrayField];
|
||||
const resourceId = resource.id;
|
||||
const existingIndex = array.findIndex(
|
||||
(item: any) => item.id === resourceId,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const newArray = [...array];
|
||||
newArray[existingIndex] = deepMerge(
|
||||
array[existingIndex],
|
||||
resource,
|
||||
noMergeFields,
|
||||
);
|
||||
console.log(
|
||||
`[${resource_type}] Updated existing item in wrapped array`,
|
||||
{
|
||||
wireMethod,
|
||||
field: arrayField,
|
||||
id: resource.id,
|
||||
},
|
||||
);
|
||||
return {
|
||||
...oldData,
|
||||
[arrayField]: newArray,
|
||||
};
|
||||
}
|
||||
|
||||
// Append if no filter OR resource passes filter
|
||||
if (
|
||||
!resourceFilter ||
|
||||
resourceFilter(resource)
|
||||
) {
|
||||
console.log(
|
||||
`[${resource_type}] Appended to wrapped array`,
|
||||
{
|
||||
wireMethod,
|
||||
field: arrayField,
|
||||
id: resource.id,
|
||||
},
|
||||
);
|
||||
return {
|
||||
...oldData,
|
||||
[arrayField]: [...array, resource],
|
||||
};
|
||||
}
|
||||
|
||||
return oldData;
|
||||
}
|
||||
|
||||
// Check for wrapped single-object field (e.g., { layout: SpaceLayout })
|
||||
for (const key of Object.keys(oldData)) {
|
||||
const wrappedValue = (oldData as any)[key];
|
||||
if (
|
||||
wrappedValue &&
|
||||
typeof wrappedValue === "object" &&
|
||||
!Array.isArray(wrappedValue) &&
|
||||
wrappedValue.id === resource.id
|
||||
) {
|
||||
console.log(
|
||||
`[${resource_type}] Updated wrapped object`,
|
||||
{
|
||||
wireMethod,
|
||||
field: key,
|
||||
id: resource.id,
|
||||
},
|
||||
);
|
||||
return {
|
||||
...oldData,
|
||||
[key]: deepMerge(
|
||||
wrappedValue,
|
||||
resource,
|
||||
noMergeFields,
|
||||
),
|
||||
} as O;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle single object response (e.g., files.by_id returns a single File)
|
||||
// Check if oldData is a single resource object
|
||||
if ((oldData as any).id === resource.id) {
|
||||
// This is the file we're displaying - merge the update
|
||||
// console.log('[Cache] Updating single resource:', {
|
||||
// oldId: (oldData as any).id,
|
||||
// newId: resource.id,
|
||||
// name: resource.name,
|
||||
// });
|
||||
return deepMerge(
|
||||
oldData,
|
||||
resource,
|
||||
noMergeFields,
|
||||
) as O;
|
||||
}
|
||||
|
||||
// Also check by content UUID for single object
|
||||
if (
|
||||
(oldData as any).content_identity?.uuid &&
|
||||
(oldData as any).content_identity.uuid ===
|
||||
resource.content_identity?.uuid
|
||||
) {
|
||||
// console.log('[Cache] Updating single resource by content UUID:', {
|
||||
// contentId: resource.content_identity.uuid,
|
||||
// name: resource.name,
|
||||
// });
|
||||
return deepMerge(
|
||||
oldData,
|
||||
resource,
|
||||
noMergeFields,
|
||||
) as O;
|
||||
}
|
||||
}
|
||||
|
||||
return oldData;
|
||||
});
|
||||
}
|
||||
} else if ("ResourceChangedBatch" in event) {
|
||||
const { resource_type, resources, metadata } =
|
||||
event.ResourceChangedBatch;
|
||||
|
||||
// Log all batch events that match our resource type
|
||||
if (resource_type === resourceType) {
|
||||
console.log(
|
||||
"targeted ResourceChangedBatch event",
|
||||
resource_type,
|
||||
resourceType,
|
||||
metadata,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
resource_type === resourceType &&
|
||||
Array.isArray(resources)
|
||||
) {
|
||||
// Filter resources by resourceId and pathScope
|
||||
let filteredResources = resources;
|
||||
|
||||
// Filter by resourceId if specified
|
||||
if (resourceId) {
|
||||
filteredResources = filteredResources.filter(
|
||||
(r: any) => r.id === resourceId,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by pathScope for file resources
|
||||
if (
|
||||
pathScope &&
|
||||
resourceType === "file" &&
|
||||
!includeDescendants
|
||||
) {
|
||||
// Exact mode: only include files directly in this directory
|
||||
const beforeCount = filteredResources.length;
|
||||
filteredResources = filteredResources.filter(
|
||||
(resource: any) => {
|
||||
const filePath = resource.sd_path;
|
||||
if (!filePath?.Physical) {
|
||||
console.log(
|
||||
"[Batch filter] No Physical path, skipping:",
|
||||
resource.name,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathStr = filePath.Physical.path;
|
||||
const scopeStr = (pathScope as any).Physical
|
||||
?.path;
|
||||
if (!scopeStr) {
|
||||
console.log(
|
||||
"[Batch filter] No scope path, skipping:",
|
||||
resource.name,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get parent directory of the file
|
||||
const lastSlash = pathStr.lastIndexOf("/");
|
||||
if (lastSlash === -1) return false;
|
||||
const parentDir = pathStr.substring(
|
||||
0,
|
||||
lastSlash,
|
||||
);
|
||||
|
||||
const matches = parentDir === scopeStr;
|
||||
console.log(
|
||||
"[Batch filter]",
|
||||
resource.name,
|
||||
"- parent:",
|
||||
parentDir,
|
||||
"scope:",
|
||||
scopeStr,
|
||||
"match:",
|
||||
matches,
|
||||
);
|
||||
|
||||
// Only include if parent directory exactly matches scope
|
||||
return matches;
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
`[Batch filter] Filtered ${beforeCount} → ${filteredResources.length} files for exact pathScope matching`,
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredResources.length === 0) {
|
||||
return; // No matching resources for this query
|
||||
}
|
||||
// Extract merge config from Identifiable metadata
|
||||
const noMergeFields = metadata?.no_merge_fields || [];
|
||||
const alternateIds = metadata?.alternate_ids || [];
|
||||
|
||||
// Atomic update: merge filtered resources into the query data
|
||||
queryClient.setQueryData<O>(queryKey, (oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
|
||||
// Helper: check if resource matches by ID or alternate IDs
|
||||
const matches = (existing: any, incoming: any) => {
|
||||
if (existing.id === incoming.id) return true;
|
||||
// Check alternate IDs (e.g., content UUID for Files)
|
||||
return alternateIds.some(
|
||||
(altId: any) =>
|
||||
existing.id === altId ||
|
||||
existing.content_identity?.uuid === altId ||
|
||||
incoming.id === altId ||
|
||||
incoming.content_identity?.uuid === altId,
|
||||
);
|
||||
};
|
||||
|
||||
// Create a map of filtered incoming resources
|
||||
const resourceMap = new Map(
|
||||
filteredResources.map((r: any) => [r.id, r]),
|
||||
);
|
||||
|
||||
if (Array.isArray(oldData)) {
|
||||
// Direct array response
|
||||
const newData = [...oldData];
|
||||
const seenIds = new Set();
|
||||
|
||||
// Update existing items with deep merge
|
||||
for (let i = 0; i < newData.length; i++) {
|
||||
const item: any = newData[i];
|
||||
if (resourceMap.has(item.id)) {
|
||||
const incomingResource = resourceMap.get(
|
||||
item.id,
|
||||
);
|
||||
newData[i] = deepMerge(
|
||||
item,
|
||||
incomingResource,
|
||||
);
|
||||
seenIds.add(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Append new items if no filter OR resource passes filter
|
||||
for (const resource of resources) {
|
||||
if (seenIds.has(resource.id)) {
|
||||
continue; // Already updated by ID
|
||||
}
|
||||
|
||||
// Check if we should process this resource
|
||||
if (
|
||||
resourceFilter &&
|
||||
!resourceFilter(resource)
|
||||
) {
|
||||
continue; // Filtered out
|
||||
}
|
||||
|
||||
// For Content-based paths with multiple entries, update by content UUID
|
||||
// (sidecar events can create multiple File resources for the same content)
|
||||
if (
|
||||
resource.sd_path?.Content &&
|
||||
resource.content_identity?.uuid
|
||||
) {
|
||||
const contentId =
|
||||
resource.content_identity.uuid;
|
||||
|
||||
// Find existing item with same content
|
||||
const existingIndex = newData.findIndex(
|
||||
(item: any) =>
|
||||
item.content_identity?.uuid ===
|
||||
contentId,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing item (merge sidecars, etc.)
|
||||
newData[existingIndex] = deepMerge(
|
||||
newData[existingIndex],
|
||||
resource,
|
||||
noMergeFields,
|
||||
);
|
||||
console.log(
|
||||
"[Cache] Updated existing file by content UUID:",
|
||||
{
|
||||
name: resource.name,
|
||||
contentId,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// New item - append it
|
||||
newData.push(resource);
|
||||
}
|
||||
|
||||
return newData as O;
|
||||
} else if (oldData && typeof oldData === "object") {
|
||||
// Check if this is a single resource object vs wrapper
|
||||
// Single resource has: id field
|
||||
// Wrapper has: array field (files, locations, etc.) + pagination fields
|
||||
const isSingleResource = !!(oldData as any).id;
|
||||
|
||||
// console.log("[Cache] Batch - response type check:", {
|
||||
// isSingleResource,
|
||||
// hasId: !!(oldData as any).id,
|
||||
// hasSdPath: !!(oldData as any).sd_path,
|
||||
// firstKey: Object.keys(oldData)[0],
|
||||
// });
|
||||
|
||||
if (isSingleResource) {
|
||||
// For File resources with sd_path, validate path matches (prevent cross-path pollution)
|
||||
// Skip this check if resourceId is provided - we've already filtered to the exact file
|
||||
const oldPath = (oldData as any).sd_path;
|
||||
|
||||
if (oldPath && !resourceId) {
|
||||
// This is a File with a path - filter to matching path only
|
||||
// Only needed when resourceId is NOT provided (list queries)
|
||||
const filteredByPath =
|
||||
filteredResources.filter(
|
||||
(resource: any) => {
|
||||
if (!resource.sd_path)
|
||||
return false;
|
||||
|
||||
// Deep compare sd_path objects
|
||||
return (
|
||||
JSON.stringify(oldPath) ===
|
||||
JSON.stringify(
|
||||
resource.sd_path,
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (filteredByPath.length === 0) {
|
||||
return oldData; // No matching paths, don't update
|
||||
}
|
||||
|
||||
// Update to only process path-matching resources
|
||||
filteredResources.length = 0;
|
||||
filteredResources.push(...filteredByPath);
|
||||
resourceMap.clear();
|
||||
filteredByPath.forEach((r) =>
|
||||
resourceMap.set(r.id, r),
|
||||
);
|
||||
}
|
||||
|
||||
// For non-File resources (SpaceLayout, etc), no path filtering needed
|
||||
// They're already filtered by resourceId above
|
||||
|
||||
// Single object response - check each incoming resource
|
||||
for (const resource of filteredResources) {
|
||||
// Match by ID
|
||||
if ((oldData as any).id === resource.id) {
|
||||
console.log(
|
||||
"[Cache] ✓ Updating single object by ID:",
|
||||
{
|
||||
name: resource.name,
|
||||
id: resource.id,
|
||||
},
|
||||
);
|
||||
return deepMerge(
|
||||
oldData,
|
||||
resource,
|
||||
noMergeFields,
|
||||
) as O;
|
||||
}
|
||||
|
||||
// Match by content UUID
|
||||
if (
|
||||
(oldData as any).content_identity
|
||||
?.uuid &&
|
||||
(oldData as any).content_identity
|
||||
.uuid ===
|
||||
resource.content_identity?.uuid
|
||||
) {
|
||||
console.log(
|
||||
"[Cache] ✓ Updating single object by content UUID:",
|
||||
{
|
||||
name: resource.name,
|
||||
contentId:
|
||||
resource.content_identity
|
||||
.uuid,
|
||||
},
|
||||
);
|
||||
return deepMerge(
|
||||
oldData,
|
||||
resource,
|
||||
noMergeFields,
|
||||
) as O;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[Cache] ✗ No match found for single object",
|
||||
);
|
||||
// No match - return unchanged
|
||||
return oldData;
|
||||
}
|
||||
|
||||
// Wrapped response with array field
|
||||
const arrayField = Object.keys(oldData).find(
|
||||
(key) => Array.isArray((oldData as any)[key]),
|
||||
);
|
||||
|
||||
if (arrayField) {
|
||||
const array = [...(oldData as any)[arrayField]];
|
||||
const seenIds = new Set();
|
||||
|
||||
// Update existing items with deep merge
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
const item: any = array[i];
|
||||
if (resourceMap.has(item.id)) {
|
||||
const incomingResource =
|
||||
resourceMap.get(item.id);
|
||||
array[i] = deepMerge(
|
||||
item,
|
||||
incomingResource,
|
||||
);
|
||||
seenIds.add(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Append new items if no filter OR resource passes filter
|
||||
for (const resource of resources) {
|
||||
if (seenIds.has(resource.id)) {
|
||||
continue; // Already updated by ID
|
||||
}
|
||||
|
||||
// Check if we should process this resource
|
||||
if (
|
||||
resourceFilter &&
|
||||
!resourceFilter(resource)
|
||||
) {
|
||||
continue; // Filtered out
|
||||
}
|
||||
|
||||
// For Content-based paths, update existing item by content UUID
|
||||
if (
|
||||
resource.sd_path?.Content &&
|
||||
resource.content_identity?.uuid
|
||||
) {
|
||||
const contentId =
|
||||
resource.content_identity.uuid;
|
||||
const existingIndex = array.findIndex(
|
||||
(item: any) =>
|
||||
item.content_identity?.uuid ===
|
||||
contentId,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing item
|
||||
array[existingIndex] = deepMerge(
|
||||
array[existingIndex],
|
||||
resource,
|
||||
noMergeFields,
|
||||
);
|
||||
console.log(
|
||||
"[Cache] Updated existing file by content UUID:",
|
||||
{
|
||||
name: resource.name,
|
||||
contentId,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// New item - append
|
||||
array.push(resource);
|
||||
}
|
||||
|
||||
return { ...oldData, [arrayField]: array };
|
||||
}
|
||||
}
|
||||
|
||||
return oldData;
|
||||
});
|
||||
}
|
||||
} else if ("ResourceDeleted" in event) {
|
||||
const { resource_type, resource_id } = event.ResourceDeleted;
|
||||
|
||||
if (resource_type === resourceType) {
|
||||
// Atomic update: remove deleted resource
|
||||
queryClient.setQueryData<O>(queryKey, (oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
|
||||
if (Array.isArray(oldData)) {
|
||||
return oldData.filter(
|
||||
(item: any) => item.id !== resource_id,
|
||||
) as O;
|
||||
} else if (oldData && typeof oldData === "object") {
|
||||
const arrayField = Object.keys(oldData).find(
|
||||
(key) => Array.isArray((oldData as any)[key]),
|
||||
);
|
||||
|
||||
if (arrayField) {
|
||||
const array = (oldData as any)[arrayField];
|
||||
return {
|
||||
...oldData,
|
||||
[arrayField]: array.filter(
|
||||
(item: any) => item.id !== resource_id,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return oldData;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create filtered subscription for this specific hook
|
||||
if (!libraryId) return;
|
||||
|
||||
// For file queries, require pathScope to prevent overly broad subscriptions
|
||||
if (resourceType === "file" && !pathScope) {
|
||||
console.log(
|
||||
"[useNormalizedCache] Skipping subscription - file query requires pathScope",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const filter = {
|
||||
resource_type: resourceType,
|
||||
path_scope: pathScope,
|
||||
library_id: libraryId,
|
||||
include_descendants: includeDescendants,
|
||||
};
|
||||
|
||||
console.log("[useNormalizedCache] Creating filtered subscription:", {
|
||||
wireMethod,
|
||||
filter,
|
||||
});
|
||||
|
||||
client.subscribeFiltered(filter, handleEvent).then((unsub) => {
|
||||
unsubscribe = unsub;
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log("[useNormalizedCache] Cleaning up subscription:", {
|
||||
wireMethod,
|
||||
filter,
|
||||
});
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
resourceType,
|
||||
pathScope,
|
||||
libraryId,
|
||||
includeDescendants,
|
||||
queryKey,
|
||||
queryClient,
|
||||
resourceId,
|
||||
]);
|
||||
|
||||
return query;
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query";
|
||||
import { useSpacedriveClient } from "./useClient";
|
||||
import type { Event } from "../generated/types";
|
||||
import { merge } from "ts-deepmerge";
|
||||
import invariant from "tiny-invariant";
|
||||
import * as v from "valibot";
|
||||
import type { Simplify } from "type-fest";
|
||||
@@ -146,7 +145,8 @@ export function useNormalizedQuery<I, O>(
|
||||
if (!libraryId) return;
|
||||
|
||||
// Skip subscription for file queries without pathScope (prevent overly broad subscriptions)
|
||||
if (options.resourceType === "file" && !options.pathScope) {
|
||||
// Unless resourceId is provided (single-file queries like FileInspector don't need pathScope)
|
||||
if (options.resourceType === "file" && !options.pathScope && !options.resourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ export function useNormalizedQuery<I, O>(
|
||||
client,
|
||||
queryClient,
|
||||
options.resourceType,
|
||||
options.resourceId,
|
||||
options.pathScope,
|
||||
options.includeDescendants,
|
||||
libraryId,
|
||||
@@ -246,6 +247,7 @@ export function handleResourceEvent(
|
||||
|
||||
const { resource_type, resources, metadata } =
|
||||
result.output.ResourceChangedBatch;
|
||||
|
||||
if (resource_type === options.resourceType && Array.isArray(resources)) {
|
||||
updateBatchResources(resources, metadata, options, queryKey, queryClient);
|
||||
}
|
||||
@@ -490,6 +492,14 @@ function updateWrappedCache(
|
||||
newResources: any[],
|
||||
noMergeFields: string[],
|
||||
): any {
|
||||
// First check: if oldData has an id that matches incoming, merge directly
|
||||
// This handles single object responses like files.by_id
|
||||
const match = newResources.find((r: any) => r.id === oldData.id);
|
||||
if (match) {
|
||||
return safeMerge(oldData, match, noMergeFields);
|
||||
}
|
||||
|
||||
// Second check: wrapped responses like { files: [...] }
|
||||
const arrayField = Object.keys(oldData).find((key) =>
|
||||
Array.isArray(oldData[key]),
|
||||
);
|
||||
@@ -518,17 +528,18 @@ function updateWrappedCache(
|
||||
return { ...oldData, [arrayField]: array };
|
||||
}
|
||||
|
||||
// Single object response
|
||||
const match = newResources.find((r: any) => r.id === oldData.id);
|
||||
if (match) {
|
||||
return safeMerge(oldData, match, noMergeFields);
|
||||
}
|
||||
|
||||
return oldData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe deep merge using ts-deepmerge with noMergeFields support
|
||||
* Safe deep merge for resource updates
|
||||
*
|
||||
* Arrays are REPLACED (not concatenated) because:
|
||||
* - sidecars: Server sends complete list, duplicating would corrupt data
|
||||
* - alternate_paths: Same - server is authoritative
|
||||
* - tags: Same pattern
|
||||
*
|
||||
* Only nested objects are deep merged (like content_identity).
|
||||
*
|
||||
* Exported for testing
|
||||
*/
|
||||
@@ -542,17 +553,36 @@ export function safeMerge(
|
||||
return existing !== null && existing !== undefined ? existing : incoming;
|
||||
}
|
||||
|
||||
// For fields that should be replaced entirely, remove them from existing
|
||||
// so ts-deepmerge doesn't try to merge them
|
||||
if (noMergeFields.length > 0) {
|
||||
const existingCopy = { ...existing };
|
||||
for (const field of noMergeFields) {
|
||||
delete existingCopy[field];
|
||||
// Shallow merge with incoming winning, but deep merge nested objects
|
||||
const result: any = { ...existing };
|
||||
|
||||
for (const key of Object.keys(incoming)) {
|
||||
const incomingVal = incoming[key];
|
||||
const existingVal = existing[key];
|
||||
|
||||
// noMergeFields: incoming always wins
|
||||
if (noMergeFields.includes(key)) {
|
||||
result[key] = incomingVal;
|
||||
}
|
||||
// Arrays: replace entirely (don't concatenate)
|
||||
else if (Array.isArray(incomingVal)) {
|
||||
result[key] = incomingVal;
|
||||
}
|
||||
// Nested objects: deep merge recursively
|
||||
else if (
|
||||
incomingVal !== null &&
|
||||
typeof incomingVal === "object" &&
|
||||
existingVal !== null &&
|
||||
typeof existingVal === "object" &&
|
||||
!Array.isArray(existingVal)
|
||||
) {
|
||||
result[key] = safeMerge(existingVal, incomingVal, noMergeFields);
|
||||
}
|
||||
// Primitives: incoming wins
|
||||
else {
|
||||
result[key] = incomingVal;
|
||||
}
|
||||
// Now merge - incoming's noMergeFields will win
|
||||
return merge(existingCopy, incoming);
|
||||
}
|
||||
|
||||
// Standard deep merge
|
||||
return merge(existing, incoming);
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user