delete files etc ..

This commit is contained in:
m
2026-07-29 23:30:53 +02:00
parent 4119e84f33
commit 7e7d669d63
10 changed files with 149 additions and 34 deletions
@@ -74,7 +74,7 @@ CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE TABLE resource_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tag_id UUID NOT NULL REFERENCES tags(id),
resource_id UUID NOT NULL REFERENCES resources(id),
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
UNIQUE(tag_id, resource_id)
);
@@ -0,0 +1,2 @@
ALTER TABLE resource_tags DROP CONSTRAINT resource_tags_resource_id_fkey,
ADD CONSTRAINT resource_tags_resource_id_fkey FOREIGN KEY (resource_id) REFERENCES resources(id);
@@ -0,0 +1,2 @@
ALTER TABLE resource_tags DROP CONSTRAINT resource_tags_resource_id_fkey,
ADD CONSTRAINT resource_tags_resource_id_fkey FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE;
+7 -1
View File
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"log"
"net/http"
"os"
@@ -248,10 +249,15 @@ func (h *ResourceHandler) Get(c *gin.Context) {
}
func (h *ResourceHandler) Delete(c *gin.Context) {
userID := c.GetString(auth.UserIDKey)
id := c.Param("id")
result, err := h.resources.DeleteRecursive(id)
result, err := h.resources.DeleteRecursive(id, userID)
if err != nil {
if errors.Is(err, service.ErrForbidden) {
api.Error(c, http.StatusForbidden, "FORBIDDEN", "You do not own this resource")
return
}
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to delete resource")
return
}
+12 -2
View File
@@ -20,6 +20,8 @@ import (
"github.com/vaultdrop/backend/internal/model"
)
var ErrForbidden = errors.New("forbidden")
type ResourceService struct {
db *sql.DB
queries *db.Queries
@@ -200,14 +202,22 @@ func (s *ResourceService) Delete(id string) error {
return s.queries.DeleteResource(context.Background(), resourceUUID)
}
func (s *ResourceService) DeleteRecursive(id string) (*DeleteResult, error) {
func (s *ResourceService) DeleteRecursive(id, userID string) (*DeleteResult, error) {
resourceUUID, _ := uuid.Parse(id)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
return nil, ErrForbidden
}
r, err := s.queries.GetResource(context.Background(), resourceUUID)
if err != nil {
return nil, fmt.Errorf("get resource: %w", err)
}
if r.OwnerID != ownerUUID {
return nil, ErrForbidden
}
result := &DeleteResult{}
if r.IsFolder {
@@ -217,7 +227,7 @@ func (s *ResourceService) DeleteRecursive(id string) (*DeleteResult, error) {
}
for _, child := range children {
childResult, err := s.DeleteRecursive(child.ID.String())
childResult, err := s.DeleteRecursive(child.ID.String(), userID)
if err != nil {
return nil, fmt.Errorf("delete child %s: %w", child.ID, err)
}
+39 -6
View File
@@ -16,6 +16,7 @@ import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal';
import { UploadModal } from '../components/UploadModal';
import { SyncStatusIcon } from '../components/SyncStatusIcon';
import { useSyncQueue } from '../hooks/useSyncQueue';
import { useUploadQueue } from '../hooks/useUploadQueue';
import { useAutoSync } from '../hooks/useAutoSync';
import { safDirectory, SyncMode, SyncGlobalMode } from '../services/safDirectory';
import { fileStore } from '../services/fileStore';
@@ -67,6 +68,8 @@ const FileGridItem = React.memo(function FileGridItem({ file, size, onPress, onL
size={size}
syncStatus={file.syncStatus}
isFolder={file.isFolder}
isUploading={file.isUploading}
uploadProgress={file.uploadProgress}
/>
{selected && (
<View style={styles.selectedOverlay}>
@@ -123,6 +126,7 @@ export function HomeScreen() {
const [confirmDeleteState, setConfirmDeleteState] = useState<{ message: string; options: ConfirmOption[] } | null>(null);
const [removeFolderConfirmId, setRemoveFolderConfirmId] = useState<string | null>(null);
const { pendingCount, isSyncing } = useSyncQueue();
const { tasks: uploadTasks } = useUploadQueue();
useAutoSync();
const pinchScale = useSharedValue(1);
@@ -159,6 +163,8 @@ export function HomeScreen() {
<SyncStatusIcon
isSyncing={isSyncing}
pendingCount={pendingCount}
isUploading={uploadTasks.some(t => t.status === 'uploading')}
uploadPendingCount={uploadTasks.filter(t => t.status === 'pending' || t.status === 'uploading').length}
onPress={() => navigation.navigate('SyncDetail')}
/>
<TouchableOpacity onPress={() => setUploadModalVisible(true)} style={{ padding: 8 }}>
@@ -170,7 +176,8 @@ export function HomeScreen() {
</View>
),
});
}, [navigation, pendingCount, isSyncing]);
}, [navigation, pendingCount, isSyncing, uploadTasks]);
const selectionMode = selectedIds.size > 0;
@@ -191,14 +198,40 @@ export function HomeScreen() {
});
}, [filteredFiles, mediaFilter]);
const sortedFiles = useMemo(() =>
[...mediaFilteredFiles].sort((a, b) => {
const uploadGhostItems = useMemo(() => {
return uploadTasks
.filter((t) => t.status === 'pending' || t.status === 'uploading')
.map((t) => ({
id: t.id,
name: t.file.name,
mimeType: t.file.type,
size: 0,
createdAt: new Date(t.createdAt).toISOString(),
source: 'local' as const,
syncStatus: 'local' as const,
localUri: t.file.uri,
tags: [],
isFolder: false,
isDeviceFile: false,
isUploading: true,
uploadProgress: t.progress,
uploadStatus: t.status,
}));
}, [uploadTasks]);
const sortedFiles = useMemo(() => {
const uploadedExistingIds = new Set(
mediaFilteredFiles.map((f) => f.localUri).filter(Boolean)
);
const ghosts = uploadGhostItems.filter(
(g) => g.localUri && !uploadedExistingIds.has(g.localUri)
);
return [...ghosts, ...mediaFilteredFiles].sort((a, b) => {
const da = parseBackendDate(a.createdAt);
const db = parseBackendDate(b.createdAt);
return (db?.getTime() ?? 0) - (da?.getTime() ?? 0);
}),
[mediaFilteredFiles]
);
});
}, [mediaFilteredFiles, uploadGhostItems]);
const fileIdToIndex = useMemo(() => {
const map = new Map<string, number>();
+51 -2
View File
@@ -40,9 +40,11 @@ interface FileThumbnailProps {
isLoading?: boolean;
syncStatus?: SyncStatus;
isFolder?: boolean;
isUploading?: boolean;
uploadProgress?: number;
}
export const FileThumbnail = React.memo(function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isLoading, syncStatus, isFolder }: FileThumbnailProps) {
export const FileThumbnail = React.memo(function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isLoading, syncStatus, isFolder, isUploading, uploadProgress }: FileThumbnailProps) {
const info = getFileInfo(mimeType, fileName);
const ext = getExtension(fileName);
@@ -75,16 +77,33 @@ export const FileThumbnail = React.memo(function FileThumbnail({ uri, thumbnailU
cachePolicy="memory-disk"
/>
{syncStatus && <SyncStatusBadge status={syncStatus} />}
{isUploading && (
<View style={styles.uploadOverlay}>
<ActivityIndicator size="small" color="#fff" />
<Text style={styles.uploadProgressText}>{uploadProgress ?? 0}%</Text>
<View style={[styles.uploadProgressBar, { width: `${uploadProgress ?? 0}%` }]} />
</View>
)}
</View>
);
}
return (
<View style={[styles.container, { width: size, height: size, backgroundColor: info.bg }]}>
<View style={[styles.container, { width: size, height: size, backgroundColor: isUploading ? '#E3F2FD' : info.bg }]}>
{isUploading ? (
<View style={styles.uploadGhost}>
<MaterialIcons name="cloud-upload" size={size * 0.3} color="#1976D2" />
<ActivityIndicator size="small" color="#1976D2" />
<Text style={styles.uploadPercent}>{uploadProgress ?? 0}%</Text>
</View>
) : (
<>
<MaterialIcons name={info.icon} size={size * 0.35} color={info.color} />
{ext.length <= 4 && (
<Text style={[styles.ext, { color: info.color }]}>{ext}</Text>
)}
</>
)}
{syncStatus && <SyncStatusBadge status={syncStatus} />}
</View>
);
@@ -104,4 +123,34 @@ const styles = StyleSheet.create({
fontSize: 11,
fontWeight: '700',
},
uploadGhost: {
alignItems: 'center',
gap: 4,
},
uploadPercent: {
fontSize: 11,
fontWeight: '700',
color: '#1976D2',
},
uploadOverlay: {
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0,0,0,0.45)',
borderRadius: 6,
justifyContent: 'center',
alignItems: 'center',
gap: 4,
},
uploadProgressText: {
fontSize: 12,
fontWeight: '700',
color: '#fff',
},
uploadProgressBar: {
position: 'absolute',
bottom: 0,
left: 0,
height: 3,
backgroundColor: '#1976D2',
borderBottomLeftRadius: 6,
},
});
+10 -6
View File
@@ -14,14 +14,18 @@ import Animated, {
interface SyncStatusIconProps {
isSyncing: boolean;
pendingCount: number;
isUploading: boolean;
uploadPendingCount: number;
onPress: () => void;
}
export function SyncStatusIcon({ isSyncing, pendingCount, onPress }: SyncStatusIconProps) {
export function SyncStatusIcon({ isSyncing, pendingCount, isUploading, uploadPendingCount, onPress }: SyncStatusIconProps) {
const rotation = useSharedValue(0);
const isActive = isSyncing || isUploading;
const totalPending = pendingCount + uploadPendingCount;
useEffect(() => {
if (isSyncing) {
if (isActive) {
rotation.value = withRepeat(
withSequence(
withTiming(360, { duration: 1000, easing: Easing.linear }),
@@ -33,7 +37,7 @@ export function SyncStatusIcon({ isSyncing, pendingCount, onPress }: SyncStatusI
cancelAnimation(rotation);
rotation.value = withTiming(0, { duration: 200 });
}
}, [isSyncing, rotation]);
}, [isActive, rotation]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
@@ -45,13 +49,13 @@ export function SyncStatusIcon({ isSyncing, pendingCount, onPress }: SyncStatusI
<MaterialIcons
name="sync"
size={22}
color={isSyncing ? '#1976D2' : pendingCount > 0 ? '#F57C00' : '#666'}
color={isActive ? '#1976D2' : totalPending > 0 ? '#F57C00' : '#666'}
/>
</Animated.View>
{pendingCount > 0 && !isSyncing && (
{totalPending > 0 && !isActive && (
<View style={styles.badge}>
<Text style={styles.badgeText}>
{pendingCount > 99 ? '99+' : pendingCount}
{totalPending > 99 ? '99+' : totalPending}
</Text>
</View>
)}
+6
View File
@@ -236,6 +236,12 @@ class UploadQueue {
fieldName: 'file',
mimeType: task.file.type,
headers,
onProgress: (progress) => {
if (progress.totalBytes > 0) {
task.progress = Math.round((progress.bytesSent / progress.totalBytes) * 100);
this.notify();
}
},
});
if (result.status >= 400) {
+3
View File
@@ -28,6 +28,9 @@ export interface UnifiedFileItem {
thumbnailUrl?: string;
variants?: Variant[];
isDeviceFile?: boolean;
isUploading?: boolean;
uploadProgress?: number;
uploadStatus?: 'pending' | 'uploading' | 'done' | 'error';
}
export type FileItem = UnifiedFileItem;