page + upload queue + folders
This commit is contained in:
@@ -51,7 +51,7 @@ func main() {
|
|||||||
conversionSvc.Start(cfg.ConversionWorkers)
|
conversionSvc.Start(cfg.ConversionWorkers)
|
||||||
defer conversionSvc.Stop()
|
defer conversionSvc.Stop()
|
||||||
|
|
||||||
h := handler.New(resourceSvc, ocrSvc, urlSvc, auth.NewAuthHandler(authSvc), conversionSvc, rebacSvc, placementSvc, syncSvc, eventBroker)
|
h := handler.New(database, resourceSvc, ocrSvc, urlSvc, auth.NewAuthHandler(authSvc), conversionSvc, rebacSvc, placementSvc, syncSvc, eventBroker)
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
handler.SetupRoutes(r, h, authSvc)
|
handler.SetupRoutes(r, h, authSvc)
|
||||||
|
|||||||
@@ -56,12 +56,22 @@ SELECT * FROM resources
|
|||||||
WHERE checksum = $1 AND is_folder = false AND owner_id = $2
|
WHERE checksum = $1 AND is_folder = false AND owner_id = $2
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: CountResourcesByOwner :one
|
||||||
|
SELECT COUNT(*) FROM resources
|
||||||
|
WHERE owner_id = $1 AND parent_resource_id IS NULL;
|
||||||
|
|
||||||
-- name: ListResourcesByOwner :many
|
-- name: ListResourcesByOwner :many
|
||||||
SELECT * FROM resources
|
SELECT * FROM resources
|
||||||
WHERE owner_id = $1 AND parent_resource_id IS NULL
|
WHERE owner_id = $1 AND parent_resource_id IS NULL
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3;
|
||||||
|
|
||||||
|
-- name: CountResourcesByParentAndOwner :one
|
||||||
|
SELECT COUNT(*) FROM resources
|
||||||
|
WHERE parent_resource_id = $1 AND owner_id = $2;
|
||||||
|
|
||||||
-- name: ListResourcesByParentAndOwner :many
|
-- name: ListResourcesByParentAndOwner :many
|
||||||
SELECT * FROM resources
|
SELECT * FROM resources
|
||||||
WHERE parent_resource_id = $1 AND owner_id = $2
|
WHERE parent_resource_id = $1 AND owner_id = $2
|
||||||
ORDER BY is_folder DESC, created_at DESC;
|
ORDER BY is_folder DESC, created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4;
|
||||||
|
|||||||
@@ -13,6 +13,35 @@ import (
|
|||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const countResourcesByOwner = `-- name: CountResourcesByOwner :one
|
||||||
|
SELECT COUNT(*) FROM resources
|
||||||
|
WHERE owner_id = $1 AND parent_resource_id IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountResourcesByOwner(ctx context.Context, ownerID uuid.UUID) (int64, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, countResourcesByOwner, ownerID)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResourcesByParentAndOwner = `-- name: CountResourcesByParentAndOwner :one
|
||||||
|
SELECT COUNT(*) FROM resources
|
||||||
|
WHERE parent_resource_id = $1 AND owner_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type CountResourcesByParentAndOwnerParams struct {
|
||||||
|
ParentResourceID uuid.NullUUID `json:"parent_resource_id"`
|
||||||
|
OwnerID uuid.UUID `json:"owner_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CountResourcesByParentAndOwner(ctx context.Context, arg CountResourcesByParentAndOwnerParams) (int64, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, countResourcesByParentAndOwner, arg.ParentResourceID, arg.OwnerID)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
const createFolder = `-- name: CreateFolder :one
|
const createFolder = `-- name: CreateFolder :one
|
||||||
INSERT INTO resources (name, is_folder, owner_id, created_at, updated_at)
|
INSERT INTO resources (name, is_folder, owner_id, created_at, updated_at)
|
||||||
VALUES ($1, true, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES ($1, true, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
@@ -323,10 +352,17 @@ const listResourcesByOwner = `-- name: ListResourcesByOwner :many
|
|||||||
SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources
|
SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources
|
||||||
WHERE owner_id = $1 AND parent_resource_id IS NULL
|
WHERE owner_id = $1 AND parent_resource_id IS NULL
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListResourcesByOwner(ctx context.Context, ownerID uuid.UUID) ([]Resource, error) {
|
type ListResourcesByOwnerParams struct {
|
||||||
rows, err := q.db.QueryContext(ctx, listResourcesByOwner, ownerID)
|
OwnerID uuid.UUID `json:"owner_id"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListResourcesByOwner(ctx context.Context, arg ListResourcesByOwnerParams) ([]Resource, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listResourcesByOwner, arg.OwnerID, arg.Limit, arg.Offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -364,15 +400,23 @@ const listResourcesByParentAndOwner = `-- name: ListResourcesByParentAndOwner :m
|
|||||||
SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources
|
SELECT id, name, mime_type, size, checksum, ocr_text, is_folder, parent_resource_id, owner_id, created_at, updated_at FROM resources
|
||||||
WHERE parent_resource_id = $1 AND owner_id = $2
|
WHERE parent_resource_id = $1 AND owner_id = $2
|
||||||
ORDER BY is_folder DESC, created_at DESC
|
ORDER BY is_folder DESC, created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListResourcesByParentAndOwnerParams struct {
|
type ListResourcesByParentAndOwnerParams struct {
|
||||||
ParentResourceID uuid.NullUUID `json:"parent_resource_id"`
|
ParentResourceID uuid.NullUUID `json:"parent_resource_id"`
|
||||||
OwnerID uuid.UUID `json:"owner_id"`
|
OwnerID uuid.UUID `json:"owner_id"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListResourcesByParentAndOwner(ctx context.Context, arg ListResourcesByParentAndOwnerParams) ([]Resource, error) {
|
func (q *Queries) ListResourcesByParentAndOwner(ctx context.Context, arg ListResourcesByParentAndOwnerParams) ([]Resource, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listResourcesByParentAndOwner, arg.ParentResourceID, arg.OwnerID)
|
rows, err := q.db.QueryContext(ctx, listResourcesByParentAndOwner,
|
||||||
|
arg.ParentResourceID,
|
||||||
|
arg.OwnerID,
|
||||||
|
arg.Limit,
|
||||||
|
arg.Offset,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
"github.com/vaultdrop/backend/internal/auth"
|
"github.com/vaultdrop/backend/internal/auth"
|
||||||
"github.com/vaultdrop/backend/internal/service"
|
"github.com/vaultdrop/backend/internal/service"
|
||||||
)
|
)
|
||||||
@@ -17,6 +19,7 @@ type Handler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
|
database *sql.DB,
|
||||||
resourceSvc *service.ResourceService,
|
resourceSvc *service.ResourceService,
|
||||||
ocrSvc *service.OCRService,
|
ocrSvc *service.OCRService,
|
||||||
urlSvc *service.URLService,
|
urlSvc *service.URLService,
|
||||||
@@ -36,7 +39,7 @@ func New(
|
|||||||
broker: eventBroker,
|
broker: eventBroker,
|
||||||
},
|
},
|
||||||
OCR: &OCRHandler{ocr: ocrSvc, resources: resourceSvc},
|
OCR: &OCRHandler{ocr: ocrSvc, resources: resourceSvc},
|
||||||
Health: &HealthHandler{ocr: ocrSvc},
|
Health: &HealthHandler{db: database, ocr: ocrSvc},
|
||||||
Auth: authHandler,
|
Auth: authHandler,
|
||||||
Share: &ShareHandler{rebac: rebacSvc},
|
Share: &ShareHandler{rebac: rebacSvc},
|
||||||
Device: &DeviceHandler{placement: placementSvc},
|
Device: &DeviceHandler{placement: placementSvc},
|
||||||
|
|||||||
@@ -1,14 +1,44 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/vaultdrop/backend/pkg/api"
|
"github.com/vaultdrop/backend/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HealthHandler struct {
|
type HealthHandler struct {
|
||||||
|
db *sql.DB
|
||||||
ocr interface{ HealthCheck() error }
|
ocr interface{ HealthCheck() error }
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HealthHandler) Check(c *gin.Context) {
|
func (h *HealthHandler) Check(c *gin.Context) {
|
||||||
api.Success(c, gin.H{"status": "healthy"})
|
checks := gin.H{}
|
||||||
|
|
||||||
|
dbErr := h.db.Ping()
|
||||||
|
if dbErr != nil {
|
||||||
|
checks["database"] = "error: " + dbErr.Error()
|
||||||
|
} else {
|
||||||
|
checks["database"] = "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
ocrErr := h.ocr.HealthCheck()
|
||||||
|
if ocrErr != nil {
|
||||||
|
checks["ocr"] = "error: " + ocrErr.Error()
|
||||||
|
} else {
|
||||||
|
checks["ocr"] = "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "healthy"
|
||||||
|
for _, v := range checks {
|
||||||
|
if v != "ok" {
|
||||||
|
status = "degraded"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.Success(c, gin.H{
|
||||||
|
"status": status,
|
||||||
|
"checks": checks,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,11 +65,31 @@ func (h *ResourceHandler) Upload(c *gin.Context) {
|
|||||||
api.Success(c, results)
|
api.Success(c, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePagination(c *gin.Context) (page, limit int) {
|
||||||
|
page = 1
|
||||||
|
limit = 20
|
||||||
|
if p := c.Query("page"); p != "" {
|
||||||
|
if n, err := strconv.Atoi(p); err == nil && n > 0 {
|
||||||
|
page = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l := c.Query("limit"); l != "" {
|
||||||
|
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||||
|
if n > 100 {
|
||||||
|
n = 100
|
||||||
|
}
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ResourceHandler) List(c *gin.Context) {
|
func (h *ResourceHandler) List(c *gin.Context) {
|
||||||
userID := c.GetString(auth.UserIDKey)
|
userID := c.GetString(auth.UserIDKey)
|
||||||
thumbnailQuality := c.Query("thumbnail")
|
thumbnailQuality := c.Query("thumbnail")
|
||||||
|
page, limit := parsePagination(c)
|
||||||
|
|
||||||
resources, err := h.resources.List(userID)
|
resources, total, err := h.resources.List(userID, page, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ERROR List resources: %v", err)
|
log.Printf("ERROR List resources: %v", err)
|
||||||
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list resources")
|
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list resources")
|
||||||
@@ -132,7 +152,7 @@ func (h *ResourceHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
api.Paginated(c, resp, 1, len(resp))
|
api.Paginated(c, resp, page, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ResourceHandler) Download(c *gin.Context) {
|
func (h *ResourceHandler) Download(c *gin.Context) {
|
||||||
@@ -335,8 +355,9 @@ func (h *ResourceHandler) ListByParent(c *gin.Context) {
|
|||||||
userID := c.GetString(auth.UserIDKey)
|
userID := c.GetString(auth.UserIDKey)
|
||||||
parentID := c.Param("id")
|
parentID := c.Param("id")
|
||||||
thumbnailQuality := c.Query("thumbnail")
|
thumbnailQuality := c.Query("thumbnail")
|
||||||
|
page, limit := parsePagination(c)
|
||||||
|
|
||||||
resources, err := h.resources.ListResourcesByParentID(parentID, userID)
|
resources, total, err := h.resources.ListResourcesByParentID(parentID, userID, page, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list resources in folder")
|
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list resources in folder")
|
||||||
return
|
return
|
||||||
@@ -396,7 +417,7 @@ func (h *ResourceHandler) ListByParent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
api.Success(c, resp)
|
api.Paginated(c, resp, page, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ResourceHandler) GetVariants(c *gin.Context) {
|
func (h *ResourceHandler) GetVariants(c *gin.Context) {
|
||||||
|
|||||||
@@ -147,22 +147,33 @@ func (s *ResourceService) ensureServerPlacementQtx(q *db.Queries, resourceID, ow
|
|||||||
return placement, nil
|
return placement, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ResourceService) List(ownerID string) ([]model.Resource, error) {
|
func (s *ResourceService) List(ownerID string, page, limit int) ([]model.Resource, int, error) {
|
||||||
ownerUUID, _ := uuid.Parse(ownerID)
|
ownerUUID, _ := uuid.Parse(ownerID)
|
||||||
dbResources, err := s.queries.ListResourcesByOwner(context.Background(), ownerUUID)
|
|
||||||
|
total, err := s.queries.CountResourcesByOwner(context.Background(), ownerUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list resources: %w", err)
|
return nil, 0, fmt.Errorf("count resources: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
dbResources, err := s.queries.ListResourcesByOwner(context.Background(), db.ListResourcesByOwnerParams{
|
||||||
|
OwnerID: ownerUUID,
|
||||||
|
Limit: int32(limit),
|
||||||
|
Offset: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("list resources: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resources := make([]model.Resource, len(dbResources))
|
resources := make([]model.Resource, len(dbResources))
|
||||||
for i, r := range dbResources {
|
for i, r := range dbResources {
|
||||||
tags, err := s.queries.GetTagsByResourceID(context.Background(), r.ID)
|
tags, err := s.queries.GetTagsByResourceID(context.Background(), r.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get tags for resource %s: %w", r.ID, err)
|
return nil, 0, fmt.Errorf("get tags for resource %s: %w", r.ID, err)
|
||||||
}
|
}
|
||||||
resources[i] = dbResourceToModel(r, tags)
|
resources[i] = dbResourceToModel(r, tags)
|
||||||
}
|
}
|
||||||
return resources, nil
|
return resources, int(total), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ResourceService) Get(id string) (*model.Resource, error) {
|
func (s *ResourceService) Get(id string) (*model.Resource, error) {
|
||||||
@@ -309,25 +320,37 @@ func (s *ResourceService) ListFolders(ownerID string) ([]model.Resource, error)
|
|||||||
return folders, nil
|
return folders, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ResourceService) ListResourcesByParentID(parentID, ownerID string) ([]model.Resource, error) {
|
func (s *ResourceService) ListResourcesByParentID(parentID, ownerID string, page, limit int) ([]model.Resource, int, error) {
|
||||||
parentUUID, _ := uuid.Parse(parentID)
|
parentUUID, _ := uuid.Parse(parentID)
|
||||||
ownerUUID, _ := uuid.Parse(ownerID)
|
ownerUUID, _ := uuid.Parse(ownerID)
|
||||||
dbResources, err := s.queries.ListResourcesByParentAndOwner(context.Background(), db.ListResourcesByParentAndOwnerParams{
|
|
||||||
|
total, err := s.queries.CountResourcesByParentAndOwner(context.Background(), db.CountResourcesByParentAndOwnerParams{
|
||||||
ParentResourceID: uuid.NullUUID{UUID: parentUUID, Valid: true},
|
ParentResourceID: uuid.NullUUID{UUID: parentUUID, Valid: true},
|
||||||
OwnerID: ownerUUID,
|
OwnerID: ownerUUID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list resources by parent: %w", err)
|
return nil, 0, fmt.Errorf("count resources by parent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
dbResources, err := s.queries.ListResourcesByParentAndOwner(context.Background(), db.ListResourcesByParentAndOwnerParams{
|
||||||
|
ParentResourceID: uuid.NullUUID{UUID: parentUUID, Valid: true},
|
||||||
|
OwnerID: ownerUUID,
|
||||||
|
Limit: int32(limit),
|
||||||
|
Offset: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("list resources by parent: %w", err)
|
||||||
}
|
}
|
||||||
resources := make([]model.Resource, len(dbResources))
|
resources := make([]model.Resource, len(dbResources))
|
||||||
for i, r := range dbResources {
|
for i, r := range dbResources {
|
||||||
tags, err := s.queries.GetTagsByResourceID(context.Background(), r.ID)
|
tags, err := s.queries.GetTagsByResourceID(context.Background(), r.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get tags for resource %s: %w", r.ID, err)
|
return nil, 0, fmt.Errorf("get tags for resource %s: %w", r.ID, err)
|
||||||
}
|
}
|
||||||
resources[i] = dbResourceToModel(r, tags)
|
resources[i] = dbResourceToModel(r, tags)
|
||||||
}
|
}
|
||||||
return resources, nil
|
return resources, int(total), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ResourceService) GetVariantsByResourceID(resourceID string) ([]model.Variant, error) {
|
func (s *ResourceService) GetVariantsByResourceID(resourceID string) ([]model.Variant, error) {
|
||||||
|
|||||||
+40
-1
@@ -22,6 +22,7 @@ import { deleteAsync } from 'expo-file-system/legacy';
|
|||||||
const NUM_COLUMNS = 3;
|
const NUM_COLUMNS = 3;
|
||||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
const ITEM_SIZE = (SCREEN_WIDTH - 16 * 2 - (NUM_COLUMNS - 1) * 6) / NUM_COLUMNS;
|
const ITEM_SIZE = (SCREEN_WIDTH - 16 * 2 - (NUM_COLUMNS - 1) * 6) / NUM_COLUMNS;
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
type RootStackParamList = {
|
type RootStackParamList = {
|
||||||
Folder: { folderId: string; folderName: string };
|
Folder: { folderId: string; folderName: string };
|
||||||
@@ -75,7 +76,8 @@ export function FolderScreen() {
|
|||||||
const route = useRoute<FolderRouteProp>();
|
const route = useRoute<FolderRouteProp>();
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const { folderId, folderName } = route.params;
|
const { folderId, folderName } = route.params;
|
||||||
const { data, isLoading } = useFiles(folderId);
|
const [page, setPage] = useState(1);
|
||||||
|
const { data, isLoading, isFetching } = useFiles(folderId, page, PAGE_SIZE);
|
||||||
const deleteFile = useDeleteFile();
|
const deleteFile = useDeleteFile();
|
||||||
const freeLocalSpace = useFreeLocalSpace();
|
const freeLocalSpace = useFreeLocalSpace();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -87,6 +89,17 @@ export function FolderScreen() {
|
|||||||
const { data: foldersData } = useFolders();
|
const { data: foldersData } = useFolders();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (isFetching) return;
|
||||||
|
const total = data?.meta?.total ?? 0;
|
||||||
|
const loaded = data?.data?.length ?? 0;
|
||||||
|
if (loaded < total) {
|
||||||
|
setPage((p) => p + 1);
|
||||||
|
}
|
||||||
|
}, [isFetching, data?.meta?.total, data?.data?.length]);
|
||||||
|
|
||||||
|
const hasMore = (data?.data?.length ?? 0) > 0 && (data?.data?.length ?? 0) < (data?.meta?.total ?? 0);
|
||||||
|
|
||||||
const [tagModalVisible, setTagModalVisible] = useState(false);
|
const [tagModalVisible, setTagModalVisible] = useState(false);
|
||||||
const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag');
|
const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag');
|
||||||
const [tagInput, setTagInput] = useState('');
|
const [tagInput, setTagInput] = useState('');
|
||||||
@@ -246,12 +259,25 @@ export function FolderScreen() {
|
|||||||
data={files}
|
data={files}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<MaterialIcons name="folder-open" size={48} color="#ccc" />
|
<MaterialIcons name="folder-open" size={48} color="#ccc" />
|
||||||
<Text style={styles.emptyText}>Dossier vide</Text>
|
<Text style={styles.emptyText}>Dossier vide</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetching ? (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={styles.footerText}>Chargement...</Text>
|
||||||
|
</View>
|
||||||
|
) : hasMore ? (
|
||||||
|
<TouchableOpacity style={styles.footer} onPress={loadMore}>
|
||||||
|
<Text style={styles.footerLink}>Charger plus</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
renderItem={({ item: file }) => (
|
renderItem={({ item: file }) => (
|
||||||
<FolderGridItem
|
<FolderGridItem
|
||||||
file={file}
|
file={file}
|
||||||
@@ -496,4 +522,17 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#333',
|
color: '#333',
|
||||||
},
|
},
|
||||||
|
footer: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
footerLink: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#1976D2',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,15 +39,15 @@ function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getRootFiles
|
|||||||
|
|
||||||
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) {
|
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) {
|
||||||
const queryKey = parentId
|
const queryKey = parentId
|
||||||
? ['resources', parentId]
|
? ['resources', parentId, page, limit]
|
||||||
: ['resources', 'root', page, limit];
|
: ['resources', 'root', page, limit];
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
const backendRes = await apiClient.get<{ data: FileItem[] }>(
|
const backendRes = await apiClient.get<PaginatedResponse<FileItem>>(
|
||||||
`${ENDPOINTS.RESOURCES}/folders/${parentId}/resources?thumbnail=thumbnail_small`,
|
`${ENDPOINTS.RESOURCES}/folders/${parentId}/resources?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
|
||||||
);
|
);
|
||||||
fileStore.mergeFromBackend(
|
fileStore.mergeFromBackend(
|
||||||
backendRes.data.map((f) => ({
|
backendRes.data.map((f) => ({
|
||||||
@@ -69,7 +69,7 @@ export function useFiles(parentId?: string | null, page: number = 1, limit: numb
|
|||||||
const children = fileStore.getChildrenByParent(parentId);
|
const children = fileStore.getChildrenByParent(parentId);
|
||||||
return {
|
return {
|
||||||
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
||||||
meta: { page: 0, total: children.length },
|
meta: { page, total: backendRes.meta?.total ?? children.length },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ export function usePullSync() {
|
|||||||
try {
|
try {
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
|
|
||||||
const res = await apiClient.get<{ data: Array<{
|
let page = 1;
|
||||||
|
const limit = 100;
|
||||||
|
let total = 0;
|
||||||
|
const backendResources: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
@@ -28,9 +31,17 @@ export function usePullSync() {
|
|||||||
url?: string;
|
url?: string;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
}> }>(`${ENDPOINTS.RESOURCES}?page=1&limit=100&thumbnail=thumbnail_small`);
|
}> = [];
|
||||||
|
|
||||||
|
do {
|
||||||
|
const res = await apiClient.get<{ data: Array<typeof backendResources[number]>; meta?: { total: number } }>(
|
||||||
|
`${ENDPOINTS.RESOURCES}?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
|
||||||
|
);
|
||||||
|
backendResources.push(...(res.data ?? []));
|
||||||
|
total = res.meta?.total ?? res.data.length;
|
||||||
|
page++;
|
||||||
|
} while (backendResources.length < total);
|
||||||
|
|
||||||
const backendResources = res.data ?? [];
|
|
||||||
const registry = fileStore.getAllSynced();
|
const registry = fileStore.getAllSynced();
|
||||||
const existingBackendIds = new Set(
|
const existingBackendIds = new Set(
|
||||||
registry.filter((e) => e.backendId).map((e) => e.backendId)
|
registry.filter((e) => e.backendId).map((e) => e.backendId)
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { ApiError, UploadError } from '../types';
|
|||||||
export type UploadFile = { uri: string; type: string; name: string };
|
export type UploadFile = { uri: string; type: string; name: string };
|
||||||
export type UploadResult = { name: string; id: string };
|
export type UploadResult = { name: string; id: string };
|
||||||
|
|
||||||
|
export const UPLOAD_MAX_RETRIES = 3;
|
||||||
|
const BASE_RETRY_DELAY_MS = 1000;
|
||||||
|
|
||||||
export type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error';
|
export type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error';
|
||||||
|
|
||||||
export type UploadTask = {
|
export type UploadTask = {
|
||||||
@@ -16,6 +19,7 @@ export type UploadTask = {
|
|||||||
progress: number;
|
progress: number;
|
||||||
result?: UploadResult;
|
result?: UploadResult;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
retryCount: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
@@ -33,6 +37,7 @@ function serialize(task: UploadTask): unknown {
|
|||||||
progress: task.progress,
|
progress: task.progress,
|
||||||
result: task.result ?? null,
|
result: task.result ?? null,
|
||||||
error: task.error ?? null,
|
error: task.error ?? null,
|
||||||
|
retryCount: task.retryCount,
|
||||||
createdAt: task.createdAt,
|
createdAt: task.createdAt,
|
||||||
updatedAt: task.updatedAt,
|
updatedAt: task.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -50,6 +55,7 @@ function deserialize(data: unknown): UploadTask | null {
|
|||||||
progress: d.progress as number,
|
progress: d.progress as number,
|
||||||
result: d.result ? (d.result as UploadResult) : undefined,
|
result: d.result ? (d.result as UploadResult) : undefined,
|
||||||
error: d.error ? (d.error as string) : undefined,
|
error: d.error ? (d.error as string) : undefined,
|
||||||
|
retryCount: (d.retryCount as number) ?? 0,
|
||||||
createdAt: d.createdAt as number,
|
createdAt: d.createdAt as number,
|
||||||
updatedAt: d.updatedAt as number,
|
updatedAt: d.updatedAt as number,
|
||||||
};
|
};
|
||||||
@@ -147,6 +153,7 @@ class UploadQueue {
|
|||||||
file,
|
file,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
retryCount: 0,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
@@ -171,6 +178,7 @@ class UploadQueue {
|
|||||||
if (!task || task.status !== 'error') return;
|
if (!task || task.status !== 'error') return;
|
||||||
task.status = 'pending';
|
task.status = 'pending';
|
||||||
task.progress = 0;
|
task.progress = 0;
|
||||||
|
task.retryCount = 0;
|
||||||
task.error = undefined;
|
task.error = undefined;
|
||||||
task.result = undefined;
|
task.result = undefined;
|
||||||
task.updatedAt = Date.now();
|
task.updatedAt = Date.now();
|
||||||
@@ -184,6 +192,7 @@ class UploadQueue {
|
|||||||
if (task.status === 'error') {
|
if (task.status === 'error') {
|
||||||
task.status = 'pending';
|
task.status = 'pending';
|
||||||
task.progress = 0;
|
task.progress = 0;
|
||||||
|
task.retryCount = 0;
|
||||||
task.error = undefined;
|
task.error = undefined;
|
||||||
task.result = undefined;
|
task.result = undefined;
|
||||||
task.updatedAt = Date.now();
|
task.updatedAt = Date.now();
|
||||||
@@ -212,6 +221,7 @@ class UploadQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async runTask(task: UploadTask) {
|
private async runTask(task: UploadTask) {
|
||||||
|
let willRetry = false;
|
||||||
try {
|
try {
|
||||||
const fsFile = new File(task.file.uri);
|
const fsFile = new File(task.file.uri);
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
@@ -251,20 +261,36 @@ class UploadQueue {
|
|||||||
this.notify();
|
this.notify();
|
||||||
this.scheduleCleanup();
|
this.scheduleCleanup();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
task.status = 'error';
|
task.retryCount++;
|
||||||
task.error =
|
if (task.retryCount <= UPLOAD_MAX_RETRIES) {
|
||||||
err instanceof UploadError
|
willRetry = true;
|
||||||
? `${err.fileName} : ${err.message}`
|
task.status = 'pending';
|
||||||
: err instanceof Error
|
task.progress = 0;
|
||||||
? err.message
|
task.error = undefined;
|
||||||
: 'Erreur inconnue';
|
task.updatedAt = Date.now();
|
||||||
task.updatedAt = Date.now();
|
this.persist();
|
||||||
this.persist();
|
this.notify();
|
||||||
this.notify();
|
const delay = BASE_RETRY_DELAY_MS * Math.pow(2, task.retryCount - 1);
|
||||||
|
setTimeout(() => this.processNext(), delay);
|
||||||
|
} else {
|
||||||
|
task.status = 'error';
|
||||||
|
task.error =
|
||||||
|
err instanceof UploadError
|
||||||
|
? `${err.fileName} : ${err.message}`
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Erreur inconnue';
|
||||||
|
task.error += ` (${task.retryCount} tentative(s))`;
|
||||||
|
task.updatedAt = Date.now();
|
||||||
|
this.persist();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.active--;
|
this.active--;
|
||||||
this.notify();
|
this.notify();
|
||||||
this.processNext();
|
if (!willRetry) {
|
||||||
|
this.processNext();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user