From 46030ef1fe48f12a9110f1242a93769c04e8208f Mon Sep 17 00:00:00 2001 From: 7eliassen Date: Fri, 26 Jun 2026 16:56:34 +0300 Subject: [PATCH 1/8] feat: add parallel covers loading --- src/domain/noteList.service.ts | 45 +++++++++++++++------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/src/domain/noteList.service.ts b/src/domain/noteList.service.ts index 87367b41..bb9827cb 100644 --- a/src/domain/noteList.service.ts +++ b/src/domain/noteList.service.ts @@ -30,43 +30,38 @@ export default class NoteListService { const noteList = await this.repository.getNoteList(page, onlyCreatedByUser); /** - * Note list with valid image urls in cover + * Load cover image for a single note in parallel + * @param note - Note entity + * @returns Note with cover blob URL (or null cover on error) */ - const parsedNoteList: NoteList = { - items: [], - }; - - for (const note of noteList.items) { - /** - * If note has no cover, we have no need to load it - */ + const loadCover = async (note: NoteList['items'][number]): Promise => { if (note.cover === null) { - parsedNoteList.items.push(note); - continue; + return note; } - /** - * Cover object url for passing to the element - */ - let objUrl: string | null = null; - try { const imageData = await this.noteAttachmentRepository.load(note.id, note.cover); - /** - * Make url from blob data - */ // eslint-disable-next-line n/no-unsupported-features/node-builtins - objUrl = URL.createObjectURL(imageData); + const objUrl = URL.createObjectURL(imageData); + + return { + ...note, + cover: objUrl, + }; } catch { console.log('Error while loading cover for note ', note.id); + + return note; } + }; - parsedNoteList.items.push({ - ...note, - cover: objUrl, - }); - } + /** + * Load all cover images in parallel + */ + const parsedNoteList: NoteList = { + items: await Promise.all(noteList.items.map(loadCover)), + }; return parsedNoteList; } From c359154d13352b217cdb9a0f412fea965f283e25 Mon Sep 17 00:00:00 2001 From: 7eliassen Date: Fri, 26 Jun 2026 20:14:40 +0300 Subject: [PATCH 2/8] feat: add reactive covers update --- src/application/services/useNoteList.ts | 49 +++++++++++++++++++++- src/domain/noteList.service.ts | 54 +++++++++---------------- 2 files changed, 66 insertions(+), 37 deletions(-) diff --git a/src/application/services/useNoteList.ts b/src/application/services/useNoteList.ts index 1d29b6ea..5b96a03d 100644 --- a/src/application/services/useNoteList.ts +++ b/src/application/services/useNoteList.ts @@ -69,7 +69,7 @@ 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 => { @@ -82,6 +82,47 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState return list; }; + /** + * 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; + + await Promise.all(items.map(async (item, index) => { + /** + * If cover is null, the note has no cover image + */ + if (item.cover === null) { + return; + } + + /** + * If cover is already a blob URL, it was already loaded + */ + if (item.cover.startsWith('blob:')) { + return; + } + + const url = await noteListService.loadCover(item.id, item.cover); + + if (url !== null) { + /** + * Update the specific note's cover reactively so the card renders the image + */ + list.items[index] = { + ...item, + cover: url, + }; + } + })); + }; + /** * Load next page of the notes */ @@ -102,6 +143,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); }; /** diff --git a/src/domain/noteList.service.ts b/src/domain/noteList.service.ts index bb9827cb..941161c0 100644 --- a/src/domain/noteList.service.ts +++ b/src/domain/noteList.service.ts @@ -20,49 +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); - - /** - * Load cover image for a single note in parallel - * @param note - Note entity - * @returns Note with cover blob URL (or null cover on error) - */ - const loadCover = async (note: NoteList['items'][number]): Promise => { - if (note.cover === null) { - return note; - } - - try { - const imageData = await this.noteAttachmentRepository.load(note.id, note.cover); - - // eslint-disable-next-line n/no-unsupported-features/node-builtins - const objUrl = URL.createObjectURL(imageData); - - return { - ...note, - cover: objUrl, - }; - } catch { - console.log('Error while loading cover for note ', note.id); + return await this.repository.getNoteList(page, onlyCreatedByUser); + } - return note; - } - }; + /** + * 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); - /** - * Load all cover images in parallel - */ - const parsedNoteList: NoteList = { - items: await Promise.all(noteList.items.map(loadCover)), - }; + // eslint-disable-next-line n/no-unsupported-features/node-builtins + return URL.createObjectURL(imageData); + } catch { + console.log('Error while loading cover for note ', noteId); - return parsedNoteList; + return null; + } } } From 5022ddf72d1423d6aaa8376d561c080c48f6f44b Mon Sep 17 00:00:00 2001 From: 7eliassen Date: Wed, 8 Jul 2026 16:59:31 +0300 Subject: [PATCH 3/8] fix: prevent extra requests --- src/presentation/components/note-list/NoteList.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/presentation/components/note-list/NoteList.vue b/src/presentation/components/note-list/NoteList.vue index e1574f92..d51ac81e 100644 --- a/src/presentation/components/note-list/NoteList.vue +++ b/src/presentation/components/note-list/NoteList.vue @@ -15,7 +15,7 @@ From ba4bd1247f8e5519260027024ef4dffc21d9b559 Mon Sep 17 00:00:00 2001 From: 7eliassen Date: Wed, 15 Jul 2026 19:59:36 +0300 Subject: [PATCH 4/8] feat: add error handler to always reset loading state --- src/application/services/useNoteList.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/application/services/useNoteList.ts b/src/application/services/useNoteList.ts index 5b96a03d..b4ae8212 100644 --- a/src/application/services/useNoteList.ts +++ b/src/application/services/useNoteList.ts @@ -74,12 +74,11 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState */ const load = async (page: number): Promise => { isLoading.value = true; - - const list = await noteListService.getNoteList(page, onlyCreatedByUser); - - isLoading.value = false; - - return list; + try { + return await noteListService.getNoteList(page, onlyCreatedByUser); + } finally { + isLoading.value = false; + } }; /** From a870d20f8bdae899cc3b5ba2a77ba7785ca0dbaf Mon Sep 17 00:00:00 2001 From: 7eliassen Date: Wed, 15 Jul 2026 20:11:25 +0300 Subject: [PATCH 5/8] feat: add overwrite protection for list of notes --- src/application/services/useNoteList.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/application/services/useNoteList.ts b/src/application/services/useNoteList.ts index b4ae8212..3e4a4ab2 100644 --- a/src/application/services/useNoteList.ts +++ b/src/application/services/useNoteList.ts @@ -111,11 +111,16 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState 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] = { - ...item, + ...currentItem, cover: url, }; } From 9af82f4369cfcb9555007176f68e56c6b2834ec6 Mon Sep 17 00:00:00 2001 From: 7eliassen Date: Tue, 21 Jul 2026 19:00:44 +0300 Subject: [PATCH 6/8] feat: prevent covers re-download --- src/application/services/useNoteList.ts | 44 ++++++++++++++++++------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/application/services/useNoteList.ts b/src/application/services/useNoteList.ts index 3e4a4ab2..f6444513 100644 --- a/src/application/services/useNoteList.ts +++ b/src/application/services/useNoteList.ts @@ -81,6 +81,13 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState } }; + /** + * 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 = new Set(); + /** * Load cover images for all notes in the list in the background * Updates each note's cover reactively as it arrives @@ -108,21 +115,34 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState return; } - const url = await noteListService.loadCover(item.id, item.cover); + /** + * If this note's cover is already being fetched, skip it + */ + if (coversLoading.has(item.id)) { + return; + } + + coversLoading.add(item.id); + + try { + const url = await noteListService.loadCover(item.id, item.cover); - if (url !== null) { - const currentItem = list.items[index]; + if (url !== null) { + const currentItem = list.items[index]; - if (currentItem?.id !== item.id) { - return; + 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, + }; } - /** - * Update the specific note's cover reactively so the card renders the image - */ - list.items[index] = { - ...currentItem, - cover: url, - }; + } finally { + coversLoading.delete(item.id); } })); }; From e331b94963bb898827c3378957d6489348301379 Mon Sep 17 00:00:00 2001 From: 7eliassen Date: Tue, 21 Jul 2026 20:51:56 +0300 Subject: [PATCH 7/8] feat: add skeleton loading for notes --- .../ui/src/vue/components/card/Card.vue | 37 ++++++++++++++++++- src/application/services/useNoteList.ts | 17 +++++++-- .../components/note-list/NoteList.vue | 2 + 3 files changed, 50 insertions(+), 6 deletions(-) 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 f6444513..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>; } /** @@ -86,7 +91,7 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState * Prevents duplicate requests when loadMoreNotes() fires again before * covers for the previous page have finished loading. */ - const coversLoading = new Set(); + const coversLoading = ref(new Set()); /** * Load cover images for all notes in the list in the background @@ -118,11 +123,11 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState /** * If this note's cover is already being fetched, skip it */ - if (coversLoading.has(item.id)) { + if (coversLoading.value.has(item.id)) { return; } - coversLoading.add(item.id); + coversLoading.value = new Set(coversLoading.value).add(item.id); try { const url = await noteListService.loadCover(item.id, item.cover); @@ -142,7 +147,10 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState }; } } finally { - coversLoading.delete(item.id); + const next = new Set(coversLoading.value); + + next.delete(item.id); + coversLoading.value = next; } })); }; @@ -205,5 +213,6 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState load, loadMoreNotes, isLoading, + coversLoading, }; } diff --git a/src/presentation/components/note-list/NoteList.vue b/src/presentation/components/note-list/NoteList.vue index d51ac81e..b70626aa 100644 --- a/src/presentation/components/note-list/NoteList.vue +++ b/src/presentation/components/note-list/NoteList.vue @@ -16,6 +16,7 @@ :title="getTitle(note.content)" :subtitle="getSubtitle(note)" :src="note.cover?.startsWith('blob:') ? note.cover : undefined" + :is-cover-loading="note.cover !== null && coversLoading.has(note.id)" orientation="vertical" /> @@ -62,6 +63,7 @@ const { loadMoreNotes, hasMoreNotes, isLoading, + coversLoading, } = useNoteList(props.onlyCreatedByUser); const { t } = useI18n(); From 1d7b30022a1dba18586fbe126901f82a0e720ccb Mon Sep 17 00:00:00 2001 From: 7eliassen Date: Tue, 21 Jul 2026 21:41:41 +0300 Subject: [PATCH 8/8] chore: update codexui version --- @codexteam/ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",