diff --git a/@codexteam/ui/package.json b/@codexteam/ui/package.json index 50db201b..2550ce0f 100644 --- a/@codexteam/ui/package.json +++ b/@codexteam/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codexteam/ui", - "version": "0.2.3", + "version": "0.2.5", "type": "module", "sideEffects": [ "*.css", diff --git a/@codexteam/ui/src/vue/components/card/Card.vue b/@codexteam/ui/src/vue/components/card/Card.vue index d63133ad..93bfafb3 100644 --- a/@codexteam/ui/src/vue/components/card/Card.vue +++ b/@codexteam/ui/src/vue/components/card/Card.vue @@ -6,8 +6,13 @@ ]" >
@@ -55,12 +60,19 @@ withDefaults( * Cover image source */ src?: string; + + /** + * Loading state. + * When true shows skeleton shimmer on cover + */ + isCoverLoading?: boolean; }>(), { title: '', subtitle: '', orientation: 'vertical', src: '', + isCoverLoading: false, } ); @@ -113,10 +125,31 @@ withDefaults( border-radius: var(--radius-m); background-color: var(--base--bg-primary); background-size: cover; + + &--skeleton { + background-color: color-mix(in srgb, var(--base--text-secondary) 10%, transparent); + background-image: linear-gradient( + to left, + color-mix(in srgb, var(--base--text-secondary) 30%, transparent) 0%, + color-mix(in srgb, var(--base--text-secondary) 10%, transparent) 50%, + color-mix(in srgb, var(--base--text-secondary) 30%, transparent) 100% + ); + background-size: 200% 100%; + animation: card-skeleton-animation 3s infinite linear; + } } &__title { color: var(--base--text); } } + +@keyframes card-skeleton-animation { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} diff --git a/src/application/services/useNoteList.ts b/src/application/services/useNoteList.ts index 1d29b6ea..cf198899 100644 --- a/src/application/services/useNoteList.ts +++ b/src/application/services/useNoteList.ts @@ -34,6 +34,11 @@ interface UseNoteListComposableState { * Loading state */ isLoading: Ref; + + /** + * Set of note IDs whose cover downloads are currently in-flight + */ + coversLoading: Ref>; } /** @@ -69,17 +74,85 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState const isLoading = ref(false); /** - * Get note list + * Get note list (metadata only, covers are not downloaded) * @param page - number of pages */ const load = async (page: number): Promise => { isLoading.value = true; + try { + return await noteListService.getNoteList(page, onlyCreatedByUser); + } finally { + isLoading.value = false; + } + }; + + /** + * Set of note IDs whose cover downloads are currently in-flight + * Prevents duplicate requests when loadMoreNotes() fires again before + * covers for the previous page have finished loading. + */ + const coversLoading = ref(new Set()); + + /** + * Load cover images for all notes in the list in the background + * Updates each note's cover reactively as it arrives + */ + const loadCovers = async (): Promise => { + if (isEmpty(noteList.value)) { + return; + } + + const list = noteList.value; + const items = list.items; - const list = await noteListService.getNoteList(page, onlyCreatedByUser); + await Promise.all(items.map(async (item, index) => { + /** + * If cover is null, the note has no cover image + */ + if (item.cover === null) { + return; + } - isLoading.value = false; + /** + * If cover is already a blob URL, it was already loaded + */ + if (item.cover.startsWith('blob:')) { + return; + } - return list; + /** + * If this note's cover is already being fetched, skip it + */ + if (coversLoading.value.has(item.id)) { + return; + } + + coversLoading.value = new Set(coversLoading.value).add(item.id); + + try { + const url = await noteListService.loadCover(item.id, item.cover); + + if (url !== null) { + const currentItem = list.items[index]; + + if (currentItem?.id !== item.id) { + return; + } + /** + * Update the specific note's cover reactively so the card renders the image + */ + list.items[index] = { + ...currentItem, + cover: url, + }; + } + } finally { + const next = new Set(coversLoading.value); + + next.delete(item.id); + coversLoading.value = next; + } + })); }; /** @@ -102,6 +175,12 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState } else { noteList.value = loadedNotes; } + + /** + * Kick off cover downloads in the background + * List is already rendered, covers will appear one by one as they load + */ + loadCovers().catch(console.error); }; /** @@ -134,5 +213,6 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState load, loadMoreNotes, isLoading, + coversLoading, }; } diff --git a/src/domain/noteList.service.ts b/src/domain/noteList.service.ts index 87367b41..941161c0 100644 --- a/src/domain/noteList.service.ts +++ b/src/domain/noteList.service.ts @@ -20,54 +20,31 @@ export default class NoteListService { } /** - * Returns note list - * @todo - move loading images data logic to separate service for optimization + * Returns note list with metadata only (covers are not downloaded) * @param page - number of current pages * @param onlyCreatedByUser - if true, returns notes created by the user * @returns list of notes */ public async getNoteList(page: number, onlyCreatedByUser = false): Promise { - const noteList = await this.repository.getNoteList(page, onlyCreatedByUser); - - /** - * Note list with valid image urls in cover - */ - const parsedNoteList: NoteList = { - items: [], - }; - - for (const note of noteList.items) { - /** - * If note has no cover, we have no need to load it - */ - if (note.cover === null) { - parsedNoteList.items.push(note); - continue; - } - - /** - * Cover object url for passing to the element - */ - let objUrl: string | null = null; + return await this.repository.getNoteList(page, onlyCreatedByUser); + } - try { - const imageData = await this.noteAttachmentRepository.load(note.id, note.cover); + /** + * Load cover image for a single note and return its blob URL + * @param noteId - Note identifier + * @param coverKey - Cover file key on server + * @returns Blob URL for the cover image, or null on error + */ + public async loadCover(noteId: string, coverKey: string): Promise { + try { + const imageData = await this.noteAttachmentRepository.load(noteId, coverKey); - /** - * Make url from blob data - */ - // eslint-disable-next-line n/no-unsupported-features/node-builtins - objUrl = URL.createObjectURL(imageData); - } catch { - console.log('Error while loading cover for note ', note.id); - } + // eslint-disable-next-line n/no-unsupported-features/node-builtins + return URL.createObjectURL(imageData); + } catch { + console.log('Error while loading cover for note ', noteId); - parsedNoteList.items.push({ - ...note, - cover: objUrl, - }); + return null; } - - return parsedNoteList; } } diff --git a/src/presentation/components/note-list/NoteList.vue b/src/presentation/components/note-list/NoteList.vue index e1574f92..b70626aa 100644 --- a/src/presentation/components/note-list/NoteList.vue +++ b/src/presentation/components/note-list/NoteList.vue @@ -15,7 +15,8 @@ @@ -62,6 +63,7 @@ const { loadMoreNotes, hasMoreNotes, isLoading, + coversLoading, } = useNoteList(props.onlyCreatedByUser); const { t } = useI18n();