Add Empty type and centralized icon utilities

This commit is contained in:
Jamie Pine
2025-11-24 07:05:47 -08:00
parent 8808e85f4e
commit 742e9f32bf
11 changed files with 560 additions and 497 deletions

View File

@@ -57,6 +57,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"// This file is auto-generated. See core/src/bin/generate_typescript_types.rs\n\n",
);
// Add Empty type for unit type (())
typescript_code.push_str("// Empty type for operations with no input\n");
typescript_code.push_str("export type Empty = Record<string, never>;\n\n");
// Generate all types using Specta TypeScript
// Note: TypeScript Specta doesn't have built-in duplicate handling like Swift
// The error about duplicate types is expected - multiple operations reference the same types

View File

@@ -535,7 +535,7 @@ pub fn get_fk_mappings(model_type: &str) -> Option<Vec<super::FKMapping>> {
FKMapping::new("entry_id", "entries"),
]),
"user_metadata_tag" => Some(vec![
FKMapping::new("metadata_id", "user_metadata"),
FKMapping::new("user_metadata_id", "user_metadata"),
FKMapping::new("tag_id", "tag"),
]),
"tag_relationship" => Some(vec![

View File

@@ -1,7 +1,9 @@
{
"name": "spacedrive",
"private": true,
"scripts": {},
"scripts": {
"preinstall": "npx only-allow bun"
},
"devDependencies": {
"@babel/plugin-syntax-import-assertions": "^7.24.0",
"@cspell/dict-rust": "^4.0.2",

View File

@@ -1,32 +1,13 @@
import { CaretRight, Desktop, WifiHigh } from "@phosphor-icons/react";
import clsx from "clsx";
import { useNavigate } from "react-router-dom";
import { useLibraryQuery } from "../../context";
import NodeIcon from "@sd/assets/icons/Node.png";
import LaptopIcon from "@sd/assets/icons/Laptop.png";
import MobileIcon from "@sd/assets/icons/Mobile.png";
import PCIcon from "@sd/assets/icons/PC.png";
import { useLibraryQuery, getDeviceIcon } from "../../context";
interface DevicesGroupProps {
isCollapsed: boolean;
onToggle: () => void;
}
// Helper to get icon based on OS
function getDeviceIcon(os: string): string {
const osLower = os.toLowerCase();
if (osLower.includes("mac") || osLower.includes("darwin")) {
return LaptopIcon;
}
if (osLower.includes("ios") || osLower.includes("android")) {
return MobileIcon;
}
if (osLower.includes("windows") || osLower.includes("linux")) {
return PCIcon;
}
return NodeIcon;
}
export function DevicesGroup({ isCollapsed, onToggle }: DevicesGroupProps) {
const navigate = useNavigate();
@@ -77,7 +58,7 @@ export function DevicesGroup({ isCollapsed, onToggle }: DevicesGroupProps) {
)}
>
{/* Device Icon */}
<img src={getDeviceIcon(device.os)} alt="" className="size-4" />
<img src={getDeviceIcon(device)} alt="" className="size-4" />
{/* Device Name */}
<span className="flex-1 truncate text-left">{device.name}</span>

View File

@@ -1,20 +1,9 @@
import { CaretRight } from "@phosphor-icons/react";
import clsx from "clsx";
import { useNavigate } from "react-router-dom";
import { useNormalizedCache } from "@sd/ts-client";
import { useNormalizedCache, getVolumeIcon } from "@sd/ts-client";
import { SpaceItem } from "./SpaceItem";
import type { VolumeItem, CloudServiceType } from "@sd/ts-client";
// Import cloud provider icons
import DriveAmazonS3 from "@sd/assets/icons/Drive-AmazonS3.png";
import DriveGoogleDrive from "@sd/assets/icons/Drive-GoogleDrive.png";
import DriveDropbox from "@sd/assets/icons/Drive-Dropbox.png";
import DriveOneDrive from "@sd/assets/icons/Drive-OneDrive.png";
import DriveBackBlaze from "@sd/assets/icons/Drive-BackBlaze.png";
import DrivePCloud from "@sd/assets/icons/Drive-PCloud.png";
import DriveBox from "@sd/assets/icons/Drive-Box.png";
import HDDIcon from "@sd/assets/icons/HDD.png";
import DriveIcon from "@sd/assets/icons/Drive.png";
import type { VolumeItem } from "@sd/ts-client";
interface VolumesGroupProps {
isCollapsed: boolean;
@@ -23,60 +12,6 @@ interface VolumesGroupProps {
filter?: "TrackedOnly" | "UntrackedOnly" | "All";
}
// Map cloud service types to icons
const cloudProviderIcons: Record<CloudServiceType, string> = {
s3: DriveAmazonS3,
gdrive: DriveGoogleDrive,
dropbox: DriveDropbox,
onedrive: DriveOneDrive,
gcs: DriveGoogleDrive,
azblob: DriveBox,
b2: DriveBackBlaze,
wasabi: DriveAmazonS3,
spaces: DriveAmazonS3,
cloud: DrivePCloud,
};
// Helper to parse cloud service from volume fingerprint
function parseCloudService(volume: VolumeItem): CloudServiceType | null {
// Check if this is a cloud volume by looking at mount_point pattern
const mountPoint = volume.mount_point;
if (!mountPoint) return null;
// Parse mount_point for cloud service (format: "s3://bucket-name")
const match = mountPoint.match(/^(\w+):\/\//);
if (!match) return null;
const scheme = match[1];
// Verify it's a cloud scheme (not file:// or other local schemes)
if (scheme === "s3" || scheme === "gdrive" || scheme === "dropbox" ||
scheme === "onedrive" || scheme === "gcs" || scheme === "azblob" ||
scheme === "b2" || scheme === "wasabi" || scheme === "spaces" ||
scheme === "cloud") {
return scheme as CloudServiceType;
}
return null;
}
// Get icon for a volume based on its type
function getVolumeIcon(volume: VolumeItem): string {
// Check if it's a cloud volume (by mount_point pattern or filesystem type)
const cloudService = parseCloudService(volume);
if (cloudService) {
return cloudProviderIcons[cloudService] || DriveIcon;
}
// For external drives, use HDD icon
if (volume.volume_type === "External") {
return HDDIcon;
}
// Default to generic drive icon
return DriveIcon;
}
export function VolumesGroup({
isCollapsed,
onToggle,

View File

@@ -1,470 +1,515 @@
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import {
ArrowsClockwise,
CheckCircle,
CircleNotch,
DeviceMobile,
Share,
SignIn
} from '@phosphor-icons/react';
import type { LibrarySyncAction, PairedDeviceInfo, RemoteLibraryInfo } from '@sd/ts-client';
import { Button, Dialog, dialogManager, useDialog } from '@sd/ui';
import { useCoreQuery, useCoreMutation, useSpacedriveClient } from '../context';
ArrowsClockwise,
CheckCircle,
CircleNotch,
DeviceMobile,
Share,
SignIn,
} from "@phosphor-icons/react";
import type {
LibrarySyncAction,
PairedDeviceInfo,
RemoteLibraryInfo,
} from "@sd/ts-client";
import { Button, Dialog, dialogManager, useDialog } from "@sd/ui";
import { useCoreQuery, useCoreMutation, useSpacedriveClient } from "../context";
interface SyncSetupDialogProps {
id: number;
id: number;
}
type SyncStep = 'select-device' | 'choose-action' | 'confirm' | 'executing';
type SyncStep = "select-device" | "choose-action" | "confirm" | "executing";
export function useSyncSetupDialog() {
return dialogManager.create((props: SyncSetupDialogProps) => (
<SyncSetupDialog {...props} />
));
return dialogManager.create((props: SyncSetupDialogProps) => (
<SyncSetupDialog {...props} />
));
}
function SyncSetupDialog(props: SyncSetupDialogProps) {
const dialog = useDialog(props);
const client = useSpacedriveClient();
const [step, setStep] = useState<SyncStep>('select-device');
const [selectedDevice, setSelectedDevice] = useState<PairedDeviceInfo | null>(null);
const [selectedAction, setSelectedAction] = useState<'share' | 'join' | null>(null);
const [selectedRemoteLibrary, setSelectedRemoteLibrary] =
useState<RemoteLibraryInfo | null>(null);
const dialog = useDialog(props);
const client = useSpacedriveClient();
const [step, setStep] = useState<SyncStep>("select-device");
const [selectedDevice, setSelectedDevice] = useState<PairedDeviceInfo | null>(
null,
);
const [selectedAction, setSelectedAction] = useState<"share" | "join" | null>(
null,
);
const [selectedRemoteLibrary, setSelectedRemoteLibrary] =
useState<RemoteLibraryInfo | null>(null);
// Get current device info and library
const { data: coreStatus } = useCoreQuery({
type: 'core.status',
input: {}
});
// Get current device info and library
const { data: coreStatus, isLoading, error, isFetching } = useCoreQuery({
type: "core.status",
input: null as any, // Unit type () in Rust = null in JSON
});
const currentLibraryId = client.getCurrentLibraryId();
const currentDeviceId = coreStatus?.device_info.id;
console.log({
coreStatus,
isLoading,
error: error?.message || error,
isFetching,
hasData: !!coreStatus
});
// Query paired devices
const pairedDevicesQuery = useCoreQuery({
type: 'network.devices.list',
input: { connectedOnly: false }
});
const currentLibraryId = client.getCurrentLibraryId();
const currentDeviceId = coreStatus?.device_info.id;
// Query remote libraries when device is selected
const discoveryQuery = useCoreQuery(
{
type: 'network.sync_setup.discover',
input: { deviceId: selectedDevice?.id || '' }
},
{
enabled: selectedDevice !== null
}
);
// Query paired devices
const pairedDevicesQuery = useCoreQuery({
type: "network.devices.list",
input: { connectedOnly: false },
});
// Sync setup mutation
const syncSetupMutation = useCoreMutation('network.sync_setup', {
onSuccess: () => {
// Close dialog on success
dialog.state.open = false;
}
});
// Query remote libraries when device is selected
const discoveryQuery = useCoreQuery(
{
type: "network.sync_setup.discover",
input: { deviceId: selectedDevice?.id || "" },
},
{
enabled: selectedDevice !== null,
},
);
const form = useForm();
// Sync setup mutation
const syncSetupMutation = useCoreMutation("network.sync_setup", {
onSuccess: () => {
// Close dialog on success
dialog.state.open = false;
},
});
const handleDeviceSelect = (device: PairedDeviceInfo) => {
setSelectedDevice(device);
setStep('choose-action');
};
const form = useForm();
const handleActionSelect = (action: 'share' | 'join', library?: RemoteLibraryInfo) => {
setSelectedAction(action);
if (action === 'join' && library) {
setSelectedRemoteLibrary(library);
}
setStep('confirm');
};
const handleDeviceSelect = (device: PairedDeviceInfo) => {
setSelectedDevice(device);
setStep("choose-action");
};
const handleConfirm = async () => {
if (!selectedDevice || !selectedAction || !currentLibraryId || !currentDeviceId) return;
const handleActionSelect = (
action: "share" | "join",
library?: RemoteLibraryInfo,
) => {
setSelectedAction(action);
if (action === "join" && library) {
setSelectedRemoteLibrary(library);
}
setStep("confirm");
};
setStep('executing');
const handleConfirm = async () => {
console.log("Confirming sync setup", {
selectedDevice,
selectedAction,
currentLibraryId,
currentDeviceId,
});
if (
!selectedDevice ||
!selectedAction ||
!currentLibraryId ||
!currentDeviceId
)
return;
// Build the LibrarySyncAction
let action: LibrarySyncAction;
let remoteLibraryId: string | undefined;
setStep("executing");
// Get current library info
const currentLibrary = coreStatus?.libraries.find((lib) => lib.id === currentLibraryId);
const libraryName = currentLibrary?.name || 'My Library';
// Build the LibrarySyncAction
let action: LibrarySyncAction;
let remoteLibraryId: string | undefined;
if (selectedAction === 'share') {
action = {
type: 'shareLocalLibrary',
libraryName
};
} else if (selectedAction === 'join' && selectedRemoteLibrary) {
action = {
type: 'joinRemoteLibrary',
remoteLibraryId: selectedRemoteLibrary.id,
remoteLibraryName: selectedRemoteLibrary.name
};
remoteLibraryId = selectedRemoteLibrary.id;
} else {
return;
}
// Get current library info
const currentLibrary = coreStatus?.libraries.find(
(lib) => lib.id === currentLibraryId,
);
const libraryName = currentLibrary?.name || "My Library";
// Execute sync setup
syncSetupMutation.mutate({
localDeviceId: currentDeviceId,
remoteDeviceId: selectedDevice.id,
localLibraryId: currentLibraryId,
remoteLibraryId,
action,
leaderDeviceId: currentDeviceId // Deprecated but required
});
};
if (selectedAction === "share") {
action = {
type: "shareLocalLibrary",
libraryName,
};
} else if (selectedAction === "join" && selectedRemoteLibrary) {
action = {
type: "joinRemoteLibrary",
remoteLibraryId: selectedRemoteLibrary.id,
remoteLibraryName: selectedRemoteLibrary.name,
};
remoteLibraryId = selectedRemoteLibrary.id;
} else {
return;
}
const renderContent = () => {
switch (step) {
case 'select-device':
return (
<SelectDeviceStep
devices={pairedDevicesQuery.data?.devices || []}
isLoading={pairedDevicesQuery.isLoading}
onSelect={handleDeviceSelect}
/>
);
const data = {
localDeviceId: currentDeviceId,
remoteDeviceId: selectedDevice.id,
localLibraryId: currentLibraryId,
remoteLibraryId,
action,
leaderDeviceId: currentDeviceId, // Deprecated but required
};
case 'choose-action':
return (
<ChooseActionStep
device={selectedDevice!}
remoteLibraries={discoveryQuery.data?.libraries || []}
isOnline={discoveryQuery.data?.isOnline || false}
isLoading={discoveryQuery.isLoading}
onSelectAction={handleActionSelect}
onBack={() => setStep('select-device')}
/>
);
console.log({ data });
case 'confirm':
return (
<ConfirmStep
device={selectedDevice!}
action={selectedAction!}
remoteLibrary={selectedRemoteLibrary}
onBack={() => setStep('choose-action')}
onConfirm={handleConfirm}
/>
);
// Execute sync setup
syncSetupMutation.mutate(data);
};
case 'executing':
return (
<ExecutingStep
isLoading={syncSetupMutation.isPending}
error={syncSetupMutation.error?.message}
/>
);
}
};
const renderContent = () => {
switch (step) {
case "select-device":
return (
<SelectDeviceStep
devices={pairedDevicesQuery.data?.devices || []}
isLoading={pairedDevicesQuery.isLoading}
onSelect={handleDeviceSelect}
/>
);
return (
<Dialog
dialog={dialog}
form={form}
title="Setup Library Sync"
description="Sync your library with another device"
icon={<ArrowsClockwise />}
closeBtn
hideButtons
>
<div className="min-h-[400px]">{renderContent()}</div>
</Dialog>
);
case "choose-action":
return (
<ChooseActionStep
device={selectedDevice!}
remoteLibraries={discoveryQuery.data?.libraries || []}
isOnline={discoveryQuery.data?.isOnline || false}
isLoading={discoveryQuery.isLoading}
onSelectAction={handleActionSelect}
onBack={() => setStep("select-device")}
/>
);
case "confirm":
return (
<ConfirmStep
device={selectedDevice!}
action={selectedAction!}
remoteLibrary={selectedRemoteLibrary}
onBack={() => setStep("choose-action")}
onConfirm={handleConfirm}
/>
);
case "executing":
return (
<ExecutingStep
isLoading={syncSetupMutation.isPending}
error={syncSetupMutation.error?.message}
/>
);
}
};
return (
<Dialog
dialog={dialog}
form={form}
title="Setup Library Sync"
description="Sync your library with another device"
icon={<ArrowsClockwise />}
closeBtn
hideButtons
>
<div className="min-h-[400px]">{renderContent()}</div>
</Dialog>
);
}
// Step 1: Select Device
interface SelectDeviceStepProps {
devices: PairedDeviceInfo[];
isLoading: boolean;
onSelect: (device: PairedDeviceInfo) => void;
devices: PairedDeviceInfo[];
isLoading: boolean;
onSelect: (device: PairedDeviceInfo) => void;
}
function SelectDeviceStep({ devices, isLoading, onSelect }: SelectDeviceStepProps) {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<CircleNotch className="animate-spin" size={32} />
</div>
);
}
function SelectDeviceStep({
devices,
isLoading,
onSelect,
}: SelectDeviceStepProps) {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<CircleNotch className="animate-spin" size={32} />
</div>
);
}
if (devices.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<DeviceMobile size={48} className="text-ink-faint" />
<div className="text-center">
<p className="text-ink-dull">No paired devices found</p>
<p className="text-sm text-ink-faint">
Pair a device first using the "Pair Device" button
</p>
</div>
</div>
);
}
if (devices.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<DeviceMobile size={48} className="text-ink-faint" />
<div className="text-center">
<p className="text-ink-dull">No paired devices found</p>
<p className="text-sm text-ink-faint">
Pair a device first using the "Pair Device" button
</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
<p className="text-sm text-ink-dull">
Select a paired device to sync your library with:
</p>
<div className="space-y-2">
{devices.map((device) => (
<button
key={device.id}
onClick={() => onSelect(device)}
className="w-full rounded-lg border border-app-line bg-app-box p-4 text-left transition-colors hover:border-accent hover:bg-app-darkBox"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<DeviceMobile size={20} />
<h3 className="font-medium text-ink">{device.name}</h3>
{device.isConnected && (
<span className="rounded-full bg-green-500 px-2 py-0.5 text-xs text-white">
Connected
</span>
)}
</div>
<p className="mt-1 text-sm text-ink-dull">
{device.deviceType} {device.osVersion}
</p>
<p className="text-xs text-ink-faint">
Last seen:{' '}
{new Date(device.lastSeen).toLocaleString()}
</p>
</div>
</div>
</button>
))}
</div>
</div>
);
return (
<div className="space-y-4">
<p className="text-sm text-ink-dull">
Select a paired device to sync your library with:
</p>
<div className="space-y-2">
{devices.map((device) => (
<button
key={device.id}
onClick={() => onSelect(device)}
className="w-full rounded-lg border border-app-line bg-app-box p-4 text-left transition-colors hover:border-accent hover:bg-app-darkBox"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<DeviceMobile size={20} />
<h3 className="font-medium text-ink">{device.name}</h3>
{device.isConnected && (
<span className="rounded-full bg-green-500 px-2 py-0.5 text-xs text-white">
Connected
</span>
)}
</div>
<p className="mt-1 text-sm text-ink-dull">
{device.deviceType} {device.osVersion}
</p>
<p className="text-xs text-ink-faint">
Last seen: {new Date(device.lastSeen).toLocaleString()}
</p>
</div>
</div>
</button>
))}
</div>
</div>
);
}
// Step 2: Choose Action
interface ChooseActionStepProps {
device: PairedDeviceInfo;
remoteLibraries: RemoteLibraryInfo[];
isOnline: boolean;
isLoading: boolean;
onSelectAction: (action: 'share' | 'join', library?: RemoteLibraryInfo) => void;
onBack: () => void;
device: PairedDeviceInfo;
remoteLibraries: RemoteLibraryInfo[];
isOnline: boolean;
isLoading: boolean;
onSelectAction: (
action: "share" | "join",
library?: RemoteLibraryInfo,
) => void;
onBack: () => void;
}
function ChooseActionStep({
device,
remoteLibraries,
isOnline,
isLoading,
onSelectAction,
onBack
device,
remoteLibraries,
isOnline,
isLoading,
onSelectAction,
onBack,
}: ChooseActionStepProps) {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<CircleNotch className="animate-spin" size={32} />
<p className="ml-3 text-ink-dull">Discovering libraries...</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<CircleNotch className="animate-spin" size={32} />
<p className="ml-3 text-ink-dull">Discovering libraries...</p>
</div>
);
}
if (!isOnline) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<DeviceMobile size={48} className="text-ink-faint" />
<div className="text-center">
<p className="text-ink-dull">Device is offline</p>
<p className="text-sm text-ink-faint">
{device.name} must be online to set up sync
</p>
</div>
<Button variant="outline" onClick={onBack}>
Back
</Button>
</div>
);
}
if (!isOnline) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<DeviceMobile size={48} className="text-ink-faint" />
<div className="text-center">
<p className="text-ink-dull">Device is offline</p>
<p className="text-sm text-ink-faint">
{device.name} must be online to set up sync
</p>
</div>
<Button variant="outline" onClick={onBack}>
Back
</Button>
</div>
);
}
return (
<div className="space-y-4">
<div>
<p className="text-sm text-ink-dull">
Syncing with: <span className="font-medium text-ink">{device.name}</span>
</p>
</div>
return (
<div className="space-y-4">
<div>
<p className="text-sm text-ink-dull">
Syncing with:{" "}
<span className="font-medium text-ink">{device.name}</span>
</p>
</div>
<div className="space-y-3">
<h3 className="font-medium text-ink">Choose an action:</h3>
<div className="space-y-3">
<h3 className="font-medium text-ink">Choose an action:</h3>
{/* Share Local Library */}
<button
onClick={() => onSelectAction('share')}
className="w-full rounded-lg border border-app-line bg-app-box p-4 text-left transition-colors hover:border-accent hover:bg-app-darkBox"
>
<div className="flex items-start gap-3">
<Share size={24} className="mt-1 text-accent" />
<div className="flex-1">
<h4 className="font-medium text-ink">
Share my library to this device
</h4>
<p className="mt-1 text-sm text-ink-dull">
Create a shared library from your local library. The other
device will receive a copy.
</p>
</div>
</div>
</button>
{/* Share Local Library */}
<button
onClick={() => onSelectAction("share")}
className="w-full rounded-lg border border-app-line bg-app-box p-4 text-left transition-colors hover:border-accent hover:bg-app-darkBox"
>
<div className="flex items-start gap-3">
<Share size={24} className="mt-1 text-accent" />
<div className="flex-1">
<h4 className="font-medium text-ink">
Share my library to this device
</h4>
<p className="mt-1 text-sm text-ink-dull">
Create a shared library from your local library. The other
device will receive a copy.
</p>
</div>
</div>
</button>
{/* Join Remote Library */}
{remoteLibraries.length > 0 ? (
<div className="space-y-2">
<h4 className="text-sm font-medium text-ink">
Or join a library from {device.name}:
</h4>
{remoteLibraries.map((library) => (
<button
key={library.id}
onClick={() => onSelectAction('join', library)}
className="w-full rounded-lg border border-app-line bg-app-box p-4 text-left transition-colors hover:border-accent hover:bg-app-darkBox"
>
<div className="flex items-start gap-3">
<SignIn size={24} className="mt-1 text-accent" />
<div className="flex-1">
<h4 className="font-medium text-ink">
{library.name}
</h4>
<p className="mt-1 text-sm text-ink-dull">
{library.statistics.total_files.toLocaleString()}{' '}
files {' '}
{library.statistics.location_count.toLocaleString()}{' '}
locations
</p>
<p className="text-xs text-ink-faint">
Created:{' '}
{new Date(library.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</button>
))}
</div>
) : (
<div className="rounded-lg border border-app-line bg-app-box p-4">
<p className="text-sm text-ink-faint">
No libraries found on {device.name}
</p>
</div>
)}
</div>
{/* Join Remote Library */}
{remoteLibraries.length > 0 ? (
<div className="space-y-2">
<h4 className="text-sm font-medium text-ink">
Or join a library from {device.name}:
</h4>
{remoteLibraries.map((library) => (
<button
key={library.id}
onClick={() => onSelectAction("join", library)}
className="w-full rounded-lg border border-app-line bg-app-box p-4 text-left transition-colors hover:border-accent hover:bg-app-darkBox"
>
<div className="flex items-start gap-3">
<SignIn size={24} className="mt-1 text-accent" />
<div className="flex-1">
<h4 className="font-medium text-ink">{library.name}</h4>
<p className="mt-1 text-sm text-ink-dull">
{library.statistics.total_files.toLocaleString()} files {" "}
{library.statistics.location_count.toLocaleString()}{" "}
locations
</p>
<p className="text-xs text-ink-faint">
Created:{" "}
{new Date(library.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</button>
))}
</div>
) : (
<div className="rounded-lg border border-app-line bg-app-box p-4">
<p className="text-sm text-ink-faint">
No libraries found on {device.name}
</p>
</div>
)}
</div>
<div className="flex justify-start">
<Button variant="outline" onClick={onBack}>
Back
</Button>
</div>
</div>
);
<div className="flex justify-start">
<Button variant="outline" onClick={onBack}>
Back
</Button>
</div>
</div>
);
}
// Step 3: Confirm
interface ConfirmStepProps {
device: PairedDeviceInfo;
action: 'share' | 'join';
remoteLibrary: RemoteLibraryInfo | null;
onBack: () => void;
onConfirm: () => void;
device: PairedDeviceInfo;
action: "share" | "join";
remoteLibrary: RemoteLibraryInfo | null;
onBack: () => void;
onConfirm: () => void;
}
function ConfirmStep({ device, action, remoteLibrary, onBack, onConfirm }: ConfirmStepProps) {
return (
<div className="space-y-6">
<div className="rounded-lg bg-app-darkBox p-4">
<h3 className="mb-3 font-medium text-ink">Sync Configuration</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-ink-dull">Remote Device:</span>
<span className="font-medium text-ink">{device.name}</span>
</div>
<div className="flex justify-between">
<span className="text-ink-dull">Action:</span>
<span className="font-medium text-ink">
{action === 'share' ? 'Share My Library' : 'Join Remote Library'}
</span>
</div>
{action === 'join' && remoteLibrary && (
<div className="flex justify-between">
<span className="text-ink-dull">Remote Library:</span>
<span className="font-medium text-ink">
{remoteLibrary.name}
</span>
</div>
)}
</div>
</div>
function ConfirmStep({
device,
action,
remoteLibrary,
onBack,
onConfirm,
}: ConfirmStepProps) {
return (
<div className="space-y-6">
<div className="rounded-lg bg-app-darkBox p-4">
<h3 className="mb-3 font-medium text-ink">Sync Configuration</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-ink-dull">Remote Device:</span>
<span className="font-medium text-ink">{device.name}</span>
</div>
<div className="flex justify-between">
<span className="text-ink-dull">Action:</span>
<span className="font-medium text-ink">
{action === "share" ? "Share My Library" : "Join Remote Library"}
</span>
</div>
{action === "join" && remoteLibrary && (
<div className="flex justify-between">
<span className="text-ink-dull">Remote Library:</span>
<span className="font-medium text-ink">{remoteLibrary.name}</span>
</div>
)}
</div>
</div>
<div className="rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-4">
<p className="text-sm text-yellow-200">
{action === 'share'
? 'This will create a synchronized copy of your library on the remote device. Both devices will stay in sync.'
: 'This will download the remote library to your device. Your local library will sync with theirs.'}
</p>
</div>
<div className="rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-4">
<p className="text-sm text-yellow-200">
{action === "share"
? "This will create a synchronized copy of your library on the remote device. Both devices will stay in sync."
: "This will download the remote library to your device. Your local library will sync with theirs."}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onBack} className="flex-1">
Back
</Button>
<Button variant="accent" onClick={onConfirm} className="flex-1">
Confirm & Setup Sync
</Button>
</div>
</div>
);
<div className="flex gap-2">
<Button variant="outline" onClick={onBack} className="flex-1">
Back
</Button>
<Button variant="accent" onClick={onConfirm} className="flex-1">
Confirm & Setup Sync
</Button>
</div>
</div>
);
}
// Step 4: Executing
interface ExecutingStepProps {
isLoading: boolean;
error?: string;
isLoading: boolean;
error?: string;
}
function ExecutingStep({ isLoading, error }: ExecutingStepProps) {
if (isLoading) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<CircleNotch className="animate-spin" size={48} />
<p className="text-ink-dull">Setting up sync...</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<CircleNotch className="animate-spin" size={48} />
<p className="text-ink-dull">Setting up sync...</p>
</div>
);
}
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<div className="text-red-500">
<p className="font-medium">Sync setup failed</p>
<p className="text-sm">{error}</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<div className="text-red-500">
<p className="font-medium">Sync setup failed</p>
<p className="text-sm">{error}</p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<CheckCircle size={48} className="text-green-500" />
<div className="text-center">
<p className="font-medium text-ink">Sync setup complete!</p>
<p className="text-sm text-ink-dull">Your library is now syncing</p>
</div>
</div>
);
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<CheckCircle size={48} className="text-green-500" />
<div className="text-center">
<p className="font-medium text-ink">Sync setup complete!</p>
<p className="text-sm text-ink-dull">Your library is now syncing</p>
</div>
</div>
);
}

View File

@@ -28,4 +28,7 @@ export type {
LocationInfo,
LocationsListOutput,
LibraryInfo,
} from "@sd/ts-client";
} from "@sd/ts-client";
// Export icon utilities
export { getDeviceIcon, getVolumeIcon } from "@sd/ts-client";

View File

@@ -24,13 +24,14 @@ export function getDeviceIcon(device: LibraryDeviceInfo): DeviceIcon {
if (device.hardware_model) {
const model = device.hardware_model.toLowerCase();
// Mac Studio
if (model.includes("mac studio") || model.includes("macstudio")) {
// Mac Studio: Mac13,1 Mac13,2 (M1 Max/Ultra 2022), Mac14,13 Mac14,14 (M2 Max/Ultra 2023)
// Mac Pro: Mac14,8 (M2 Ultra 2023)
if (model.match(/mac1[34],(1|2|8|13|14)/)) {
return SilverBox;
}
// Mac Mini
if (model.includes("mac mini") || model.includes("macmini")) {
// Mac Mini: Mac14,3 Mac14,12 (M2/Pro 2023), Mac15,12 Mac15,13 (M4 2024)
if (model.match(/mac1[45],(3|12|13)/)) {
return MiniSilverBox;
}

View File

@@ -1,6 +1,9 @@
// Generated by Spacedrive using Specta + rspc-inspired type extraction - DO NOT EDIT
// This file is auto-generated. See core/src/bin/generate_typescript_types.rs
// Empty type for operations with no input
export type Empty = Record<string, never>;
// This file has been generated by Specta. DO NOT EDIT.
export type ActionContextInfo = { action_type: string; initiated_at: string; initiated_by: string | null; action_input: JsonValue; context: JsonValue };

View File

@@ -59,8 +59,9 @@ export * from "./hooks";
// Zustand stores
export * from "./stores/sidebar";
// Device utilities
// Device and volume utilities
export * from "./deviceIcons";
export * from "./volumeIcons";
// All auto-generated types
export * from "./generated/types";

View File

@@ -0,0 +1,88 @@
// @ts-nocheck
import type { CloudServiceType } from "./generated/types";
import DriveAmazonS3 from "@sd/assets/icons/Drive-AmazonS3.png";
import DriveGoogleDrive from "@sd/assets/icons/Drive-GoogleDrive.png";
import DriveDropbox from "@sd/assets/icons/Drive-Dropbox.png";
import DriveOneDrive from "@sd/assets/icons/Drive-OneDrive.png";
import DriveBackBlaze from "@sd/assets/icons/Drive-BackBlaze.png";
import DrivePCloud from "@sd/assets/icons/Drive-PCloud.png";
import DriveBox from "@sd/assets/icons/Drive-Box.png";
import HDDIcon from "@sd/assets/icons/HDD.png";
import DriveIcon from "@sd/assets/icons/Drive.png";
export type VolumeIcon = string;
// Map cloud service types to icons
const cloudProviderIcons: Record<CloudServiceType, string> = {
s3: DriveAmazonS3,
gdrive: DriveGoogleDrive,
dropbox: DriveDropbox,
onedrive: DriveOneDrive,
gcs: DriveGoogleDrive,
azblob: DriveBox,
b2: DriveBackBlaze,
wasabi: DriveAmazonS3,
spaces: DriveAmazonS3,
cloud: DrivePCloud,
};
/**
* Parse cloud service type from volume mount point.
* Cloud volumes typically have mount points like "s3://bucket-name"
*/
function parseCloudService(mountPoint: string | null): CloudServiceType | null {
if (!mountPoint) return null;
// Parse mount_point for cloud service (format: "s3://bucket-name")
const match = mountPoint.match(/^(\w+):\/\//);
if (!match) return null;
const scheme = match[1];
// Verify it's a cloud scheme (not file:// or other local schemes)
const cloudSchemes: CloudServiceType[] = [
"s3",
"gdrive",
"dropbox",
"onedrive",
"gcs",
"azblob",
"b2",
"wasabi",
"spaces",
"cloud",
];
if (cloudSchemes.includes(scheme as CloudServiceType)) {
return scheme as CloudServiceType;
}
return null;
}
/**
* Determines the appropriate volume icon based on volume information.
*
* Priority order:
* 1. Cloud service type (parsed from mount point)
* 2. Volume type (External vs Internal)
* 3. Default to generic drive icon
*/
export function getVolumeIcon(volume: {
mount_point: string | null;
volume_type?: "Internal" | "External" | "Removable";
}): VolumeIcon {
// Check if it's a cloud volume
const cloudService = parseCloudService(volume.mount_point);
if (cloudService) {
return cloudProviderIcons[cloudService] || DriveIcon;
}
// For external/removable drives, use HDD icon
if (volume.volume_type === "External" || volume.volume_type === "Removable") {
return HDDIcon;
}
// Default to generic drive icon
return DriveIcon;
}