Skip to content

Commit 9523657

Browse files
committed
feat: Add reactive image (re)fetching on storage subscription event
1 parent 3e049f3 commit 9523657

3 files changed

Lines changed: 91 additions & 44 deletions

File tree

src/components/imgLazy.vue

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Created Date: 2026-03-24 21:17:26
66
* Author: 3urobeat
77
*
8-
* Last Modified: 2026-03-26 18:24:13
8+
* Last Modified: 2026-05-10 19:12:42
99
* Modified By: 3urobeat
1010
*
1111
* Copyright (c) 2026 3urobeat <https://github.com/3urobeat>
@@ -24,15 +24,15 @@
2424
:class="props.conClass"
2525
v-element-visibility="onVisibility"
2626
>
27-
<div v-if="imgBlob === null" class="absolute self-center loader"></div>
28-
<PhImageBroken v-else-if="imgBlob === undefined" class="h-2/3 w-2/3 self-center text-text-secondary-light/50 dark:text-text-secondary-dark/50" />
27+
<div v-if="cachedImage?.imgBlob === null" class="absolute self-center loader"></div>
28+
<PhImageBroken v-else-if="cachedImage?.imgBlob === undefined" class="h-2/3 w-2/3 self-center text-text-secondary-light/50 dark:text-text-secondary-dark/50" />
2929

3030
<!-- Show image if available -->
3131
<img
3232
v-else
3333
class="max-w-full max-h-full"
3434
:class="imgClass"
35-
:src="'data:image/png;base64,' + imgBlob"
35+
:src="'data:image/png;base64,' + cachedImage.imgBlob"
3636
:alt="$t('imageFallbackText', { name: itemName })"
3737
>
3838
</div>
@@ -44,28 +44,6 @@
4444
import { PhImageBroken } from "@phosphor-icons/vue";
4545
import { vElementVisibility } from "@vueuse/components";
4646
47-
// If null, image has not been loaded yet. If undefined, image does not exist
48-
const imgBlob: Ref<string | null | undefined> = ref(null);
49-
50-
// Fetch image upon being visible
51-
const isVisible = shallowRef(false)
52-
53-
function onVisibility(state: boolean) {
54-
isVisible.value = state;
55-
56-
if (isVisible.value && imgBlob.value === null) {
57-
console.debug(`[DEBUG] ImgLazy: Image '${props.imgPath}' became visible and is not fetched yet, loading...`);
58-
59-
getImageFromServer(props.imgPath, props.imgWidth)
60-
.then((res) => {
61-
imgBlob.value = res?.imgBlob;
62-
})
63-
.catch((err) => {
64-
console.warn(`ImgLazy: Failed to load image '${props.imgPath}': ${err}`);
65-
imgBlob.value = undefined;
66-
})
67-
}
68-
}
6947
7048
// Define Props to be accepted by this component
7149
const props = defineProps({
@@ -94,4 +72,20 @@
9472
// Define stuff that can be accessed by the page
9573
// defineExpose();
9674
75+
76+
// If null, image has not been loaded yet. If undefined, image does not exist
77+
const { cachedImage, load } = useImage(toRef(props, "imgPath"), props.imgWidth); // Get image cache ref
78+
79+
// Fetch image upon being visible
80+
const isVisible = shallowRef(false);
81+
82+
function onVisibility(state: boolean) {
83+
isVisible.value = state;
84+
85+
if (state && cachedImage.value === null) {
86+
console.debug(`[DEBUG] ImgLazy: Image '${props.imgPath}' became visible and is not fetched yet, loading...`);
87+
load(); // Trigger load
88+
}
89+
}
90+
9791
</script>

src/composables/storage.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Created Date: 2026-03-23 21:34:56
55
* Author: 3urobeat
66
*
7-
* Last Modified: 2026-05-09 20:37:01
7+
* Last Modified: 2026-05-10 19:10:42
88
* Modified By: 3urobeat
99
*
1010
* Copyright (c) 2026 3urobeat <https://github.com/3urobeat>
@@ -30,6 +30,7 @@ import { State } from "./state";
3030

3131

3232
let cachedImages: Ref<StorageKindDataMap<StorageKind.IMAGES>[]>; // Perhaps replaceable by using useFetch() with !immediate?
33+
const imageCacheVersion: Ref<number> = ref(0); // Incremented on every IMAGES cache invalidation from server subscription
3334

3435

3536
/**
@@ -98,7 +99,8 @@ export async function handleStorageSubscriptionEvent(event: StorageSubscriptionE
9899
case StorageKind.IMAGES:
99100
newData = event.newData as StorageKindDataMap<StorageKind.IMAGES>;
100101
cachedImages.value = cachedImages.value.filter((e) => e.id !== newData.id);
101-
console.debug(`[DEBUG] handleStorageSubscriptionEvent: Deleting image '${newData.id}' from cache...`);
102+
imageCacheVersion.value++;
103+
console.debug(`[DEBUG] handleStorageSubscriptionEvent: Deleting image '${newData.id}' from cache (v${imageCacheVersion.value})...`);
102104
break;
103105
case StorageKind.CLOTHES:
104106
newData = event.newData as StorageKindDataMap<StorageKind.CLOTHES>;
@@ -264,14 +266,14 @@ export async function setServerSettingsToServer(data: ServerSettings): Promise<A
264266
-------------------- IMAGES --------------------
265267
*/
266268

267-
export async function getSSRImageFromServer(imgPath: string, scaleToWidth: number | undefined): Promise<Ref<ApiResponse<CachedImage>>> { // Variant that supports SSR for image loads on page load
269+
/* export async function getSSRImageFromServer(imgPath: string, scaleToWidth: number | undefined): Promise<Ref<ApiResponse<CachedImage>>> { // Variant that supports SSR for image loads on page load
268270
const body = {
269271
filePath: imgPath,
270272
width: scaleToWidth
271273
};
272274
273275
return (await useFetch("/api/get-image", { method: "POST", body: body })).data as Ref<ApiResponse<CachedImage>>;
274-
}
276+
} */ // Unused atm
275277

276278
export async function getImageFromServer(imgPath: string, scaleToWidth: number | undefined): Promise<CachedImage | null> {
277279
if (!imgPath) return null;
@@ -296,7 +298,52 @@ export async function getImageFromServer(imgPath: string, scaleToWidth: number |
296298

297299
return cachedImages.value[cachedImages.value.length - 1]!;
298300
}
299-
// TODO: SSR?
301+
302+
/**
303+
* Reactive image cache ref wrapper for getImageFromServer()
304+
* @param imgPath Image ref that triggers refetch on change
305+
* @param scaleToWidth Optional width to scale the image to
306+
* @returns Returns a reactive imgBlob ref (null if not fetched, undefined if no match) and load function to trigger initial image load
307+
*/
308+
export function useImage(imgPath: Ref<string>, scaleToWidth?: number): { cachedImage: Ref<CachedImage | null | undefined>; load: () => Promise<void> } {
309+
const cachedImage: Ref<CachedImage | null | undefined> = ref(null);
310+
let hasLoaded = false;
311+
312+
// Loads image
313+
async function load() {
314+
const path = unref(imgPath);
315+
if (!path) {
316+
cachedImage.value = undefined;
317+
return;
318+
}
319+
320+
try {
321+
cachedImage.value = await getImageFromServer(path, scaleToWidth) ?? undefined;
322+
hasLoaded = true;
323+
} catch (err) {
324+
console.warn(`[WARN] useImage: Failed to load image '${path}': ${err}`);
325+
if (!hasLoaded) {
326+
cachedImage.value = undefined;
327+
}
328+
}
329+
}
330+
331+
// Watch cache for update and reload image
332+
watch(imageCacheVersion, () => {
333+
if (hasLoaded) {
334+
load();
335+
}
336+
});
337+
338+
// Watch imgPath ref parameter to refetch on update
339+
watch(imgPath, () => {
340+
cachedImage.value = null;
341+
hasLoaded = false;
342+
load();
343+
});
344+
345+
return { cachedImage: cachedImage, load };
346+
}
300347

301348
export async function sendImageToServer(file: File): Promise<ApiResponse<{ filePath: string }>> {
302349

src/pages/clothing/view.vue

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Created Date: 2025-09-08 15:39:55
66
* Author: 3urobeat
77
*
8-
* Last Modified: 2026-05-09 23:23:22
8+
* Last Modified: 2026-05-10 19:12:09
99
* Modified By: 3urobeat
1010
*
1111
* Copyright (c) 2025 - 2026 3urobeat <https://github.com/3urobeat>
@@ -64,10 +64,10 @@
6464
>
6565
<!-- Show image if available -->
6666
<img
67-
v-if="thisClothingImgBlob"
67+
v-if="cachedImage?.imgBlob"
6868
class="rounded-2xl self-center h-fit max-h-full select-none"
6969
:class="editModeEnabled ? 'opacity-50' : ''"
70-
:src="'data:image/png;base64,' + thisClothingImgBlob"
70+
:src="'data:image/png;base64,' + cachedImage.imgBlob"
7171
alt=""
7272
>
7373
<PhImageBroken v-else-if="!editModeEnabled" class="h-2/3 w-2/3 self-center text-text-secondary-light/50 dark:text-text-secondary-dark/50" />
@@ -160,7 +160,7 @@
160160
import { getNewLastLabelOrderIndex, sortLabelsList, type Label } from "~/model/label";
161161
import { getLabelsOfCategory, type Category } from "~/model/label-category";
162162
import { CategorySpecialityMap } from "~/model/label-category";
163-
import { getImageFromServer, setCategoriesAndLabelsToServer } from "~/composables/storage";
163+
import { useImage, setCategoriesAndLabelsToServer } from "~/composables/storage";
164164
import { StorageKind, type ItemID } from "~/model/storage";
165165
import { SubscriptionEventAction, SubscriptionEventType, type ApiResponse, type StorageSubscriptionEvent, type SubscriptionEvent } from "~/model/api";
166166
@@ -172,9 +172,11 @@
172172
const storedCategories = getAllLabelCategoriesFromCache();
173173
174174
// Refs, init for new piece of clothing
175-
let storedClothing: Ref<ApiResponse<Clothing>> = ref({ success: true, message: null, document: { id: "", title: "", description: "", imgPath: "", labelIDs: [], addedTimestamp: 0, modifiedTimestamp: 0 } });
176-
let localClothing: Ref<ApiResponse<Clothing>> = storedClothing;
177-
const thisClothingImgBlob: Ref<string> = ref("");
175+
let storedClothing: Ref<ApiResponse<Clothing>> = ref({ success: true, message: null, document: { id: "", title: "", description: "", imgPath: "", labelIDs: [], addedTimestamp: 0, modifiedTimestamp: 0 } });
176+
let localClothing: Ref<ApiResponse<Clothing>> = storedClothing;
177+
178+
const currentImgPath = ref("");
179+
const { cachedImage, load } = useImage(currentImgPath, 512);
178180
179181
180182
// Check if edit mode is enabled based on if name of this route is outfits-view or outfits-edit
@@ -200,10 +202,9 @@
200202
}
201203
202204
// I think it actually provides a better user experience fetching the image afterwards here
203-
// thisClothingImgBlob.value = (await getSSRImageFromServer(thisClothing.value.imgPath, 512))?.value.document?.imgBlob || "";
204-
onMounted(async () => {
205-
thisClothingImgBlob.value = (await getImageFromServer(localClothing.value.document!.imgPath, 512))?.imgBlob || ""; // TODO: Does ref break?
206-
});
205+
// cachedImage.value = (await getSSRImageFromServer(thisClothing.value.imgPath, 512))?.value.document?.imgBlob || "";
206+
currentImgPath.value = localClothing.value.document!.imgPath;
207+
onMounted(() => { load(); });
207208
}
208209
209210
@@ -225,6 +226,11 @@
225226
const diff = getDiff(storedClothing.value.document!, newClothingData);
226227
localClothing.value.document = applyDiff(localClothing.value.document!, diff);
227228
}
229+
230+
// Sync imgPath so useImage re-fetches new image
231+
if (newClothingData.imgPath !== currentImgPath.value) {
232+
currentImgPath.value = newClothingData.imgPath;
233+
}
228234
}
229235
}
230236
}
@@ -280,13 +286,13 @@
280286
281287
282288
// Triggered when new image was uploaded
283-
async function updateImage(fileName: string) {
289+
function updateImage(fileName: string) {
284290
if (!fileName) throw("Error: Image was uploaded without file name?");
285291
286292
localClothing.value.document!.imgPath = fileName;
287293
console.debug("DEBUG - updateImage: Setting imgPath of clothing to " + localClothing.value.document!.imgPath);
288294
289-
thisClothingImgBlob.value = (await getImageFromServer(fileName, 512))?.imgBlob || "";
295+
currentImgPath.value = fileName;
290296
}
291297
292298

0 commit comments

Comments
 (0)