feat: add upload, preview, download, selection, folder creation

This commit is contained in:
Linus Rath
2026-05-08 10:24:36 +02:00
parent 2ed9c29bd5
commit 2a3b1f90dc
5 changed files with 651 additions and 68 deletions
+10
View File
@@ -17,6 +17,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
@@ -5216,6 +5217,15 @@
"react-native": "*"
}
},
"node_modules/expo-document-picker": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
+1
View File
@@ -23,6 +23,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
+71 -8
View File
@@ -1,19 +1,35 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { FileNode } from './types';
import { getDownloadUrl, uploadBlob } from './blob';
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
export async function getFileNodes(parentId?: string | null): Promise<FileNode[]> {
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
// mark a directory; everything else is a MIME type.
const DIRECTORY_TYPES = new Set([
'd',
'application/x-directory',
'text/directory',
'httpd/unix-directory',
'inode/directory',
]);
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
return DIRECTORY_TYPES.has(node.type) || node.type.includes('directory');
}
// Stalwart's FileNode/get does not support a parentId filter — fetching with
// `ids: null` returns the whole tree, which the caller filters client-side.
// Mirrors the webmail file-store; keeping the API uniform avoids a divergence
// later when we add other JMAP servers.
export async function getAllFileNodes(): Promise<FileNode[]> {
const accountId = jmapClient.accountId;
const filter: Record<string, unknown> = {};
if (parentId !== undefined) filter.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/get', { accountId, filter }, '0']],
[['FileNode/get', { accountId, ids: null }, '0']],
USING,
);
return res.methodResponses[0][1].list;
return (res.methodResponses[0][1].list ?? []) as FileNode[];
}
export async function createFileNode(
@@ -27,13 +43,60 @@ export async function createFileNode(
}, '0']],
USING,
);
return res.methodResponses[0][1].created['new-file'];
const created = res.methodResponses[0][1].created?.['new-file'];
if (!created) {
const err = res.methodResponses[0][1].notCreated?.['new-file'];
throw new Error(err?.description || 'Create failed');
}
return created;
}
export async function createFolder(
name: string,
parentId: string | null,
): Promise<FileNode> {
return createFileNode({ name, parentId, type: 'd', blobId: null, size: 0 });
}
export async function updateFileNode(
id: string,
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
USING,
);
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
}
export async function deleteFileNodes(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
await jmapClient.request(
[['FileNode/set', { accountId, destroy: ids }, '0']],
USING,
);
}
export function getFileNodeDownloadUrl(node: FileNode): string {
if (!node.blobId) throw new Error('Folders cannot be downloaded');
return getDownloadUrl(node.blobId, node.name, node.type);
}
export async function uploadFileNode(
uri: string,
name: string,
mimeType: string,
parentId: string | null,
): Promise<FileNode> {
const blob = await uploadBlob(uri, mimeType);
return createFileNode({
name,
parentId,
type: blob.type || mimeType || 'application/octet-stream',
blobId: blob.blobId,
size: blob.size,
});
}
+2 -1
View File
@@ -429,7 +429,7 @@ export interface FileNode {
name: string;
parentId?: string | null;
type: string;
blobId?: string;
blobId?: string | null;
size?: number;
created?: string;
updated?: string;
@@ -465,4 +465,5 @@ export const CAPABILITIES = {
CALENDARS: 'urn:ietf:params:jmap:calendars',
SIEVE: 'urn:ietf:params:jmap:sieve',
QUOTA: 'urn:ietf:params:jmap:quota',
FILES: 'urn:ietf:params:jmap:filenode',
} as const;
+567 -59
View File
@@ -1,23 +1,34 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native';
import {
ChevronLeft, FileText, Folder, HardDrive, FileImage, FileVideo, FileAudio,
FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
MoreVertical, Pencil, Share2, Trash2, Upload, X, Download,
} from 'lucide-react-native';
import { getFileNodes } from '../api/files';
import * as DocumentPicker from 'expo-document-picker';
import {
createFolder, deleteFileNodes, getAllFileNodes, isDirectory, updateFileNode,
uploadFileNode,
} from '../api/files';
import { downloadAttachment, shareAttachment } from '../lib/email-export';
import type { FileNode } from '../api/types';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSettingsStore, type FilesViewMode } from '../stores/settings-store';
import Dialog from '../components/Dialog';
interface FolderFrame {
id: string | null;
@@ -82,12 +93,24 @@ function formatModified(iso?: string): string {
export default function FilesScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [stack, setStack] = useState<FolderFrame[]>([ROOT_FRAME]);
const [files, setFiles] = useState<FileNode[]>([]);
const [allNodes, setAllNodes] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selection, setSelection] = useState<Set<string>>(new Set());
const [busyId, setBusyId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<{ ids: string[] } | null>(null);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [renameTarget, setRenameTarget] = useState<FileNode | null>(null);
const [renameValue, setRenameValue] = useState('');
const [actionsTarget, setActionsTarget] = useState<FileNode | null>(null);
const showIcons = useSettingsStore((s) => s.filesShowIcons);
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles);
@@ -96,21 +119,20 @@ export default function FilesScreen() {
const defaultViewMode = useSettingsStore((s) => s.filesDefaultViewMode);
const setSetting = useSettingsStore((s) => s.updateSetting);
// Local override so the user can flip view from the toolbar without
// changing their persisted default. Falls back to the setting.
const [viewOverride, setViewOverride] = useState<FilesViewMode | null>(null);
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode;
const current = stack[stack.length - 1];
const selectionMode = selection.size > 0;
const loadFiles = useCallback(
async (parentId: string | null, mode: 'initial' | 'refresh' = 'initial') => {
async (mode: 'initial' | 'refresh' = 'initial') => {
if (mode === 'refresh') setRefreshing(true);
else setLoading(true);
setError(null);
try {
const nodes = await getFileNodes(parentId);
setFiles(nodes);
const nodes = await getAllFileNodes();
setAllNodes(nodes);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load files');
} finally {
@@ -122,18 +144,31 @@ export default function FilesScreen() {
);
useEffect(() => {
void loadFiles(current.id);
}, [loadFiles, current.id]);
void loadFiles();
}, [loadFiles]);
// If we navigated into a folder that has since been deleted, walk back up
// until we land on a still-existing one (or root).
useEffect(() => {
if (allNodes.length === 0) return;
setStack((prev) => {
const ids = new Set(allNodes.map((n) => n.id));
let cut = prev.length;
for (let i = prev.length - 1; i > 0; i--) {
const id = prev[i].id;
if (id != null && !ids.has(id)) cut = i;
}
return cut === prev.length ? prev : prev.slice(0, cut);
});
}, [allNodes]);
const visibleFiles = useMemo(() => {
let out = files;
let out = allNodes.filter((n) => (n.parentId ?? null) === current.id);
if (!showHiddenFiles) out = out.filter((f) => !isHidden(f));
// Directories always sort to the top regardless of sort key, then secondary
// sort by the requested column. This matches macOS Finder / web file pickers.
const dir = sortDir === 'desc' ? -1 : 1;
return [...out].sort((a, b) => {
const aDir = a.type === 'directory' ? 0 : 1;
const bDir = b.type === 'directory' ? 0 : 1;
const aDir = isDirectory(a) ? 0 : 1;
const bDir = isDirectory(b) ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
switch (sortKey) {
case 'size':
@@ -148,19 +183,66 @@ export default function FilesScreen() {
return a.name.localeCompare(b.name) * dir;
}
});
}, [files, showHiddenFiles, sortKey, sortDir]);
}, [allNodes, current.id, showHiddenFiles, sortKey, sortDir]);
const openNode = useCallback((node: FileNode) => {
if (node.type === 'directory') {
setStack((s) => [...s, { id: node.id, name: node.name }]);
}
// Files are no-ops for now — opening a blob would need a download flow
// wired to the JMAP download endpoint.
const clearSelection = useCallback(() => setSelection(new Set()), []);
const toggleSelect = useCallback((id: string) => {
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const previewFile = useCallback(async (node: FileNode) => {
if (!node.blobId) return;
setBusyId(node.id);
try {
await shareAttachment(node.blobId, node.name, node.type);
} catch (e) {
Alert.alert('Preview failed', e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}, []);
const downloadFile = useCallback(async (node: FileNode) => {
if (!node.blobId) return;
setBusyId(node.id);
try {
await downloadAttachment(node.blobId, node.name, node.type);
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}, []);
const handleRowPress = useCallback(
(node: FileNode) => {
if (selectionMode) {
toggleSelect(node.id);
return;
}
if (isDirectory(node)) {
setStack((s) => [...s, { id: node.id, name: node.name }]);
return;
}
void previewFile(node);
},
[selectionMode, toggleSelect, previewFile],
);
const handleRowLongPress = useCallback((node: FileNode) => {
toggleSelect(node.id);
}, [toggleSelect]);
const goBack = useCallback(() => {
setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
}, []);
clearSelection();
}, [clearSelection]);
const toggleView = useCallback(() => {
const next: FilesViewMode = viewMode === 'list' ? 'grid' : 'list';
@@ -169,12 +251,126 @@ export default function FilesScreen() {
}, [viewMode, setSetting]);
const onRefresh = useCallback(() => {
void loadFiles(current.id, 'refresh');
}, [loadFiles, current.id]);
void loadFiles('refresh');
}, [loadFiles]);
const submitNewFolder = useCallback(async () => {
const name = newFolderName.trim();
if (!name) return;
setNewFolderOpen(false);
setNewFolderName('');
try {
await createFolder(name, current.id);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Create folder failed', e instanceof Error ? e.message : String(e));
}
}, [newFolderName, current.id, loadFiles]);
const submitRename = useCallback(async () => {
if (!renameTarget) return;
const name = renameValue.trim();
if (!name || name === renameTarget.name) {
setRenameTarget(null);
return;
}
const target = renameTarget;
setRenameTarget(null);
setRenameValue('');
try {
await updateFileNode(target.id, { name });
await loadFiles('refresh');
} catch (e) {
Alert.alert('Rename failed', e instanceof Error ? e.message : String(e));
}
}, [renameTarget, renameValue, loadFiles]);
const startUpload = useCallback(async () => {
if (uploading) return;
let result;
try {
result = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: true,
multiple: false,
});
} catch (e) {
Alert.alert('Pick failed', e instanceof Error ? e.message : String(e));
return;
}
if (result.canceled || result.assets.length === 0) return;
const asset = result.assets[0];
setUploading(true);
try {
await uploadFileNode(
asset.uri,
asset.name,
asset.mimeType || 'application/octet-stream',
current.id,
);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Upload failed', e instanceof Error ? e.message : String(e));
} finally {
setUploading(false);
}
}, [uploading, current.id, loadFiles]);
const requestDelete = useCallback((ids: string[]) => {
if (ids.length === 0) return;
setConfirmDelete({ ids });
}, []);
const performDelete = useCallback(async () => {
if (!confirmDelete) return;
// Cascade: if the user deletes a folder, also delete every descendant
// we know about. Stalwart deletes folders with content silently otherwise.
const targetIds = new Set(confirmDelete.ids);
let frontier = [...confirmDelete.ids];
while (frontier.length > 0) {
const next: string[] = [];
for (const node of allNodes) {
if (node.parentId && frontier.includes(node.parentId) && !targetIds.has(node.id)) {
targetIds.add(node.id);
next.push(node.id);
}
}
frontier = next;
}
setConfirmDelete(null);
clearSelection();
try {
await deleteFileNodes(Array.from(targetIds));
await loadFiles('refresh');
} catch (e) {
Alert.alert('Delete failed', e instanceof Error ? e.message : String(e));
}
}, [confirmDelete, allNodes, clearSelection, loadFiles]);
const renderHeader = () => {
const canBack = stack.length > 1;
const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon;
if (selectionMode) {
return (
<View style={styles.header}>
<View style={styles.headerRow}>
<Pressable onPress={clearSelection} hitSlop={8} style={styles.headerBtn}>
<X size={22} color={c.text} />
</Pressable>
<Text style={styles.title}>{selection.size} selected</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => requestDelete(Array.from(selection))}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="Delete selected"
>
<Trash2 size={20} color={c.error} />
</Pressable>
</View>
</View>
</View>
);
}
return (
<View style={styles.header}>
<View style={styles.headerRow}>
@@ -188,14 +384,37 @@ export default function FilesScreen() {
{current.name}
</Text>
</View>
<Pressable
onPress={toggleView}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={viewMode === 'list' ? 'Switch to grid view' : 'Switch to list view'}
>
<ToggleIcon size={20} color={c.text} />
</Pressable>
<View style={styles.headerActions}>
<Pressable
onPress={() => setNewFolderOpen(true)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="New folder"
>
<FolderPlus size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => void startUpload()}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="Upload file"
disabled={uploading}
>
{uploading ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<Upload size={20} color={c.text} />
)}
</Pressable>
<Pressable
onPress={toggleView}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={viewMode === 'list' ? 'Switch to grid view' : 'Switch to list view'}
>
<ToggleIcon size={20} color={c.text} />
</Pressable>
</View>
</View>
{canBack ? (
<Text style={styles.breadcrumb} numberOfLines={1}>
@@ -207,18 +426,22 @@ export default function FilesScreen() {
};
const renderRow = (item: FileNode) => {
const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name);
const tint = item.type === 'directory'
const isDir = isDirectory(item);
const Icon = isDir ? Folder : pickFileIcon(item.name);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
const tappable = item.type === 'directory';
const selected = selection.has(item.id);
const busy = busyId === item.id;
return (
<Pressable
onPress={() => openNode(item)}
disabled={!tappable}
onPress={() => handleRowPress(item)}
onLongPress={() => handleRowLongPress(item)}
delayLongPress={300}
style={({ pressed }) => [
styles.fileRow,
pressed && tappable && { backgroundColor: c.surfaceHover },
selected && { backgroundColor: c.primaryBg },
pressed && !selected && { backgroundColor: c.surfaceHover },
]}
>
{showIcons && <Icon size={20} color={tint} />}
@@ -227,7 +450,7 @@ export default function FilesScreen() {
{item.name}
</Text>
<View style={styles.fileMetaRow}>
{item.size != null && item.type !== 'directory' ? (
{item.size != null && !isDir ? (
<Text style={styles.fileMeta}>{formatFileSize(item.size)}</Text>
) : null}
{item.updated ? (
@@ -235,23 +458,40 @@ export default function FilesScreen() {
) : null}
</View>
</View>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : !selectionMode ? (
<Pressable
onPress={(e) => {
e.stopPropagation();
setActionsTarget(item);
}}
hitSlop={8}
style={styles.rowMore}
>
<MoreVertical size={18} color={c.textMuted} />
</Pressable>
) : null}
</Pressable>
);
};
const renderGridCell = (item: FileNode) => {
const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name);
const tint = item.type === 'directory'
const isDir = isDirectory(item);
const Icon = isDir ? Folder : pickFileIcon(item.name);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
const tappable = item.type === 'directory';
const selected = selection.has(item.id);
return (
<Pressable
onPress={() => openNode(item)}
disabled={!tappable}
onPress={() => handleRowPress(item)}
onLongPress={() => handleRowLongPress(item)}
delayLongPress={300}
style={({ pressed }) => [
styles.gridItem,
pressed && tappable && { backgroundColor: c.surfaceHover },
selected && { backgroundColor: c.primaryBg },
pressed && !selected && { backgroundColor: c.surfaceHover },
]}
>
{showIcons ? (
@@ -262,7 +502,7 @@ export default function FilesScreen() {
<Text style={styles.gridName} numberOfLines={2}>
{item.name}
</Text>
{item.size != null && item.type !== 'directory' ? (
{item.size != null && !isDir ? (
<Text style={styles.gridMeta} numberOfLines={1}>
{formatFileSize(item.size)}
</Text>
@@ -284,7 +524,7 @@ export default function FilesScreen() {
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => void loadFiles(current.id)}
onPress={() => void loadFiles()}
>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
@@ -310,11 +550,7 @@ export default function FilesScreen() {
contentContainerStyle={styles.gridContent}
renderItem={({ item }) => renderGridCell(item)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={c.primary}
/>
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
}
/>
);
@@ -326,11 +562,7 @@ export default function FilesScreen() {
keyExtractor={(item) => item.id}
renderItem={({ item }) => renderRow(item)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={c.primary}
/>
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
}
/>
);
@@ -340,10 +572,181 @@ export default function FilesScreen() {
<View style={styles.container}>
{renderHeader()}
{body}
<PromptModal
visible={newFolderOpen}
title="New folder"
placeholder="Folder name"
confirmText="Create"
value={newFolderName}
onChange={setNewFolderName}
onCancel={() => {
setNewFolderOpen(false);
setNewFolderName('');
}}
onSubmit={submitNewFolder}
/>
<PromptModal
visible={renameTarget != null}
title="Rename"
placeholder="Name"
confirmText="Rename"
value={renameValue}
onChange={setRenameValue}
onCancel={() => {
setRenameTarget(null);
setRenameValue('');
}}
onSubmit={submitRename}
/>
<ActionsSheet
target={actionsTarget}
onClose={() => setActionsTarget(null)}
onPreview={(n) => {
setActionsTarget(null);
void previewFile(n);
}}
onDownload={(n) => {
setActionsTarget(null);
void downloadFile(n);
}}
onRename={(n) => {
setActionsTarget(null);
setRenameTarget(n);
setRenameValue(n.name);
}}
onDelete={(n) => {
setActionsTarget(null);
requestDelete([n.id]);
}}
/>
<Dialog
visible={confirmDelete != null}
title={
confirmDelete && confirmDelete.ids.length > 1
? `Delete ${confirmDelete.ids.length} items?`
: 'Delete this item?'
}
message="Folders are deleted along with everything inside them. This can't be undone."
variant="destructive"
confirmText="Delete"
onConfirm={() => void performDelete()}
onCancel={() => setConfirmDelete(null)}
/>
</View>
);
}
interface PromptModalProps {
visible: boolean;
title: string;
placeholder: string;
confirmText: string;
value: string;
onChange: (v: string) => void;
onCancel: () => void;
onSubmit: () => void;
}
function PromptModal(props: PromptModalProps) {
const c = useColors();
const styles = React.useMemo(() => makePromptStyles(c), [c]);
return (
<Modal
visible={props.visible}
transparent
animationType="fade"
onRequestClose={props.onCancel}
>
<TouchableWithoutFeedback onPress={props.onCancel}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<View style={styles.dialog}>
<Text style={styles.title}>{props.title}</Text>
<TextInput
value={props.value}
onChangeText={props.onChange}
placeholder={props.placeholder}
placeholderTextColor={c.textMuted}
style={styles.input}
autoFocus
returnKeyType="done"
onSubmitEditing={props.onSubmit}
/>
<View style={styles.row}>
<Pressable onPress={props.onCancel} style={styles.btnGhost}>
<Text style={styles.btnGhostText}>Cancel</Text>
</Pressable>
<Pressable
onPress={props.onSubmit}
style={[styles.btnPrimary, !props.value.trim() && { opacity: 0.5 }]}
disabled={!props.value.trim()}
>
<Text style={styles.btnPrimaryText}>{props.confirmText}</Text>
</Pressable>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
interface ActionsSheetProps {
target: FileNode | null;
onClose: () => void;
onPreview: (n: FileNode) => void;
onDownload: (n: FileNode) => void;
onRename: (n: FileNode) => void;
onDelete: (n: FileNode) => void;
}
function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDelete }: ActionsSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!target) return null;
const isDir = isDirectory(target);
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.name}
</Text>
{!isDir ? (
<Pressable style={styles.action} onPress={() => onPreview(target)}>
<Share2 size={18} color={c.text} />
<Text style={styles.actionLabel}>Preview / Share</Text>
</Pressable>
) : null}
{!isDir ? (
<Pressable style={styles.action} onPress={() => onDownload(target)}>
<Download size={18} color={c.text} />
<Text style={styles.actionLabel}>Save to device</Text>
</Pressable>
) : null}
<Pressable style={styles.action} onPress={() => onRename(target)}>
<Pencil size={18} color={c.text} />
<Text style={styles.actionLabel}>Rename</Text>
</Pressable>
<Pressable style={styles.action} onPress={() => onDelete(target)}>
<Trash2 size={18} color={c.error} />
<Text style={[styles.actionLabel, { color: c.error }]}>Delete</Text>
</Pressable>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: {
@@ -369,6 +772,11 @@ function makeStyles(c: ThemePalette) {
alignItems: 'center',
gap: spacing.xs,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerBtn: {
padding: spacing.xs,
borderRadius: radius.sm,
@@ -437,6 +845,9 @@ function makeStyles(c: ThemePalette) {
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
rowMore: {
padding: spacing.xs,
},
gridContent: {
padding: spacing.sm,
},
@@ -463,3 +874,100 @@ function makeStyles(c: ThemePalette) {
},
});
}
function makePromptStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
dialog: {
width: '100%',
maxWidth: 400,
backgroundColor: c.background,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.lg,
padding: spacing.lg,
gap: spacing.md,
},
title: {
...typography.h3,
color: c.text,
},
input: {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: c.text,
fontSize: typography.body.fontSize,
backgroundColor: c.surface,
},
row: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
},
btnGhost: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
},
btnGhostText: {
color: c.text,
fontWeight: '600' as const,
},
btnPrimary: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: c.primary,
},
btnPrimaryText: {
color: c.primaryForeground,
fontWeight: '600' as const,
},
});
}
function makeSheetStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xxl,
paddingHorizontal: spacing.md,
gap: spacing.xs,
},
sheetTitle: {
...typography.bodyMedium,
color: c.textMuted,
paddingHorizontal: spacing.sm,
paddingBottom: spacing.sm,
},
action: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderRadius: radius.md,
},
actionLabel: {
...typography.body,
color: c.text,
},
});
}