diff --git a/Cargo.lock b/Cargo.lock index 60565594f..5f58d5a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8095,6 +8095,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid 1.17.0", "webpki-roots 0.26.11", ] @@ -8177,6 +8178,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", + "uuid 1.17.0", "whoami", ] @@ -8216,6 +8218,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", + "uuid 1.17.0", "whoami", ] @@ -8242,6 +8245,7 @@ dependencies = [ "thiserror 2.0.12", "tracing", "url", + "uuid 1.17.0", ] [[package]] @@ -9004,11 +9008,13 @@ dependencies = [ "async-walkdir", "async_zip", "base64 0.22.1", + "bytemuck", "bytes", "chardetng", "chrono", "daedalus", "dashmap", + "data-url", "dirs", "discord-rich-presence", "dunce", @@ -9019,17 +9025,20 @@ dependencies = [ "fs4", "futures", "hashlink", + "heck 0.5.0", "hickory-resolver", "indicatif", "notify", "notify-debouncer-mini", "p256", "paste", + "png", "quartz_nbt", "quick-xml 0.37.5", "rand 0.8.5", "regex", "reqwest", + "rgb", "serde", "serde_ini", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index b06dab88c..d0101db0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [ async-walkdir = "2.1.0" base64 = "0.22.1" bitflags = "2.9.1" +bytemuck = "1.23.0" bytes = "1.10.1" censor = "0.3.0" chardetng = "0.1.17" @@ -47,6 +48,7 @@ color-thief = "0.2.2" console-subscriber = "0.4.1" daedalus = { path = "packages/daedalus" } dashmap = "6.1.0" +data-url = "0.3.1" deadpool-redis = "0.21.1" dirs = "6.0.0" discord-rich-presence = "0.2.5" @@ -61,6 +63,7 @@ fs4 = { version = "0.13.1", default-features = false } futures = { version = "0.3.31", default-features = false } futures-util = "0.3.31" hashlink = "0.10.0" +heck = "0.5.0" hex = "0.4.3" hickory-resolver = "0.25.2" hmac = "0.12.1" @@ -90,6 +93,7 @@ notify = { version = "8.0.0", default-features = false } notify-debouncer-mini = { version = "0.6.0", default-features = false } p256 = "0.13.2" paste = "1.0.15" +png = "0.17.16" prometheus = "0.14.0" quartz_nbt = "0.2.9" quick-xml = "0.37.5" @@ -98,6 +102,7 @@ rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9 redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32 regex = "1.11.1" reqwest = { version = "0.12.20", default-features = false } +rgb = "0.8.50" rust_decimal = { version = "1.37.2", features = [ "serde-with-float", "serde-with-str", diff --git a/apps/app-frontend/.prettierignore b/apps/app-frontend/.prettierignore index 581edad3d..0cb3e84e5 100644 --- a/apps/app-frontend/.prettierignore +++ b/apps/app-frontend/.prettierignore @@ -1 +1,2 @@ **/dist +*.gltf diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 6fccb56ce..0f2d7892c 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -25,12 +25,15 @@ "@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-window-state": "^2.2.2", + "@types/three": "^0.172.0", "@vintl/vintl": "^4.4.1", + "@vueuse/core": "^11.1.0", "dayjs": "^1.11.10", "floating-vue": "^5.2.2", "ofetch": "^1.3.4", "pinia": "^2.1.7", "posthog-js": "^1.158.2", + "three": "^0.172.0", "vite-svg-loader": "^5.1.0", "vue": "^3.5.13", "vue-multiselect": "3.0.0", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 5673661d4..17b2b5198 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -1,8 +1,9 @@ diff --git a/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue new file mode 100644 index 000000000..c6c25080e --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue @@ -0,0 +1,143 @@ + + diff --git a/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue new file mode 100644 index 000000000..818922eff --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue @@ -0,0 +1,140 @@ + + + diff --git a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts new file mode 100644 index 000000000..75729c5aa --- /dev/null +++ b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts @@ -0,0 +1,353 @@ +import * as THREE from 'three' +import type { Skin, Cape } from '../skins' +import { get_normalized_skin_texture, determineModelType } from '../skins' +import { reactive } from 'vue' +import { setupSkinModel, disposeCaches } from '@modrinth/utils' +import { skinPreviewStorage } from '../storage/skin-preview-storage' + +export interface RenderResult { + forwards: string + backwards: string +} + +class BatchSkinRenderer { + private renderer: THREE.WebGLRenderer + private readonly scene: THREE.Scene + private readonly camera: THREE.PerspectiveCamera + private currentModel: THREE.Group | null = null + + constructor(width: number = 360, height: number = 504) { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + + this.renderer = new THREE.WebGLRenderer({ + canvas: canvas, + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }) + + this.renderer.outputColorSpace = THREE.SRGBColorSpace + this.renderer.toneMapping = THREE.NoToneMapping + this.renderer.toneMappingExposure = 10.0 + this.renderer.setClearColor(0x000000, 0) + this.renderer.setSize(width, height) + + this.scene = new THREE.Scene() + this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000) + + const ambientLight = new THREE.AmbientLight(0xffffff, 2) + const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2) + directionalLight.castShadow = true + directionalLight.position.set(2, 4, 3) + this.scene.add(ambientLight) + this.scene.add(directionalLight) + } + + public async renderSkin( + textureUrl: string, + modelUrl: string, + capeUrl?: string, + capeModelUrl?: string, + ): Promise { + await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl) + + const headPart = this.currentModel!.getObjectByName('Head') + let lookAtTarget: [number, number, number] + + if (headPart) { + const headPosition = new THREE.Vector3() + headPart.getWorldPosition(headPosition) + lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z] + } else { + throw new Error("Failed to find 'Head' object in model.") + } + + const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3] + const backCameraPos: [number, number, number] = [-1.3, 1, -2.5] + + const forwards = await this.renderView(frontCameraPos, lookAtTarget) + const backwards = await this.renderView(backCameraPos, lookAtTarget) + + return { forwards, backwards } + } + + private async renderView( + cameraPosition: [number, number, number], + lookAtPosition: [number, number, number], + ): Promise { + this.camera.position.set(...cameraPosition) + this.camera.lookAt(...lookAtPosition) + + this.renderer.render(this.scene, this.camera) + + return new Promise((resolve, reject) => { + this.renderer.domElement.toBlob((blob) => { + if (blob) { + const url = URL.createObjectURL(blob) + resolve(url) + } else { + reject(new Error('Failed to create blob from canvas')) + } + }, 'image/png') + }) + } + + private async setupModel( + modelUrl: string, + textureUrl: string, + capeModelUrl?: string, + capeUrl?: string, + ): Promise { + if (this.currentModel) { + this.scene.remove(this.currentModel) + } + + const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl) + + const group = new THREE.Group() + group.add(model) + group.position.set(0, 0.3, 1.95) + group.scale.set(0.8, 0.8, 0.8) + + this.scene.add(group) + this.currentModel = group + } + + public dispose(): void { + this.renderer.dispose() + disposeCaches() + } +} + +function getModelUrlForVariant(variant: string): string { + switch (variant) { + case 'SLIM': + return '/src/assets/models/slim_player.gltf' + case 'CLASSIC': + case 'UNKNOWN': + default: + return '/src/assets/models/classic_player.gltf' + } +} + +export const map = reactive(new Map()) +export const headMap = reactive(new Map()) +const DEBUG_MODE = false + +export async function cleanupUnusedPreviews(skins: Skin[]): Promise { + const validKeys = new Set() + const validHeadKeys = new Set() + + for (const skin of skins) { + const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` + const headKey = `${skin.texture_key}-head` + validKeys.add(key) + validHeadKeys.add(headKey) + } + + try { + await skinPreviewStorage.cleanupInvalidKeys(validKeys) + await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys) + } catch (error) { + console.warn('Failed to cleanup unused skin previews:', error) + } +} + +export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + + img.onload = () => { + try { + const sourceCanvas = document.createElement('canvas') + const sourceCtx = sourceCanvas.getContext('2d') + + if (!sourceCtx) { + throw new Error('Could not get 2D context from source canvas') + } + + sourceCanvas.width = img.width + sourceCanvas.height = img.height + + sourceCtx.drawImage(img, 0, 0) + + const outputCanvas = document.createElement('canvas') + const outputCtx = outputCanvas.getContext('2d') + + if (!outputCtx) { + throw new Error('Could not get 2D context from output canvas') + } + + outputCanvas.width = size + outputCanvas.height = size + + outputCtx.imageSmoothingEnabled = false + + const headImageData = sourceCtx.getImageData(8, 8, 8, 8) + + const headCanvas = document.createElement('canvas') + const headCtx = headCanvas.getContext('2d') + + if (!headCtx) { + throw new Error('Could not get 2D context from head canvas') + } + + headCanvas.width = 8 + headCanvas.height = 8 + headCtx.putImageData(headImageData, 0, 0) + + outputCtx.drawImage(headCanvas, 0, 0, 8, 8, 0, 0, size, size) + + const hatImageData = sourceCtx.getImageData(40, 8, 8, 8) + + const hatCanvas = document.createElement('canvas') + const hatCtx = hatCanvas.getContext('2d') + + if (!hatCtx) { + throw new Error('Could not get 2D context from hat canvas') + } + + hatCanvas.width = 8 + hatCanvas.height = 8 + hatCtx.putImageData(hatImageData, 0, 0) + + const hatPixels = hatImageData.data + let hasHat = false + + for (let i = 3; i < hatPixels.length; i += 4) { + if (hatPixels[i] > 0) { + hasHat = true + break + } + } + + if (hasHat) { + outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size) + } + + outputCanvas.toBlob((blob) => { + if (blob) { + resolve(blob) + } else { + reject(new Error('Failed to create blob from canvas')) + } + }, 'image/png') + } catch (error) { + reject(error) + } + } + + img.onerror = () => { + reject(new Error('Failed to load skin texture image')) + } + + img.src = skinUrl + }) +} + +async function generateHeadRender(skin: Skin): Promise { + const headKey = `${skin.texture_key}-head` + + if (headMap.has(headKey)) { + if (DEBUG_MODE) { + const url = headMap.get(headKey)! + URL.revokeObjectURL(url) + headMap.delete(headKey) + } else { + return headMap.get(headKey)! + } + } + + try { + const cached = await skinPreviewStorage.retrieve(headKey) + if (cached && typeof cached === 'string') { + headMap.set(headKey, cached) + return cached + } + } catch (error) { + console.warn('Failed to retrieve cached head render:', error) + } + + const skinUrl = await get_normalized_skin_texture(skin) + const headBlob = await generatePlayerHeadBlob(skinUrl, 64) + const headUrl = URL.createObjectURL(headBlob) + + headMap.set(headKey, headUrl) + + try { + await skinPreviewStorage.store(headKey, headUrl) + } catch (error) { + console.warn('Failed to store head render in persistent storage:', error) + } + + return headUrl +} + +export async function getPlayerHeadUrl(skin: Skin): Promise { + return await generateHeadRender(skin) +} + +export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise { + const renderer = new BatchSkinRenderer() + const capeModelUrl = '/src/assets/models/cape.gltf' + + try { + for (const skin of skins) { + const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` + + if (map.has(key)) { + if (DEBUG_MODE) { + const result = map.get(key)! + URL.revokeObjectURL(result.forwards) + URL.revokeObjectURL(result.backwards) + map.delete(key) + } else continue + } + + try { + const cached = await skinPreviewStorage.retrieve(key) + if (cached) { + map.set(key, cached) + continue + } + } catch (error) { + console.warn('Failed to retrieve cached skin preview:', error) + } + + let variant = skin.variant + if (variant === 'UNKNOWN') { + try { + variant = await determineModelType(skin.texture) + } catch (error) { + console.error(`Failed to determine model type for skin ${key}:`, error) + variant = 'CLASSIC' + } + } + + const modelUrl = getModelUrlForVariant(variant) + const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id) + const renderResult = await renderer.renderSkin( + await get_normalized_skin_texture(skin), + modelUrl, + cape?.texture, + capeModelUrl, + ) + + map.set(key, renderResult) + + try { + await skinPreviewStorage.store(key, renderResult) + } catch (error) { + console.warn('Failed to store skin preview in persistent storage:', error) + } + + await generateHeadRender(skin) + } + } finally { + renderer.dispose() + await cleanupUnusedPreviews(skins) + } +} diff --git a/apps/app-frontend/src/helpers/settings.ts b/apps/app-frontend/src/helpers/settings.ts index 2988d34d8..c256575a4 100644 --- a/apps/app-frontend/src/helpers/settings.ts +++ b/apps/app-frontend/src/helpers/settings.ts @@ -37,6 +37,7 @@ export type AppSettings = { theme: ColorTheme default_page: 'home' | 'library' collapsed_navigation: boolean + hide_nametag_skins_page: boolean advanced_rendering: boolean native_decorations: boolean toggle_sidebar: boolean diff --git a/apps/app-frontend/src/helpers/skins.ts b/apps/app-frontend/src/helpers/skins.ts new file mode 100644 index 000000000..40c48d63c --- /dev/null +++ b/apps/app-frontend/src/helpers/skins.ts @@ -0,0 +1,163 @@ +import { invoke } from '@tauri-apps/api/core' +import { handleError } from '@/store/notifications' +import { arrayBufferToBase64 } from '@modrinth/utils' + +export interface Cape { + id: string + name: string + texture: string + is_default: boolean + is_equipped: boolean +} + +export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN' +export type SkinSource = 'default' | 'custom_external' | 'custom' + +export interface Skin { + texture_key: string + name?: string + variant: SkinModel + cape_id?: string + texture: string + source: SkinSource + is_equipped: boolean +} + +export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[] + +export const DEFAULT_MODELS: Record = { + Steve: 'CLASSIC', + Alex: 'SLIM', + Zuri: 'CLASSIC', + Sunny: 'CLASSIC', + Noor: 'SLIM', + Makena: 'SLIM', + Kai: 'CLASSIC', + Efe: 'SLIM', + Ari: 'CLASSIC', +} + +export function filterSavedSkins(list: Skin[]) { + const customSkins = list.filter((s) => s.source !== 'default') + fixUnknownSkins(customSkins).catch(handleError) + return customSkins +} + +export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> { + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + + if (!context) { + return reject(new Error('Failed to create canvas rendering context.')) + } + + const image = new Image() + image.crossOrigin = 'anonymous' + image.src = texture + + image.onload = () => { + canvas.width = image.width + canvas.height = image.height + + context.drawImage(image, 0, 0) + + const armX = 44 + const armY = 16 + const armWidth = 4 + const armHeight = 12 + + const imageData = context.getImageData(armX, armY, armWidth, armHeight).data + + for (let y = 0; y < armHeight; y++) { + const alphaIndex = (3 + y * armWidth) * 4 + 3 + if (imageData[alphaIndex] !== 0) { + resolve('CLASSIC') + return + } + } + + canvas.remove() + resolve('SLIM') + } + + image.onerror = () => { + canvas.remove() + reject(new Error('Failed to load the image.')) + } + }) +} + +export async function fixUnknownSkins(list: Skin[]) { + const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN') + for (const unknownSkin of unknownSkins) { + unknownSkin.variant = await determineModelType(unknownSkin.texture) + } +} + +export function filterDefaultSkins(list: Skin[]) { + return list + .filter((s) => s.source === 'default' && (!s.name || s.variant === DEFAULT_MODELS[s.name])) + .sort((a, b) => { + const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1 + const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1 + return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex) + }) +} + +export async function get_available_capes(): Promise { + return invoke('plugin:minecraft-skins|get_available_capes', {}) +} + +export async function get_available_skins(): Promise { + return invoke('plugin:minecraft-skins|get_available_skins', {}) +} + +export async function add_and_equip_custom_skin( + textureBlob: Uint8Array, + variant: SkinModel, + capeOverride?: Cape, +): Promise { + await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', { + textureBlob, + variant, + capeOverride, + }) +} + +export async function set_default_cape(cape?: Cape): Promise { + await invoke('plugin:minecraft-skins|set_default_cape', { + cape, + }) +} + +export async function equip_skin(skin: Skin): Promise { + await invoke('plugin:minecraft-skins|equip_skin', { + skin, + }) +} + +export async function remove_custom_skin(skin: Skin): Promise { + await invoke('plugin:minecraft-skins|remove_custom_skin', { + skin, + }) +} + +export async function get_normalized_skin_texture(skin: Skin): Promise { + const data = await normalize_skin_texture(skin.texture) + const base64 = arrayBufferToBase64(data) + return `data:image/png;base64,${base64}` +} + +export async function normalize_skin_texture(texture: Uint8Array | string): Promise { + return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture }) +} + +export async function unequip_skin(): Promise { + await invoke('plugin:minecraft-skins|unequip_skin') +} + +export async function get_dragged_skin_data(path: string): Promise { + const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path }) + return new Uint8Array(data) +} diff --git a/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts new file mode 100644 index 000000000..2e4990850 --- /dev/null +++ b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts @@ -0,0 +1,118 @@ +import type { RenderResult } from '../rendering/batch-skin-renderer' + +interface StoredPreview { + forwards: Blob + backwards: Blob + timestamp: number +} + +export class SkinPreviewStorage { + private dbName = 'skin-previews' + private version = 1 + private db: IDBDatabase | null = null + + async init(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + this.db = request.result + resolve() + } + + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains('previews')) { + db.createObjectStore('previews') + } + } + }) + } + + async store(key: string, result: RenderResult): Promise { + if (!this.db) await this.init() + + const forwardsBlob = await fetch(result.forwards).then((r) => r.blob()) + const backwardsBlob = await fetch(result.backwards).then((r) => r.blob()) + + const transaction = this.db!.transaction(['previews'], 'readwrite') + const store = transaction.objectStore('previews') + + const storedPreview: StoredPreview = { + forwards: forwardsBlob, + backwards: backwardsBlob, + timestamp: Date.now(), + } + + return new Promise((resolve, reject) => { + const request = store.put(storedPreview, key) + + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) + } + + async retrieve(key: string): Promise { + if (!this.db) await this.init() + + const transaction = this.db!.transaction(['previews'], 'readonly') + const store = transaction.objectStore('previews') + + return new Promise((resolve, reject) => { + const request = store.get(key) + + request.onsuccess = () => { + const result = request.result as StoredPreview | undefined + + if (!result) { + resolve(null) + return + } + + const forwards = URL.createObjectURL(result.forwards) + const backwards = URL.createObjectURL(result.backwards) + resolve({ forwards, backwards }) + } + request.onerror = () => reject(request.error) + }) + } + + async cleanupInvalidKeys(validKeys: Set): Promise { + if (!this.db) await this.init() + + const transaction = this.db!.transaction(['previews'], 'readwrite') + const store = transaction.objectStore('previews') + let deletedCount = 0 + + return new Promise((resolve, reject) => { + const request = store.openCursor() + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + + if (cursor) { + const key = cursor.primaryKey as string + + if (!validKeys.has(key)) { + const deleteRequest = cursor.delete() + deleteRequest.onsuccess = () => { + deletedCount++ + } + deleteRequest.onerror = () => { + console.warn('Failed to delete invalid entry:', key) + } + } + + cursor.continue() + } else { + resolve(deletedCount) + } + } + + request.onerror = () => reject(request.error) + }) + } +} + +export const skinPreviewStorage = new SkinPreviewStorage() diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue index e5c1e0689..3eba1ba68 100644 --- a/apps/app-frontend/src/pages/Index.vue +++ b/apps/app-frontend/src/pages/Index.vue @@ -10,6 +10,7 @@ import dayjs from 'dayjs' import { get_search_results } from '@/helpers/cache.js' import type { SearchResult } from '@modrinth/utils' import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue' +import type { GameInstance } from '@/helpers/types' const route = useRoute() const breadcrumbs = useBreadcrumbs() @@ -82,13 +83,15 @@ async function refreshFeaturedProjects() { await fetchInstances() await refreshFeaturedProjects() -const unlistenProfile = await profile_listener(async (e) => { - await fetchInstances() +const unlistenProfile = await profile_listener( + async (e: { event: string; profile_path_id: string }) => { + await fetchInstances() - if (e.event === 'added' || e.event === 'created' || e.event === 'removed') { - await refreshFeaturedProjects() - } -}) + if (e.event === 'added' || e.event === 'created' || e.event === 'removed') { + await refreshFeaturedProjects() + } + }, +) onUnmounted(() => { unlistenProfile() @@ -97,8 +100,8 @@ onUnmounted(() => {