Add fullscreen quick preview via portal layer

This commit is contained in:
Jamie Pine
2025-11-27 09:47:58 -08:00
parent ce595235b5
commit 41fa50433c
21 changed files with 741 additions and 1136 deletions

View File

@@ -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?;

View File

@@ -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>

View File

@@ -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" ? (

View File

@@ -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>
);

View File

@@ -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]">

View File

@@ -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>

View File

@@ -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":

View File

@@ -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) {

View File

@@ -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 };

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -1,2 +1,4 @@
export { QuickPreview } from './QuickPreview';
export { QuickPreviewModal } from './QuickPreviewModal';
export { QuickPreviewOverlay } from './QuickPreviewOverlay';
export { QuickPreviewFullscreen, PREVIEW_LAYER_ID } from './QuickPreviewFullscreen';

View File

@@ -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'

View File

@@ -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]">

View File

@@ -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";

View File

@@ -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}`);
}
}
},

View File

@@ -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%;
}
}

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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;
}