mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-25 00:35:02 -04:00
feat(mobile): add Jobs screen and enhance search functionality
- Introduced a new Jobs screen to display and manage job statuses with controls for pause, resume, and cancel actions. - Updated RootLayout to include navigation to the new Jobs screen. - Enhanced SearchScreen with improved search capabilities, including auto-focus and dynamic filtering based on user input. - Integrated GlassContextMenu and SearchToolbar components for better user interaction during searches. - Updated GlassSearchBar to support controlled input and clear functionality. - Refactored ExplorerScreen to incorporate search context and improve file browsing experience.
This commit is contained in:
@@ -27,6 +27,13 @@ export default function RootLayout() {
|
||||
animation: 'slide_from_bottom'
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jobs"
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom'
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</SpacedriveProvider>
|
||||
</AppResetContext.Provider>
|
||||
|
||||
259
apps/mobile/src/app/jobs.tsx
Normal file
259
apps/mobile/src/app/jobs.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text, FlatList, Pressable, ActivityIndicator } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { GlassButton } from "../components/GlassButton";
|
||||
import { useJobs, type ExtendedJobListItem } from "../hooks/useJobs";
|
||||
import { X } from "phosphor-react-native";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function getJobDisplayName(job: ExtendedJobListItem): string {
|
||||
if (job.name === "indexer") return "Indexing";
|
||||
if (job.name === "thumbnail_generation") return "Generating Thumbnails";
|
||||
|
||||
if (job.action_context?.action_type) {
|
||||
const actionType = job.action_context.action_type;
|
||||
if (actionType === "files.copy") return "Copying Files";
|
||||
if (actionType === "files.move") return "Moving Files";
|
||||
if (actionType === "files.delete") return "Deleting Files";
|
||||
}
|
||||
|
||||
return job.name
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getJobSubtext(job: ExtendedJobListItem): string {
|
||||
if (job.status_message) return job.status_message;
|
||||
if (job.current_phase) return job.current_phase;
|
||||
|
||||
if (job.generic_progress?.completion) {
|
||||
const { completed, total, bytes_completed, total_bytes } =
|
||||
job.generic_progress.completion;
|
||||
if (bytes_completed && total_bytes) {
|
||||
return `${formatBytes(bytes_completed)} / ${formatBytes(total_bytes)}`;
|
||||
}
|
||||
if (completed && total) {
|
||||
return `${completed} / ${total} items`;
|
||||
}
|
||||
}
|
||||
|
||||
return job.status;
|
||||
}
|
||||
|
||||
function formatTimeAgo(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
interface JobCardProps {
|
||||
job: ExtendedJobListItem;
|
||||
onPause: (jobId: string) => Promise<void>;
|
||||
onResume: (jobId: string) => Promise<void>;
|
||||
onCancel: (jobId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function JobCard({ job, onPause, onResume, onCancel }: JobCardProps) {
|
||||
const isRunning = job.status === "running";
|
||||
const isPaused = job.status === "paused";
|
||||
const isCompleted = job.status === "completed";
|
||||
const isFailed = job.status === "failed";
|
||||
const canControl = isRunning || isPaused;
|
||||
|
||||
const statusColor = isRunning
|
||||
? "text-accent"
|
||||
: isPaused
|
||||
? "text-yellow-500"
|
||||
: isCompleted
|
||||
? "text-green-500"
|
||||
: isFailed
|
||||
? "text-red-500"
|
||||
: "text-ink-dull";
|
||||
|
||||
return (
|
||||
<View className="bg-app-box border border-app-line rounded-xl p-4 mb-3">
|
||||
<View className="flex-row items-start justify-between mb-2">
|
||||
<View className="flex-1 mr-3">
|
||||
<Text className="text-ink text-base font-semibold" numberOfLines={1}>
|
||||
{getJobDisplayName(job)}
|
||||
</Text>
|
||||
<Text className="text-ink-dull text-sm mt-1" numberOfLines={2}>
|
||||
{getJobSubtext(job)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-end">
|
||||
<Text className={`text-xs font-medium capitalize ${statusColor}`}>
|
||||
{job.status}
|
||||
</Text>
|
||||
{job.started_at && (
|
||||
<Text className="text-ink-faint text-xs mt-1">
|
||||
{isCompleted || isFailed
|
||||
? job.completed_at
|
||||
? formatTimeAgo(new Date(job.completed_at))
|
||||
: formatTimeAgo(new Date(job.started_at))
|
||||
: formatTimeAgo(new Date(job.started_at))}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{job.progress !== null && job.progress !== undefined && (isRunning || isPaused) && (
|
||||
<View className="bg-app-darkBox h-2 rounded-full overflow-hidden mb-3">
|
||||
<View
|
||||
className={`h-full rounded-full ${isPaused ? "bg-yellow-500" : "bg-accent"}`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, job.progress))}%` }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
{canControl && (
|
||||
<View className="flex-row gap-2 mt-1">
|
||||
{isRunning && (
|
||||
<Pressable
|
||||
onPress={() => onPause(job.id)}
|
||||
className="bg-app-darkBox rounded-lg px-4 py-2 flex-1"
|
||||
>
|
||||
<Text className="text-ink-dull text-sm font-medium text-center">
|
||||
Pause
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{isPaused && (
|
||||
<Pressable
|
||||
onPress={() => onResume(job.id)}
|
||||
className="bg-accent/20 rounded-lg px-4 py-2 flex-1"
|
||||
>
|
||||
<Text className="text-accent text-sm font-medium text-center">
|
||||
Resume
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => onCancel(job.id)}
|
||||
className="bg-app-darkBox rounded-lg px-4 py-2"
|
||||
>
|
||||
<Text className="text-red-400 text-sm font-medium">Cancel</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type FilterType = "all" | "active" | "completed";
|
||||
|
||||
export default function JobsScreen() {
|
||||
const router = useRouter();
|
||||
const { jobs, activeJobCount, isLoading, pause, resume, cancel } = useJobs();
|
||||
const [filter, setFilter] = useState<FilterType>("active");
|
||||
|
||||
const filteredJobs = jobs.filter((job) => {
|
||||
if (filter === "active") {
|
||||
return job.status === "running" || job.status === "paused";
|
||||
}
|
||||
if (filter === "completed") {
|
||||
return job.status === "completed" || job.status === "failed";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const FilterButton = ({ type, label }: { type: FilterType; label: string }) => (
|
||||
<Pressable
|
||||
onPress={() => setFilter(type)}
|
||||
className={`px-4 py-2 rounded-full ${
|
||||
filter === type ? "bg-accent" : "bg-app-box"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-sm font-medium ${
|
||||
filter === type ? "text-white" : "text-ink-dull"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-app">
|
||||
{/* Header */}
|
||||
<View
|
||||
className="bg-app-box border-b border-app-line"
|
||||
style={{ paddingTop: 18 }}
|
||||
>
|
||||
<View className="px-4 pb-4 flex-row items-center gap-3">
|
||||
<View className="flex-1">
|
||||
<Text className="text-ink text-xl font-bold">Jobs</Text>
|
||||
<Text className="text-ink-dull text-sm mt-0.5">
|
||||
{activeJobCount > 0
|
||||
? `${activeJobCount} active job${activeJobCount !== 1 ? "s" : ""}`
|
||||
: "No active jobs"}
|
||||
</Text>
|
||||
</View>
|
||||
<GlassButton
|
||||
onPress={() => router.back()}
|
||||
icon={<X size={20} color="hsl(235, 10%, 55%)" weight="bold" />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<View className="flex-row gap-2 px-4 pb-3">
|
||||
<FilterButton type="active" label="Active" />
|
||||
<FilterButton type="completed" label="History" />
|
||||
<FilterButton type="all" label="All" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="hsl(208, 100%, 57%)" />
|
||||
</View>
|
||||
) : filteredJobs.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center p-8">
|
||||
<Text className="text-ink-dull text-sm text-center">
|
||||
{filter === "active"
|
||||
? "No active jobs"
|
||||
: filter === "completed"
|
||||
? "No completed jobs"
|
||||
: "No jobs"}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredJobs}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<JobCard
|
||||
job={item}
|
||||
onPause={pause}
|
||||
onResume={resume}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,155 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { View, Text, ActivityIndicator } from "react-native";
|
||||
import { useRouter, useFocusEffect } from "expo-router";
|
||||
import { GlassSearchBar } from "../components/GlassSearchBar";
|
||||
import { SearchToolbar } from "../components/SearchToolbar";
|
||||
import { ListView } from "../screens/explorer/views/ListView";
|
||||
import { GridView } from "../screens/explorer/views/GridView";
|
||||
import { GlassButton } from "../components/GlassButton";
|
||||
import { useSearchStore } from "../screens/explorer/context/SearchContext";
|
||||
import { useSearchFiles } from "@sd/ts-client";
|
||||
import type { File } from "@sd/ts-client";
|
||||
import type { TextInput } from "react-native";
|
||||
import { X } from "phosphor-react-native";
|
||||
|
||||
type SearchViewMode = "list" | "grid";
|
||||
|
||||
export default function SearchScreen() {
|
||||
return (
|
||||
<View className="flex-1 bg-app-modal items-center justify-center">
|
||||
<Text className="text-ink text-xl">Search</Text>
|
||||
<Text className="text-ink-dull text-sm mt-2">Coming soon</Text>
|
||||
</View>
|
||||
);
|
||||
const router = useRouter();
|
||||
const searchInputRef = useRef<TextInput>(null);
|
||||
const [viewMode, setViewMode] = useState<SearchViewMode>("list");
|
||||
|
||||
const { query, scope, isSearchMode, enterSearchMode, exitSearchMode, setSearchQuery } =
|
||||
useSearchStore();
|
||||
|
||||
// Ref to track current query for cleanup without triggering effect re-runs
|
||||
const queryRef = useRef(query);
|
||||
useEffect(() => {
|
||||
queryRef.current = query;
|
||||
}, [query]);
|
||||
|
||||
// Get current path from route params if needed (for folder scope)
|
||||
// For now, we'll use library scope by default
|
||||
const currentPath = undefined;
|
||||
|
||||
// Fetch search results
|
||||
const { files, isLoading } = useSearchFiles({
|
||||
query,
|
||||
scope,
|
||||
currentPath,
|
||||
enabled: isSearchMode && query.length >= 2,
|
||||
});
|
||||
|
||||
// Auto-focus search input on mount
|
||||
useEffect(() => {
|
||||
// Focus input after a short delay
|
||||
const timer = setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Exit search mode when screen loses focus (user navigated away)
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
return () => {
|
||||
// Only exit search mode if query is empty when navigating away
|
||||
// Using ref to avoid stale closure issues when query changes
|
||||
if (!queryRef.current) {
|
||||
exitSearchMode();
|
||||
}
|
||||
};
|
||||
}, [exitSearchMode])
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
// Update query in store immediately so TextInput shows what user types
|
||||
setSearchQuery(value);
|
||||
|
||||
if (value.length === 0) {
|
||||
// Exit search mode when query is cleared, but keep the screen open
|
||||
// User can manually navigate back if they want to leave
|
||||
exitSearchMode();
|
||||
} else {
|
||||
// Enter search mode when user starts typing
|
||||
// The actual search query execution is handled by useSearchFiles which checks query.length >= 2
|
||||
enterSearchMode(value, scope);
|
||||
}
|
||||
}, [setSearchQuery, exitSearchMode, enterSearchMode, scope]);
|
||||
|
||||
|
||||
const handleFilePress = (file: File) => {
|
||||
// Exit search mode when navigating to a file
|
||||
exitSearchMode();
|
||||
|
||||
// If it's a directory, navigate into it
|
||||
if (file.kind === "Directory") {
|
||||
router.push({
|
||||
pathname: "/explorer",
|
||||
params: {
|
||||
type: "path",
|
||||
path: JSON.stringify(file.sd_path),
|
||||
},
|
||||
});
|
||||
}
|
||||
// TODO: Handle file preview
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-app">
|
||||
{/* Header */}
|
||||
<View
|
||||
className="bg-app-box border-b border-app-line"
|
||||
style={{ paddingTop: 18 }}
|
||||
>
|
||||
<View className="px-4 pb-3 flex-row items-center gap-3">
|
||||
<View className="flex-1">
|
||||
<GlassSearchBar
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={handleSearchChange}
|
||||
editable={true}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<GlassButton
|
||||
onPress={() => router.back()}
|
||||
icon={<X size={20} color="hsl(235, 10%, 55%)" weight="bold" />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Search Toolbar */}
|
||||
{isSearchMode && query.length >= 2 && (
|
||||
<SearchToolbar viewMode={viewMode} setViewMode={setViewMode} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{isSearchMode && query.length < 2 ? (
|
||||
<View className="flex-1 items-center justify-center p-8">
|
||||
<Text className="text-ink-dull text-sm text-center">
|
||||
Type at least 2 characters to search
|
||||
</Text>
|
||||
</View>
|
||||
) : isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="hsl(208, 100%, 57%)" />
|
||||
</View>
|
||||
) : files.length === 0 && query.length >= 2 ? (
|
||||
<View className="flex-1 items-center justify-center p-8">
|
||||
<Text className="text-ink-dull text-sm text-center">
|
||||
No results found for "{query}"
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{viewMode === "list" ? (
|
||||
<ListView files={files} onFilePress={handleFilePress} />
|
||||
) : (
|
||||
<GridView files={files} onFilePress={handleFilePress} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,3 +13,4 @@ export {
|
||||
|
||||
// Re-export shared hooks from ts-client
|
||||
export { useNormalizedQuery } from "@sd/ts-client/src/hooks/useNormalizedQuery";
|
||||
export { useSearchFiles } from "@sd/ts-client";
|
||||
|
||||
142
apps/mobile/src/components/GlassContextMenu.tsx
Normal file
142
apps/mobile/src/components/GlassContextMenu.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useState, cloneElement, isValidElement } from "react";
|
||||
import { View, Text, Pressable, Modal, StyleSheet } from "react-native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import {
|
||||
LiquidGlassView,
|
||||
isLiquidGlassSupported,
|
||||
} from "@callstack/liquid-glass";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
icon?: ReactNode;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
interface GlassContextMenuProps {
|
||||
trigger: ReactNode;
|
||||
items: MenuItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GlassContextMenu({
|
||||
trigger,
|
||||
items,
|
||||
className,
|
||||
}: GlassContextMenuProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const handleItemPress = (item: MenuItem) => {
|
||||
item.onPress();
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const menuContent = (
|
||||
<View className="py-2 min-w-[180]">
|
||||
{items.map((item, index) => (
|
||||
<Pressable
|
||||
key={index}
|
||||
onPress={() => handleItemPress(item)}
|
||||
className={`px-4 py-3 flex-row items-center gap-3 active:bg-app-hover ${
|
||||
item.active ? "bg-accent/10" : ""
|
||||
}`}
|
||||
>
|
||||
{item.icon && (
|
||||
<View className="w-5 h-5 items-center justify-center">
|
||||
{item.icon}
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
className={`flex-1 text-ink ${item.active ? "text-accent font-medium" : ""}`}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
const handleTriggerPress = () => setVisible(true);
|
||||
|
||||
const triggerElement = isValidElement(trigger)
|
||||
? cloneElement(trigger as React.ReactElement<any>, {
|
||||
onPress: handleTriggerPress,
|
||||
className: className,
|
||||
})
|
||||
: (
|
||||
<Pressable onPress={handleTriggerPress} className={className}>
|
||||
{trigger}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{triggerElement}
|
||||
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setVisible(false)}
|
||||
>
|
||||
<Pressable
|
||||
style={StyleSheet.absoluteFill}
|
||||
onPress={() => setVisible(false)}
|
||||
className="bg-black/50"
|
||||
>
|
||||
<View className="flex-1" />
|
||||
</Pressable>
|
||||
|
||||
{isLiquidGlassSupported ? (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 60,
|
||||
right: 16,
|
||||
}}
|
||||
>
|
||||
<LiquidGlassView
|
||||
interactive
|
||||
effect="regular"
|
||||
colorScheme="dark"
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
minWidth: 180,
|
||||
}}
|
||||
>
|
||||
{menuContent}
|
||||
</LiquidGlassView>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 60,
|
||||
right: 16,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
minWidth: 180,
|
||||
}}
|
||||
>
|
||||
<BlurView
|
||||
intensity={80}
|
||||
tint="dark"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(128, 128, 128, 0.3)",
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<View className="absolute inset-0 bg-app-box/20" />
|
||||
{menuContent}
|
||||
</BlurView>
|
||||
</View>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
GlassContextMenu.displayName = "GlassContextMenu";
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import React, { forwardRef, useState, useEffect, useCallback } from "react";
|
||||
import { TextInput, View, Pressable, type TextInputProps } from "react-native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import {
|
||||
@@ -6,45 +6,131 @@ import {
|
||||
isLiquidGlassSupported,
|
||||
} from "@callstack/liquid-glass";
|
||||
import { useRouter } from "expo-router";
|
||||
import { MagnifyingGlass } from "phosphor-react-native";
|
||||
import { MagnifyingGlass, X } from "phosphor-react-native";
|
||||
|
||||
interface GlassSearchBarProps extends Omit<TextInputProps, 'style'> {
|
||||
interface GlassSearchBarProps extends Omit<TextInputProps, 'style' | 'onChange' | 'onChangeText'> {
|
||||
onPress?: () => void;
|
||||
onChange?: (value: string) => void;
|
||||
value?: string;
|
||||
className?: string;
|
||||
interactive?: boolean;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export const GlassSearchBar = forwardRef<TextInput, GlassSearchBarProps>(
|
||||
({ onPress, className, interactive = true, editable, ...textInputProps }, ref) => {
|
||||
(
|
||||
{
|
||||
onPress,
|
||||
onChange,
|
||||
value: controlledValue,
|
||||
className,
|
||||
interactive = true,
|
||||
editable = true,
|
||||
debounceMs = 300,
|
||||
...textInputProps
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const [internalValue, setInternalValue] = useState("");
|
||||
const [debounceTimer, setDebounceTimer] = useState<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handlePress = () => {
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const currentValue = isControlled ? controlledValue : internalValue;
|
||||
|
||||
const handleChange = useCallback(
|
||||
(text: string) => {
|
||||
// For controlled inputs, always call onChange immediately
|
||||
// The parent component handles the state update
|
||||
if (isControlled) {
|
||||
if (onChange) {
|
||||
onChange(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For uncontrolled inputs, update internal state immediately
|
||||
setInternalValue(text);
|
||||
|
||||
// Clear existing timer
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
// Set new debounced timer only for uncontrolled inputs
|
||||
if (onChange) {
|
||||
const timer = setTimeout(() => {
|
||||
onChange(text);
|
||||
}, debounceMs);
|
||||
setDebounceTimer(timer);
|
||||
}
|
||||
},
|
||||
[isControlled, onChange, debounceMs, debounceTimer],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (!isControlled) {
|
||||
setInternalValue("");
|
||||
}
|
||||
if (onChange) {
|
||||
onChange("");
|
||||
}
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
setDebounceTimer(null);
|
||||
}
|
||||
}, [isControlled, onChange, debounceTimer]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (onPress) {
|
||||
onPress();
|
||||
} else {
|
||||
} else if (!editable) {
|
||||
router.push("/search");
|
||||
}
|
||||
};
|
||||
}, [onPress, editable, router]);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
};
|
||||
}, [debounceTimer]);
|
||||
|
||||
const content = (
|
||||
<View className="flex-1 px-4 flex-row items-center gap-3">
|
||||
<MagnifyingGlass size={20} color="hsl(235, 10%, 55%)" weight="bold" />
|
||||
<TextInput
|
||||
ref={ref}
|
||||
editable={editable ?? false}
|
||||
placeholder="Search library"
|
||||
placeholderTextColor="hsl(235, 10%, 55%)"
|
||||
className="flex-1 text-ink text-base text-md"
|
||||
cursorColor="hsl(220, 90%, 56%)"
|
||||
{...textInputProps}
|
||||
/>
|
||||
<TextInput
|
||||
ref={ref}
|
||||
editable={editable}
|
||||
pointerEvents={editable ? "auto" : "none"}
|
||||
value={currentValue}
|
||||
onChangeText={handleChange}
|
||||
placeholder="Search library"
|
||||
placeholderTextColor="hsl(235, 10%, 55%)"
|
||||
className="flex-1 text-ink text-base text-md"
|
||||
cursorColor="hsl(220, 90%, 56%)"
|
||||
{...textInputProps}
|
||||
/>
|
||||
{currentValue.length > 0 && editable && (
|
||||
<Pressable
|
||||
onPress={handleClear}
|
||||
className="p-1 -mr-1"
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<X size={18} color="hsl(235, 10%, 55%)" weight="bold" />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isLiquidGlassSupported) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
onPress={editable ? undefined : handlePress}
|
||||
className={className}
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
@@ -67,7 +153,7 @@ export const GlassSearchBar = forwardRef<TextInput, GlassSearchBarProps>(
|
||||
// Fallback for older iOS and Android
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
onPress={editable ? undefined : handlePress}
|
||||
className={`overflow-hidden ${className || ""}`}
|
||||
style={{
|
||||
height: 48,
|
||||
@@ -89,7 +175,7 @@ export const GlassSearchBar = forwardRef<TextInput, GlassSearchBarProps>(
|
||||
</BlurView>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
GlassSearchBar.displayName = "GlassSearchBar";
|
||||
|
||||
135
apps/mobile/src/components/SearchToolbar.tsx
Normal file
135
apps/mobile/src/components/SearchToolbar.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import type React from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { FunnelSimple, List } from "phosphor-react-native";
|
||||
import { useSearchStore } from "../screens/explorer/context/SearchContext";
|
||||
import type { SearchScope } from "../screens/explorer/context/SearchContext";
|
||||
import { GlassButton } from "./GlassButton";
|
||||
import { GlassContextMenu } from "./GlassContextMenu";
|
||||
|
||||
interface ScopeButtonProps {
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function ScopeButton({ active, onPress, children }: ScopeButtonProps) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={`px-3 py-1.5 rounded-md ${
|
||||
active ? "bg-accent" : "bg-app-box/30"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs font-medium ${
|
||||
active ? "text-white" : "text-ink-dull"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchToolbarProps {
|
||||
viewMode?: "list" | "grid";
|
||||
setViewMode?: (mode: "list" | "grid") => void;
|
||||
}
|
||||
|
||||
export function SearchToolbar({ viewMode, setViewMode }: SearchToolbarProps = {}) {
|
||||
const { scope, setSearchScope } = useSearchStore();
|
||||
|
||||
const handleScopeChange = (newScope: SearchScope) => {
|
||||
setSearchScope(newScope);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center gap-3 px-4 py-2 border-b border-app-line bg-app-box/50">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text className="text-xs font-medium text-ink-dull">Search in:</Text>
|
||||
<View className="flex-row items-center gap-1">
|
||||
<ScopeButton
|
||||
active={scope === "folder"}
|
||||
onPress={() => handleScopeChange("folder")}
|
||||
>
|
||||
This Folder
|
||||
</ScopeButton>
|
||||
<ScopeButton
|
||||
active={scope === "location"}
|
||||
onPress={() => handleScopeChange("location")}
|
||||
>
|
||||
Location
|
||||
</ScopeButton>
|
||||
<ScopeButton
|
||||
active={scope === "library"}
|
||||
onPress={() => handleScopeChange("library")}
|
||||
>
|
||||
Library
|
||||
</ScopeButton>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="h-4 w-px bg-app-line" />
|
||||
<View className="flex-1" />
|
||||
|
||||
{viewMode && setViewMode && (
|
||||
<GlassContextMenu
|
||||
items={[
|
||||
{
|
||||
label: "List View",
|
||||
onPress: () => setViewMode("list"),
|
||||
icon: (
|
||||
<List
|
||||
size={20}
|
||||
color={
|
||||
viewMode === "list"
|
||||
? "hsl(208, 100%, 57%)"
|
||||
: "hsl(235, 10%, 55%)"
|
||||
}
|
||||
weight="bold"
|
||||
/>
|
||||
),
|
||||
active: viewMode === "list",
|
||||
},
|
||||
{
|
||||
label: "Grid View",
|
||||
onPress: () => setViewMode("grid"),
|
||||
icon: (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color:
|
||||
viewMode === "grid"
|
||||
? "hsl(208, 100%, 57%)"
|
||||
: "hsl(235, 10%, 55%)",
|
||||
}}
|
||||
>
|
||||
⊞
|
||||
</Text>
|
||||
),
|
||||
active: viewMode === "grid",
|
||||
},
|
||||
]}
|
||||
trigger={
|
||||
<GlassButton
|
||||
icon={
|
||||
<Text className="text-ink-dull" style={{ fontSize: 18 }}>
|
||||
⋯
|
||||
</Text>
|
||||
}
|
||||
size={32}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<GlassButton
|
||||
icon={
|
||||
<FunnelSimple size={18} color="hsl(235, 10%, 55%)" weight="bold" />
|
||||
}
|
||||
size={32}
|
||||
/>
|
||||
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import Animated, {
|
||||
import { useNormalizedQuery } from "../../client";
|
||||
import { PageIndicator } from "../../components/PageIndicator";
|
||||
import { GlassSearchBar } from "../../components/GlassSearchBar";
|
||||
import { useRouter } from "expo-router";
|
||||
import sharedColors from "@sd/ui/style/colors";
|
||||
import type { SpaceItem, SpaceGroup } from "@sd/ts-client";
|
||||
import { SpaceItem as SpaceItemComponent, SpaceGroupComponent } from "./components";
|
||||
@@ -38,6 +39,7 @@ function SpaceContent({
|
||||
space: Space;
|
||||
insets: EdgeInsets;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const scrollY = useSharedValue(0);
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
@@ -46,6 +48,10 @@ function SpaceContent({
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearchPress = () => {
|
||||
router.push("/search");
|
||||
};
|
||||
|
||||
// Fetch space layout
|
||||
const { data: layout } = useNormalizedQuery({
|
||||
query: "spaces.get_layout",
|
||||
@@ -106,7 +112,7 @@ function SpaceContent({
|
||||
|
||||
{/* Search Bar */}
|
||||
<View className="mb-6">
|
||||
<GlassSearchBar />
|
||||
<GlassSearchBar onPress={handleSearchPress} editable={false} />
|
||||
</View>
|
||||
|
||||
{/* Space Items (pinned shortcuts) */}
|
||||
|
||||
@@ -7,6 +7,11 @@ import { ListView } from "./views/ListView";
|
||||
import { GridView } from "./views/GridView";
|
||||
import type { Device } from "@sd/ts-client";
|
||||
import { useNormalizedQuery } from "../../client";
|
||||
import { GlassButton } from "../../components/GlassButton";
|
||||
import { GlassContextMenu } from "../../components/GlassContextMenu";
|
||||
import { SearchToolbar } from "../../components/SearchToolbar";
|
||||
import { useSearchStore } from "./context/SearchContext";
|
||||
import { List, ArrowLeft } from "phosphor-react-native";
|
||||
|
||||
type ViewMode = "list" | "grid";
|
||||
|
||||
@@ -20,6 +25,7 @@ export function ExplorerScreen() {
|
||||
id?: string;
|
||||
}>();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||
const { isSearchMode, query } = useSearchStore();
|
||||
|
||||
// Parse params into the format expected by hooks
|
||||
const params = useMemo(() => {
|
||||
@@ -103,52 +109,75 @@ export function ExplorerScreen() {
|
||||
className="bg-app-box border-b border-app-line"
|
||||
style={{ paddingTop: insets.top }}
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-4 h-14">
|
||||
<View className="relative flex-row items-center justify-between px-4 h-14">
|
||||
{/* Back button */}
|
||||
<Pressable
|
||||
<GlassButton
|
||||
onPress={() => router.back()}
|
||||
className="w-10 h-10 items-center justify-center -ml-2"
|
||||
>
|
||||
<Text className="text-ink text-xl">←</Text>
|
||||
</Pressable>
|
||||
icon={
|
||||
<ArrowLeft size={20} color="hsl(235, 10%, 55%)" weight="bold" />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<Text className="text-ink font-semibold text-lg flex-1 text-center">
|
||||
{/* Title - absolutely centered */}
|
||||
<Text
|
||||
className="absolute left-0 right-0 text-ink font-semibold text-lg text-center"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* View mode switcher */}
|
||||
<View className="flex-row gap-1">
|
||||
<Pressable
|
||||
onPress={() => setViewMode("list")}
|
||||
className={`w-10 h-10 items-center justify-center rounded-md ${
|
||||
viewMode === "list" ? "bg-accent/10" : ""
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
viewMode === "list" ? "text-accent" : "text-ink-dull"
|
||||
{/* View mode menu */}
|
||||
<GlassContextMenu
|
||||
items={[
|
||||
{
|
||||
label: "List View",
|
||||
onPress: () => setViewMode("list"),
|
||||
icon: (
|
||||
<List
|
||||
size={20}
|
||||
color={
|
||||
viewMode === "list"
|
||||
? "hsl(208, 100%, 57%)"
|
||||
: "hsl(235, 10%, 55%)"
|
||||
}
|
||||
weight="bold"
|
||||
/>
|
||||
),
|
||||
active: viewMode === "list",
|
||||
},
|
||||
{
|
||||
label: "Grid View",
|
||||
onPress: () => setViewMode("grid"),
|
||||
icon: (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color:
|
||||
viewMode === "grid"
|
||||
? "hsl(208, 100%, 57%)"
|
||||
: "hsl(235, 10%, 55%)",
|
||||
}}
|
||||
>
|
||||
⊞
|
||||
</Text>
|
||||
),
|
||||
active: viewMode === "grid",
|
||||
},
|
||||
]}
|
||||
trigger={
|
||||
<GlassButton
|
||||
icon={
|
||||
<Text className="text-ink-dull" style={{ fontSize: 18 }}>
|
||||
⋯
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
≡
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setViewMode("grid")}
|
||||
className={`w-10 h-10 items-center justify-center rounded-md ${
|
||||
viewMode === "grid" ? "bg-accent/10" : ""
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={
|
||||
viewMode === "grid" ? "text-accent" : "text-ink-dull"
|
||||
}
|
||||
>
|
||||
⊞
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Search Toolbar */}
|
||||
{isSearchMode && query.length >= 2 && <SearchToolbar />}
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
62
apps/mobile/src/screens/explorer/context/SearchContext.tsx
Normal file
62
apps/mobile/src/screens/explorer/context/SearchContext.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type SearchScope = "folder" | "location" | "library";
|
||||
|
||||
export interface SearchFilters {
|
||||
fileTypes?: string[];
|
||||
contentTypes?: string[];
|
||||
sizeMin?: number;
|
||||
sizeMax?: number;
|
||||
dateModifiedStart?: Date;
|
||||
dateModifiedEnd?: Date;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface SearchStore {
|
||||
// Search mode state
|
||||
isSearchMode: boolean;
|
||||
query: string;
|
||||
scope: SearchScope;
|
||||
filters: SearchFilters;
|
||||
|
||||
// Actions
|
||||
enterSearchMode: (query: string, scope?: SearchScope) => void;
|
||||
exitSearchMode: () => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSearchScope: (scope: SearchScope) => void;
|
||||
setSearchFilters: (filters: SearchFilters) => void;
|
||||
}
|
||||
|
||||
export const useSearchStore = create<SearchStore>((set) => ({
|
||||
// Initial state
|
||||
isSearchMode: false,
|
||||
query: "",
|
||||
scope: "library",
|
||||
filters: {},
|
||||
|
||||
// Actions
|
||||
enterSearchMode: (query, scope = "library") =>
|
||||
set({
|
||||
isSearchMode: true,
|
||||
query,
|
||||
scope,
|
||||
}),
|
||||
|
||||
exitSearchMode: () =>
|
||||
set({
|
||||
isSearchMode: false,
|
||||
query: "",
|
||||
filters: {},
|
||||
}),
|
||||
|
||||
setSearchQuery: (query) =>
|
||||
set((state) => ({
|
||||
query,
|
||||
// Auto-exit if query is cleared
|
||||
isSearchMode: query.length > 0 ? state.isSearchMode : false,
|
||||
})),
|
||||
|
||||
setSearchScope: (scope) => set({ scope }),
|
||||
|
||||
setSearchFilters: (filters) => set({ filters }),
|
||||
}));
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import type { File, SdPath } from "@sd/ts-client";
|
||||
import { useNormalizedQuery } from "../../../client";
|
||||
import { useNormalizedQuery, useSearchFiles } from "@sd/ts-client";
|
||||
import { useVirtualListing } from "./useVirtualListing";
|
||||
import { useSearchStore } from "../context/SearchContext";
|
||||
|
||||
export type FileSource = "virtual" | "directory";
|
||||
export type FileSource = "search" | "virtual" | "directory";
|
||||
|
||||
export interface ExplorerFilesResult {
|
||||
files: File[];
|
||||
@@ -14,9 +15,10 @@ export interface ExplorerFilesResult {
|
||||
/**
|
||||
* Centralized hook for fetching files in the mobile explorer.
|
||||
*
|
||||
* Handles two file sources with priority:
|
||||
* 1. Virtual listings (devices/volumes/locations)
|
||||
* 2. Directory listings (normal file browsing)
|
||||
* Handles three file sources with priority:
|
||||
* 1. Search results (when in search mode)
|
||||
* 2. Virtual listings (devices/volumes/locations)
|
||||
* 3. Directory listings (normal file browsing)
|
||||
*/
|
||||
export function useExplorerFiles(
|
||||
params:
|
||||
@@ -24,8 +26,11 @@ export function useExplorerFiles(
|
||||
| { type: "view"; view: string; id?: string }
|
||||
| undefined,
|
||||
): ExplorerFilesResult {
|
||||
const { isSearchMode, query, scope } = useSearchStore();
|
||||
|
||||
// Check for virtual listing first
|
||||
const { files: virtualFiles, isVirtualView, isLoading: virtualLoading } = useVirtualListing(params);
|
||||
const { files: virtualFiles, isVirtualView, isLoading: virtualLoading } =
|
||||
useVirtualListing(params);
|
||||
|
||||
// Parse path for directory listing
|
||||
const currentPath: SdPath | null = useMemo(() => {
|
||||
@@ -40,6 +45,14 @@ export function useExplorerFiles(
|
||||
return null;
|
||||
}, [params]);
|
||||
|
||||
// Search query
|
||||
const { files: searchFiles, isLoading: searchLoading } = useSearchFiles({
|
||||
query,
|
||||
scope,
|
||||
currentPath: scope === "folder" && currentPath ? currentPath : undefined,
|
||||
enabled: isSearchMode && query.length >= 2,
|
||||
});
|
||||
|
||||
// Directory query
|
||||
const directoryQuery = useNormalizedQuery({
|
||||
query: "files.directory_listing",
|
||||
@@ -51,34 +64,44 @@ export function useExplorerFiles(
|
||||
sort_by: "name", // Default to name sorting
|
||||
folders_first: true,
|
||||
}
|
||||
: null!,
|
||||
: (null as any),
|
||||
resourceType: "file",
|
||||
enabled: !!currentPath && !isVirtualView,
|
||||
enabled: !!currentPath && !isVirtualView && !isSearchMode,
|
||||
pathScope: currentPath ?? undefined,
|
||||
});
|
||||
|
||||
console.log("[useExplorerFiles] Query state:", {
|
||||
isVirtualView,
|
||||
hasPath: !!currentPath,
|
||||
enabled: !!currentPath && !isVirtualView,
|
||||
isLoading: directoryQuery.isLoading,
|
||||
hasData: !!directoryQuery.data,
|
||||
fileCount: (directoryQuery.data as any)?.files?.length || 0,
|
||||
});
|
||||
|
||||
// Determine source and files with priority: virtual > directory
|
||||
const source: FileSource = isVirtualView ? "virtual" : "directory";
|
||||
// Determine source and files with priority: search > virtual > directory
|
||||
const source: FileSource = isSearchMode
|
||||
? "search"
|
||||
: isVirtualView
|
||||
? "virtual"
|
||||
: "directory";
|
||||
|
||||
const files = useMemo(() => {
|
||||
if (isSearchMode) {
|
||||
return searchFiles || [];
|
||||
}
|
||||
if (isVirtualView) {
|
||||
return virtualFiles || [];
|
||||
}
|
||||
return (directoryQuery.data as any)?.files || [];
|
||||
}, [isVirtualView, virtualFiles, directoryQuery.data]);
|
||||
return (directoryQuery.data as { files?: File[] })?.files || [];
|
||||
}, [
|
||||
isSearchMode,
|
||||
isVirtualView,
|
||||
searchFiles,
|
||||
virtualFiles,
|
||||
directoryQuery.data,
|
||||
]);
|
||||
|
||||
const isLoading = isSearchMode
|
||||
? searchLoading
|
||||
: isVirtualView
|
||||
? virtualLoading
|
||||
: directoryQuery.isLoading;
|
||||
|
||||
return {
|
||||
files,
|
||||
isLoading: isVirtualView ? virtualLoading : directoryQuery.isLoading,
|
||||
isLoading,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import Animated, {
|
||||
interpolate,
|
||||
Extrapolation,
|
||||
withTiming,
|
||||
withRepeat,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
import { BlurView } from "expo-blur";
|
||||
@@ -20,6 +21,10 @@ import { LibrarySwitcherPanel } from "../../components/LibrarySwitcherPanel";
|
||||
import { GlassButton } from "../../components/GlassButton";
|
||||
import { GlassSearchBar } from "../../components/GlassSearchBar";
|
||||
import { JobManagerPanel } from "../../components/JobManagerPanel";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useSearchStore } from "../explorer/context/SearchContext";
|
||||
import { CircleNotch, ListBullets } from "phosphor-react-native";
|
||||
import { useJobs } from "../../hooks/useJobs";
|
||||
|
||||
const HEADER_INITIAL_HEIGHT = 40;
|
||||
const HERO_HEIGHT = 430 + HEADER_INITIAL_HEIGHT;
|
||||
@@ -29,6 +34,7 @@ const NETWORK_HEADER_HEIGHT = 50;
|
||||
export function OverviewScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const scrollY = useSharedValue(0);
|
||||
const expandedOffsetY = useSharedValue(0);
|
||||
const [showPairing, setShowPairing] = useState(false);
|
||||
@@ -36,6 +42,35 @@ export function OverviewScreen() {
|
||||
const [selectedLocationId, setSelectedLocationId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const { enterSearchMode } = useSearchStore();
|
||||
const { activeJobCount, hasRunningJobs } = useJobs();
|
||||
|
||||
// Spinning animation for jobs icon
|
||||
const spinRotation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRunningJobs) {
|
||||
spinRotation.value = withRepeat(
|
||||
withTiming(360, { duration: 1000, easing: Easing.linear }),
|
||||
-1, // infinite
|
||||
false // don't reverse
|
||||
);
|
||||
} else {
|
||||
spinRotation.value = withTiming(0, { duration: 200 });
|
||||
}
|
||||
}, [hasRunningJobs, spinRotation]);
|
||||
|
||||
const spinStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${spinRotation.value}deg` }],
|
||||
}));
|
||||
|
||||
const handleSearchPress = () => {
|
||||
router.push("/search");
|
||||
};
|
||||
|
||||
const handleJobsPress = () => {
|
||||
router.push("/jobs");
|
||||
};
|
||||
|
||||
// Fetch library info with real-time statistics updates
|
||||
const {
|
||||
@@ -313,6 +348,37 @@ export function OverviewScreen() {
|
||||
>
|
||||
{libraryInfo.name}
|
||||
</Animated.Text>
|
||||
<GlassButton
|
||||
onPress={handleJobsPress}
|
||||
icon={
|
||||
<View>
|
||||
{hasRunningJobs ? (
|
||||
<Animated.View style={spinStyle}>
|
||||
<CircleNotch
|
||||
size={22}
|
||||
color="hsl(208, 100%, 57%)"
|
||||
weight="bold"
|
||||
/>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<ListBullets
|
||||
size={22}
|
||||
color="hsl(235, 10%, 55%)"
|
||||
weight="bold"
|
||||
/>
|
||||
)}
|
||||
{activeJobCount > 0 && (
|
||||
<View
|
||||
className="absolute -top-1 -right-1 bg-accent rounded-full min-w-[16px] h-[16px] items-center justify-center"
|
||||
>
|
||||
<Text className="text-white text-[10px] font-bold">
|
||||
{activeJobCount > 9 ? "9+" : activeJobCount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
<GlassButton
|
||||
icon={
|
||||
<Text className="text-ink text-2xl leading-none">⋯</Text>
|
||||
@@ -322,7 +388,7 @@ export function OverviewScreen() {
|
||||
|
||||
{/* Search Bar */}
|
||||
<View className="px-4 mb-4" style={{ position: "relative", zIndex: 25 }} pointerEvents="auto">
|
||||
<GlassSearchBar />
|
||||
<GlassSearchBar onPress={handleSearchPress} editable={false} />
|
||||
</View>
|
||||
|
||||
{/* Wrapper to elevate HeroStats above ScrollView for touch events */}
|
||||
|
||||
@@ -8,3 +8,4 @@ export { useNormalizedQuery } from "./useNormalizedQuery";
|
||||
// Alias for backwards compatibility
|
||||
export { useNormalizedQuery as useNormalizedCache } from "./useNormalizedQuery";
|
||||
export { useJobs, type UseJobsOptions, type UseJobsReturn, type SpeedSample, type ExtendedJobListItem } from "./useJobs";
|
||||
export { useSearchFiles, type UseSearchFilesOptions, type UseSearchFilesReturn, type SearchScopeUI } from "./useSearchFiles";
|
||||
|
||||
149
packages/ts-client/src/hooks/useSearchFiles.ts
Normal file
149
packages/ts-client/src/hooks/useSearchFiles.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
File,
|
||||
FileSearchInput,
|
||||
FileSearchOutput,
|
||||
SearchScope as TSSearchScope,
|
||||
SdPath,
|
||||
} from "../generated/types";
|
||||
import { useNormalizedQuery } from "./useNormalizedQuery";
|
||||
|
||||
export type SearchScopeUI = "folder" | "location" | "library";
|
||||
|
||||
export interface UseSearchFilesOptions {
|
||||
/** Search query string (minimum 2 characters to search) */
|
||||
query: string;
|
||||
/** Search scope: "folder" (current path), "location", or "library" */
|
||||
scope: SearchScopeUI;
|
||||
/** Current path (required for "folder" scope) */
|
||||
currentPath?: SdPath | null;
|
||||
/** Location ID (required for "location" scope) */
|
||||
locationId?: string | null;
|
||||
/** Sort field: "Relevance", "Name", "Size", "ModifiedAt", "CreatedAt" */
|
||||
sortBy?: "Relevance" | "Name" | "Size" | "ModifiedAt" | "CreatedAt";
|
||||
/** Sort direction */
|
||||
sortDirection?: "Asc" | "Desc";
|
||||
/** Search mode */
|
||||
mode?: "Fast" | "Normal" | "Full";
|
||||
/** Search filters */
|
||||
filters?: {
|
||||
file_types?: string[] | null;
|
||||
tags?: { include: string[]; exclude: string[] } | null;
|
||||
date_range?: {
|
||||
field: "CreatedAt" | "ModifiedAt" | "AccessedAt" | "IndexedAt";
|
||||
start?: Date | null;
|
||||
end?: Date | null;
|
||||
} | null;
|
||||
size_range?: { min?: number | null; max?: number | null } | null;
|
||||
locations?: string[] | null;
|
||||
content_types?: string[] | null;
|
||||
include_hidden?: boolean | null;
|
||||
include_archived?: boolean | null;
|
||||
};
|
||||
/** Pagination limit */
|
||||
limit?: number;
|
||||
/** Whether query is enabled */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseSearchFilesReturn {
|
||||
/** Search results */
|
||||
files: File[];
|
||||
/** Loading state */
|
||||
isLoading: boolean;
|
||||
/** Error state */
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared search hook for fetching files via search.files query.
|
||||
* Platform-agnostic - can be used by both desktop and mobile.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { files, isLoading } = useSearchFiles({
|
||||
* query: "photos",
|
||||
* scope: "library",
|
||||
* sortBy: "Relevance",
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useSearchFiles(
|
||||
options: UseSearchFilesOptions,
|
||||
): UseSearchFilesReturn {
|
||||
const {
|
||||
query,
|
||||
scope,
|
||||
currentPath,
|
||||
locationId,
|
||||
sortBy = "Relevance",
|
||||
sortDirection = "Desc",
|
||||
mode = "Normal",
|
||||
filters,
|
||||
limit = 1000,
|
||||
enabled = true,
|
||||
} = options;
|
||||
|
||||
// Map scope to TS SearchScope type
|
||||
const tsScope: TSSearchScope = useMemo(() => {
|
||||
if (scope === "folder" && currentPath) {
|
||||
return { Path: { path: currentPath } };
|
||||
}
|
||||
if (scope === "location" && locationId) {
|
||||
return { Location: { location_id: locationId } };
|
||||
}
|
||||
return "Library";
|
||||
}, [scope, currentPath, locationId]);
|
||||
|
||||
// Build search input
|
||||
const searchInput: FileSearchInput = useMemo(
|
||||
() => ({
|
||||
query,
|
||||
scope: tsScope,
|
||||
mode,
|
||||
filters: {
|
||||
file_types: filters?.file_types ?? null,
|
||||
tags: filters?.tags ?? null,
|
||||
date_range: filters?.date_range ?? null,
|
||||
size_range: filters?.size_range ?? null,
|
||||
locations: filters?.locations ?? null,
|
||||
content_types: filters?.content_types ?? null,
|
||||
include_hidden: filters?.include_hidden ?? null,
|
||||
include_archived: filters?.include_archived ?? null,
|
||||
},
|
||||
sort: {
|
||||
field: sortBy,
|
||||
direction: sortDirection,
|
||||
},
|
||||
pagination: {
|
||||
limit,
|
||||
offset: 0,
|
||||
},
|
||||
}),
|
||||
[
|
||||
query,
|
||||
tsScope,
|
||||
mode,
|
||||
filters,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
limit,
|
||||
],
|
||||
);
|
||||
|
||||
// Execute search query
|
||||
const searchQuery = useNormalizedQuery<FileSearchInput, FileSearchOutput>({
|
||||
query: "search.files",
|
||||
input: searchInput,
|
||||
resourceType: "file",
|
||||
pathScope: scope === "folder" && currentPath ? currentPath : undefined,
|
||||
enabled: enabled && query.length >= 2,
|
||||
});
|
||||
|
||||
const files =
|
||||
(searchQuery.data as FileSearchOutput | undefined)?.files || [];
|
||||
const isLoading = searchQuery.isLoading;
|
||||
const error = searchQuery.error;
|
||||
|
||||
return { files, isLoading, error };
|
||||
}
|
||||
Reference in New Issue
Block a user