delete files etc ..
This commit is contained in:
@@ -74,7 +74,7 @@ CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
|||||||
CREATE TABLE resource_tags (
|
CREATE TABLE resource_tags (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
tag_id UUID NOT NULL REFERENCES tags(id),
|
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)
|
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;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -230,28 +231,33 @@ func (h *ResourceHandler) Get(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
api.Success(c, gin.H{
|
api.Success(c, gin.H{
|
||||||
"id": resource.ID,
|
"id": resource.ID,
|
||||||
"name": resource.Name,
|
"name": resource.Name,
|
||||||
"url": downloadURL,
|
"url": downloadURL,
|
||||||
"thumbnailUrl": thumbURL,
|
"thumbnailUrl": thumbURL,
|
||||||
"size": resource.Size,
|
"size": resource.Size,
|
||||||
"mimeType": resource.MimeType,
|
"mimeType": resource.MimeType,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"createdAt": resource.CreatedAt,
|
"createdAt": resource.CreatedAt,
|
||||||
"updatedAt": resource.UpdatedAt,
|
"updatedAt": resource.UpdatedAt,
|
||||||
"ocrText": resource.OcrText,
|
"ocrText": resource.OcrText,
|
||||||
"isFolder": resource.IsFolder,
|
"isFolder": resource.IsFolder,
|
||||||
"parentResourceId": resource.ParentResourceID,
|
"parentResourceId": resource.ParentResourceID,
|
||||||
"ownerId": resource.OwnerID,
|
"ownerId": resource.OwnerID,
|
||||||
"variants": variants,
|
"variants": variants,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ResourceHandler) Delete(c *gin.Context) {
|
func (h *ResourceHandler) Delete(c *gin.Context) {
|
||||||
|
userID := c.GetString(auth.UserIDKey)
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
result, err := h.resources.DeleteRecursive(id)
|
result, err := h.resources.DeleteRecursive(id, userID)
|
||||||
if err != nil {
|
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")
|
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to delete resource")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import (
|
|||||||
"github.com/vaultdrop/backend/internal/model"
|
"github.com/vaultdrop/backend/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrForbidden = errors.New("forbidden")
|
||||||
|
|
||||||
type ResourceService struct {
|
type ResourceService struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
queries *db.Queries
|
queries *db.Queries
|
||||||
@@ -200,14 +202,22 @@ func (s *ResourceService) Delete(id string) error {
|
|||||||
return s.queries.DeleteResource(context.Background(), resourceUUID)
|
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)
|
resourceUUID, _ := uuid.Parse(id)
|
||||||
|
ownerUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
r, err := s.queries.GetResource(context.Background(), resourceUUID)
|
r, err := s.queries.GetResource(context.Background(), resourceUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get resource: %w", err)
|
return nil, fmt.Errorf("get resource: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.OwnerID != ownerUUID {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
result := &DeleteResult{}
|
result := &DeleteResult{}
|
||||||
|
|
||||||
if r.IsFolder {
|
if r.IsFolder {
|
||||||
@@ -217,7 +227,7 @@ func (s *ResourceService) DeleteRecursive(id string) (*DeleteResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, child := range children {
|
for _, child := range children {
|
||||||
childResult, err := s.DeleteRecursive(child.ID.String())
|
childResult, err := s.DeleteRecursive(child.ID.String(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("delete child %s: %w", child.ID, err)
|
return nil, fmt.Errorf("delete child %s: %w", child.ID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-6
@@ -16,6 +16,7 @@ import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal';
|
|||||||
import { UploadModal } from '../components/UploadModal';
|
import { UploadModal } from '../components/UploadModal';
|
||||||
import { SyncStatusIcon } from '../components/SyncStatusIcon';
|
import { SyncStatusIcon } from '../components/SyncStatusIcon';
|
||||||
import { useSyncQueue } from '../hooks/useSyncQueue';
|
import { useSyncQueue } from '../hooks/useSyncQueue';
|
||||||
|
import { useUploadQueue } from '../hooks/useUploadQueue';
|
||||||
import { useAutoSync } from '../hooks/useAutoSync';
|
import { useAutoSync } from '../hooks/useAutoSync';
|
||||||
import { safDirectory, SyncMode, SyncGlobalMode } from '../services/safDirectory';
|
import { safDirectory, SyncMode, SyncGlobalMode } from '../services/safDirectory';
|
||||||
import { fileStore } from '../services/fileStore';
|
import { fileStore } from '../services/fileStore';
|
||||||
@@ -67,6 +68,8 @@ const FileGridItem = React.memo(function FileGridItem({ file, size, onPress, onL
|
|||||||
size={size}
|
size={size}
|
||||||
syncStatus={file.syncStatus}
|
syncStatus={file.syncStatus}
|
||||||
isFolder={file.isFolder}
|
isFolder={file.isFolder}
|
||||||
|
isUploading={file.isUploading}
|
||||||
|
uploadProgress={file.uploadProgress}
|
||||||
/>
|
/>
|
||||||
{selected && (
|
{selected && (
|
||||||
<View style={styles.selectedOverlay}>
|
<View style={styles.selectedOverlay}>
|
||||||
@@ -123,6 +126,7 @@ export function HomeScreen() {
|
|||||||
const [confirmDeleteState, setConfirmDeleteState] = useState<{ message: string; options: ConfirmOption[] } | null>(null);
|
const [confirmDeleteState, setConfirmDeleteState] = useState<{ message: string; options: ConfirmOption[] } | null>(null);
|
||||||
const [removeFolderConfirmId, setRemoveFolderConfirmId] = useState<string | null>(null);
|
const [removeFolderConfirmId, setRemoveFolderConfirmId] = useState<string | null>(null);
|
||||||
const { pendingCount, isSyncing } = useSyncQueue();
|
const { pendingCount, isSyncing } = useSyncQueue();
|
||||||
|
const { tasks: uploadTasks } = useUploadQueue();
|
||||||
useAutoSync();
|
useAutoSync();
|
||||||
|
|
||||||
const pinchScale = useSharedValue(1);
|
const pinchScale = useSharedValue(1);
|
||||||
@@ -159,6 +163,8 @@ export function HomeScreen() {
|
|||||||
<SyncStatusIcon
|
<SyncStatusIcon
|
||||||
isSyncing={isSyncing}
|
isSyncing={isSyncing}
|
||||||
pendingCount={pendingCount}
|
pendingCount={pendingCount}
|
||||||
|
isUploading={uploadTasks.some(t => t.status === 'uploading')}
|
||||||
|
uploadPendingCount={uploadTasks.filter(t => t.status === 'pending' || t.status === 'uploading').length}
|
||||||
onPress={() => navigation.navigate('SyncDetail')}
|
onPress={() => navigation.navigate('SyncDetail')}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity onPress={() => setUploadModalVisible(true)} style={{ padding: 8 }}>
|
<TouchableOpacity onPress={() => setUploadModalVisible(true)} style={{ padding: 8 }}>
|
||||||
@@ -170,7 +176,8 @@ export function HomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [navigation, pendingCount, isSyncing]);
|
}, [navigation, pendingCount, isSyncing, uploadTasks]);
|
||||||
|
|
||||||
|
|
||||||
const selectionMode = selectedIds.size > 0;
|
const selectionMode = selectedIds.size > 0;
|
||||||
|
|
||||||
@@ -191,14 +198,40 @@ export function HomeScreen() {
|
|||||||
});
|
});
|
||||||
}, [filteredFiles, mediaFilter]);
|
}, [filteredFiles, mediaFilter]);
|
||||||
|
|
||||||
const sortedFiles = useMemo(() =>
|
const uploadGhostItems = useMemo(() => {
|
||||||
[...mediaFilteredFiles].sort((a, b) => {
|
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 da = parseBackendDate(a.createdAt);
|
||||||
const db = parseBackendDate(b.createdAt);
|
const db = parseBackendDate(b.createdAt);
|
||||||
return (db?.getTime() ?? 0) - (da?.getTime() ?? 0);
|
return (db?.getTime() ?? 0) - (da?.getTime() ?? 0);
|
||||||
}),
|
});
|
||||||
[mediaFilteredFiles]
|
}, [mediaFilteredFiles, uploadGhostItems]);
|
||||||
);
|
|
||||||
|
|
||||||
const fileIdToIndex = useMemo(() => {
|
const fileIdToIndex = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ interface FileThumbnailProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
syncStatus?: SyncStatus;
|
syncStatus?: SyncStatus;
|
||||||
isFolder?: boolean;
|
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 info = getFileInfo(mimeType, fileName);
|
||||||
const ext = getExtension(fileName);
|
const ext = getExtension(fileName);
|
||||||
|
|
||||||
@@ -75,15 +77,32 @@ export const FileThumbnail = React.memo(function FileThumbnail({ uri, thumbnailU
|
|||||||
cachePolicy="memory-disk"
|
cachePolicy="memory-disk"
|
||||||
/>
|
/>
|
||||||
{syncStatus && <SyncStatusBadge status={syncStatus} />}
|
{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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 }]}>
|
||||||
<MaterialIcons name={info.icon} size={size * 0.35} color={info.color} />
|
{isUploading ? (
|
||||||
{ext.length <= 4 && (
|
<View style={styles.uploadGhost}>
|
||||||
<Text style={[styles.ext, { color: info.color }]}>{ext}</Text>
|
<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} />}
|
{syncStatus && <SyncStatusBadge status={syncStatus} />}
|
||||||
</View>
|
</View>
|
||||||
@@ -104,4 +123,34 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: '700',
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,14 +14,18 @@ import Animated, {
|
|||||||
interface SyncStatusIconProps {
|
interface SyncStatusIconProps {
|
||||||
isSyncing: boolean;
|
isSyncing: boolean;
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
|
isUploading: boolean;
|
||||||
|
uploadPendingCount: number;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SyncStatusIcon({ isSyncing, pendingCount, onPress }: SyncStatusIconProps) {
|
export function SyncStatusIcon({ isSyncing, pendingCount, isUploading, uploadPendingCount, onPress }: SyncStatusIconProps) {
|
||||||
const rotation = useSharedValue(0);
|
const rotation = useSharedValue(0);
|
||||||
|
const isActive = isSyncing || isUploading;
|
||||||
|
const totalPending = pendingCount + uploadPendingCount;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSyncing) {
|
if (isActive) {
|
||||||
rotation.value = withRepeat(
|
rotation.value = withRepeat(
|
||||||
withSequence(
|
withSequence(
|
||||||
withTiming(360, { duration: 1000, easing: Easing.linear }),
|
withTiming(360, { duration: 1000, easing: Easing.linear }),
|
||||||
@@ -33,7 +37,7 @@ export function SyncStatusIcon({ isSyncing, pendingCount, onPress }: SyncStatusI
|
|||||||
cancelAnimation(rotation);
|
cancelAnimation(rotation);
|
||||||
rotation.value = withTiming(0, { duration: 200 });
|
rotation.value = withTiming(0, { duration: 200 });
|
||||||
}
|
}
|
||||||
}, [isSyncing, rotation]);
|
}, [isActive, rotation]);
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ rotate: `${rotation.value}deg` }],
|
transform: [{ rotate: `${rotation.value}deg` }],
|
||||||
@@ -45,13 +49,13 @@ export function SyncStatusIcon({ isSyncing, pendingCount, onPress }: SyncStatusI
|
|||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name="sync"
|
name="sync"
|
||||||
size={22}
|
size={22}
|
||||||
color={isSyncing ? '#1976D2' : pendingCount > 0 ? '#F57C00' : '#666'}
|
color={isActive ? '#1976D2' : totalPending > 0 ? '#F57C00' : '#666'}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{pendingCount > 0 && !isSyncing && (
|
{totalPending > 0 && !isActive && (
|
||||||
<View style={styles.badge}>
|
<View style={styles.badge}>
|
||||||
<Text style={styles.badgeText}>
|
<Text style={styles.badgeText}>
|
||||||
{pendingCount > 99 ? '99+' : pendingCount}
|
{totalPending > 99 ? '99+' : totalPending}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -236,6 +236,12 @@ class UploadQueue {
|
|||||||
fieldName: 'file',
|
fieldName: 'file',
|
||||||
mimeType: task.file.type,
|
mimeType: task.file.type,
|
||||||
headers,
|
headers,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
if (progress.totalBytes > 0) {
|
||||||
|
task.progress = Math.round((progress.bytesSent / progress.totalBytes) * 100);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.status >= 400) {
|
if (result.status >= 400) {
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export interface UnifiedFileItem {
|
|||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
variants?: Variant[];
|
variants?: Variant[];
|
||||||
isDeviceFile?: boolean;
|
isDeviceFile?: boolean;
|
||||||
|
isUploading?: boolean;
|
||||||
|
uploadProgress?: number;
|
||||||
|
uploadStatus?: 'pending' | 'uploading' | 'done' | 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileItem = UnifiedFileItem;
|
export type FileItem = UnifiedFileItem;
|
||||||
|
|||||||
Reference in New Issue
Block a user