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();