diff --git a/core/src/domain/resource_manager.rs b/core/src/domain/resource_manager.rs index 5543696ac..ad6d1be24 100644 --- a/core/src/domain/resource_manager.rs +++ b/core/src/domain/resource_manager.rs @@ -163,7 +163,8 @@ impl ResourceManager { }); } - return Ok(()); + // Continue to check for virtual resource dependencies + // (e.g., space_item -> space_layout, entry -> file) } // Check if any virtual resources depend on this type (dependency routing) @@ -180,8 +181,9 @@ impl ResourceManager { } if all_virtual_resources.is_empty() { - tracing::warn!( - "No resource info found for type '{}' and no virtual mappings", + // No virtual resources depend on this type - that's fine for simple resources + tracing::debug!( + "No virtual resource dependencies for type '{}'", resource_type ); return Ok(()); diff --git a/core/src/domain/space.rs b/core/src/domain/space.rs index 5ddf6c1d0..832cec780 100644 --- a/core/src/domain/space.rs +++ b/core/src/domain/space.rs @@ -417,6 +417,9 @@ pub enum ItemType { /// Favorited files (fixed) Favorites, + /// File kinds (images, videos, audio, etc.) + FileKinds, + /// Indexed location Location { location_id: Uuid }, diff --git a/core/src/library/manager.rs b/core/src/library/manager.rs index 0c74053f2..a6ef330d0 100644 --- a/core/src/library/manager.rs +++ b/core/src/library/manager.rs @@ -1130,11 +1130,12 @@ impl LibraryManager { info!("Created default space for library {}", library.id()); - // Create space-level items (Overview, Recents, Favorites) - these appear outside groups + // Create space-level items (Overview, Recents, Favorites, File Kinds) - these appear outside groups let space_items = vec![ (ItemType::Overview, "Overview", 0), (ItemType::Recents, "Recents", 1), (ItemType::Favorites, "Favorites", 2), + (ItemType::FileKinds, "File Kinds", 3), ]; use crate::infra::db::entities::space_item::{Column as ItemColumn, Entity as ItemEntity}; diff --git a/core/src/ops/spaces/delete_group/action.rs b/core/src/ops/spaces/delete_group/action.rs index c1ee918b9..e5f19c41a 100644 --- a/core/src/ops/spaces/delete_group/action.rs +++ b/core/src/ops/spaces/delete_group/action.rs @@ -47,6 +47,16 @@ impl LibraryAction for DeleteGroupAction { use crate::domain::{resource::EventEmitter, SpaceGroup}; SpaceGroup::emit_deleted(group_id, library.event_bus()); + // Emit virtual resource events (space_layout) via ResourceManager + let resource_manager = crate::domain::ResourceManager::new( + std::sync::Arc::new(library.db().conn().clone()), + library.event_bus().clone(), + ); + resource_manager + .emit_resource_events("space_group", vec![group_id]) + .await + .map_err(|e| ActionError::Internal(format!("Failed to emit resource events: {}", e)))?; + Ok(DeleteGroupOutput { success: true }) } diff --git a/core/src/ops/spaces/delete_item/action.rs b/core/src/ops/spaces/delete_item/action.rs index 84b821ed7..7d4b2624c 100644 --- a/core/src/ops/spaces/delete_item/action.rs +++ b/core/src/ops/spaces/delete_item/action.rs @@ -46,6 +46,16 @@ impl LibraryAction for DeleteItemAction { use crate::domain::{resource::EventEmitter, SpaceItem}; SpaceItem::emit_deleted(item_id, library.event_bus()); + // Emit virtual resource events (space_layout) via ResourceManager + let resource_manager = crate::domain::ResourceManager::new( + std::sync::Arc::new(library.db().conn().clone()), + library.event_bus().clone(), + ); + resource_manager + .emit_resource_events("space_item", vec![item_id]) + .await + .map_err(|e| ActionError::Internal(format!("Failed to emit resource events: {}", e)))?; + Ok(DeleteItemOutput { success: true }) } diff --git a/packages/interface/src/Explorer.tsx b/packages/interface/src/Explorer.tsx index 47c283106..82329bf42 100644 --- a/packages/interface/src/Explorer.tsx +++ b/packages/interface/src/Explorer.tsx @@ -55,6 +55,7 @@ import type { File } from "@sd/ts-client"; import { File as FileComponent } from "./components/Explorer/File"; import { DaemonDisconnectedOverlay } from "./components/DaemonDisconnectedOverlay"; import { useFileOperationDialog } from "./components/FileOperationModal"; +import { House, Clock, Heart, Folders } from "@phosphor-icons/react"; /** * QuickPreviewSyncer - Syncs selection changes to QuickPreview @@ -563,6 +564,35 @@ function DndWrapper({ children }: { children: React.ReactNode }) { groupId: dropData?.groupId, }); + // Handle palette item drops (from customization panel) + if (dragData?.type === "palette-item") { + const libraryId = client.getCurrentLibraryId(); + const currentSpace = + spaces?.find((s: any) => s.id === currentSpaceId) ?? + spaces?.[0]; + + if (!currentSpace || !libraryId) return; + + console.log("[DnD] Adding palette item:", { + itemType: dragData.itemType, + spaceId: currentSpace.id, + dropAction: dropData?.action, + groupId: dropData?.groupId, + }); + + try { + await addItem.mutateAsync({ + space_id: currentSpace.id, + group_id: dropData?.groupId || null, + item_type: dragData.itemType, + }); + console.log("[DnD] Successfully added palette item"); + } catch (err) { + console.error("[DnD] Failed to add palette item:", err); + } + return; + } + if (!dragData || dragData.type !== "explorer-file") return; // Add to space (root-level drop zones between groups) @@ -720,7 +750,36 @@ function DndWrapper({ children }: { children: React.ReactNode }) { > {children} - {activeItem?.file ? ( + {activeItem?.type === "palette-item" ? ( + // Palette item preview +
+ {activeItem.itemType === "Overview" && ( + + )} + {activeItem.itemType === "Recents" && ( + + )} + {activeItem.itemType === "Favorites" && ( + + )} + {activeItem.itemType === "FileKinds" && ( + + )} + + {activeItem.itemType === "Overview" && "Overview"} + {activeItem.itemType === "Recents" && "Recents"} + {activeItem.itemType === "Favorites" && "Favorites"} + {activeItem.itemType === "FileKinds" && "File Kinds"} + +
+ ) : activeItem?.label ? ( + // Group or SpaceItem preview (from sortable context) +
+ + {activeItem.label} + +
+ ) : activeItem?.file ? ( activeItem.gridSize ? ( // Grid view preview
diff --git a/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx b/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx index dcf1cfe91..301a42028 100644 --- a/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx @@ -8,9 +8,16 @@ import type { ListLibraryDevicesInput, LibraryDeviceInfo } from "@sd/ts-client"; interface DevicesGroupProps { isCollapsed: boolean; onToggle: () => void; + sortableAttributes?: any; + sortableListeners?: any; } -export function DevicesGroup({ isCollapsed, onToggle }: DevicesGroupProps) { +export function DevicesGroup({ + isCollapsed, + onToggle, + sortableAttributes, + sortableListeners, +}: DevicesGroupProps) { const navigate = useNavigate(); // Use normalized query for automatic updates when device events are emitted @@ -33,6 +40,8 @@ export function DevicesGroup({ isCollapsed, onToggle }: DevicesGroupProps) { label="Devices" isCollapsed={isCollapsed} onToggle={onToggle} + sortableAttributes={sortableAttributes} + sortableListeners={sortableListeners} /> {/* Items */} diff --git a/packages/interface/src/components/SpacesSidebar/GroupHeader.tsx b/packages/interface/src/components/SpacesSidebar/GroupHeader.tsx index 0dd9e740f..708d3d3ef 100644 --- a/packages/interface/src/components/SpacesSidebar/GroupHeader.tsx +++ b/packages/interface/src/components/SpacesSidebar/GroupHeader.tsx @@ -1,5 +1,9 @@ -import { CaretRight } from "@phosphor-icons/react"; +import { CaretRight, DotsSixVertical, PencilSimple, Trash } from "@phosphor-icons/react"; import clsx from "clsx"; +import { useState } from "react"; +import { useContextMenu } from "../../hooks/useContextMenu"; +import { useLibraryMutation } from "@sd/ts-client"; +import type { SpaceGroup } from "@sd/ts-client"; interface GroupHeaderProps { label: string; @@ -8,6 +12,8 @@ interface GroupHeaderProps { rightComponent?: React.ReactNode; sortableAttributes?: any; sortableListeners?: any; + group?: SpaceGroup; + allowCustomization?: boolean; } export function GroupHeader({ @@ -17,21 +23,121 @@ export function GroupHeader({ rightComponent, sortableAttributes, sortableListeners, + group, + allowCustomization = false, }: GroupHeaderProps) { + const hasSortable = sortableAttributes && sortableListeners; + const [isRenaming, setIsRenaming] = useState(false); + const [newName, setNewName] = useState(label); + + const updateGroup = useLibraryMutation("spaces.update_group"); + const deleteGroup = useLibraryMutation("spaces.delete_group"); + + const handleRename = async () => { + if (!group || !newName.trim() || newName === label) { + setIsRenaming(false); + setNewName(label); + return; + } + + try { + await updateGroup.mutateAsync({ + group_id: group.id, + name: newName.trim(), + }); + setIsRenaming(false); + } catch (error) { + console.error("Failed to rename group:", error); + setNewName(label); + setIsRenaming(false); + } + }; + + const handleDelete = async () => { + if (!group) return; + + try { + await deleteGroup.mutateAsync({ group_id: group.id }); + } catch (error) { + console.error("Failed to delete group:", error); + } + }; + + const contextMenu = useContextMenu({ + items: [ + { + icon: PencilSimple, + label: "Rename Group", + onClick: () => { + setNewName(label); + setIsRenaming(true); + }, + condition: () => allowCustomization, + }, + { type: "separator" }, + { + icon: Trash, + label: "Delete Group", + onClick: handleDelete, + variant: "danger" as const, + condition: () => allowCustomization, + }, + ], + }); + + const handleContextMenu = async (e: React.MouseEvent) => { + if (!allowCustomization) return; + e.preventDefault(); + e.stopPropagation(); + await contextMenu.show(e); + }; + return ( - +
+ {/* Drag Handle - Only show if sortable */} + {hasSortable && ( +
+ +
+ )} + + {/* Collapsible Button or Rename Input */} + {isRenaming ? ( + setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleRename(); + } else if (e.key === "Escape") { + setIsRenaming(false); + setNewName(label); + } + }} + onBlur={handleRename} + autoFocus + className="flex-1 px-2 py-1 text-tiny font-semibold tracking-wider rounded-md bg-sidebar-box border border-sidebar-line text-sidebar-ink placeholder:text-sidebar-ink-faint outline-none focus:border-accent" + /> + ) : ( + + )} +
); } diff --git a/packages/interface/src/components/SpacesSidebar/LocationsGroup.tsx b/packages/interface/src/components/SpacesSidebar/LocationsGroup.tsx index 9788a9f14..1b36660fd 100644 --- a/packages/interface/src/components/SpacesSidebar/LocationsGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/LocationsGroup.tsx @@ -6,9 +6,16 @@ import { GroupHeader } from "./GroupHeader"; interface LocationsGroupProps { isCollapsed: boolean; onToggle: () => void; + sortableAttributes?: any; + sortableListeners?: any; } -export function LocationsGroup({ isCollapsed, onToggle }: LocationsGroupProps) { +export function LocationsGroup({ + isCollapsed, + onToggle, + sortableAttributes, + sortableListeners, +}: LocationsGroupProps) { const navigate = useNavigate(); const { data: locationsData } = useNormalizedQuery({ @@ -21,7 +28,13 @@ export function LocationsGroup({ isCollapsed, onToggle }: LocationsGroupProps) { return (
- + {/* Items */} {!isCollapsed && ( diff --git a/packages/interface/src/components/SpacesSidebar/SpaceCustomizationPanel.tsx b/packages/interface/src/components/SpacesSidebar/SpaceCustomizationPanel.tsx new file mode 100644 index 000000000..f44c1a799 --- /dev/null +++ b/packages/interface/src/components/SpacesSidebar/SpaceCustomizationPanel.tsx @@ -0,0 +1,301 @@ +import { motion, AnimatePresence } from "framer-motion"; +import { X, Plus } from "@phosphor-icons/react"; +import { useDraggable } from "@dnd-kit/core"; +import clsx from "clsx"; +import type { ItemType, SpaceItem as SpaceItemType, GroupType } from "@sd/ts-client"; +import { SpaceItem } from "./SpaceItem"; +import { createPortal } from "react-dom"; +import { useState } from "react"; +import { useLibraryMutation } from "../../context"; +import { Input } from "@sd/ui"; + +interface PaletteItem { + type: ItemType; + label: string; +} + +const PALETTE_ITEMS: PaletteItem[] = [ + { + type: "Overview", + label: "Overview", + }, + { + type: "Recents", + label: "Recents", + }, + { + type: "Favorites", + label: "Favorites", + }, + { + type: "FileKinds", + label: "File Kinds", + }, +]; + +function DraggablePaletteItem({ item }: { item: PaletteItem }) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `palette-${item.label}`, + data: { + type: "palette-item", + itemType: item.type, + }, + }); + + // Create a mock SpaceItem for rendering + const mockSpaceItem: SpaceItemType = { + id: `palette-${item.label}`, + space_id: "", + group_id: null, + item_type: item.type, + order: 0, + created_at: new Date().toISOString(), + }; + + return ( +
+ { + e.preventDefault(); + e.stopPropagation(); + }} + /> +
+ ); +} + +interface SpaceCustomizationPanelProps { + isOpen: boolean; + onClose: () => void; + spaceId: string | null; +} + +function getDefaultGroupName(groupType: GroupType): string { + if (groupType === "Devices") return "Devices"; + if (groupType === "Locations") return "Locations"; + if (groupType === "Tags") return "Tags"; + if (groupType === "Cloud") return "Cloud"; + if (groupType === "Custom") return "Custom Group"; + if (typeof groupType === "object" && "Device" in groupType) return "Device"; + return "Group"; +} + +export function SpaceCustomizationPanel({ + isOpen, + onClose, + spaceId, +}: SpaceCustomizationPanelProps) { + const [groupType, setGroupType] = useState("Custom"); + const [groupName, setGroupName] = useState(""); + const [isAddingGroup, setIsAddingGroup] = useState(false); + const addGroup = useLibraryMutation("spaces.add_group"); + + if (!spaceId) return null; + + const handleAddGroup = async () => { + if (!spaceId) return; + + try { + const result = await addGroup.mutateAsync({ + space_id: spaceId, + name: groupName.trim() || getDefaultGroupName(groupType), + group_type: groupType, + }); + + // Reset form + setGroupName(""); + setGroupType("Custom"); + setIsAddingGroup(false); + + // Scroll to the newly created group in the sidebar after a brief delay + // (allows time for the group to be added to the DOM) + setTimeout(() => { + const groupElement = document.querySelector( + `[data-group-id="${result.group.id}"]`, + ); + if (groupElement) { + groupElement.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + // Add a temporary highlight effect + groupElement.classList.add("ring-2", "ring-accent/50"); + setTimeout(() => { + groupElement.classList.remove( + "ring-2", + "ring-accent/50", + ); + }, 2000); + } + }, 100); + } catch (err) { + console.error("Failed to add group:", err); + } + }; + + const content = ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Panel */} + +
+ {/* Header */} +
+
+

+ Customize +

+

+ Drag to sidebar +

+
+ +
+ + {/* Content */} +
+ {/* Quick Access Items */} +
+ {PALETTE_ITEMS.map((item) => ( + + ))} +
+ + {/* Add Group Section */} +
+
+ + Groups + +
+ + {!isAddingGroup ? ( + + ) : ( +
+ + + {groupType === "Custom" && ( + + setGroupName(e.target.value) + } + placeholder="Group name" + className="text-xs" + onKeyDown={(e) => { + if (e.key === "Enter") { + handleAddGroup(); + } else if (e.key === "Escape") { + setIsAddingGroup(false); + setGroupName(""); + } + }} + autoFocus + /> + )} + +
+ + +
+
+ )} +
+
+ + {/* Footer */} +
+

+ Drag items to your space +

+
+
+
+ + )} +
+ ); + + return createPortal(content, document.body); +} + diff --git a/packages/interface/src/components/SpacesSidebar/SpaceGroup.tsx b/packages/interface/src/components/SpacesSidebar/SpaceGroup.tsx index 03c5bd701..2d1ad007d 100644 --- a/packages/interface/src/components/SpacesSidebar/SpaceGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/SpaceGroup.tsx @@ -2,14 +2,14 @@ import type { SpaceGroup as SpaceGroupType, SpaceItem as SpaceItemType, } from "@sd/ts-client"; -import { useSidebarStore } from "@sd/ts-client"; +import { useSidebarStore, useLibraryMutation } from "@sd/ts-client"; import { SpaceItem } from "./SpaceItem"; import { DevicesGroup } from "./DevicesGroup"; import { LocationsGroup } from "./LocationsGroup"; import { VolumesGroup } from "./VolumesGroup"; import { TagsGroup } from "./TagsGroup"; import { GroupHeader } from "./GroupHeader"; -import { useDroppable } from "@dnd-kit/core"; +import { useDroppable, useDndContext } from "@dnd-kit/core"; interface SpaceGroupProps { group: SpaceGroupType; @@ -26,9 +26,33 @@ export function SpaceGroup({ sortableAttributes, sortableListeners, }: SpaceGroupProps) { - const { collapsedGroups, toggleGroup } = useSidebarStore(); + const { collapsedGroups, toggleGroup: toggleGroupLocal } = useSidebarStore(); + const { active } = useDndContext(); + const updateGroup = useLibraryMutation("spaces.update_group"); + // Use backend's is_collapsed value as the source of truth, fallback to local state const isCollapsed = group.is_collapsed ?? collapsedGroups.has(group.id); + + // Toggle handler that updates both local and backend state + const handleToggle = async () => { + // Optimistically update local state for immediate UI feedback + toggleGroupLocal(group.id); + + // Update backend + try { + await updateGroup.mutateAsync({ + group_id: group.id, + is_collapsed: !isCollapsed, + }); + } catch (error) { + console.error("Failed to update group collapse state:", error); + // Revert local state on error + toggleGroupLocal(group.id); + } + }; + + // Disable insertion drop zones when dragging groups or space items (they have 'label' in their data) + const isDraggingSortableItem = active?.data?.current?.label != null; // System groups (Locations, Volumes, etc.) are dynamic - don't allow insertion/reordering // Custom/QuickAccess groups allow insertion @@ -38,47 +62,63 @@ export function SpaceGroup({ // Devices group - fetches all devices (library + paired) if (group.group_type === "Devices") { return ( - toggleGroup(group.id)} - /> +
+ +
); } // Locations group - fetches all locations if (group.group_type === "Locations") { return ( - toggleGroup(group.id)} - /> +
+ +
); } // Volumes group - fetches all volumes if (group.group_type === "Volumes") { return ( - toggleGroup(group.id)} - /> +
+ +
); } // Tags group - fetches all tags if (group.group_type === "Tags") { return ( - toggleGroup(group.id)} - /> +
+ +
); } // Empty drop zone for groups with no items const { setNodeRef: setEmptyRef, isOver: isOverEmpty } = useDroppable({ id: `group-${group.id}-empty`, - disabled: !allowInsertion || isCollapsed, + disabled: !allowInsertion || isCollapsed || isDraggingSortableItem, data: { action: "add-to-group", groupId: group.id, @@ -88,13 +128,15 @@ export function SpaceGroup({ // QuickAccess and Custom groups render stored items return ( -
+
toggleGroup(group.id)} + onToggle={handleToggle} sortableAttributes={sortableAttributes} sortableListeners={sortableListeners} + group={group} + allowCustomization={allowInsertion} /> {/* Items */} @@ -116,9 +158,9 @@ export function SpaceGroup({ ref={setEmptyRef} className="absolute inset-0 z-10" > - {isOverEmpty && ( -
- )} + {isOverEmpty && !isDraggingSortableItem && ( +
+ )}
)}
diff --git a/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx b/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx index b9abc66c4..b02aa8296 100644 --- a/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx +++ b/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx @@ -12,6 +12,7 @@ import { MagnifyingGlass, Trash, Database, + Folders, } from "@phosphor-icons/react"; import { Location } from "@sd/assets/icons"; import type { @@ -23,7 +24,7 @@ import { Thumb } from "../Explorer/File/Thumb"; import { useContextMenu } from "../../hooks/useContextMenu"; import { usePlatform } from "../../platform"; import { useLibraryMutation } from "../../context"; -import { useDroppable } from "@dnd-kit/core"; +import { useDroppable, useDndContext } from "@dnd-kit/core"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -59,6 +60,7 @@ function getItemIcon(itemType: ItemType): any { if (itemType === "Overview") return { type: "component", icon: House }; if (itemType === "Recents") return { type: "component", icon: Clock }; if (itemType === "Favorites") return { type: "component", icon: Heart }; + if (itemType === "FileKinds") return { type: "component", icon: Folders }; if (typeof itemType === "object" && "Location" in itemType) return { type: "image", icon: Location }; if (typeof itemType === "object" && "Volume" in itemType) @@ -74,6 +76,7 @@ function getItemLabel(itemType: ItemType): string { if (itemType === "Overview") return "Overview"; if (itemType === "Recents") return "Recents"; if (itemType === "Favorites") return "Favorites"; + if (itemType === "FileKinds") return "File Kinds"; if (typeof itemType === "object" && "Location" in itemType) { return itemType.Location.name || "Unnamed Location"; } @@ -103,6 +106,7 @@ function getItemPath( if (itemType === "Overview") return "/"; if (itemType === "Recents") return "/recents"; if (itemType === "Favorites") return "/favorites"; + if (itemType === "FileKinds") return "/file-kinds"; if (typeof itemType === "object" && "Location" in itemType) { // For proper SpaceItem with Location type, we need the sd_path // This requires the parent to pass volumeData or similar @@ -152,26 +156,10 @@ export function SpaceItem({ const platform = usePlatform(); const deleteItem = useLibraryMutation("spaces.delete_item"); const indexVolume = useLibraryMutation("volumes.index"); - - // Sortable hook (for reordering) - const sortableProps = useSortable({ - id: item.id, - disabled: !sortable, - }); - - const { - attributes: sortableAttributes, - listeners: sortableListeners, - setNodeRef: setSortableRef, - transform, - transition, - isDragging: isSortableDragging, - } = sortableProps; - - const style = sortable ? { - transform: CSS.Transform.toString(transform), - transition, - } : undefined; + const { active } = useDndContext(); + + // Disable insertion drop zones when dragging groups or space items (they have 'label' in their data) + const isDraggingSortableItem = active?.data?.current?.label != null; // Check if this is a raw location object (has 'name' and 'sd_path' but no 'item_type') const isRawLocation = @@ -207,6 +195,29 @@ export function SpaceItem({ label = customLabel; } + // Sortable hook (for reordering) - must be after label is defined + const sortableProps = useSortable({ + id: item.id, + disabled: !sortable, + data: { + label: label, + }, + }); + + const { + attributes: sortableAttributes, + listeners: sortableListeners, + setNodeRef: setSortableRef, + transform, + transition, + isDragging: isSortableDragging, + } = sortableProps; + + const style = sortable ? { + transform: CSS.Transform.toString(transform), + transition, + } : undefined; + // Check if this item is active by comparing SD paths const isActive = (() => { if (!path) return false; @@ -306,21 +317,21 @@ export function SpaceItem({ return false; }, }, - { type: "separator" }, - { - icon: Trash, - label: "Remove from Space", - onClick: async () => { - try { - await deleteItem.mutateAsync({ item_id: item.id }); - } catch (err) { - console.error("Failed to remove item:", err); - } - }, - variant: "danger" as const, - // Can only remove custom Path items, not built-in items - condition: () => typeof item.item_type === "object" && "Path" in item.item_type, + { type: "separator" }, + { + icon: Trash, + label: "Remove from Space", + onClick: async () => { + try { + await deleteItem.mutateAsync({ item_id: item.id }); + } catch (err) { + console.error("Failed to remove item:", err); + } }, + variant: "danger" as const, + // All space items can be removed (Overview, Recents, Favorites, FileKinds, Locations, Volumes, Tags, Paths) + condition: () => spaceId != null, + }, ], }); @@ -382,7 +393,7 @@ export function SpaceItem({ const { setNodeRef: setTopRef, isOver: isOverTop } = useDroppable({ id: `space-item-${item.id}-top`, - disabled: !allowInsertion, + disabled: !allowInsertion || isDraggingSortableItem, data: { action: "insert-before", itemId: item.id, @@ -393,7 +404,7 @@ export function SpaceItem({ const { setNodeRef: setBottomRef, isOver: isOverBottom } = useDroppable({ id: `space-item-${item.id}-bottom`, - disabled: !allowInsertion, + disabled: !allowInsertion || isDraggingSortableItem, data: { action: "insert-after", itemId: item.id, @@ -427,7 +438,7 @@ export function SpaceItem({ const { setNodeRef: setMiddleRef, isOver: isOverMiddle } = useDroppable({ id: `space-item-${item.id}-middle`, - disabled: !isDropTarget, + disabled: !isDropTarget || isDraggingSortableItem, data: { action: "move-into", targetType, @@ -443,14 +454,14 @@ export function SpaceItem({ className={clsx("relative", isSortableDragging && "opacity-50 z-50")} > {/* Insertion line indicator - only show top (bottom of previous item handles gaps) */} - {isOverTop && !isSortableDragging && ( -
- )} + {isOverTop && !isSortableDragging && !isDraggingSortableItem && ( +
+ )} {/* Ring highlight for drop-into */} - {isOverMiddle && isDropTarget && !isSortableDragging && ( -
- )} + {isOverMiddle && isDropTarget && !isSortableDragging && !isDraggingSortableItem && ( +
+ )}
{/* Drop zones - invisible overlays, only active during drag */} @@ -496,14 +507,14 @@ export function SpaceItem({ onClick={handleClick} onContextMenu={handleContextMenu} {...(sortable ? { ...sortableAttributes, ...sortableListeners } : {})} - className={clsx( - "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors relative cursor-default", - className || - (isActive - ? "bg-sidebar-selected/30 text-sidebar-ink" - : "text-sidebar-inkDull"), - isOverMiddle && isDropTarget && "bg-accent/10", - )} + className={clsx( + "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors relative cursor-default", + className || + (isActive + ? "bg-sidebar-selected/30 text-sidebar-ink" + : "text-sidebar-inkDull"), + isOverMiddle && isDropTarget && !isDraggingSortableItem && "bg-accent/10", + )} > {resolvedFile ? ( @@ -518,9 +529,9 @@ export function SpaceItem({
{/* Insertion line indicator - bottom (only for last item to allow dropping at end) */} - {isOverBottom && isLastItem && ( -
- )} + {isOverBottom && isLastItem && !isDraggingSortableItem && ( +
+ )}
); } diff --git a/packages/interface/src/components/SpacesSidebar/TagsGroup.tsx b/packages/interface/src/components/SpacesSidebar/TagsGroup.tsx index 787dec838..d5812e2c6 100644 --- a/packages/interface/src/components/SpacesSidebar/TagsGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/TagsGroup.tsx @@ -9,6 +9,8 @@ import { GroupHeader } from './GroupHeader'; interface TagsGroupProps { isCollapsed: boolean; onToggle: () => void; + sortableAttributes?: any; + sortableListeners?: any; } interface TagItemProps { @@ -79,7 +81,12 @@ function TagItem({ tag, depth = 0 }: TagItemProps) { ); } -export function TagsGroup({ isCollapsed, onToggle }: TagsGroupProps) { +export function TagsGroup({ + isCollapsed, + onToggle, + sortableAttributes, + sortableListeners, +}: TagsGroupProps) { const navigate = useNavigate(); const [isCreating, setIsCreating] = useState(false); const [newTagName, setNewTagName] = useState(''); @@ -124,6 +131,8 @@ export function TagsGroup({ isCollapsed, onToggle }: TagsGroupProps) { label="Tags" isCollapsed={isCollapsed} onToggle={onToggle} + sortableAttributes={sortableAttributes} + sortableListeners={sortableListeners} rightComponent={ tags.length > 0 && ( {tags.length} diff --git a/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx b/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx index 2ebaa312f..75c4e63fa 100644 --- a/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx @@ -1,5 +1,5 @@ import { useNavigate } from "react-router-dom"; -import { WifiSlash } from "@phosphor-icons/react"; +import { Plugs, WifiSlash } from "@phosphor-icons/react"; import { useNormalizedQuery, getVolumeIcon } from "@sd/ts-client"; import { SpaceItem } from "./SpaceItem"; import { GroupHeader } from "./GroupHeader"; @@ -10,12 +10,16 @@ interface VolumesGroupProps { onToggle: () => void; /** Filter to show tracked, untracked, or all volumes (default: "All") */ filter?: "TrackedOnly" | "UntrackedOnly" | "All"; + sortableAttributes?: any; + sortableListeners?: any; } export function VolumesGroup({ isCollapsed, onToggle, filter = "All", + sortableAttributes, + sortableListeners, }: VolumesGroupProps) { const navigate = useNavigate(); @@ -31,7 +35,7 @@ export function VolumesGroup({ const getVolumeIndicator = (volume: VolumeItem) => ( <> {!volume.is_tracked && ( - + )} ); @@ -42,6 +46,8 @@ export function VolumesGroup({ label="Volumes" isCollapsed={isCollapsed} onToggle={onToggle} + sortableAttributes={sortableAttributes} + sortableListeners={sortableListeners} /> {/* Volumes List */} diff --git a/packages/interface/src/components/SpacesSidebar/index.tsx b/packages/interface/src/components/SpacesSidebar/index.tsx index a49e26ac6..0f0e85352 100644 --- a/packages/interface/src/components/SpacesSidebar/index.tsx +++ b/packages/interface/src/components/SpacesSidebar/index.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { GearSix } from "@phosphor-icons/react"; +import { GearSix, Palette } from "@phosphor-icons/react"; import { useSidebarStore, useLibraryMutation } from "@sd/ts-client"; import type { SpaceGroup as SpaceGroupType, SpaceItem as SpaceItemType } from "@sd/ts-client"; import { useSpaces, useSpaceLayout } from "./hooks/useSpaces"; @@ -7,13 +7,14 @@ import { SpaceSwitcher } from "./SpaceSwitcher"; import { SpaceGroup } from "./SpaceGroup"; import { SpaceItem } from "./SpaceItem"; import { AddGroupButton } from "./AddGroupButton"; +import { SpaceCustomizationPanel } from "./SpaceCustomizationPanel"; import { useSpacedriveClient } from "../../context"; import { useLibraries } from "../../hooks/useLibraries"; import { usePlatform } from "../../platform"; import { JobManagerPopover } from "../JobManager/JobManagerPopover"; import { SyncMonitorPopover } from "../SyncMonitor"; import clsx from "clsx"; -import { useDroppable } from "@dnd-kit/core"; +import { useDroppable, useDndContext } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -29,9 +30,15 @@ function SpaceGroupWithDropZone({ spaceId?: string; isFirst: boolean; }) { + const { active } = useDndContext(); + + // Disable drop zone when dragging groups or space items (they have 'label' in their data) + // This allows sortable collision detection to work for reordering + const isDraggingSortableItem = active?.data?.current?.label != null; + const { setNodeRef: setDropRef, isOver } = useDroppable({ id: `space-root-before-${group.id}`, - disabled: !spaceId, + disabled: !spaceId || isDraggingSortableItem, data: { action: 'add-to-space', spaceId, @@ -47,8 +54,12 @@ function SpaceGroupWithDropZone({ transform, transition, isDragging, + setActivatorNodeRef, } = useSortable({ id: group.id, + data: { + label: group.name, + }, }); const style = { @@ -60,7 +71,7 @@ function SpaceGroupWithDropZone({
{/* Drop zone before this group (for adding root-level items) */}
- {isOver && !isDragging && ( + {isOver && !isDragging && !isDraggingSortableItem && (
)}
@@ -86,6 +97,7 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) { const [currentLibraryId, setCurrentLibraryId] = useState( () => client.getCurrentLibraryId(), ); + const [customizePanelOpen, setCustomizePanelOpen] = useState(false); const { currentSpaceId, setCurrentSpace } = useSidebarStore(); const { data: spacesData } = useSpaces(); @@ -196,10 +208,20 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) { {currentSpace && }
- {/* Sync Monitor, Job Manager & Settings (pinned to bottom) */} + {/* Sync Monitor, Job Manager, Customize & Settings (pinned to bottom) */}
+
+ + {/* Customization Panel */} + setCustomizePanelOpen(false)} + spaceId={currentSpace?.id ?? null} + />
); } diff --git a/packages/interface/src/router.tsx b/packages/interface/src/router.tsx index 84ef42621..c2cd6e1ff 100644 --- a/packages/interface/src/router.tsx +++ b/packages/interface/src/router.tsx @@ -5,69 +5,74 @@ import { ExplorerLayout } from "./Explorer"; import { JobsScreen } from "./components/JobManager"; import { DaemonManager } from "./routes/DaemonManager"; import { TagView } from "./routes/tag"; +import { FileKindsView } from "./routes/file-kinds"; /** * Router for the main Explorer interface */ export function createExplorerRouter() { - return createBrowserRouter([ - { - path: "/", - element: , - children: [ - { - index: true, - element: , - }, - { - path: "explorer", - element: , - }, - { - path: "location/:locationId", - element: , - }, - { - path: "location/:locationId/*", - element: , - }, - { - path: "favorites", - element: ( -
- Favorites (coming soon) -
- ), - }, - { - path: "recents", - element: ( -
- Recents (coming soon) -
- ), - }, - { - path: "tag/:tagId", - element: , - }, - { - path: "search", - element: ( -
- Search (coming soon) -
- ), - }, - { - path: "jobs", - element: , - }, - { - path: "daemon", - element: , - }, - ], - }, - ]); + return createBrowserRouter([ + { + path: "/", + element: , + children: [ + { + index: true, + element: , + }, + { + path: "explorer", + element: , + }, + { + path: "location/:locationId", + element: , + }, + { + path: "location/:locationId/*", + element: , + }, + { + path: "favorites", + element: ( +
+ Favorites (coming soon) +
+ ), + }, + { + path: "recents", + element: ( +
+ Recents (coming soon) +
+ ), + }, + { + path: "file-kinds", + element: , + }, + { + path: "tag/:tagId", + element: , + }, + { + path: "search", + element: ( +
+ Search (coming soon) +
+ ), + }, + { + path: "jobs", + element: , + }, + { + path: "daemon", + element: , + }, + ], + }, + ]); } diff --git a/packages/interface/src/routes/file-kinds/index.tsx b/packages/interface/src/routes/file-kinds/index.tsx new file mode 100644 index 000000000..7e7173d99 --- /dev/null +++ b/packages/interface/src/routes/file-kinds/index.tsx @@ -0,0 +1,189 @@ +import { useNavigate } from "react-router-dom"; +import { useNormalizedQuery } from "../../context"; +import type { ContentKind } from "@sd/ts-client"; +import { getIcon } from "@sd/assets/util"; + +interface ContentKindStat { + kind: ContentKind; + name: string; + file_count: bigint | number; +} + +interface ContentKindStatsOutput { + stats: ContentKindStat[]; + total_files: bigint | number; +} + +// Map content kind names to icon names and colors +// Keys must match backend ContentKind variants (lowercase) +// Icon names must match actual files in packages/assets/icons/ +const CONTENT_KIND_CONFIG: Record = + { + image: { iconName: "Image", color: "#3B82F6" }, + video: { iconName: "Video", color: "#8B5CF6" }, + audio: { iconName: "Audio", color: "#10B981" }, + document: { iconName: "Document", color: "#F59E0B" }, + archive: { iconName: "Archive", color: "#6366F1" }, + code: { iconName: "Text", color: "#EF4444" }, // No Code.png, using Text.png + text: { iconName: "Text", color: "#6B7280" }, + database: { iconName: "Database", color: "#14B8A6" }, + book: { iconName: "Book", color: "#8B5CF6" }, + font: { iconName: "Text", color: "#F59E0B" }, + mesh: { iconName: "Mesh", color: "#06B6D4" }, + config: { iconName: "Document", color: "#6366F1" }, + encrypted: { iconName: "Encrypted", color: "#DC2626" }, + key: { iconName: "Key", color: "#FCD34D" }, + executable: { iconName: "Executable", color: "#7C3AED" }, + binary: { iconName: "Executable", color: "#6B7280" }, + spreadsheet: { iconName: "Document", color: "#10B981" }, + presentation: { iconName: "Document", color: "#F97316" }, + email: { iconName: "Document", color: "#3B82F6" }, + calendar: { iconName: "Document", color: "#06B6D4" }, + contact: { iconName: "Document", color: "#EC4899" }, + web: { iconName: "Globe", color: "#3B82F6" }, + shortcut: { iconName: "Link", color: "#8B5CF6" }, + package: { iconName: "Package", color: "#F59E0B" }, + model_entry: { iconName: "Mesh", color: "#06B6D4" }, + memory: { iconName: "Database", color: "#6366F1" }, + unknown: { iconName: "Document", color: "#6B7280" }, + }; + +function formatFileCount(count: number): string { + if (count >= 1000000) { + return `${(count / 1000000).toFixed(1)}M`; + } + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}K`; + } + return count.toString(); +} + +/** + * File Kinds View + * Shows content kinds (images, videos, audio, etc.) with file counts + */ +export function FileKindsView() { + const navigate = useNavigate(); + + // Fetch content kind statistics + const { data: statsData, isLoading } = useNormalizedQuery< + Record, + ContentKindStatsOutput + >({ + wireMethod: "query:files.content_kind_stats", + input: {}, + resourceType: "content_kind", + }); + + const stats = (statsData?.stats ?? []).sort( + (a, b) => Number(b.file_count) - Number(a.file_count), + ); + const totalFiles = Number(statsData?.total_files ?? 0); + + if (isLoading) { + return ( +
+ Loading file kinds... +
+ ); + } + + const handleKindClick = (kind: ContentKind) => { + // TODO: Navigate to explorer with content kind filter + // For now, just log + console.log("Content kind clicked:", kind); + }; + + return ( +
+ {/* Header */} +
+
+
+

+ File Kinds +

+

+ Browse your files by content type +

+
+
+
+ {formatFileCount(totalFiles)} +
+
Total Files
+
+
+
+ + {/* Content Grid */} +
+
+ {stats.map((stat) => { + // Get config for this content kind + const config = + CONTENT_KIND_CONFIG[stat.name] || + CONTENT_KIND_CONFIG.unknown; + const icon = getIcon( + config.iconName, + true, // Dark theme + null, + false, + ); + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/packages/interface/src/routes/overview/HeroStats.tsx b/packages/interface/src/routes/overview/HeroStats.tsx index b4ef4fad2..3a5f04098 100644 --- a/packages/interface/src/routes/overview/HeroStats.tsx +++ b/packages/interface/src/routes/overview/HeroStats.tsx @@ -76,11 +76,10 @@ export function HeroStats({ {/* Storage Health - Future feature */}
diff --git a/packages/ts-client/src/generated/types.ts b/packages/ts-client/src/generated/types.ts index 0c76f6d35..5b0851ae6 100644 --- a/packages/ts-client/src/generated/types.ts +++ b/packages/ts-client/src/generated/types.ts @@ -150,6 +150,41 @@ export type ContentIdentity = { uuid: string; kind: ContentKind; content_hash: s */ export type ContentKind = "unknown" | "image" | "video" | "audio" | "document" | "archive" | "code" | "text" | "database" | "book" | "font" | "mesh" | "config" | "encrypted" | "key" | "executable" | "binary" | "spreadsheet" | "presentation" | "email" | "calendar" | "contact" | "web" | "shortcut" | "package" | "model_entry" | "memory"; +/** + * A single content kind with its file count + */ +export type ContentKindStat = { +/** + * The content kind (image, video, audio, etc.) + */ +kind: ContentKind; +/** + * The name of the content kind + */ +name: string; +/** + * The number of files with this content kind + */ +file_count: number }; + +/** + * Input for content kind statistics query + */ +export type ContentKindStatsInput = Record; + +/** + * Output containing content kind statistics + */ +export type ContentKindStatsOutput = { +/** + * Statistics for each content kind + */ +stats: ContentKindStat[]; +/** + * Total number of files across all content kinds + */ +total_files: number }; + /** * Copy method preference for file operations */ @@ -1558,6 +1593,10 @@ export type ItemType = * Favorited files (fixed) */ "Favorites" | +/** + * File kinds (images, videos, audio, etc.) + */ +"FileKinds" | /** * Indexed location */ @@ -4013,211 +4052,213 @@ success: boolean }; // ===== API Type Unions ===== export type CoreAction = - { type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput } - | { type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput } - | { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput } - | { type: 'libraries.delete'; input: LibraryDeleteInput; output: LibraryDeleteOutput } - | { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput } - | { type: 'network.sync_setup'; input: LibrarySyncSetupInput; output: LibrarySyncSetupOutput } + { type: 'libraries.delete'; input: LibraryDeleteInput; output: LibraryDeleteOutput } | { type: 'models.whisper.delete'; input: DeleteWhisperModelInput; output: DeleteWhisperModelOutput } | { type: 'models.whisper.download'; input: DownloadWhisperModelInput; output: DownloadWhisperModelOutput } - | { type: 'libraries.open'; input: LibraryOpenInput; output: LibraryOpenOutput } - | { type: 'core.reset'; input: ResetDataInput; output: ResetDataOutput } - | { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput } + | { type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput } | { type: 'network.start'; input: NetworkStartInput; output: NetworkStartOutput } | { type: 'network.stop'; input: NetworkStopInput; output: NetworkStopOutput } + | { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput } + | { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput } + | { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput } | { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput } + | { type: 'network.sync_setup'; input: LibrarySyncSetupInput; output: LibrarySyncSetupOutput } + | { type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput } + | { type: 'core.reset'; input: ResetDataInput; output: ResetDataOutput } + | { type: 'libraries.open'; input: LibraryOpenInput; output: LibraryOpenOutput } ; export type LibraryAction = - { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput } + { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput } + | { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput } + | { type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput } + | { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput } + | { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt } + | { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput } + | { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput } + | { type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput } + | { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput } + | { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput } + | { type: 'locations.add'; input: LocationAddInput; output: LocationAddOutput } + | { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput } + | { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput } + | { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput } + | { type: 'spaces.add_group'; input: AddGroupInput; output: AddGroupOutput } + | { type: 'media.splat.generate'; input: GenerateSplatInput; output: GenerateSplatOutput } + | { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput } + | { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput } + | { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput } + | { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput } + | { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput } + | { type: 'files.copy'; input: FileCopyInput; output: JobReceipt } + | { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput } + | { type: 'volumes.untrack'; input: VolumeUntrackInput; output: VolumeUntrackOutput } + | { type: 'spaces.delete'; input: SpaceDeleteInput; output: SpaceDeleteOutput } + | { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput } + | { type: 'volumes.index'; input: IndexVolumeInput; output: IndexVolumeOutput } | { type: 'media.thumbnail.regenerate'; input: RegenerateThumbnailInput; output: RegenerateThumbnailOutput } | { type: 'media.thumbnail'; input: ThumbnailInput; output: JobReceipt } - | { type: 'volumes.untrack'; input: VolumeUntrackInput; output: VolumeUntrackOutput } - | { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput } + | { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput } + | { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput } | { type: 'spaces.create'; input: SpaceCreateInput; output: SpaceCreateOutput } - | { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput } | { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput } - | { type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput } - | { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput } - | { type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput } - | { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput } - | { type: 'locations.triggerJob'; input: LocationTriggerJobInput; output: LocationTriggerJobOutput } - | { type: 'indexing.start'; input: IndexInput; output: JobReceipt } + | { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput } + | { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput } + | { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput } | { type: 'spaces.reorder_items'; input: ReorderItemsInput; output: ReorderOutput } | { type: 'spaces.reorder_groups'; input: ReorderGroupsInput; output: ReorderOutput } - | { type: 'volumes.index'; input: IndexVolumeInput; output: IndexVolumeOutput } - | { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput } - | { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput } - | { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput } - | { type: 'files.copy'; input: FileCopyInput; output: JobReceipt } - | { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput } - | { type: 'media.splat.generate'; input: GenerateSplatInput; output: GenerateSplatOutput } - | { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput } - | { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput } - | { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput } - | { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput } - | { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput } - | { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput } - | { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput } - | { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput } - | { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput } - | { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput } - | { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput } - | { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput } - | { type: 'locations.add'; input: LocationAddInput; output: LocationAddOutput } | { type: 'jobs.resume'; input: JobResumeInput; output: JobResumeOutput } + | { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput } + | { type: 'locations.triggerJob'; input: LocationTriggerJobInput; output: LocationTriggerJobOutput } + | { type: 'indexing.start'; input: IndexInput; output: JobReceipt } | { type: 'media.ocr.extract'; input: ExtractTextInput; output: ExtractTextOutput } - | { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput } - | { type: 'spaces.add_group'; input: AddGroupInput; output: AddGroupOutput } - | { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput } - | { type: 'spaces.delete'; input: SpaceDeleteInput; output: SpaceDeleteOutput } - | { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt } ; export type CoreQuery = { type: 'network.status'; input: NetworkStatusQueryInput; output: NetworkStatus } - | { type: 'network.sync_setup.discover'; input: DiscoverRemoteLibrariesInput; output: DiscoverRemoteLibrariesOutput } | { type: 'core.events.list'; input: ListEventsInput; output: ListEventsOutput } - | { type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput } - | { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput } | { type: 'core.ephemeral_status'; input: EphemeralCacheStatusInput; output: EphemeralCacheStatus } + | { type: 'network.sync_setup.discover'; input: DiscoverRemoteLibrariesInput; output: DiscoverRemoteLibrariesOutput } + | { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput } + | { type: 'core.status'; input: Empty; output: CoreStatus } | { type: 'jobs.remote.all_devices'; input: RemoteJobsAllDevicesInput; output: RemoteJobsAllDevicesOutput } | { type: 'jobs.remote.for_device'; input: RemoteJobsForDeviceInput; output: RemoteJobsForDeviceOutput } | { type: 'models.whisper.list'; input: ListWhisperModelsInput; output: ListWhisperModelsOutput } - | { type: 'core.status'; input: Empty; output: CoreStatus } + | { type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput } | { type: 'libraries.list'; input: ListLibrariesInput; output: [LibraryInfo] } ; export type LibraryQuery = - { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput } - | { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput } - | { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput } + { type: 'files.by_id'; input: FileByIdQuery; output: File } + | { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput } | { type: 'jobs.info'; input: JobInfoQueryInput; output: JobInfoOutput } - | { type: 'files.by_id'; input: FileByIdQuery; output: File } - | { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput } - | { type: 'jobs.active'; input: ActiveJobsInput; output: ActiveJobsOutput } - | { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput } - | { type: 'test.ping'; input: PingInput; output: PingOutput } - | { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput } - | { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput } - | { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput } - | { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput } - | { type: 'libraries.info'; input: LibraryInfoQueryInput; output: Library } - | { type: 'sync.metrics'; input: GetSyncMetricsInput; output: GetSyncMetricsOutput } + | { type: 'jobs.list'; input: JobListInput; output: JobListOutput } | { type: 'spaces.get_layout'; input: SpaceLayoutQueryInput; output: SpaceLayout } + | { type: 'files.content_kind_stats'; input: ContentKindStatsInput; output: ContentKindStatsOutput } + | { type: 'locations.validate_path'; input: ValidateLocationPathInput; output: ValidateLocationPathOutput } + | { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput } + | { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput } + | { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput } + | { type: 'files.unique_to_location'; input: UniqueToLocationInput; output: UniqueToLocationOutput } + | { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput } + | { type: 'test.ping'; input: PingInput; output: PingOutput } | { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput } + | { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput } + | { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput } + | { type: 'files.by_path'; input: FileByPathQuery; output: File } + | { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput } + | { type: 'sync.metrics'; input: GetSyncMetricsInput; output: GetSyncMetricsOutput } + | { type: 'jobs.active'; input: ActiveJobsInput; output: ActiveJobsOutput } + | { type: 'libraries.info'; input: LibraryInfoQueryInput; output: Library } | { type: 'devices.list'; input: ListLibraryDevicesInput; output: [Device] } | { type: 'spaces.list'; input: SpacesListQueryInput; output: SpacesListOutput } - | { type: 'locations.validate_path'; input: ValidateLocationPathInput; output: ValidateLocationPathOutput } - | { type: 'files.unique_to_location'; input: UniqueToLocationInput; output: UniqueToLocationOutput } - | { type: 'jobs.list'; input: JobListInput; output: JobListOutput } - | { type: 'files.by_path'; input: FileByPathQuery; output: File } + | { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput } ; // ===== Wire Method Mappings ===== export const WIRE_METHODS = { coreActions: { - 'network.pair.cancel': 'action:network.pair.cancel.input', - 'libraries.create': 'action:libraries.create.input', - 'network.pair.join': 'action:network.pair.join.input', 'libraries.delete': 'action:libraries.delete.input', - 'network.spacedrop.send': 'action:network.spacedrop.send.input', - 'network.sync_setup': 'action:network.sync_setup.input', 'models.whisper.delete': 'action:models.whisper.delete.input', 'models.whisper.download': 'action:models.whisper.download.input', - 'libraries.open': 'action:libraries.open.input', - 'core.reset': 'action:core.reset.input', - 'network.pair.generate': 'action:network.pair.generate.input', + 'libraries.create': 'action:libraries.create.input', 'network.start': 'action:network.start.input', 'network.stop': 'action:network.stop.input', + 'network.pair.join': 'action:network.pair.join.input', + 'network.spacedrop.send': 'action:network.spacedrop.send.input', + 'network.pair.generate': 'action:network.pair.generate.input', 'network.device.revoke': 'action:network.device.revoke.input', + 'network.sync_setup': 'action:network.sync_setup.input', + 'network.pair.cancel': 'action:network.pair.cancel.input', + 'core.reset': 'action:core.reset.input', + 'libraries.open': 'action:libraries.open.input', }, libraryActions: { + 'volumes.remove_cloud': 'action:volumes.remove_cloud.input', + 'indexing.verify': 'action:indexing.verify.input', + 'locations.export': 'action:locations.export.input', + 'jobs.cancel': 'action:jobs.cancel.input', + 'files.delete': 'action:files.delete.input', + 'spaces.delete_item': 'action:spaces.delete_item.input', + 'locations.remove': 'action:locations.remove.input', + 'spaces.delete_group': 'action:spaces.delete_group.input', + 'volumes.speed_test': 'action:volumes.speed_test.input', + 'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input', + 'locations.add': 'action:locations.add.input', + 'media.speech.transcribe': 'action:media.speech.transcribe.input', + 'spaces.update_group': 'action:spaces.update_group.input', + 'spaces.update': 'action:spaces.update.input', + 'spaces.add_group': 'action:spaces.add_group.input', + 'media.splat.generate': 'action:media.splat.generate.input', + 'tags.create': 'action:tags.create.input', + 'locations.update': 'action:locations.update.input', + 'jobs.pause': 'action:jobs.pause.input', + 'libraries.export': 'action:libraries.export.input', + 'locations.enable_indexing': 'action:locations.enable_indexing.input', + 'files.copy': 'action:files.copy.input', 'tags.apply': 'action:tags.apply.input', + 'volumes.untrack': 'action:volumes.untrack.input', + 'spaces.delete': 'action:spaces.delete.input', + 'spaces.add_item': 'action:spaces.add_item.input', + 'volumes.index': 'action:volumes.index.input', 'media.thumbnail.regenerate': 'action:media.thumbnail.regenerate.input', 'media.thumbnail': 'action:media.thumbnail.input', - 'volumes.untrack': 'action:volumes.untrack.input', - 'media.proxy.generate': 'action:media.proxy.generate.input', + 'locations.rescan': 'action:locations.rescan.input', + 'volumes.track': 'action:volumes.track.input', 'spaces.create': 'action:spaces.create.input', - 'volumes.speed_test': 'action:volumes.speed_test.input', 'libraries.rename': 'action:libraries.rename.input', - 'locations.export': 'action:locations.export.input', - 'spaces.update': 'action:spaces.update.input', - 'spaces.delete_group': 'action:spaces.delete_group.input', - 'spaces.add_item': 'action:spaces.add_item.input', - 'locations.triggerJob': 'action:locations.triggerJob.input', - 'indexing.start': 'action:indexing.start.input', + 'volumes.add_cloud': 'action:volumes.add_cloud.input', + 'locations.import': 'action:locations.import.input', + 'media.proxy.generate': 'action:media.proxy.generate.input', 'spaces.reorder_items': 'action:spaces.reorder_items.input', 'spaces.reorder_groups': 'action:spaces.reorder_groups.input', - 'volumes.index': 'action:volumes.index.input', - 'jobs.pause': 'action:jobs.pause.input', - 'locations.update': 'action:locations.update.input', - 'spaces.delete_item': 'action:spaces.delete_item.input', - 'files.copy': 'action:files.copy.input', - 'tags.create': 'action:tags.create.input', - 'media.splat.generate': 'action:media.splat.generate.input', - 'indexing.verify': 'action:indexing.verify.input', - 'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input', - 'volumes.refresh': 'action:volumes.refresh.input', - 'volumes.add_cloud': 'action:volumes.add_cloud.input', - 'libraries.export': 'action:libraries.export.input', - 'locations.import': 'action:locations.import.input', - 'locations.rescan': 'action:locations.rescan.input', - 'spaces.update_group': 'action:spaces.update_group.input', - 'media.speech.transcribe': 'action:media.speech.transcribe.input', - 'volumes.track': 'action:volumes.track.input', - 'locations.enable_indexing': 'action:locations.enable_indexing.input', - 'locations.remove': 'action:locations.remove.input', - 'locations.add': 'action:locations.add.input', 'jobs.resume': 'action:jobs.resume.input', + 'volumes.refresh': 'action:volumes.refresh.input', + 'locations.triggerJob': 'action:locations.triggerJob.input', + 'indexing.start': 'action:indexing.start.input', 'media.ocr.extract': 'action:media.ocr.extract.input', - 'jobs.cancel': 'action:jobs.cancel.input', - 'spaces.add_group': 'action:spaces.add_group.input', - 'volumes.remove_cloud': 'action:volumes.remove_cloud.input', - 'spaces.delete': 'action:spaces.delete.input', - 'files.delete': 'action:files.delete.input', }, coreQueries: { 'network.status': 'query:network.status', - 'network.sync_setup.discover': 'query:network.sync_setup.discover', 'core.events.list': 'query:core.events.list', - 'network.devices.list': 'query:network.devices.list', - 'network.pair.status': 'query:network.pair.status', 'core.ephemeral_status': 'query:core.ephemeral_status', + 'network.sync_setup.discover': 'query:network.sync_setup.discover', + 'network.pair.status': 'query:network.pair.status', + 'core.status': 'query:core.status', 'jobs.remote.all_devices': 'query:jobs.remote.all_devices', 'jobs.remote.for_device': 'query:jobs.remote.for_device', 'models.whisper.list': 'query:models.whisper.list', - 'core.status': 'query:core.status', + 'network.devices.list': 'query:network.devices.list', 'libraries.list': 'query:libraries.list', }, libraryQueries: { - 'volumes.list': 'query:volumes.list', - 'spaces.get': 'query:spaces.get', - 'sync.activity': 'query:sync.activity', - 'jobs.info': 'query:jobs.info', 'files.by_id': 'query:files.by_id', - 'tags.search': 'query:tags.search', - 'jobs.active': 'query:jobs.active', - 'sync.eventLog': 'query:sync.eventLog', - 'test.ping': 'query:test.ping', - 'locations.list': 'query:locations.list', - 'files.directory_listing': 'query:files.directory_listing', - 'search.files': 'query:search.files', - 'files.media_listing': 'query:files.media_listing', - 'libraries.info': 'query:libraries.info', - 'sync.metrics': 'query:sync.metrics', + 'volumes.list': 'query:volumes.list', + 'jobs.info': 'query:jobs.info', + 'jobs.list': 'query:jobs.list', 'spaces.get_layout': 'query:spaces.get_layout', + 'files.content_kind_stats': 'query:files.content_kind_stats', + 'locations.validate_path': 'query:locations.validate_path', + 'tags.search': 'query:tags.search', + 'sync.eventLog': 'query:sync.eventLog', + 'search.files': 'query:search.files', + 'files.unique_to_location': 'query:files.unique_to_location', + 'files.directory_listing': 'query:files.directory_listing', + 'test.ping': 'query:test.ping', 'locations.suggested': 'query:locations.suggested', + 'spaces.get': 'query:spaces.get', + 'locations.list': 'query:locations.list', + 'files.by_path': 'query:files.by_path', + 'sync.activity': 'query:sync.activity', + 'sync.metrics': 'query:sync.metrics', + 'jobs.active': 'query:jobs.active', + 'libraries.info': 'query:libraries.info', 'devices.list': 'query:devices.list', 'spaces.list': 'query:spaces.list', - 'locations.validate_path': 'query:locations.validate_path', - 'files.unique_to_location': 'query:files.unique_to_location', - 'jobs.list': 'query:jobs.list', - 'files.by_path': 'query:files.by_path', + 'files.media_listing': 'query:files.media_listing', }, } as const;