feat: photo upload progress

This commit is contained in:
2026-04-07 15:42:42 +03:00
parent 674a4d3d82
commit 11cb7aa233
4 changed files with 103 additions and 14 deletions

View File

@@ -45,6 +45,7 @@ export async function uploadPhoto(
file: File,
folderId?: number | null,
caption?: string | null,
onProgress?: (progress: number) => void,
): Promise<Photo> {
const form = new FormData()
form.append('image', file, file.name)
@@ -54,7 +55,11 @@ export async function uploadPhoto(
if (caption) {
form.append('caption', caption)
}
const resp = await ocs.post<Photo>(`/houses/${houseId}/photos`, form)
const resp = await ocs.post<Photo>(`/houses/${houseId}/photos`, form, {
onUploadProgress: onProgress
? (e) => onProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0)
: undefined,
})
return resp.data
}

View File

@@ -127,7 +127,7 @@ describe('usePhotos', () => {
const file = new File(['data'], 'test.jpg')
const result = await wall.upload(file, 5)
expect(mockApi.uploadPhoto).toHaveBeenCalledWith(1, file, 5)
expect(mockApi.uploadPhoto).toHaveBeenCalledWith(1, file, 5, null, expect.any(Function))
expect(result).toEqual(newPhoto)
expect(wall.photos.value).toHaveLength(1)
expect(wall.photos.value[0]).toEqual(newPhoto)

View File

@@ -2,11 +2,21 @@ import { computed, ref } from 'vue'
import * as api from '@/api/photos'
import type { Photo, PhotoFolder } from '@/api/types'
export interface UploadEntry {
id: string
fileName: string
folderId: number | null
progress: number
}
let uploadSeq = 0
export function usePhotos(houseId: number) {
const photos = ref<Photo[]>([])
const folders = ref<PhotoFolder[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const uploads = ref<UploadEntry[]>([])
async function load(): Promise<void> {
loading.value = true
@@ -22,18 +32,35 @@ export function usePhotos(houseId: number) {
}
}
const rootPhotos = computed(() => photos.value.filter((p) => p.folderId === null))
const rootPhotos = computed(() =>
photos.value.filter((p) => p.folderId === null).sort((a, b) => b.createdAt - a.createdAt),
)
function photosInFolder(folderId: number): Photo[] {
return photos.value.filter((p) => p.folderId === folderId)
return photos.value
.filter((p) => p.folderId === folderId)
.sort((a, b) => b.createdAt - a.createdAt)
}
// ----- Photos -----
async function upload(file: File, folderId?: number | null): Promise<Photo> {
const created = await api.uploadPhoto(houseId, file, folderId)
photos.value = [...photos.value, created]
return created
const entry: UploadEntry = {
id: `upload-${++uploadSeq}`,
fileName: file.name,
folderId: folderId ?? null,
progress: 0,
}
uploads.value = [...uploads.value, entry]
try {
const created = await api.uploadPhoto(houseId, file, folderId, null, (progress) => {
uploads.value = uploads.value.map((u) => (u.id === entry.id ? { ...u, progress } : u))
})
photos.value = [...photos.value, created]
return created
} finally {
uploads.value = uploads.value.filter((u) => u.id !== entry.id)
}
}
async function updatePhoto(
@@ -92,6 +119,7 @@ export function usePhotos(houseId: number) {
return {
photos,
folders,
uploads,
loading,
error,
load,

View File

@@ -80,6 +80,10 @@
@dragover.prevent
@drop.prevent.stop="onPlaceholderDrop"
/>
<div v-else-if="item.type === 'upload'" class="pantry-photos__upload-card">
<NcProgressBar :value="item.progress" size="medium" />
<span class="pantry-photos__upload-name">{{ item.fileName }}</span>
</div>
<PhotoCard
v-else
:photo="item.photo"
@@ -120,6 +124,10 @@
@dragover.prevent
@drop.prevent.stop="onPlaceholderDrop"
/>
<div v-else-if="item.type === 'upload'" class="pantry-photos__upload-card">
<NcProgressBar :value="item.progress" size="medium" />
<span class="pantry-photos__upload-name">{{ item.fileName }}</span>
</div>
<PhotoCard
v-else
:photo="item.photo"
@@ -230,6 +238,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcDialog from '@nextcloud/vue/components/NcDialog'
@@ -241,7 +250,7 @@ import ImageIcon from '@icons/Image.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import FolderPlusIcon from '@icons/FolderPlus.vue'
import type { Photo, PhotoFolder } from '@/api/types'
import { usePhotos } from '@/composables/usePhotos'
import { usePhotos, type UploadEntry } from '@/composables/usePhotos'
const props = defineProps<{ houseId: string; folderId?: string }>()
const router = useRouter()
@@ -260,6 +269,7 @@ const {
createFolder,
updateFolder,
removeFolder,
uploads,
} = usePhotos(houseIdNum.value)
onMounted(load)
@@ -294,15 +304,31 @@ function navigateToFolder(folderId: number | null) {
// ----- Reorder state -----
type GridItem = { type: 'photo'; key: string; photo: Photo } | { type: 'placeholder'; key: string }
type GridItem =
| { type: 'photo'; key: string; photo: Photo }
| { type: 'placeholder'; key: string }
| { type: 'upload'; key: string; fileName: string; progress: number }
const draggingPhotoId = ref<number | null>(null)
const dropIndex = ref<number | null>(null)
function buildGridItems(source: Photo[]): GridItem[] {
function buildGridItems(source: Photo[], activeUploads: UploadEntry[]): GridItem[] {
// Upload placeholders go first (newest-first sort means in-progress uploads are at the top).
const uploadItems: GridItem[] = activeUploads.map((u) => ({
type: 'upload' as const,
key: u.id,
fileName: u.fileName,
progress: u.progress,
}))
const dragId = draggingPhotoId.value
if (dragId === null || dropIndex.value === null) {
return source.map((p) => ({ type: 'photo' as const, key: 'p-' + p.id, photo: p }))
const photoItems: GridItem[] = source.map((p) => ({
type: 'photo' as const,
key: 'p-' + p.id,
photo: p,
}))
return [...uploadItems, ...photoItems]
}
const without = source.filter((p) => p.id !== dragId)
@@ -314,11 +340,18 @@ function buildGridItems(source: Photo[]): GridItem[] {
const clampedIndex = Math.min(dropIndex.value, items.length)
items.splice(clampedIndex, 0, { type: 'placeholder', key: 'drop-placeholder' })
return items
return [...uploadItems, ...items]
}
const rootGridItems = computed(() => buildGridItems(rootPhotos.value))
const folderGridItems = computed(() => buildGridItems(activeFolderPhotos.value))
const rootUploads = computed(() => uploads.value.filter((u) => u.folderId === null))
const folderUploads = computed(() =>
uploads.value.filter((u) => u.folderId === activeFolderId.value),
)
const rootGridItems = computed(() => buildGridItems(rootPhotos.value, rootUploads.value))
const folderGridItems = computed(() =>
buildGridItems(activeFolderPhotos.value, folderUploads.value),
)
function onPhotoDragStart(photoId: number) {
draggingPhotoId.value = photoId
@@ -614,6 +647,29 @@ const strings = {
background: rgba(var(--color-primary-element-rgb, 0, 120, 212), 0.08);
}
&__upload-card {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large, 12px);
background: var(--color-background-dark);
padding: 1rem;
}
&__upload-name {
font-size: 0.75rem;
color: var(--color-text-maxcontrast);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-align: center;
}
&__drop-overlay {
position: absolute;
inset: 0;