From 7e7d669d637f1205946a0a309d34df1c89f90849 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 29 Jul 2026 23:30:53 +0200 Subject: [PATCH] delete files etc .. --- .../db/migrations/012_v3_schema.up.sql | 2 +- .../015_resource_tags_cascade.down.sql | 2 + .../015_resource_tags_cascade.up.sql | 2 + backend/internal/handler/resource.go | 34 ++++++----- backend/internal/service/resource.go | 14 ++++- mobile/app/index.tsx | 45 ++++++++++++-- mobile/components/FileThumbnail.tsx | 59 +++++++++++++++++-- mobile/components/SyncStatusIcon.tsx | 16 +++-- mobile/services/uploadQueue.ts | 6 ++ mobile/types/index.ts | 3 + 10 files changed, 149 insertions(+), 34 deletions(-) create mode 100644 backend/internal/db/migrations/015_resource_tags_cascade.down.sql create mode 100644 backend/internal/db/migrations/015_resource_tags_cascade.up.sql diff --git a/backend/internal/db/migrations/012_v3_schema.up.sql b/backend/internal/db/migrations/012_v3_schema.up.sql index 4f78973..7868c8d 100644 --- a/backend/internal/db/migrations/012_v3_schema.up.sql +++ b/backend/internal/db/migrations/012_v3_schema.up.sql @@ -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) ); diff --git a/backend/internal/db/migrations/015_resource_tags_cascade.down.sql b/backend/internal/db/migrations/015_resource_tags_cascade.down.sql new file mode 100644 index 0000000..d26b2eb --- /dev/null +++ b/backend/internal/db/migrations/015_resource_tags_cascade.down.sql @@ -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); diff --git a/backend/internal/db/migrations/015_resource_tags_cascade.up.sql b/backend/internal/db/migrations/015_resource_tags_cascade.up.sql new file mode 100644 index 0000000..82c56cd --- /dev/null +++ b/backend/internal/db/migrations/015_resource_tags_cascade.up.sql @@ -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; diff --git a/backend/internal/handler/resource.go b/backend/internal/handler/resource.go index b27e22a..44caa89 100644 --- a/backend/internal/handler/resource.go +++ b/backend/internal/handler/resource.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "log" "net/http" "os" @@ -230,28 +231,33 @@ func (h *ResourceHandler) Get(c *gin.Context) { } api.Success(c, gin.H{ - "id": resource.ID, - "name": resource.Name, - "url": downloadURL, - "thumbnailUrl": thumbURL, - "size": resource.Size, - "mimeType": resource.MimeType, - "tags": tags, - "createdAt": resource.CreatedAt, - "updatedAt": resource.UpdatedAt, - "ocrText": resource.OcrText, - "isFolder": resource.IsFolder, + "id": resource.ID, + "name": resource.Name, + "url": downloadURL, + "thumbnailUrl": thumbURL, + "size": resource.Size, + "mimeType": resource.MimeType, + "tags": tags, + "createdAt": resource.CreatedAt, + "updatedAt": resource.UpdatedAt, + "ocrText": resource.OcrText, + "isFolder": resource.IsFolder, "parentResourceId": resource.ParentResourceID, - "ownerId": resource.OwnerID, - "variants": variants, + "ownerId": resource.OwnerID, + "variants": variants, }) } 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 } diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index beef250..4b95f72 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -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) } diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index f8523f9..4c0158e 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -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 && ( @@ -123,6 +126,7 @@ export function HomeScreen() { const [confirmDeleteState, setConfirmDeleteState] = useState<{ message: string; options: ConfirmOption[] } | null>(null); const [removeFolderConfirmId, setRemoveFolderConfirmId] = useState(null); const { pendingCount, isSyncing } = useSyncQueue(); + const { tasks: uploadTasks } = useUploadQueue(); useAutoSync(); const pinchScale = useSharedValue(1); @@ -159,6 +163,8 @@ export function HomeScreen() { t.status === 'uploading')} + uploadPendingCount={uploadTasks.filter(t => t.status === 'pending' || t.status === 'uploading').length} onPress={() => navigation.navigate('SyncDetail')} /> setUploadModalVisible(true)} style={{ padding: 8 }}> @@ -170,7 +176,8 @@ export function HomeScreen() { ), }); - }, [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(); diff --git a/mobile/components/FileThumbnail.tsx b/mobile/components/FileThumbnail.tsx index f5e76d9..919cbda 100644 --- a/mobile/components/FileThumbnail.tsx +++ b/mobile/components/FileThumbnail.tsx @@ -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,15 +77,32 @@ export const FileThumbnail = React.memo(function FileThumbnail({ uri, thumbnailU cachePolicy="memory-disk" /> {syncStatus && } + {isUploading && ( + + + {uploadProgress ?? 0}% + + + )} ); } return ( - - - {ext.length <= 4 && ( - {ext} + + {isUploading ? ( + + + + {uploadProgress ?? 0}% + + ) : ( + <> + + {ext.length <= 4 && ( + {ext} + )} + )} {syncStatus && } @@ -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, + }, }); diff --git a/mobile/components/SyncStatusIcon.tsx b/mobile/components/SyncStatusIcon.tsx index 3994f3c..7e62f75 100644 --- a/mobile/components/SyncStatusIcon.tsx +++ b/mobile/components/SyncStatusIcon.tsx @@ -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 0 ? '#F57C00' : '#666'} + color={isActive ? '#1976D2' : totalPending > 0 ? '#F57C00' : '#666'} /> - {pendingCount > 0 && !isSyncing && ( + {totalPending > 0 && !isActive && ( - {pendingCount > 99 ? '99+' : pendingCount} + {totalPending > 99 ? '99+' : totalPending} )} diff --git a/mobile/services/uploadQueue.ts b/mobile/services/uploadQueue.ts index 62cfc15..f839c70 100644 --- a/mobile/services/uploadQueue.ts +++ b/mobile/services/uploadQueue.ts @@ -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) { diff --git a/mobile/types/index.ts b/mobile/types/index.ts index f18fd49..8959692 100644 --- a/mobile/types/index.ts +++ b/mobile/types/index.ts @@ -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;