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;