mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-04-23 07:59:59 -04: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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user