diff --git a/frontend/app/components/Flags.vue b/frontend/app/components/Flags.vue index 69690b71a..2c1c5eae0 100644 --- a/frontend/app/components/Flags.vue +++ b/frontend/app/components/Flags.vue @@ -19,7 +19,8 @@ function resolve(flag: HangarProjectFlag) { handleRequestError(err) ); if (newFlags) { - flags.value = newFlags; + const queryCache = useQueryCache(); + queryCache.setQueryData([props.resolved ? "resolvedFlags" : "unresolvedFlags"], newFlags); } } }) diff --git a/frontend/app/components/layout/Header.vue b/frontend/app/components/layout/Header.vue index e2e89aaf2..28eb1197a 100644 --- a/frontend/app/components/layout/Header.vue +++ b/frontend/app/components/layout/Header.vue @@ -86,7 +86,7 @@ function markNotificationsRead() { function markNotificationRead(notification: HangarNotification) { if (!notification.read) { notification.read = true; - unreadCount.value.notifications--; + unreadCount.value = { ...unreadCount.value, notifications: unreadCount.value.notifications - 1 }; loadedUnreadNotifications.value--; useInternalApi(`notifications/${notification.id}`, "post").catch((err) => handleRequestError(err)); } diff --git a/frontend/app/composables/useData.ts b/frontend/app/composables/useData.ts index 13ebac3aa..b01204754 100644 --- a/frontend/app/composables/useData.ts +++ b/frontend/app/composables/useData.ts @@ -1,53 +1,26 @@ import type { Router } from "vue-router"; -import { NamedPermission } from "#shared/types/backend"; -import type { - FinishedOrPendingHealthReport, - ApiKey, - DayStats, - HangarChannel, - HangarProjectFlag, - HangarProjectNote, - HangarReview, - Invites, - JarScanResult, - OrganizationRoleTable, - PaginatedResultHangarLoggedAction, - PaginatedResultHangarNotification, - PaginatedResultHangarProjectFlag, - PaginatedResultProject, - PaginatedResultProjectCompact, - PaginatedResultUser, - PaginatedResultVersion, - ProjectCompact, - ProjectOwner, - ReviewQueue, - SettingsResponse, - User, - VersionInfo, - ProjectPageTable, - Platform, -} from "#shared/types/backend"; +import type { PaginatedResultProject, PaginatedResultVersion, ProjectOwner, Platform } from "#shared/types/backend"; + +type LegacyStatus = "idle" | "loading" | "success" | "error"; + +// Maps Pinia Colada's status/asyncStatus to the legacy status values used by consumers +function mapStatus(queryReturn: { asyncStatus: { value: string }; status: { value: string } }): ComputedRef { + return computed(() => { + if (queryReturn.asyncStatus.value === "loading") return "loading"; + if (queryReturn.status.value === "success") return "success"; + if (queryReturn.status.value === "error") return "error"; + return "idle"; + }); +} export function useOrganizationVisibility(user: () => string) { - const { data: organizationVisibility, status: organizationVisibilityStatus } = useData( - user, - (u) => "organizationVisibility:" + u, - (u) => useInternalApi<{ [key: string]: boolean }>(`organizations/${u}/userOrganizationsVisibility`), - false, - (u) => u !== useAuthStore().user?.name - ); - return { organizationVisibility, organizationVisibilityStatus }; + const q = useOrganizationVisibilityQuery(user); + return { organizationVisibility: q.data, organizationVisibilityStatus: mapStatus(q) }; } export function usePossibleAlts(user: () => string) { - const { data: possibleAlts, status: possibleAltsStatus } = useData( - user, - (u) => "possibleAlts:" + u, - (u) => useInternalApi(`users/${u}/alts`), - false, - () => !hasPerms(NamedPermission.IsStaff) - ); - return { possibleAlts, possibleAltsStatus }; + const q = usePossibleAltsQuery(user); + return { possibleAlts: q.data, possibleAltsStatus: mapStatus(q) }; } export function useProjects( @@ -63,388 +36,219 @@ export function useProjects( tag?: string[]; sort?: string; }, - router?: Router + _router?: Router ) { - const { - data: projects, - status: projectsStatus, - refresh: refreshProjects, - } = useData( - params, - (p) => "projects:" + (p.member || p.owner || "main") + ":" + p.offset, - (p) => useApi("projects", "get", { ...p }), - true, - () => false, - ({ offset, limit, member, ...paramsWithoutLimit }) => { - if (router) { - const oldQuery = router.currentRoute.value.query; - router.replace({ query: { ...oldQuery, page: offset && limit ? Math.floor(offset / limit) : undefined, ...paramsWithoutLimit } }); - } - } - ); - return { projects, projectsStatus, refreshProjects }; + const q = useProjectsQuery(params); + return { projects: q.data as Ref, projectsStatus: mapStatus(q), refreshProjects: () => q.refetch() }; } export function useStarred(user: () => string) { - const { data: starred, status: starredStatus } = useData( - user, - (u) => "starred:" + u, - (u) => useApi(`users/${u}/starred`) - ); - return { starred, starredStatus }; + const q = useStarredQuery(user); + return { starred: q.data, starredStatus: mapStatus(q) }; } export function useWatching(user: () => string) { - const { data: watching, status: watchingStatus } = useData( - user, - (u) => "watching:" + u, - (u) => useApi(`users/${u}/watching`) - ); - return { watching, watchingStatus }; + const q = useWatchingQuery(user); + return { watching: q.data, watchingStatus: mapStatus(q) }; } export function usePinned(user: () => string) { - const { data: pinned, status: pinnedStatus } = useData( - user, - (u) => "pinned:" + u, - (u) => useApi(`users/${u}/pinned`) - ); - return { pinned, pinnedStatus }; + const q = usePinnedQuery(user); + return { pinned: q.data, pinnedStatus: mapStatus(q) }; } export function useOrganizations(user: () => string) { - const { data: organizations, status: organizationsStatus } = useData( - user, - (u) => "organizations:" + u, - (u) => useInternalApi<{ [key: string]: OrganizationRoleTable }>(`organizations/${u}/userOrganizations`) - ); - return { organizations, organizationsStatus }; + const q = useOrganizationsQuery(user); + return { organizations: q.data, organizationsStatus: mapStatus(q) }; } export function useVersionInfo() { - const { data: version, status: versionStatus } = useData( - () => ({}), - () => "versionInfo", - () => useInternalApi(`data/version-info`) - ); - return { version, versionStatus }; + const q = useVersionInfoQuery(); + return { version: q.data, versionStatus: mapStatus(q) }; } export function useUnreadNotifications() { - const { data: unreadNotifications, status: unreadNotificationsStatus } = useData( - () => ({}), - () => "unreadNotifications", - () => useInternalApi("unreadnotifications") - ); - return { unreadNotifications, unreadNotificationsStatus }; + const q = useUnreadNotificationsQuery(); + const queryCache = useQueryCache(); + const unreadNotifications = computed({ + get: () => q.data.value, + set: (val) => queryCache.setQueryData(["unreadNotifications"], val), + }); + return { unreadNotifications, unreadNotificationsStatus: mapStatus(q) }; } export function useReadNotifications() { - const { data: readNotifications, status: readNotificationsStatus } = useData( - () => ({}), - () => "readNotifications", - () => useInternalApi("readnotifications") - ); - return { readNotifications, readNotificationsStatus }; + const q = useReadNotificationsQuery(); + const queryCache = useQueryCache(); + const readNotifications = computed({ + get: () => q.data.value, + set: (val) => queryCache.setQueryData(["readNotifications"], val), + }); + return { readNotifications, readNotificationsStatus: mapStatus(q) }; } export function useUnreadCount() { - const authStore = useAuthStore(); - const { - data: unreadCount, - status: unreadCountStatus, - refresh: refreshUnreadCount, - } = useData( - () => ({}), - () => "unreadCount", - () => useInternalApi<{ notifications: number; invites: number }>("unreadcount"), - false, - () => !authStore.user, - () => {}, - authStore.user?.headerData?.unreadCount - ); - // TODO a default value should change the type so that this cast isnt needed - return { unreadCount: unreadCount as Ref<{ notifications: number; invites: number }>, unreadCountStatus, refreshUnreadCount }; + const q = useUnreadCountQuery(); + // Provide a safe default so consumers don't need to handle undefined + const unreadCount = computed({ + get: () => q.data.value ?? { notifications: 0, invites: 0 }, + set: (val) => { + // Allow consumers to update the value optimistically + const queryCache = useQueryCache(); + queryCache.setQueryData(["unreadCount"], val); + }, + }); + return { unreadCount, unreadCountStatus: mapStatus(q), refreshUnreadCount: () => q.refetch() }; } export function useNotifications() { - const { data: notifications, status: notificationsStatus } = useData( - () => ({}), - () => "notifications", - () => useInternalApi("notifications") - ); - return { notifications, notificationsStatus }; + const q = useNotificationsQuery(); + const queryCache = useQueryCache(); + const notifications = computed({ + get: () => q.data.value, + set: (val) => queryCache.setQueryData(["notifications"], val), + }); + return { notifications, notificationsStatus: mapStatus(q) }; } export function useInvites() { - const { data: invites, status: invitesStatus } = useData( - () => ({}), - () => "invites", - () => useInternalApi("invites") - ); - return { invites, invitesStatus }; + const q = useInvitesQuery(); + const queryCache = useQueryCache(); + const invites = computed({ + get: () => q.data.value, + set: (val) => queryCache.setQueryData(["invites"], val), + }); + return { invites, invitesStatus: mapStatus(q) }; } export function usePossibleOwners() { - const { data: projectOwners, status: projectOwnersStatus } = useData( - () => ({}), - () => "possibleOwners", - () => useInternalApi("projects/possibleOwners"), - true, - () => false, - () => {}, - [] - ); - // TODO a default value should change the type so that this cast isnt needed - return { projectOwners: projectOwners as Ref, projectOwnersStatus }; + const q = usePossibleOwnersQuery(); + const projectOwners = computed(() => q.data.value ?? ([] as ProjectOwner[])); + return { projectOwners, projectOwnersStatus: mapStatus(q) }; } export function useAuthSettings() { - const { - data: authSettings, - status: authSettingsStatus, - refresh: refreshAuthSettings, - } = useData( - () => ({}), - () => "authSettings", - () => useInternalApi(`auth/settings`, "POST") - ); - return { authSettings, authSettingsStatus, refreshAuthSettings }; + const q = useAuthSettingsQuery(); + return { authSettings: q.data, authSettingsStatus: mapStatus(q), refreshAuthSettings: () => q.refetch() }; } export function useApiKeys(user: () => string) { - const { data: apiKeys, status: apiKeysStatus } = useData( - user, - (u) => "apiKeys:" + u, - (u) => useInternalApi("api-keys/existing-keys/" + u) - ); - return { apiKeys, apiKeysStatus }; + const q = useApiKeysQuery(user); + const queryCache = useQueryCache(); + const apiKeys = computed({ + get: () => q.data.value, + set: (val) => queryCache.setQueryData(["apiKeys", user()], val), + }); + return { apiKeys, apiKeysStatus: mapStatus(q) }; } export function usePossiblePerms(user: () => string) { - const { data: possiblePerms, status: possiblePermsStatus } = useData( - user, - (u) => "possiblePerms:" + u, - (u) => useInternalApi("api-keys/possible-perms/" + u) - ); - return { possiblePerms, possiblePermsStatus }; + const q = usePossiblePermsQuery(user); + return { possiblePerms: q.data, possiblePermsStatus: mapStatus(q) }; } export function useAdminStats(params: () => { from: string; to: string }) { - const { data: adminStats, status: adminStatsStatus } = useData( - params, - (p) => "adminStats:" + p.from + ":" + p.to, - (p) => useInternalApi("admin/stats", "get", p) - ); - return { adminStats, adminStatsStatus }; + const q = useAdminStatsQuery(params); + return { adminStats: q.data, adminStatsStatus: mapStatus(q) }; } export function useHealthReport() { - const { - data: healthReport, - status: healthReportStatus, - refresh: healthReportRefresh, - } = useData( - () => ({}), - () => "healthReport", - () => useInternalApi("health/", "GET") - ); - return { healthReport, healthReportStatus, healthReportRefresh }; + const q = useHealthReportQuery(); + return { healthReport: q.data, healthReportStatus: mapStatus(q), healthReportRefresh: () => q.refetch() }; } export function useResolvedFlags() { - const { data: flags, status: flagsStatus } = useData( - () => ({}), - () => "resolvedFlags", - () => useInternalApi("flags/resolved") - ); - return { flags, flagsStatus }; + const q = useResolvedFlagsQuery(); + return { flags: q.data, flagsStatus: mapStatus(q) }; } export function useUnresolvedFlags() { - const { data: flags, status: flagsStatus } = useData( - () => ({}), - () => "unresolvedFlags", - () => useInternalApi("flags/unresolved") - ); - return { flags, flagsStatus }; + const q = useUnresolvedFlagsQuery(); + return { flags: q.data, flagsStatus: mapStatus(q) }; } export function useVersionApprovals() { - const { data: versionApprovals, status: versionApprovalsStatus } = useData( - () => ({}), - () => "versionApprovals", - () => useInternalApi("admin/approval/versions") - ); - return { versionApprovals, versionApprovalsStatus }; + const q = useVersionApprovalsQuery(); + return { versionApprovals: q.data, versionApprovalsStatus: mapStatus(q) }; } export function useUser(userName: () => string) { - const { - data: user, - status: userStatus, - refresh: refreshUser, - } = useData( - userName, - (u) => "user:" + u, - (u) => useApi("users/" + u) - ); - return { user, userStatus, refreshUser }; + const q = useUserQuery(userName); + return { user: q.data, userStatus: mapStatus(q), refreshUser: () => q.refetch() }; } export function useUsers(params: () => { query?: string; limit?: number; offset?: number; sort?: string[] }) { - const { data: users, status: usersStatus } = useData( - params, - (p) => "users:" + p.query + ":" + p.offset + ":" + p.sort, - (p) => useApi("users", "get", p) - ); - return { users, usersStatus }; + const q = useUsersQuery(params); + return { users: q.data, usersStatus: mapStatus(q) }; } export function useActionLogs( params: () => { limit: number; offset: number; sort: string[]; user?: string; logAction?: string; authorName?: string; projectSlug?: string }, - router?: Router + _router?: Router ) { - const { data: actionLogs, status: actionLogsStatus } = useData( - params, - (p) => "actionLogs:" + p.offset + ":" + p.sort + ":" + p.user + ":" + p.logAction + ":" + p.authorName + ":" + p.projectSlug, - (p) => useInternalApi("admin/log", "get", p), - true, - () => false, - ({ offset, limit, ...paramsWithoutLimit }) => { - if (router) { - const oldQuery = router.currentRoute.value.query; - router.replace({ query: { ...oldQuery, ...paramsWithoutLimit } }); - } - } - ); - return { actionLogs, actionLogsStatus }; + const q = useActionLogsQuery(params); + return { actionLogs: q.data, actionLogsStatus: mapStatus(q) }; } export function useStaff(params: () => { offset?: number; limit?: number; sort?: string[]; query?: string }) { - const { data: staff, status: staffStatus } = useData( - params, - (p) => "staff:" + p.offset + ":" + p.sort + ":" + p.query, - (p) => useApi("staff", "GET", p) - ); - return { staff, staffStatus }; + const q = useStaffQuery(params); + return { staff: q.data, staffStatus: mapStatus(q) }; } export function useAuthors(params: () => { offset?: number; limit?: number; sort?: string[]; query?: string }) { - const { data: authors, status: authorStatus } = useData( - params, - (p) => "authors:" + p.offset + ":" + p.sort + ":" + p.query, - (p) => useApi("authors", "GET", p) - ); - return { authors, authorStatus }; + const q = useAuthorsQuery(params); + return { authors: q.data, authorStatus: mapStatus(q) }; } export function useWatchers(project: () => string) { - const { data: watchers, status: watchersStatus } = useData( - project, - (p) => "watchers:" + p, - (p) => useApi(`projects/${p}/watchers`) - ); - return { watchers, watchersStatus }; + const q = useWatchersQuery(project); + return { watchers: q.data, watchersStatus: mapStatus(q) }; } export function useStargazers(project: () => string) { - const { data: stargazers, status: stargazersStatus } = useData( - project, - (p) => "stargazers:" + p, - (p) => useApi(`projects/${p}/stargazers`) - ); - return { stargazers, stargazersStatus }; + const q = useStargazersQuery(project); + return { stargazers: q.data, stargazersStatus: mapStatus(q) }; } export function useProjectChannels(project: () => string) { - const { - data: channels, - status: channelsStatus, - refresh: refreshChannels, - promise: channelPromise, - } = useData( - project, - (p) => "channels:" + p, - (p) => useInternalApi<(HangarChannel & { temp?: boolean })[]>(`channels/${p}`) - ); - return { channels, channelsStatus, refreshChannels, channelPromise }; + const q = useProjectChannelsQuery(project); + return { + channels: q.data, + channelsStatus: mapStatus(q), + refreshChannels: () => q.refetch(), + channelPromise: undefined as Promise | undefined, + }; } export function useProjectNotes(project: () => string) { - const { - data: notes, - status: notesStatus, - refresh: refreshNotes, - } = useData( - project, - (p) => "notes:" + p, - (p) => useInternalApi("projects/notes/" + p) - ); - return { notes, notesStatus, refreshNotes }; + const q = useProjectNotesQuery(project); + return { notes: q.data, notesStatus: mapStatus(q), refreshNotes: () => q.refetch() }; } export function useProjectFlags(project: () => string) { - const { data: flags, status: flagsStatus } = useData( - project, - (p) => "flags:" + p, - (p) => useInternalApi("flags/" + p) - ); - return { flags, flagsStatus }; + const q = useProjectFlagsQuery(project); + return { flags: q.data, flagsStatus: mapStatus(q) }; } export function useProjectVersions( params: () => { project: string; data: { limit: number; offset: number; channel: string[]; platform: Platform[]; includeHiddenChannels: boolean } }, - router: Router + _router: Router ) { - const { data: versions, status: versionsStatus } = useData( - params, - (p) => "versions:" + p.project + ":" + p.data.offset + ":" + p.data.channel + ":" + p.data.platform + ":" + p.data.includeHiddenChannels, - (p) => useApi(`projects/${p.project}/versions`, "GET", p.data), - true, - () => false, - ({ data }) => { - const { offset, limit, channel, platform } = data; - if (router) { - const oldQuery = router.currentRoute.value.query; - router.replace({ query: { ...oldQuery, page: offset && limit ? Math.floor(offset / limit) : undefined, channel, platform } }); - } - } - ); - return { versions, versionsStatus }; + const q = useProjectVersionsQuery(params); + return { versions: q.data as Ref, versionsStatus: mapStatus(q) }; } export function usePage(params: () => { project: string; path?: string }) { - const { data: page, status: pageStatus } = useData( - params, - (p) => "page:" + p.project + ":" + p.path, - (p) => useInternalApi(`pages/page/${p.project}` + (p.path ? "/" + p.path.replaceAll(",", "/") : "")) - ); - return { page, pageStatus }; + const q = usePageQuery(params); + return { page: q.data, pageStatus: mapStatus(q) }; } export function useReviews(version: () => string) { - const { - data: reviews, - status: reviewsStatus, - refresh: refreshReviews, - } = useData( - version, - (v) => "reviews:" + v, - (v) => useInternalApi(`reviews/${v}/reviews`) - ); - return { reviews, reviewsStatus, refreshReviews }; + const q = useReviewsQuery(version); + return { reviews: q.data, reviewsStatus: mapStatus(q), refreshReviews: () => q.refetch() }; } export function useJarScans(version: () => string) { - const { - data: jarScans, - status: jarScansStatus, - refresh: refreshJarScans, - } = useData( - version, - (v) => "jarScans:" + v, - (v) => useInternalApi(`jarscanning/result/${v}`) - ); - return { jarScans, jarScansStatus, refreshJarScans }; + const q = useJarScansQuery(version); + return { jarScans: q.data, jarScansStatus: mapStatus(q), refreshJarScans: () => q.refetch() }; } diff --git a/frontend/app/composables/useDataLoader.ts b/frontend/app/composables/useDataLoader.ts index f072d6265..137102968 100644 --- a/frontend/app/composables/useDataLoader.ts +++ b/frontend/app/composables/useDataLoader.ts @@ -1,6 +1,5 @@ import type { RouteLocationNormalized } from "vue-router"; import type { HangarOrganization, HangarProject, Version, User, ProjectPageTable, GlobalData } from "#shared/types/backend"; -import * as Sentry from "@sentry/nuxt"; type routeParams = "user" | "project" | "version" | "page"; type DataLoaderTypes = { @@ -12,7 +11,10 @@ type DataLoaderTypes = { globalData: GlobalData; }; -// TODO check every handling of the reject stuff (for both composables) +// Route-level data loader used by middleware to fetch data during navigation. +// Data is stored in Nuxt's useState for SSR payload sharing and also seeded +// into the Pinia Colada query cache so that subsequent useQuery calls are +// pre-populated. export function useDataLoader(key: K) { const data = useState(key); @@ -29,7 +31,6 @@ export function useDataLoader(key: K) { const oldParam = param && param in from.params ? (from.params[param as never] as string) : undefined; const newParam = param && param in to.params ? (to.params[param as never] as string) : undefined; if (data.value && oldParam === newParam) { - console.log("skip loading", key); // TODO test this return newParam; } else if (!param || newParam) { // sanitize a bit to make undertow happy @@ -40,15 +41,12 @@ export function useDataLoader(key: K) { promises.push( new Promise(async (resolve, reject) => { - console.log("load loading", key, newParam); const result = await loader(newParam!).catch((err) => { if (lenient) resolve(); else reject(err); }); - // await new Promise((resolve) => setTimeout(resolve, 5000)); if (result) { data.value = result; - console.log("load loaded", key, newParam); resolve(); } }) @@ -65,150 +63,3 @@ export function useDataLoader(key: K) { return { loader, data }; } - -export function useData | string>( - params: () => P, - key: (params: P) => string, - loader: (params: P) => Promise, - server = true, - skip: (params: P) => boolean = () => false, - callback: (params: P) => void = () => {}, - defaultValue?: T | undefined -) { - // state tracking is twofold. - // `state` is used store data in the nuxt payload, so it will be shared between server and client side and on client side navigation - const state = useState>("useData", () => ({})); - // `data` is used to store a reference into the state, using the current key. it points to the data we want to return - // we are not using a computed here, since consumers might manually want to update the data. this kinda corrupts the cache, but we can't do much about it - const data = ref(); - - const status = ref<"idle" | "loading" | "success" | "error">("idle"); - let promise: Promise | undefined; - - function refresh() { - console.log("refresh", key(params())); - return load(params()); - } - - function setState(newState?: T) { - state.value[key(params())] = newState; - data.value = newState; - } - - if (import.meta.server && !server) { - setState(defaultValue ?? undefined); - return { data, status, refresh }; - } - - function load(params: P) { - status.value = "loading"; - setState(defaultValue ?? undefined); - - if (skip(params)) { - console.log("skip", key(params)); - status.value = "idle"; - return; - } - - return Sentry.startSpan( - { op: "hangar.data", name: key(params) }, - () => - new Promise(async (resolve, reject) => { - console.log("load", key(params)); - try { - const result = await loader(params); - // await new Promise((resolve) => setTimeout(resolve, 5000)); - console.log("loaded", key(params)); - setState(result); - status.value = "success"; - callback(params); - resolve(); - } catch (err) { - status.value = "error"; - callback(params); - reject(err); - } - }) - ); - } - - // load initial state - data.value = state.value[key(params())]; - // if we have no state, queue a load - if (data.value === undefined) { - promise = load(params()); - - // if on server (and we dont wanna skip server fetching, we need await the promise onServerPrefetch) - if (import.meta.server && server && promise) { - onServerPrefetch(async () => { - console.log("server prefetch", key(params())); - await promise; - console.log("server prefetch done", key(params())); - }); - } - } - - // when the key changes, we move the data from the old key to the new key - watch( - () => key(params()), - (newKey, oldKey) => { - if (newKey === oldKey) { - return; - } - const oldState = state.value[oldKey]; - state.value[newKey] = oldState; - state.value[oldKey] = undefined; - data.value = oldState; - console.log("watchKey", newKey, oldKey); - } - ); - - // when the params change, we load the new data - watchDebounced( - params, - (newParams, oldParams) => { - if (checkEqual(newParams, oldParams)) { - console.log("equals"); - return; - } - console.log("watch", key(params()), newParams, oldParams, newParams === oldParams, checkEqual(newParams, oldParams)); - load(params()); - }, - { debounce: 250 } - ); - - return { data, status, refresh, promise }; -} - -function checkEqual(a: Record | string, b: Record | string) { - if (!a) { - return !b; - } else if (!b) { - return false; - } - - if (typeof a === "string" || typeof b === "string") { - return a === b; - } - - const keys1 = Object.keys(a); - const keys2 = Object.keys(b); - - if (keys1.length !== keys2.length) { - return false; - } - - for (const key of keys1) { - if (a[key] !== b[key]) { - if (typeof a[key] === "object" && typeof b[key] === "object") { - if (!checkEqual(a[key] as Record, b[key] as Record)) { - return false; - } - } else { - return false; - } - } - } - - return true; -} diff --git a/frontend/app/pages/auth/settings/api-keys.vue b/frontend/app/pages/auth/settings/api-keys.vue index 01811937e..22e7b71fb 100644 --- a/frontend/app/pages/auth/settings/api-keys.vue +++ b/frontend/app/pages/auth/settings/api-keys.vue @@ -29,15 +29,13 @@ async function create() { }).catch((err) => handleRequestError(err)); if (key) { createdKey.value = key; - if (!apiKeys.value) { - apiKeys.value = []; - } - apiKeys.value.unshift({ + const newKey = { tokenIdentifier: key.slice(0, Math.max(0, key.indexOf("."))), name: name.value, permissions: selectedPerms.value, createdAt: new Date().toISOString(), - }); + }; + apiKeys.value = [newKey, ...(apiKeys.value ?? [])]; const val = name.value; name.value = ""; selectedPerms.value = []; @@ -52,7 +50,7 @@ async function deleteKey(key: ApiKey) { await useInternalApi(`api-keys/delete-key/${auth.user?.name}`, "post", { content: key.name, }).catch((err) => handleRequestError(err)); - apiKeys.value = apiKeys.value?.filter((k) => k.name !== key.name); + apiKeys.value = (apiKeys.value ?? []).filter((k) => k.name !== key.name); notification.success(i18n.t("apiKeys.success.delete", [key.name])); loadingDelete[key.name] = false; } diff --git a/frontend/app/pages/notifications.vue b/frontend/app/pages/notifications.vue index 106faa21f..afa95279f 100644 --- a/frontend/app/pages/notifications.vue +++ b/frontend/app/pages/notifications.vue @@ -58,17 +58,23 @@ useSeo(computed(() => ({ title: "Notifications", route }))); async function markAllAsRead() { await useInternalApi(`markallread`, "post").catch((err) => handleRequestError(err)); if (!unreadNotifications.value) return; - unreadNotifications.value.result = []; - unreadNotifications.value.pagination.limit = 0; - unreadNotifications.value.pagination.offset = 0; - unreadNotifications.value.pagination.count = 0; + unreadNotifications.value = { + ...unreadNotifications.value, + result: [], + pagination: { ...unreadNotifications.value.pagination, limit: 0, offset: 0, count: 0 }, + }; } async function markNotificationRead(notification: HangarNotification, push = true) { await useInternalApi(`notifications/${notification.id}`, "post").catch((err) => handleRequestError(err)); notification.read = true; - if (!notifications.value) return; - notifications.value.result = notifications.value.result.filter((n) => n !== notification); + // Update the source data based on selected tab + const source = selectedTab.value === "unread" ? unreadNotifications : (selectedTab.value === "read" ? readNotifications : allNotifications); + if (!source.value) return; + source.value = { + ...source.value, + result: source.value.result.filter((n) => n !== notification), + }; if (notification.action && push) { await router.push(notification.action); } @@ -80,11 +86,13 @@ async function updateInvite(invite: (HangarOrganizationInvite | HangarProjectInv invite.accepted = true; } else { if (!invites.value) return; - if (invite.type === InviteType.Project) { - invites.value.project = invites.value.project.filter((i) => i.roleId !== invite.roleId); - } else { - invites.value.organization = invites.value.organization.filter((i) => i.roleId !== invite.roleId); - } + invites.value = invite.type === InviteType.Project ? { + ...invites.value, + project: invites.value.project.filter((i) => i.roleId !== invite.roleId), + } : { + ...invites.value, + organization: invites.value.organization.filter((i) => i.roleId !== invite.roleId), + }; } notificationStore.success(i18n.t(`notifications.invite.msgs.${status}`, [invite.name])); } diff --git a/frontend/app/queries/useAdminQueries.ts b/frontend/app/queries/useAdminQueries.ts new file mode 100644 index 000000000..e73e61381 --- /dev/null +++ b/frontend/app/queries/useAdminQueries.ts @@ -0,0 +1,66 @@ +import type { + FinishedOrPendingHealthReport, + DayStats, + PaginatedResultHangarLoggedAction, + PaginatedResultHangarProjectFlag, + ReviewQueue, +} from "#shared/types/backend"; + +export function useAdminStatsQuery(params: () => { from: string; to: string }) { + return useQuery({ + key: () => ["adminStats", params().from, params().to] as const, + query: () => useInternalApi("admin/stats", "get", params()), + staleTime: 60_000, + }); +} + +export function useHealthReportQuery() { + return useQuery({ + key: () => ["healthReport"] as const, + query: () => useInternalApi("health/", "GET"), + staleTime: 10_000, + }); +} + +export function useResolvedFlagsQuery() { + return useQuery({ + key: () => ["resolvedFlags"] as const, + query: () => useInternalApi("flags/resolved"), + staleTime: 30_000, + }); +} + +export function useUnresolvedFlagsQuery() { + return useQuery({ + key: () => ["unresolvedFlags"] as const, + query: () => useInternalApi("flags/unresolved"), + staleTime: 30_000, + }); +} + +export function useVersionApprovalsQuery() { + return useQuery({ + key: () => ["versionApprovals"] as const, + query: () => useInternalApi("admin/approval/versions"), + staleTime: 30_000, + }); +} + +export function useActionLogsQuery( + params: () => { limit: number; offset: number; sort: string[]; user?: string; logAction?: string; authorName?: string; projectSlug?: string } +) { + return useQuery({ + key: () => + [ + "actionLogs", + params().offset, + params().sort, + params().user ?? "", + params().logAction ?? "", + params().authorName ?? "", + params().projectSlug ?? "", + ] as const, + query: () => useInternalApi("admin/log", "get", params()), + staleTime: 30_000, + }); +} diff --git a/frontend/app/queries/useMiscQueries.ts b/frontend/app/queries/useMiscQueries.ts new file mode 100644 index 000000000..06f3c4b8e --- /dev/null +++ b/frontend/app/queries/useMiscQueries.ts @@ -0,0 +1,58 @@ +import type { NamedPermission, ApiKey, HangarReview, JarScanResult, ProjectOwner, SettingsResponse, VersionInfo } from "#shared/types/backend"; + +export function useVersionInfoQuery() { + return useQuery({ + key: () => ["versionInfo"] as const, + query: () => useInternalApi(`data/version-info`), + staleTime: 300_000, + }); +} + +export function usePossibleOwnersQuery() { + return useQuery({ + key: () => ["possibleOwners"] as const, + query: () => useInternalApi("projects/possibleOwners"), + placeholderData: () => [] as ProjectOwner[], + staleTime: 60_000, + }); +} + +export function useAuthSettingsQuery() { + return useQuery({ + key: () => ["authSettings"] as const, + query: () => useInternalApi(`auth/settings`, "POST"), + staleTime: 30_000, + }); +} + +export function useApiKeysQuery(user: () => string) { + return useQuery({ + key: () => ["apiKeys", user()] as const, + query: () => useInternalApi("api-keys/existing-keys/" + user()), + staleTime: 30_000, + }); +} + +export function usePossiblePermsQuery(user: () => string) { + return useQuery({ + key: () => ["possiblePerms", user()] as const, + query: () => useInternalApi("api-keys/possible-perms/" + user()), + staleTime: 60_000, + }); +} + +export function useReviewsQuery(version: () => string) { + return useQuery({ + key: () => ["reviews", version()] as const, + query: () => useInternalApi(`reviews/${version()}/reviews`), + staleTime: 30_000, + }); +} + +export function useJarScansQuery(version: () => string) { + return useQuery({ + key: () => ["jarScans", version()] as const, + query: () => useInternalApi(`jarscanning/result/${version()}`), + staleTime: 30_000, + }); +} diff --git a/frontend/app/queries/useNotificationQueries.ts b/frontend/app/queries/useNotificationQueries.ts new file mode 100644 index 000000000..0f1f480d6 --- /dev/null +++ b/frontend/app/queries/useNotificationQueries.ts @@ -0,0 +1,44 @@ +import type { PaginatedResultHangarNotification, Invites } from "#shared/types/backend"; + +export function useUnreadNotificationsQuery() { + return useQuery({ + key: () => ["unreadNotifications"] as const, + query: () => useInternalApi("unreadnotifications"), + staleTime: 30_000, + }); +} + +export function useReadNotificationsQuery() { + return useQuery({ + key: () => ["readNotifications"] as const, + query: () => useInternalApi("readnotifications"), + staleTime: 30_000, + }); +} + +export function useNotificationsQuery() { + return useQuery({ + key: () => ["notifications"] as const, + query: () => useInternalApi("notifications"), + staleTime: 30_000, + }); +} + +export function useUnreadCountQuery() { + const authStore = useAuthStore(); + return useQuery({ + key: () => ["unreadCount"] as const, + query: () => useInternalApi<{ notifications: number; invites: number }>("unreadcount"), + enabled: () => !!authStore.user, + placeholderData: () => authStore.user?.headerData?.unreadCount ?? { notifications: 0, invites: 0 }, + staleTime: 30_000, + }); +} + +export function useInvitesQuery() { + return useQuery({ + key: () => ["invites"] as const, + query: () => useInternalApi("invites"), + staleTime: 30_000, + }); +} diff --git a/frontend/app/queries/useProjectQueries.ts b/frontend/app/queries/useProjectQueries.ts new file mode 100644 index 000000000..f8bd56c3e --- /dev/null +++ b/frontend/app/queries/useProjectQueries.ts @@ -0,0 +1,121 @@ +import type { + HangarChannel, + HangarProjectFlag, + HangarProjectNote, + PaginatedResultProject, + PaginatedResultProjectCompact, + PaginatedResultVersion, + Platform, + ProjectCompact, + ProjectPageTable, +} from "#shared/types/backend"; + +export function useProjectsQuery( + params: () => { + member?: string; + limit?: number; + offset?: number; + query?: string; + owner?: string; + version?: string[]; + category?: string[]; + platform?: Platform[]; + tag?: string[]; + sort?: string; + } +) { + return useQuery({ + key: () => ["projects", params().member || params().owner || "main", params().offset ?? 0] as const, + query: () => useApi("projects", "get", { ...params() }), + staleTime: 30_000, + }); +} + +export function useStarredQuery(user: () => string) { + return useQuery({ + key: () => ["starred", user()] as const, + query: () => useApi(`users/${user()}/starred`), + staleTime: 60_000, + }); +} + +export function useWatchingQuery(user: () => string) { + return useQuery({ + key: () => ["watching", user()] as const, + query: () => useApi(`users/${user()}/watching`), + staleTime: 60_000, + }); +} + +export function usePinnedQuery(user: () => string) { + return useQuery({ + key: () => ["pinned", user()] as const, + query: () => useApi(`users/${user()}/pinned`), + staleTime: 60_000, + }); +} + +export function useProjectChannelsQuery(project: () => string) { + return useQuery({ + key: () => ["channels", project()] as const, + query: () => useInternalApi<(HangarChannel & { temp?: boolean })[]>(`channels/${project()}`), + staleTime: 60_000, + }); +} + +export function useProjectNotesQuery(project: () => string) { + return useQuery({ + key: () => ["notes", project()] as const, + query: () => useInternalApi("projects/notes/" + project()), + staleTime: 30_000, + }); +} + +export function useProjectFlagsQuery(project: () => string) { + return useQuery({ + key: () => ["flags", project()] as const, + query: () => useInternalApi("flags/" + project()), + staleTime: 30_000, + }); +} + +export function useProjectVersionsQuery( + params: () => { + project: string; + data: { limit: number; offset: number; channel: string[]; platform: Platform[]; includeHiddenChannels: boolean }; + } +) { + return useQuery({ + key: () => + ["versions", params().project, params().data.offset, params().data.channel, params().data.platform, params().data.includeHiddenChannels] as const, + query: () => useApi(`projects/${params().project}/versions`, "GET", params().data), + staleTime: 30_000, + }); +} + +export function usePageQuery(params: () => { project: string; path?: string }) { + return useQuery({ + key: () => ["page", params().project, params().path ?? ""] as const, + query: () => { + const p = params(); + return useInternalApi(`pages/page/${p.project}` + (p.path ? "/" + p.path.replaceAll(",", "/") : "")); + }, + staleTime: 60_000, + }); +} + +export function useWatchersQuery(project: () => string) { + return useQuery({ + key: () => ["watchers", project()] as const, + query: () => useApi(`projects/${project()}/watchers`), + staleTime: 60_000, + }); +} + +export function useStargazersQuery(project: () => string) { + return useQuery({ + key: () => ["stargazers", project()] as const, + query: () => useApi(`projects/${project()}/stargazers`), + staleTime: 60_000, + }); +} diff --git a/frontend/app/queries/useUserQueries.ts b/frontend/app/queries/useUserQueries.ts new file mode 100644 index 000000000..c78ef8b9c --- /dev/null +++ b/frontend/app/queries/useUserQueries.ts @@ -0,0 +1,60 @@ +import type { PaginatedResultUser, OrganizationRoleTable, User } from "#shared/types/backend"; +import { NamedPermission } from "#shared/types/backend"; + +export function useOrganizationVisibilityQuery(user: () => string) { + return useQuery({ + key: () => ["organizationVisibility", user()] as const, + query: () => useInternalApi<{ [key: string]: boolean }>(`organizations/${user()}/userOrganizationsVisibility`), + enabled: () => user() === useAuthStore().user?.name, + staleTime: 60_000, + }); +} + +export function usePossibleAltsQuery(user: () => string) { + return useQuery({ + key: () => ["possibleAlts", user()] as const, + query: () => useInternalApi(`users/${user()}/alts`), + enabled: () => hasPerms(NamedPermission.IsStaff), + staleTime: 60_000, + }); +} + +export function useOrganizationsQuery(user: () => string) { + return useQuery({ + key: () => ["organizations", user()] as const, + query: () => useInternalApi<{ [key: string]: OrganizationRoleTable }>(`organizations/${user()}/userOrganizations`), + staleTime: 60_000, + }); +} + +export function useUserQuery(userName: () => string) { + return useQuery({ + key: () => ["user", userName()] as const, + query: () => useApi("users/" + userName()), + staleTime: 60_000, + }); +} + +export function useUsersQuery(params: () => { query?: string; limit?: number; offset?: number; sort?: string[] }) { + return useQuery({ + key: () => ["users", params().query ?? "", params().offset ?? 0, params().sort ?? []] as const, + query: () => useApi("users", "get", params()), + staleTime: 30_000, + }); +} + +export function useStaffQuery(params: () => { offset?: number; limit?: number; sort?: string[]; query?: string }) { + return useQuery({ + key: () => ["staff", params().offset ?? 0, params().sort ?? [], params().query ?? ""] as const, + query: () => useApi("staff", "GET", params()), + staleTime: 60_000, + }); +} + +export function useAuthorsQuery(params: () => { offset?: number; limit?: number; sort?: string[]; query?: string }) { + return useQuery({ + key: () => ["authors", params().offset ?? 0, params().sort ?? [], params().query ?? ""] as const, + query: () => useApi("authors", "GET", params()), + staleTime: 60_000, + }); +} diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 347d222df..9186ff70e 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -18,7 +18,7 @@ export default defineNuxtConfig({ }, ], imports: { - dirs: ["store"], + dirs: ["store", "queries"], presets: [ { from: "@vuelidate/core", @@ -56,6 +56,7 @@ export default defineNuxtConfig({ modules: [ "@unocss/nuxt", "@pinia/nuxt", + "@pinia/colada-nuxt", "@vueuse/nuxt", "@nuxt/eslint", "@nuxtjs/i18n", diff --git a/frontend/package.json b/frontend/package.json index c574499d0..4d2aa9238 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,8 @@ "dependencies": { "@headlessui/vue": "1.7.23", "@nuxt/scripts": "0.13.0", + "@pinia/colada": "1.0.0", + "@pinia/colada-nuxt": "0.3.2", "@pinia/nuxt": "0.11.3", "@vuelidate/core": "2.0.3", "@vuelidate/validators": "2.0.4", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 25aa05e36..72a1f7b8c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@nuxt/scripts': specifier: 0.13.0 version: 0.13.0(@googlemaps/markerclusterer@2.6.2)(@netlify/blobs@9.1.2)(@types/google.maps@3.58.1)(@types/vimeo__player@2.18.3)(@types/youtube@0.1.0)(@unhead/vue@2.0.17(vue@3.5.24(typescript@5.9.3)))(db0@0.3.4)(ioredis@5.8.0)(magicast@0.3.5)(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)) + '@pinia/colada': + specifier: 1.0.0 + version: 1.0.0(pinia@3.0.4(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)))(vue@3.5.24(typescript@5.9.3)) + '@pinia/colada-nuxt': + specifier: 0.3.2 + version: 0.3.2(@pinia/colada@1.0.0(pinia@3.0.4(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)))(vue@3.5.24(typescript@5.9.3)))(magicast@0.3.5) '@pinia/nuxt': specifier: 0.11.3 version: 0.11.3(magicast@0.3.5)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3))) @@ -1058,6 +1064,10 @@ packages: resolution: {integrity: sha512-lLt8KLHyl7IClc3RqRpRikz15eCfTRlAWL9leVzPyg5N87FfKE/7EWgWvpiL/z4Tf3dQCIqQb88TmHE0JTIDvA==} engines: {node: '>=18.12.0'} + '@nuxt/kit@4.3.1': + resolution: {integrity: sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==} + engines: {node: '>=18.12.0'} + '@nuxt/schema@4.1.2': resolution: {integrity: sha512-uFr13C6c52OFbF3hZVIV65KvhQRyrwp1GlAm7EVNGjebY8279QEel57T4R9UA1dn2Et6CBynBFhWoFwwo97Pig==} engines: {node: ^14.18.0 || >=16.10.0} @@ -1791,6 +1801,17 @@ packages: resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} engines: {node: '>= 10.0.0'} + '@pinia/colada-nuxt@0.3.2': + resolution: {integrity: sha512-ICPbRZ03g43CPKYwxkWf3yFbW8iz7L7kF5QoJw7lok7k7fkz9t0tFc3SrOgjl7B8PEiw3syD7rRtu3CeGmuJeg==} + peerDependencies: + '@pinia/colada': '>=0.21.6' + + '@pinia/colada@1.0.0': + resolution: {integrity: sha512-YKSybA6wusFK4CAUPzItoSgPCfScVnnnO2MSlmaaisE/L7luE77GxFyhTzipM8IbvbXh4zkCy97OE7w9WX34wA==} + peerDependencies: + pinia: ^2.2.6 || ^3.0.0 + vue: ^3.5.17 + '@pinia/nuxt@0.11.3': resolution: {integrity: sha512-7WVNHpWx4qAEzOlnyrRC88kYrwnlR/PrThWT0XI1dSNyUAXu/KBv9oR37uCgYkZroqP5jn8DfzbkNF3BtKvE9w==} peerDependencies: @@ -3348,6 +3369,14 @@ packages: magicast: optional: true + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -3402,6 +3431,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -4140,6 +4173,9 @@ packages: exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -5829,6 +5865,9 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc9@3.0.0: + resolution: {integrity: sha512-MGOue0VqscKWQ104udASX/3GYDcKyPI4j4F8gu/jHHzglpmy9a/anZK3PNe8ug6aZFl+9GxLtdhe3kVZuMaQbA==} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -5847,6 +5886,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -6019,6 +6062,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.0: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} @@ -6475,6 +6523,9 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} @@ -6491,6 +6542,9 @@ packages: unctx@2.4.1: resolution: {integrity: sha512-AbaYw0Nm4mK4qjhns67C+kgxR2YWiwlDBPzxrN8h8C6VtAdCgditAY5Dezu3IJy4XVqAnbrXt9oQJvsn3fyozg==} + unctx@2.5.0: + resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} + undici-types@7.13.0: resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==} @@ -6615,6 +6669,10 @@ packages: resolution: {integrity: sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw==} engines: {node: '>=18.12.0'} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + unraw@3.0.0: resolution: {integrity: sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==} @@ -7919,7 +7977,7 @@ snapshots: https-proxy-agent: 7.0.6 node-fetch: 2.7.0 nopt: 8.1.0 - semver: 7.7.2 + semver: 7.7.3 tar: 7.5.1 transitivePeerDependencies: - encoding @@ -7998,7 +8056,7 @@ snapshots: '@nuxt/cli@3.28.0(magicast@0.3.5)': dependencies: - c12: 3.3.0(magicast@0.3.5) + c12: 3.3.1(magicast@0.3.5) citty: 0.1.6 clipboardy: 4.0.0 confbox: 0.2.2 @@ -8019,7 +8077,7 @@ snapshots: perfect-debounce: 1.0.0 pkg-types: 2.3.0 scule: 1.3.0 - semver: 7.7.2 + semver: 7.7.3 std-env: 3.9.0 tinyexec: 1.0.1 ufo: 1.6.1 @@ -8046,7 +8104,7 @@ snapshots: pathe: 2.0.3 pkg-types: 2.3.0 prompts: 2.4.2 - semver: 7.7.2 + semver: 7.7.3 '@nuxt/devtools@2.6.5(vite@7.1.9(@types/node@24.6.2)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.19.3)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))': dependencies: @@ -8073,7 +8131,7 @@ snapshots: pathe: 2.0.3 perfect-debounce: 1.0.0 pkg-types: 2.3.0 - semver: 7.7.2 + semver: 7.7.3 simple-git: 3.28.0 sirv: 3.0.2 structured-clone-es: 1.0.0 @@ -8159,7 +8217,7 @@ snapshots: '@nuxt/kit@3.19.2(magicast@0.3.5)': dependencies: - c12: 3.3.0(magicast@0.3.5) + c12: 3.3.1(magicast@0.3.5) consola: 3.4.2 defu: 6.1.4 destr: 2.0.5 @@ -8175,7 +8233,7 @@ snapshots: pkg-types: 2.3.0 rc9: 2.1.2 scule: 1.3.0 - semver: 7.7.2 + semver: 7.7.3 std-env: 3.9.0 tinyglobby: 0.2.15 ufo: 1.6.1 @@ -8237,6 +8295,31 @@ snapshots: transitivePeerDependencies: - magicast + '@nuxt/kit@4.3.1(magicast@0.3.5)': + dependencies: + c12: 3.3.3(magicast@0.3.5) + consola: 3.4.2 + defu: 6.1.4 + destr: 2.0.5 + errx: 0.1.0 + exsolve: 1.0.8 + ignore: 7.0.5 + jiti: 2.6.1 + klona: 2.0.6 + mlly: 1.8.0 + ohash: 2.0.11 + pathe: 2.0.3 + pkg-types: 2.3.0 + rc9: 3.0.0 + scule: 1.3.0 + semver: 7.7.4 + tinyglobby: 0.2.15 + ufo: 1.6.3 + unctx: 2.5.0 + untyped: 2.0.0 + transitivePeerDependencies: + - magicast + '@nuxt/schema@4.1.2': dependencies: '@vue/shared': 3.5.22 @@ -8329,7 +8412,7 @@ snapshots: h3: 1.15.4 jiti: 2.6.1 knitwork: 1.2.0 - magic-string: 0.30.19 + magic-string: 0.30.21 mlly: 1.8.0 mocked-exports: 0.1.1 pathe: 2.0.3 @@ -8640,7 +8723,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.14.4 require-in-the-middle: 7.5.2 - semver: 7.7.2 + semver: 7.7.3 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -8951,6 +9034,18 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.1 '@parcel/watcher-win32-x64': 2.5.1 + '@pinia/colada-nuxt@0.3.2(@pinia/colada@1.0.0(pinia@3.0.4(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)))(vue@3.5.24(typescript@5.9.3)))(magicast@0.3.5)': + dependencies: + '@nuxt/kit': 4.3.1(magicast@0.3.5) + '@pinia/colada': 1.0.0(pinia@3.0.4(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)))(vue@3.5.24(typescript@5.9.3)) + transitivePeerDependencies: + - magicast + + '@pinia/colada@1.0.0(pinia@3.0.4(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)))(vue@3.5.24(typescript@5.9.3))': + dependencies: + pinia: 3.0.4(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)) + vue: 3.5.24(typescript@5.9.3) + '@pinia/nuxt@0.11.3(magicast@0.3.5)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)))': dependencies: '@nuxt/kit': 4.2.1(magicast@0.3.5) @@ -9951,7 +10046,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.2 + semver: 7.7.3 ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -10023,7 +10118,7 @@ snapshots: chokidar: 3.6.0 colorette: 2.0.20 consola: 3.4.2 - magic-string: 0.30.19 + magic-string: 0.30.21 pathe: 2.0.3 perfect-debounce: 1.0.0 tinyglobby: 0.2.15 @@ -10235,7 +10330,7 @@ snapshots: '@unocss/rule-utils@66.5.2': dependencies: '@unocss/core': 66.5.5 - magic-string: 0.30.19 + magic-string: 0.30.21 '@unocss/transformer-attributify-jsx@66.3.3': dependencies: @@ -10299,7 +10394,7 @@ snapshots: '@unocss/core': 66.5.2 '@unocss/inspector': 66.5.2 chokidar: 3.6.0 - magic-string: 0.30.19 + magic-string: 0.30.21 pathe: 2.0.3 tinyglobby: 0.2.15 unplugin-utils: 0.3.0 @@ -10311,7 +10406,7 @@ snapshots: '@unocss/config': 66.5.2 '@unocss/core': 66.5.2 chokidar: 3.6.0 - magic-string: 0.30.19 + magic-string: 0.30.21 pathe: 2.0.3 tinyglobby: 0.2.15 unplugin: 2.3.10 @@ -10540,7 +10635,7 @@ snapshots: '@vue/compiler-ssr': 3.5.22 '@vue/shared': 3.5.22 estree-walker: 2.0.2 - magic-string: 0.30.19 + magic-string: 0.30.21 postcss: 8.5.6 source-map-js: 1.2.1 @@ -11150,6 +11245,23 @@ snapshots: optionalDependencies: magicast: 0.3.5 + c12@3.3.3(magicast@0.3.5): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 17.2.3 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.3.5 + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -11216,6 +11328,10 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + chownr@3.0.0: {} chrome-trace-event@1.0.4: {} @@ -11803,7 +11919,7 @@ snapshots: eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 minimatch: 10.0.3 - semver: 7.7.2 + semver: 7.7.3 stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 optionalDependencies: @@ -11852,7 +11968,7 @@ snapshots: espree: 10.4.0 esquery: 1.6.0 parse-imports-exports: 0.2.4 - semver: 7.7.2 + semver: 7.7.3 spdx-expression-parse: 4.0.0 transitivePeerDependencies: - supports-color @@ -11891,7 +12007,7 @@ snapshots: pluralize: 8.0.0 regexp-tree: 0.1.27 regjsparser: 0.12.0 - semver: 7.7.2 + semver: 7.7.3 strip-indent: 4.1.0 eslint-plugin-unicorn@61.0.2(eslint@9.37.0(jiti@2.6.1)): @@ -11923,7 +12039,7 @@ snapshots: natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 6.1.2 - semver: 7.7.2 + semver: 7.7.3 vue-eslint-parser: 10.2.0(eslint@9.37.0(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: @@ -12059,6 +12175,8 @@ snapshots: exsolve@1.0.7: {} + exsolve@1.0.8: {} + fast-deep-equal@3.1.3: {} fast-equals@5.3.2: @@ -12725,7 +12843,7 @@ snapshots: acorn: 8.15.0 eslint-visitor-keys: 3.4.3 espree: 9.6.1 - semver: 7.7.2 + semver: 7.7.3 jsonc-parser@2.3.1: {} @@ -12880,7 +12998,7 @@ snapshots: magic-regexp@0.10.0: dependencies: estree-walker: 3.0.3 - magic-string: 0.30.19 + magic-string: 0.30.21 mlly: 1.8.0 regexp-tree: 0.1.27 type-level-regexp: 0.1.17 @@ -12893,7 +13011,7 @@ snapshots: magic-string-ast@1.0.2: dependencies: - magic-string: 0.30.19 + magic-string: 0.30.21 magic-string@0.30.19: dependencies: @@ -13064,7 +13182,7 @@ snapshots: '@rollup/plugin-terser': 0.4.4(rollup@4.52.4) '@vercel/nft': 0.30.2(rollup@4.52.4) archiver: 7.0.1 - c12: 3.3.0(magicast@0.3.5) + c12: 3.3.1(magicast@0.3.5) chokidar: 4.0.3 citty: 0.1.6 compatx: 0.2.0 @@ -13091,7 +13209,7 @@ snapshots: klona: 2.0.6 knitwork: 1.2.0 listhen: 1.9.0 - magic-string: 0.30.19 + magic-string: 0.30.21 magicast: 0.3.5 mime: 4.1.0 mlly: 1.8.0 @@ -13107,7 +13225,7 @@ snapshots: rollup: 4.52.4 rollup-plugin-visualizer: 6.0.4(rollup@4.52.4) scule: 1.3.0 - semver: 7.7.2 + semver: 7.7.3 serve-placeholder: 2.0.2 serve-static: 2.2.0 source-map: 0.7.6 @@ -13972,6 +14090,11 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + rc9@3.0.0: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -14000,6 +14123,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.0.0: {} + redis-errors@1.2.0: {} redis-parser@3.0.0: @@ -14191,6 +14316,8 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: {} + send@1.2.0: dependencies: debug: 4.4.3 @@ -14745,7 +14872,7 @@ snapshots: typescript-auto-import-cache@0.3.6: dependencies: - semver: 7.7.2 + semver: 7.7.3 typescript@5.9.3: {} @@ -14755,6 +14882,8 @@ snapshots: ufo@1.6.1: {} + ufo@1.6.3: {} + ultrahtml@1.6.0: {} unbox-primitive@1.1.0: @@ -14777,9 +14906,16 @@ snapshots: dependencies: acorn: 8.15.0 estree-walker: 3.0.3 - magic-string: 0.30.19 + magic-string: 0.30.21 unplugin: 2.3.10 + unctx@2.5.0: + dependencies: + acorn: 8.15.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + unplugin: 2.3.11 + undici-types@7.13.0: {} unenv@2.0.0-rc.21: @@ -14804,7 +14940,7 @@ snapshots: escape-string-regexp: 5.0.0 estree-walker: 3.0.3 local-pkg: 1.1.2 - magic-string: 0.30.19 + magic-string: 0.30.21 mlly: 1.8.0 pathe: 2.0.3 picomatch: 4.0.3 @@ -14929,7 +15065,7 @@ snapshots: fast-glob: 3.3.3 json5: 2.2.3 local-pkg: 1.1.2 - magic-string: 0.30.19 + magic-string: 0.30.21 micromatch: 4.0.8 mlly: 1.8.0 pathe: 2.0.3 @@ -14986,6 +15122,13 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.15.0 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 + unraw@3.0.0: {} unrs-resolver@1.11.1: @@ -15222,7 +15365,7 @@ snapshots: volar-service-typescript@0.0.65(@volar/language-service@2.4.23): dependencies: path-browserify: 1.0.1 - semver: 7.7.2 + semver: 7.7.3 typescript-auto-import-cache: 0.3.6 vscode-languageserver-textdocument: 1.0.12 vscode-nls: 5.2.0 @@ -15301,7 +15444,7 @@ snapshots: eslint-visitor-keys: 4.2.1 espree: 10.4.0 esquery: 1.6.0 - semver: 7.7.2 + semver: 7.7.3 transitivePeerDependencies: - supports-color