Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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