Enhance resource management and UI components

- Updated `ResourceManager` to improve handling of virtual resource dependencies, adding debug logging for better traceability.
- Introduced a new `FileKinds` item type in the `ItemType` enum to categorize file types (images, videos, audio, etc.).
- Enhanced the `LibraryManager` to create space-level items, including the new `FileKinds` category.
- Implemented virtual resource event emissions in `DeleteGroupAction` and `DeleteItemAction` to ensure proper resource management during deletions.
- Added a new `SpaceCustomizationPanel` for managing space groups and items, allowing users to customize their workspace effectively.
- Updated various UI components to support drag-and-drop functionality for palette items and improved context menu interactions for group management.
This commit is contained in:
Jamie Pine
2025-12-20 07:48:20 -08:00
parent 1c5a9d3558
commit 19fed1376a
19 changed files with 1151 additions and 306 deletions

View File

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

View File

@@ -417,6 +417,9 @@ pub enum ItemType {
/// Favorited files (fixed)
Favorites,
/// File kinds (images, videos, audio, etc.)
FileKinds,
/// Indexed location
Location { location_id: Uuid },

View File

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

View File

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

View File

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

View File

@@ -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}
<DragOverlay dropAnimation={null}>
{activeItem?.file ? (
{activeItem?.type === "palette-item" ? (
// Palette item preview
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-accent text-white shadow-lg min-w-[180px]">
{activeItem.itemType === "Overview" && (
<House size={20} weight="bold" />
)}
{activeItem.itemType === "Recents" && (
<Clock size={20} weight="bold" />
)}
{activeItem.itemType === "Favorites" && (
<Heart size={20} weight="bold" />
)}
{activeItem.itemType === "FileKinds" && (
<Folders size={20} weight="bold" />
)}
<span className="text-sm font-medium">
{activeItem.itemType === "Overview" && "Overview"}
{activeItem.itemType === "Recents" && "Recents"}
{activeItem.itemType === "Favorites" && "Favorites"}
{activeItem.itemType === "FileKinds" && "File Kinds"}
</span>
</div>
) : activeItem?.label ? (
// Group or SpaceItem preview (from sortable context)
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-sidebar/95 backdrop-blur-sm text-sidebar-ink shadow-lg border border-sidebar-line min-w-[180px]">
<span className="text-sm font-medium">
{activeItem.label}
</span>
</div>
) : activeItem?.file ? (
activeItem.gridSize ? (
// Grid view preview
<div style={{ width: activeItem.gridSize }}>

View File

@@ -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 */}

View File

@@ -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 (
<button
onClick={onToggle}
{...(sortableAttributes || {})}
{...(sortableListeners || {})}
className="mb-1 flex w-full cursor-default items-center gap-2 px-1 text-tiny font-semibold tracking-wider opacity-60 text-sidebar-ink-faint hover:text-sidebar-ink"
>
<CaretRight
className={clsx("transition-transform", !isCollapsed && "rotate-90")}
size={10}
weight="bold"
/>
<span>{label}</span>
{rightComponent}
</button>
<div className="mb-1 flex w-full items-center gap-1 px-1">
{/* Drag Handle - Only show if sortable */}
{hasSortable && (
<div
{...sortableAttributes}
{...sortableListeners}
className="cursor-grab active:cursor-grabbing py-2 px-0.5 -ml-1 text-sidebar-inkFaint hover:text-sidebar-ink transition-colors"
>
<DotsSixVertical size={12} weight="bold" />
</div>
)}
{/* Collapsible Button or Rename Input */}
{isRenaming ? (
<input
type="text"
value={newName}
onChange={(e) => 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"
/>
) : (
<button
onClick={onToggle}
onContextMenu={handleContextMenu}
className="flex-1 flex items-center gap-2 py-2 text-tiny font-semibold tracking-wider opacity-60 text-sidebar-ink-faint hover:text-sidebar-ink transition-colors min-h-[32px]"
>
<CaretRight
className={clsx("transition-transform", !isCollapsed && "rotate-90")}
size={10}
weight="bold"
/>
<span>{label}</span>
{rightComponent}
</button>
)}
</div>
);
}

View File

@@ -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 (
<div>
<GroupHeader label="Locations" isCollapsed={isCollapsed} onToggle={onToggle} />
<GroupHeader
label="Locations"
isCollapsed={isCollapsed}
onToggle={onToggle}
sortableAttributes={sortableAttributes}
sortableListeners={sortableListeners}
/>
{/* Items */}
{!isCollapsed && (

View File

@@ -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 (
<div
ref={setNodeRef}
{...attributes}
{...listeners}
className={clsx(
"cursor-move transition-opacity",
isDragging && "opacity-50",
)}
>
<SpaceItem
item={mockSpaceItem}
allowInsertion={false}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</div>
);
}
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<GroupType>("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 = (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/20 z-[65]"
onClick={onClose}
/>
{/* Panel */}
<motion.div
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -20, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.25, 1, 0.5, 1] }}
className="fixed left-[228px] top-2 bottom-2 w-[220px] z-[70]"
>
<div className="h-full rounded-2xl bg-sidebar flex flex-col p-2.5">
{/* Header */}
<div className="flex items-center justify-between px-2 py-2 mb-2">
<div>
<h2 className="text-sm font-semibold text-sidebar-ink">
Customize
</h2>
<p className="text-xs text-sidebar-inkDull mt-0.5">
Drag to sidebar
</p>
</div>
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-sidebar-selected/30 transition-colors"
>
<X size={14} className="text-sidebar-inkDull" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto space-y-4">
{/* Quick Access Items */}
<div className="space-y-0.5">
{PALETTE_ITEMS.map((item) => (
<DraggablePaletteItem
key={item.label}
item={item}
/>
))}
</div>
{/* Add Group Section */}
<div className="space-y-2 pt-2 border-t border-sidebar-line/50">
<div className="flex items-center justify-between px-2">
<span className="text-xs font-semibold text-sidebar-inkDull uppercase tracking-wider">
Groups
</span>
</div>
{!isAddingGroup ? (
<button
onClick={() => setIsAddingGroup(true)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-inkDull hover:text-sidebar-ink hover:bg-sidebar-selected/30 transition-colors"
>
<Plus size={16} weight="bold" />
<span>Add Group</span>
</button>
) : (
<div className="space-y-2 px-2">
<select
value={
typeof groupType === "string"
? groupType
: "Custom"
}
onChange={(e) =>
setGroupType(
e.target.value as GroupType,
)
}
className="w-full rounded-md border border-sidebar-line bg-sidebar-box px-2 py-1.5 text-xs text-sidebar-ink focus:outline-none focus:ring-1 focus:ring-accent"
>
<option value="Devices">
All Devices
</option>
<option value="Locations">
All Locations
</option>
<option value="Tags">Tags</option>
<option value="Cloud">
Cloud Storage
</option>
<option value="Custom">Custom</option>
</select>
{groupType === "Custom" && (
<Input
value={groupName}
onChange={(e) =>
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
/>
)}
<div className="flex gap-2">
<button
onClick={handleAddGroup}
className="flex-1 px-2 py-1 rounded-md bg-accent text-white text-xs font-medium hover:bg-accent/90 transition-colors"
>
Add
</button>
<button
onClick={() => {
setIsAddingGroup(false);
setGroupName("");
setGroupType("Custom");
}}
className="px-2 py-1 rounded-md text-xs font-medium text-sidebar-inkDull hover:bg-sidebar-selected/30 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="px-2 py-2 mt-2 border-t border-sidebar-line/50">
<p className="text-xs text-sidebar-inkFaint text-center">
Drag items to your space
</p>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
return createPortal(content, document.body);
}

View File

@@ -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 (
<DevicesGroup
isCollapsed={isCollapsed}
onToggle={() => toggleGroup(group.id)}
/>
<div data-group-id={group.id}>
<DevicesGroup
isCollapsed={isCollapsed}
onToggle={handleToggle}
sortableAttributes={sortableAttributes}
sortableListeners={sortableListeners}
/>
</div>
);
}
// Locations group - fetches all locations
if (group.group_type === "Locations") {
return (
<LocationsGroup
isCollapsed={isCollapsed}
onToggle={() => toggleGroup(group.id)}
/>
<div data-group-id={group.id}>
<LocationsGroup
isCollapsed={isCollapsed}
onToggle={handleToggle}
sortableAttributes={sortableAttributes}
sortableListeners={sortableListeners}
/>
</div>
);
}
// Volumes group - fetches all volumes
if (group.group_type === "Volumes") {
return (
<VolumesGroup
isCollapsed={isCollapsed}
onToggle={() => toggleGroup(group.id)}
/>
<div data-group-id={group.id}>
<VolumesGroup
isCollapsed={isCollapsed}
onToggle={handleToggle}
sortableAttributes={sortableAttributes}
sortableListeners={sortableListeners}
/>
</div>
);
}
// Tags group - fetches all tags
if (group.group_type === "Tags") {
return (
<TagsGroup
isCollapsed={isCollapsed}
onToggle={() => toggleGroup(group.id)}
/>
<div data-group-id={group.id}>
<TagsGroup
isCollapsed={isCollapsed}
onToggle={handleToggle}
sortableAttributes={sortableAttributes}
sortableListeners={sortableListeners}
/>
</div>
);
}
// 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 (
<div className="rounded-lg">
<div className="rounded-lg" data-group-id={group.id}>
<GroupHeader
label={group.name}
isCollapsed={isCollapsed}
onToggle={() => 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 && (
<div className="absolute top-1/2 -translate-y-1/2 left-2 right-2 h-[2px] bg-accent rounded-full" />
)}
{isOverEmpty && !isDraggingSortableItem && (
<div className="absolute top-1/2 -translate-y-1/2 left-2 right-2 h-[2px] bg-accent rounded-full" />
)}
</div>
)}
</div>

View File

@@ -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 && (
<div className="absolute -top-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
)}
{isOverTop && !isSortableDragging && !isDraggingSortableItem && (
<div className="absolute -top-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
)}
{/* Ring highlight for drop-into */}
{isOverMiddle && isDropTarget && !isSortableDragging && (
<div className="absolute inset-0 rounded-md ring-2 ring-accent/50 ring-inset pointer-events-none z-10" />
)}
{isOverMiddle && isDropTarget && !isSortableDragging && !isDraggingSortableItem && (
<div className="absolute inset-0 rounded-md ring-2 ring-accent/50 ring-inset pointer-events-none z-10" />
)}
<div className="relative">
{/* 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 ? (
<Thumb file={resolvedFile} size={16} className="shrink-0" />
@@ -518,9 +529,9 @@ export function SpaceItem({
</div>
{/* Insertion line indicator - bottom (only for last item to allow dropping at end) */}
{isOverBottom && isLastItem && (
<div className="absolute -bottom-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
)}
{isOverBottom && isLastItem && !isDraggingSortableItem && (
<div className="absolute -bottom-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
)}
</div>
);
}

View File

@@ -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 && (
<span className="ml-auto text-sidebar-ink-faint">{tags.length}</span>

View File

@@ -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 && (
<WifiSlash size={14} weight="bold" className="text-ink-faint" />
<Plugs size={14} weight="bold" className="text-ink-faint" />
)}
</>
);
@@ -42,6 +46,8 @@ export function VolumesGroup({
label="Volumes"
isCollapsed={isCollapsed}
onToggle={onToggle}
sortableAttributes={sortableAttributes}
sortableListeners={sortableListeners}
/>
{/* Volumes List */}

View File

@@ -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({
<div ref={setSortableRef} style={style} className={clsx("relative", isDragging && "opacity-50 z-50")}>
{/* Drop zone before this group (for adding root-level items) */}
<div ref={setDropRef} className="absolute -top-2.5 left-0 right-0 h-5 z-10">
{isOver && !isDragging && (
{isOver && !isDragging && !isDraggingSortableItem && (
<div className="absolute top-1/2 -translate-y-1/2 left-2 right-2 h-[2px] bg-accent rounded-full" />
)}
</div>
@@ -86,6 +97,7 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
const [currentLibraryId, setCurrentLibraryId] = useState<string | null>(
() => 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 && <AddGroupButton spaceId={currentSpace.id} />}
</div>
{/* Sync Monitor, Job Manager & Settings (pinned to bottom) */}
{/* Sync Monitor, Job Manager, Customize & Settings (pinned to bottom) */}
<div className="space-y-0.5">
<SyncMonitorPopover />
<JobManagerPopover />
<button
onClick={() => setCustomizePanelOpen(true)}
className={clsx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
"text-sidebar-inkDull hover:text-sidebar-ink hover:bg-sidebar-selected",
)}
>
<Palette className="size-4" weight="bold" />
<span className="truncate">Customize</span>
</button>
<button
onClick={() => {
if (platform.showWindow) {
@@ -219,6 +241,13 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
</div>
</nav>
</div>
{/* Customization Panel */}
<SpaceCustomizationPanel
isOpen={customizePanelOpen}
onClose={() => setCustomizePanelOpen(false)}
spaceId={currentSpace?.id ?? null}
/>
</div>
);
}

View File

@@ -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: <ExplorerLayout />,
children: [
{
index: true,
element: <Overview />,
},
{
path: "explorer",
element: <ExplorerView />,
},
{
path: "location/:locationId",
element: <ExplorerView />,
},
{
path: "location/:locationId/*",
element: <ExplorerView />,
},
{
path: "favorites",
element: (
<div className="flex items-center justify-center h-full text-ink">
Favorites (coming soon)
</div>
),
},
{
path: "recents",
element: (
<div className="flex items-center justify-center h-full text-ink">
Recents (coming soon)
</div>
),
},
{
path: "tag/:tagId",
element: <TagView />,
},
{
path: "search",
element: (
<div className="flex items-center justify-center h-full text-ink">
Search (coming soon)
</div>
),
},
{
path: "jobs",
element: <JobsScreen />,
},
{
path: "daemon",
element: <DaemonManager />,
},
],
},
]);
return createBrowserRouter([
{
path: "/",
element: <ExplorerLayout />,
children: [
{
index: true,
element: <Overview />,
},
{
path: "explorer",
element: <ExplorerView />,
},
{
path: "location/:locationId",
element: <ExplorerView />,
},
{
path: "location/:locationId/*",
element: <ExplorerView />,
},
{
path: "favorites",
element: (
<div className="flex items-center justify-center h-full text-ink">
Favorites (coming soon)
</div>
),
},
{
path: "recents",
element: (
<div className="flex items-center justify-center h-full text-ink">
Recents (coming soon)
</div>
),
},
{
path: "file-kinds",
element: <FileKindsView />,
},
{
path: "tag/:tagId",
element: <TagView />,
},
{
path: "search",
element: (
<div className="flex items-center justify-center h-full text-ink">
Search (coming soon)
</div>
),
},
{
path: "jobs",
element: <JobsScreen />,
},
{
path: "daemon",
element: <DaemonManager />,
},
],
},
]);
}

View File

@@ -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<string, { iconName: string; color: string }> =
{
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<string, never>,
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 (
<div className="flex items-center justify-center h-full">
<span className="text-ink-dull">Loading file kinds...</span>
</div>
);
}
const handleKindClick = (kind: ContentKind) => {
// TODO: Navigate to explorer with content kind filter
// For now, just log
console.log("Content kind clicked:", kind);
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-6 py-4 border-b border-app-line">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-ink">
File Kinds
</h1>
<p className="text-sm text-ink-dull mt-1">
Browse your files by content type
</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-ink">
{formatFileCount(totalFiles)}
</div>
<div className="text-xs text-ink-dull">Total Files</div>
</div>
</div>
</div>
{/* Content Grid */}
<div className="flex-1 overflow-auto p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{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 (
<button
key={stat.name}
onClick={() => handleKindClick(stat.kind)}
className="relative flex flex-col items-start p-6 rounded-lg border border-app-line hover:bg-app-box/30 transition-all group"
>
<img
src={icon}
alt={stat.name}
className="absolute top-6 right-6 w-16 h-16"
/>
<div className="w-full text-left">
<div className="text-lg font-semibold text-ink mb-1">
{stat.name}
</div>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-ink">
{formatFileCount(
Number(stat.file_count),
)}
</span>
<span className="text-xs text-ink-dull">
{Number(stat.file_count) === 1
? "file"
: "files"}
</span>
</div>
{totalFiles > 0 && (
<div className="mt-2">
<div className="h-1 bg-app-line rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all bg-accent"
style={{
width: `${(Number(stat.file_count) / totalFiles) * 100}%`,
}}
/>
</div>
<div className="text-xs text-ink-faint mt-1">
{(
(Number(stat.file_count) /
totalFiles) *
100
).toFixed(1)}
% of library
</div>
</div>
)}
</div>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -76,11 +76,10 @@ export function HeroStats({
{/* Storage Health - Future feature */}
<StatCard
icon={Cpu}
label="Storage Health"
value="Good"
subtitle="all volumes healthy"
color="from-orange-500 to-red-500"
badge="PREVIEW"
label="AI Compute Power"
value="70 TOPS"
subtitle="across all devices"
color="from-purple-500 to-pink-500"
/>
</div>
</div>

View File

@@ -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<string, never>;
/**
* 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;