Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion @codexteam/ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codexteam/ui",
"version": "0.2.3",
"version": "0.2.5",
"type": "module",
"sideEffects": [
"*.css",
Expand Down
37 changes: 35 additions & 2 deletions @codexteam/ui/src/vue/components/card/Card.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
]"
>
<div
:class="$style['card__cover']"
:style="`background-image: url(${src})`"
:class="[
$style['card__cover'],
{
[$style['card__cover--skeleton']]: isCoverLoading,
},
]"
:style="src ? `background-image: url(${src})` : undefined"
/>

<div :class="$style['card__body']">
Expand Down Expand Up @@ -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,
}
);
</script>
Expand Down Expand Up @@ -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;
}
}
</style>
88 changes: 84 additions & 4 deletions src/application/services/useNoteList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ interface UseNoteListComposableState {
* Loading state
*/
isLoading: Ref<boolean>;

/**
* Set of note IDs whose cover downloads are currently in-flight
*/
coversLoading: Ref<Set<string>>;
}

/**
Expand Down Expand Up @@ -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<NoteList> => {
isLoading.value = true;
try {
return await noteListService.getNoteList(page, onlyCreatedByUser);
} finally {
isLoading.value = false;
}
};
Comment thread
7eliassen marked this conversation as resolved.

/**
* 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<string>());

/**
* 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<void> => {
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;
}
}));
};
Comment thread
Reversean marked this conversation as resolved.

/**
Expand All @@ -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);
};

/**
Expand Down Expand Up @@ -134,5 +213,6 @@ export default function (onlyCreatedByUser = false): UseNoteListComposableState
load,
loadMoreNotes,
isLoading,
coversLoading,
};
}
57 changes: 17 additions & 40 deletions src/domain/noteList.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NoteList> {
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<string | null> {
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;
}
}
4 changes: 3 additions & 1 deletion src/presentation/components/note-list/NoteList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
<Card
:title="getTitle(note.content)"
:subtitle="getSubtitle(note)"
:src="note.cover || undefined"
:src="note.cover?.startsWith('blob:') ? note.cover : undefined"
:is-cover-loading="note.cover !== null && coversLoading.has(note.id)"
orientation="vertical"
/>
</RouterLink>
Expand Down Expand Up @@ -62,6 +63,7 @@ const {
loadMoreNotes,
hasMoreNotes,
isLoading,
coversLoading,
} = useNoteList(props.onlyCreatedByUser);
const { t } = useI18n();

Expand Down
Loading