Skip to content

Commit d60c45c

Browse files
committed
feat: Add frontend image cache
1 parent 20617d0 commit d60c45c

9 files changed

Lines changed: 157 additions & 78 deletions

File tree

src/components/fileUpload.vue

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Created Date: 2025-12-06 19:23:27
66
* Author: 3urobeat
77
*
8-
* Last Modified: 2026-02-02 21:32:26
8+
* Last Modified: 2026-03-24 18:48:35
99
* Modified By: 3urobeat
1010
*
1111
* Copyright (c) 2025 - 2026 3urobeat <https://github.com/3urobeat>
@@ -49,25 +49,9 @@
4949
5050
// Process image upload
5151
async function uploadImg(file: File) {
52-
// Construct form to post
53-
const formData = new FormData();
54-
formData.append("file", file);
55-
//formData.append("imgType", "clothing"); // TODO: Image type is hardcoded
56-
5752
try {
58-
5953
// Attempt to post file to API
60-
const res = await fetch("/api/set-clothing-image", {
61-
method: "POST",
62-
body: formData
63-
});
64-
65-
if (!res.ok) {
66-
throw("Failed to upload image: " + res.statusText);
67-
}
68-
69-
// Get file name from response
70-
const resBody = await res.json();
54+
const resBody = await sendImageToServer(file);
7155
7256
// Emit uploadSuccess event for parent to listen for
7357
emit("uploadSuccess", resBody.filePath);

src/components/outfitRecommendationDialog.vue

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Created Date: 2026-03-01 15:17:09
66
* Author: 3urobeat
77
*
8-
* Last Modified: 2026-03-23 18:16:30
8+
* Last Modified: 2026-03-24 19:21:57
99
* Modified By: 3urobeat
1010
*
1111
* Copyright (c) 2026 3urobeat <https://github.com/3urobeat>
@@ -48,7 +48,7 @@
4848
> <!-- TODO: How much does this search suck compared to some guideline? -->
4949
<img
5050
class="w-fit h-2/3 mb-1 self-center"
51-
:src="'data:image/png;base64,' + outfitImages.find((e) => e.id == thisOutfit.id)?.imgBlob"
51+
:src="'data:image/png;base64,' + outfitImages.find((e) => e.outfitID == thisOutfit.id)?.imgBlob"
5252
:alt="$t('imageFallbackText', { name: thisOutfit.title })"
5353
>
5454
<label class="self-start text-sm font-semibold ml-0.5">{{ thisOutfit.title }}</label>
@@ -88,8 +88,8 @@
8888
const storedLabels: Ref<Label[]> = useState("storedLabels");
8989
const storedCategories: Ref<Category[]> = useState("storedCategories");
9090
91-
const storedOutfits: Ref<Outfit[]> = ref([]);
92-
const outfitImages: Ref<{ id: string, imgBlob: string }[]> = ref([]);
91+
const storedOutfits: Ref<Outfit[]> = ref([]);
92+
const outfitImages: Ref<{ outfitID: string, imgBlob: string }[]> = ref([]);
9393
9494
const recommendedOutfits: Ref<Outfit[]> = ref([]);
9595
let weatherAPIErrorMessage: string | null = null;
@@ -102,10 +102,11 @@
102102
// Load images for outfits // TODO: Lazy load
103103
onMounted(async () => {
104104
storedOutfits.value.forEach(async (e) => {
105-
outfitImages.value.push({
106-
id: e.id,
107-
imgBlob: await getImageFromServer(e.previewImgPath, 384)
108-
})
105+
const outfitImage = await getImageFromServer(e.previewImgPath, 384);
106+
107+
if (outfitImage) {
108+
outfitImages.value.push({ outfitID: e.id, imgBlob: outfitImage.imgBlob });
109+
}
109110
});
110111
111112
recommendedOutfits.value = (await getOutfitsToShowInPopout()) || [];

src/composables/storage.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* File: storage.ts
3+
* Project: wardrobe
4+
* Created Date: 2026-03-23 21:34:56
5+
* Author: 3urobeat
6+
*
7+
* Last Modified: 2026-03-24 19:19:18
8+
* Modified By: 3urobeat
9+
*
10+
* Copyright (c) 2026 3urobeat <https://github.com/3urobeat>
11+
*
12+
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
13+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
14+
* You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
15+
*/
16+
17+
18+
import type { CachedImage } from "~/model/storage";
19+
20+
21+
// Cache
22+
const cachedImages: Ref<CachedImage[]> = ref([]);
23+
// TODO: Test Reactivity
24+
// TODO: Limit size
25+
// TODO: Establish cache update socket with server
26+
27+
28+
/**
29+
* Helper function to get image from server
30+
* @param imgPath File path to request
31+
* @param width Optional: Request scaled down image
32+
* @returns Returns image blob as string on success
33+
*/
34+
export async function getImageFromServer(imgPath: string, width: number | undefined): Promise<CachedImage | null> {
35+
if (!imgPath) return null;
36+
37+
// Attempt to find image with matching size (or none) in cache
38+
const cachedImg = cachedImages.value.find((e) => e.imgPath == imgPath && e.imgWidth == width);
39+
40+
if (cachedImg) {
41+
console.debug(`[DEBUG] getImageFromServer: Found image '${imgPath}' in cache!`);
42+
return cachedImg;
43+
}
44+
45+
// Fetch image from server
46+
const res = await fetch("/api/get-image", {
47+
method: "POST",
48+
headers: {
49+
"Content-Type": "application/json"
50+
},
51+
body: JSON.stringify({
52+
filePath: imgPath,
53+
width: width
54+
})
55+
});
56+
57+
const imageBlob = await res.text();
58+
59+
// Add to cache
60+
const cacheLength = cachedImages.value.push({ imgPath: imgPath, imgBlob: imageBlob, imgWidth: width });
61+
console.debug(`[DEBUG] getImageFromServer: Fetched image '${imgPath}' from server. Image cache has ${cacheLength} entries now.`);
62+
63+
return cachedImages.value[cacheLength - 1] as CachedImage;
64+
}
65+
66+
67+
/**
68+
* Sends new image to server and updates cache
69+
* @param file File to send
70+
* @returns API response body
71+
*/
72+
export async function sendImageToServer(file: File): Promise<any> {
73+
74+
// Construct form to post
75+
const formData = new FormData();
76+
formData.append("file", file);
77+
//formData.append("imgType", "clothing"); // TODO: Image type is hardcoded
78+
79+
// Attempt to post file to API
80+
const res = await fetch("/api/set-clothing-image", {
81+
method: "POST",
82+
body: formData
83+
});
84+
85+
if (!res.ok) {
86+
throw("Failed to upload image: " + res.statusText);
87+
}
88+
89+
// Get file name from response
90+
const resBody = await res.json();
91+
92+
// Remove all references of image from cache to fetch next usage from server again
93+
// TODO: Return imgBlob from API route and replace every matching imgPath using map()
94+
cachedImages.value = cachedImages.value.filter((e) => {
95+
const match = e.imgPath == resBody.filePath;
96+
if (match) console.debug(`[DEBUG] sendImageToServer: Removing '${resBody.filePath}' from image cache...`);
97+
return !match;
98+
});
99+
100+
return resBody;
101+
102+
}

src/model/storage.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Created Date: 2025-09-08 15:21:35
55
* Author: 3urobeat
66
*
7-
* Last Modified: 2026-03-16 18:58:31
7+
* Last Modified: 2026-03-24 18:25:58
88
* Modified By: 3urobeat
99
*
1010
* Copyright (c) 2025 - 2026 3urobeat <https://github.com/3urobeat>
@@ -76,3 +76,15 @@ export const defaultServerSettings: ServerSettings = {
7676
weatherApiKey: "",
7777
temperatureUnit: Unit.CELSIUS
7878
};
79+
80+
81+
82+
/**
83+
* Storage Composable
84+
*/
85+
86+
export type CachedImage = {
87+
imgPath: string,
88+
imgBlob: string,
89+
imgWidth: number | undefined
90+
}

src/pages/clothing/index.vue

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Created Date: 2024-03-23 13:03:16
66
* Author: 3urobeat
77
*
8-
* Last Modified: 2026-03-16 19:47:06
8+
* Last Modified: 2026-03-24 19:22:07
99
* Modified By: 3urobeat
1010
*
1111
* Copyright (c) 2024 - 2026 3urobeat <https://github.com/3urobeat>
@@ -42,7 +42,7 @@
4242
>
4343
<img
4444
class="w-fit h-5/7 mb-1 md:mb-2 self-center"
45-
:src="'data:image/png;base64,' + clothingImages.find((e) => e.id == thisClothing.id)?.imgBlob"
45+
:src="'data:image/png;base64,' + clothingImages.find((e) => e.clothingID == thisClothing.id)?.imgBlob"
4646
:alt="$t('imageFallbackText', { name: thisClothing.title })"
4747
>
4848

@@ -86,6 +86,7 @@
8686

8787
<script setup lang="ts">
8888
import { PhBinoculars, PhMagnifyingGlass, PhPlus } from "@phosphor-icons/vue";
89+
import { UseElementVisibility } from "@vueuse/components";
8990
import TitleBarFull from "~/components/titleBarFull.vue";
9091
import type { Clothing } from "~/model/clothing";
9192
import type { Label } from "~/model/label";
@@ -103,7 +104,7 @@
103104
104105
// Cache
105106
const storedClothing: Ref<Clothing[]> = ref([]);
106-
const clothingImages: Ref<{ id: string, imgBlob: string }[]> = ref([]);
107+
const clothingImages: Ref<{ clothingID: string, imgBlob: string }[]> = ref([]);
107108
108109
// Get refs to props exported by defineExpose() in TitleBarFull
109110
const titleBarFull: Ref<{ selectedSort: sortModes, selectedFilters: string[], selectedScaling: number, toggleFilter: (thisFilter: string) => void }> = ref({ selectedSort: defaultSortMode, selectedFilters: [], selectedScaling: 0, toggleFilter: () => {} }); // TODO: Can this be an exported type somewhere?
@@ -116,10 +117,11 @@
116117
// Load images for clothes // TODO: Lazy load
117118
onMounted(() => {
118119
storedClothing.value.forEach(async (e) => {
119-
clothingImages.value.push({
120-
id: e.id,
121-
imgBlob: await getImageFromServer(e.imgPath, 384)
122-
})
120+
const clothingImage = await getImageFromServer(e.imgPath, 384);
121+
122+
if (clothingImage) {
123+
clothingImages.value.push({ clothingID: e.id, imgBlob: clothingImage.imgBlob });
124+
}
123125
});
124126
});
125127

src/pages/clothing/view.vue

Lines changed: 4 additions & 4 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-03-15 21:25:16
8+
* Last Modified: 2026-03-24 19:07:46
99
* Modified By: 3urobeat
1010
*
1111
* Copyright (c) 2025 - 2026 3urobeat <https://github.com/3urobeat>
@@ -193,7 +193,7 @@
193193
194194
thisClothing.value = await res.json(); // TODO: Error handling
195195
196-
thisClothingImgBlob.value = await getImageFromServer(thisClothing.value.imgPath, 512) || "";
196+
thisClothingImgBlob.value = (await getImageFromServer(thisClothing.value.imgPath, 512))?.imgBlob || "";
197197
}
198198
});
199199
@@ -262,7 +262,7 @@
262262
thisClothing.value.imgPath = fileName;
263263
console.debug("DEBUG - updateImage: Setting imgPath of clothing to " + thisClothing.value.imgPath);
264264
265-
thisClothingImgBlob.value = await getImageFromServer(fileName, 512) || "";
265+
thisClothingImgBlob.value = (await getImageFromServer(fileName, 512))?.imgBlob || "";
266266
}
267267
268268
@@ -324,7 +324,7 @@
324324
325325
emitChangesMadeEvent(false);
326326
thisClothing.value = resBody.document;
327-
thisClothingImgBlob.value = await getImageFromServer(resBody.document.imgPath, 512) || "";
327+
thisClothingImgBlob.value = (await getImageFromServer(resBody.document.imgPath, 512))?.imgBlob || "";
328328
} else {
329329
responseIndicatorFailure();
330330
}

src/pages/outfits/index.vue

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Created Date: 2025-09-08 15:40:46
66
* Author: 3urobeat
77
*
8-
* Last Modified: 2026-03-16 19:47:00
8+
* Last Modified: 2026-03-24 19:08:31
99
* Modified By: 3urobeat
1010
*
1111
* Copyright (c) 2025 - 2026 3urobeat <https://github.com/3urobeat>
@@ -44,7 +44,7 @@
4444
>
4545
<img
4646
class="w-fit h-5/7 mb-1 md:mb-2 self-center"
47-
:src="'data:image/png;base64,' + outfitImages.find((e) => e.id == thisOutfit.id)?.imgBlob"
47+
:src="'data:image/png;base64,' + outfitImages.find((e) => e.outfitID == thisOutfit.id)?.imgBlob"
4848
:alt="$t('imageFallbackText', { name: thisOutfit.title })"
4949
>
5050

@@ -111,7 +111,7 @@
111111
112112
// Cache
113113
const storedOutfits: Ref<Outfit[]> = ref([]);
114-
const outfitImages: Ref<{ id: string, imgBlob: string }[]> = ref([]);
114+
const outfitImages: Ref<{ outfitID: string, imgBlob: string }[]> = ref([]);
115115
116116
// Get refs to props exported by defineExpose() in TitleBarFull
117117
const titleBarFull: Ref<{ selectedSort: sortModes, selectedFilters: string[], selectedScaling: number, toggleFilter: (thisFilter: string) => void }> = ref({ selectedSort: defaultSortMode, selectedFilters: [], selectedScaling: 0, toggleFilter: () => {} }); // TODO: Can this be an exported type somewhere?
@@ -124,10 +124,11 @@
124124
// Load images for outfits // TODO: Lazy load
125125
onMounted(() => {
126126
storedOutfits.value.forEach(async (e) => {
127-
outfitImages.value.push({
128-
id: e.id,
129-
imgBlob: await getImageFromServer(e.previewImgPath, 384)
130-
})
127+
const outfitImage = await getImageFromServer(e.previewImgPath, 384);
128+
129+
if (outfitImage) {
130+
outfitImages.value.push({ outfitID: e.id, imgBlob: outfitImage.imgBlob });
131+
}
131132
});
132133
});
133134

src/pages/outfits/view.vue

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Created Date: 2025-09-10 17:37:07
66
* Author: 3urobeat
77
*
8-
* Last Modified: 2026-03-15 21:26:00
8+
* Last Modified: 2026-03-24 19:09:17
99
* Modified By: 3urobeat
1010
*
1111
* Copyright (c) 2025 - 2026 3urobeat <https://github.com/3urobeat>
@@ -166,7 +166,7 @@
166166
</button>
167167
</div>
168168

169-
<img class="h-35 my-1.5 self-center" :src="'data:image/png;base64,' + clothingImages.find((e) => e.id == thisClothing.id)?.imgBlob" :alt="$t('imageFallbackText', { name: thisClothing.title })">
169+
<img class="h-35 my-1.5 self-center" :src="'data:image/png;base64,' + clothingImages.find((e) => e.clothingID == thisClothing.id)?.imgBlob" :alt="$t('imageFallbackText', { name: thisClothing.title })">
170170
<label class="self-start font-semibold mx-1">{{ thisClothing.title }}</label>
171171
</div>
172172
</div>
@@ -192,7 +192,7 @@
192192
>
193193
<img
194194
class="w-fit h-2/3 mb-1 self-center"
195-
:src="'data:image/png;base64,' + clothingImages.find((e) => e.id == thisClothing.id)?.imgBlob"
195+
:src="'data:image/png;base64,' + clothingImages.find((e) => e.clothingID == thisClothing.id)?.imgBlob"
196196
:alt="$t('imageFallbackText', { name: thisClothing.title })"
197197
>
198198
<label class="self-start text-sm font-semibold ml-0.5">{{ thisClothing.title }}</label>
@@ -254,7 +254,7 @@
254254
const thisOutfit: Ref<Outfit> = ref({ id: "", title: "", clothes: [], labelIDs: [], previewImgPath: "", addedTimestamp: 0, modifiedTimestamp: 0 });
255255
const bodyPartLabels: Ref<Label[]> = ref([]);
256256
const storedClothes: Ref<Clothing[]> = ref([]); // Edit Mode only
257-
const clothingImages: Ref<{ id: string, imgBlob: string }[]> = ref([]); // Edit Mode only
257+
const clothingImages: Ref<{ clothingID: string, imgBlob: string }[]> = ref([]); // Edit Mode only
258258
259259
260260
// Check if edit mode is enabled based on if name of this route is outfits-view or outfits-edit
@@ -298,10 +298,11 @@
298298
}
299299
300300
storedClothes.value.forEach(async (e) => {
301-
clothingImages.value.push({
302-
id: e.id,
303-
imgBlob: await getImageFromServer(e.imgPath, 256)
304-
})
301+
const clothingImage = await getImageFromServer(e.imgPath, 256);
302+
303+
if (clothingImage) {
304+
clothingImages.value.push({ clothingID: e.id, imgBlob: clothingImage.imgBlob });
305+
}
305306
});
306307
});
307308

0 commit comments

Comments
 (0)