mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-02-20 07:37:26 -05:00
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:
@@ -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(());
|
||||
|
||||
@@ -417,6 +417,9 @@ pub enum ItemType {
|
||||
/// Favorited files (fixed)
|
||||
Favorites,
|
||||
|
||||
/// File kinds (images, videos, audio, etc.)
|
||||
FileKinds,
|
||||
|
||||
/// Indexed location
|
||||
Location { location_id: Uuid },
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
189
packages/interface/src/routes/file-kinds/index.tsx
Normal file
189
packages/interface/src/routes/file-kinds/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user