diff --git a/components/app/home/membership-card.vue b/components/app/home/membership-card.vue new file mode 100644 index 0000000..0d12774 --- /dev/null +++ b/components/app/home/membership-card.vue @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + {{ user.name }} + + + {{ user.memberId }} + + + + + Class of {{ user.graduationYear }} + + + {{ user.memberType?.[0].toLocaleUpperCase() }}{{ user.memberType?.slice(1, user.memberType?.length) }} + member + + + + + + + Coming back? + + Tap on this card and present it to the security at the front gate. + + + + + diff --git a/components/app/home/page.vue b/components/app/home/page.vue index 6ca6adb..dc1e09b 100644 --- a/components/app/home/page.vue +++ b/components/app/home/page.vue @@ -9,7 +9,8 @@ defineProps<{ const auth = useCurrentUser() const authLoaded = useIsCurrentUserLoaded() -const { data: user } = useUser() +// TODO @qin-guan: Error state handling +const { data: user, isLoading: userIsLoading } = useUser() const state = reactive({ showLoginScreen: false, @@ -27,21 +28,17 @@ watch([authLoaded, auth], (values) => { - Nice + SSTAA - Welcome, - - Placeholder name - - - {{ user?.name }} - + SSTAA - + + + What's Happening diff --git a/composables/user.ts b/composables/user.ts index 218bdbb..346f582 100644 --- a/composables/user.ts +++ b/composables/user.ts @@ -1,6 +1,15 @@ +import { useQuery } from '@tanstack/vue-query' import type { User } from '~/shared/types' +const queryKeyFactory = { + user: ['user'], +} + export function useUser() { - const auth = useCurrentUser() - return useApiFetch(() => `/api/user/${auth.value?.uid}`, { immediate: false, watch: [() => auth.value?.uid] }) + const firebaseCurrentUser = useCurrentUser() + return useQuery({ + queryKey: queryKeyFactory.user, + queryFn: () => $api(`/api/user/${firebaseCurrentUser.value?.uid}`), + enabled: computed(() => !!firebaseCurrentUser.value), // Only run when user exists + }) } diff --git a/nuxt.config.ts b/nuxt.config.ts index 09bcdd9..b510b12 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -13,6 +13,7 @@ export default defineNuxtConfig({ 'nuxt-vuefire', '@unocss/nuxt', '@vite-pwa/nuxt', + '@vueuse/nuxt', ], routeRules: { diff --git a/package.json b/package.json index 4b78a95..43dd6ac 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,14 @@ "@libsql/client": "^0.3.4", "@nuxt/devtools": "latest", "@paralleldrive/cuid2": "^2.2.2", + "@tanstack/query-persist-client-core": "^4.35.3", + "@tanstack/query-sync-storage-persister": "^4.35.3", + "@tanstack/vue-query": "^4.35.3", "@unocss/nuxt": "^0.56.0", "@vite-pwa/assets-generator": "^0.0.10", "@vite-pwa/nuxt": "^0.1.1", + "@vueuse/core": "^10.4.1", + "@vueuse/nuxt": "^10.4.1", "dayjs": "^1.11.9", "dotenv": "^16.3.1", "drizzle-kit": "^0.19.13", diff --git a/plugins/vue-query.ts b/plugins/vue-query.ts new file mode 100644 index 0000000..42feede --- /dev/null +++ b/plugins/vue-query.ts @@ -0,0 +1,24 @@ +import { VueQueryPlugin, type VueQueryPluginOptions } from '@tanstack/vue-query' +import { persistQueryClient } from '@tanstack/query-persist-client-core' +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' + +export default defineNuxtPlugin((nuxtApp) => { + const vueQueryOptions: VueQueryPluginOptions = { + queryClientConfig: { + defaultOptions: { + queries: { + cacheTime: 1000 * 60 * 60 * 24, + staleTime: 1000 * 60 * 60 * 24, + }, + }, + }, + clientPersister: (queryClient) => { + return persistQueryClient({ + queryClient, + persister: createSyncStoragePersister({ storage: localStorage }), + }) + }, + } + + nuxtApp.vueApp.use(VueQueryPlugin, vueQueryOptions) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c415c84..34195db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: devDependencies: '@antfu/eslint-config': specifier: latest - version: 1.0.0-beta.3(eslint@8.49.0)(typescript@5.2.2) + version: 1.0.0-beta.4(eslint@8.49.0)(typescript@5.2.2) '@libsql/client': specifier: ^0.3.4 version: 0.3.4 @@ -17,6 +17,15 @@ devDependencies: '@paralleldrive/cuid2': specifier: ^2.2.2 version: 2.2.2 + '@tanstack/query-persist-client-core': + specifier: ^4.35.3 + version: 4.35.3 + '@tanstack/query-sync-storage-persister': + specifier: ^4.35.3 + version: 4.35.3 + '@tanstack/vue-query': + specifier: ^4.35.3 + version: 4.35.3(vue@3.3.4) '@unocss/nuxt': specifier: ^0.56.0 version: 0.56.1(postcss@8.4.30)(rollup@2.79.1)(vite@4.4.9)(webpack@5.88.2) @@ -26,6 +35,12 @@ devDependencies: '@vite-pwa/nuxt': specifier: ^0.1.1 version: 0.1.1(@nuxt/kit@3.7.3)(vite-plugin-pwa@0.16.5) + '@vueuse/core': + specifier: ^10.4.1 + version: 10.4.1(vue@3.3.4) + '@vueuse/nuxt': + specifier: ^10.4.1 + version: 10.4.1(nuxt@3.7.3)(rollup@2.79.1)(vue@3.3.4) dayjs: specifier: ^1.11.9 version: 1.11.10 @@ -87,20 +102,19 @@ packages: '@jridgewell/trace-mapping': 0.3.19 dev: true - /@antfu/eslint-config@1.0.0-beta.3(eslint@8.49.0)(typescript@5.2.2): - resolution: {integrity: sha512-k+UCHm9Ios7fZlQht8ptU7pMq++2644ZATAbXwz1AzZJ/glYAchVzVp42HdlNdwnj+GiBBkBVC/YaOV2TYLtxA==} + /@antfu/eslint-config@1.0.0-beta.4(eslint@8.49.0)(typescript@5.2.2): + resolution: {integrity: sha512-4QLToVBpsMo0W8Xw6m2E64ALC2Aq0um3MPT7J1fWj3ySkvLAcGG0GP8F/tl/3iJvWBjKua2nyMNzO4IdEannZw==} peerDependencies: eslint: '>=8.0.0' dependencies: '@eslint-stylistic/metadata': 0.0.4 - '@eslint/js': 8.49.0 '@stylistic/eslint-plugin-js': 0.0.4 '@stylistic/eslint-plugin-ts': 0.0.4(eslint@8.49.0)(typescript@5.2.2) '@typescript-eslint/eslint-plugin': 6.7.2(@typescript-eslint/parser@6.7.2)(eslint@8.49.0)(typescript@5.2.2) '@typescript-eslint/parser': 6.7.2(eslint@8.49.0)(typescript@5.2.2) eslint: 8.49.0 eslint-define-config: 1.23.0 - eslint-plugin-antfu: 1.0.0-beta.3(eslint@8.49.0)(typescript@5.2.2) + eslint-plugin-antfu: 1.0.0-beta.4(eslint@8.49.0)(typescript@5.2.2) eslint-plugin-eslint-comments: 3.2.0(eslint@8.49.0) eslint-plugin-i: 2.28.1(@typescript-eslint/parser@6.7.2)(eslint@8.49.0) eslint-plugin-jsdoc: 46.8.2(eslint@8.49.0) @@ -3356,6 +3370,45 @@ packages: string.prototype.matchall: 4.0.10 dev: true + /@tanstack/match-sorter-utils@8.8.4: + resolution: {integrity: sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw==} + engines: {node: '>=12'} + dependencies: + remove-accents: 0.4.2 + dev: true + + /@tanstack/query-core@4.35.3: + resolution: {integrity: sha512-PS+WEjd9wzKTyNjjQymvcOe1yg8f3wYc6mD+vb6CKyZAKvu4sIJwryfqfBULITKCla7P9C4l5e9RXePHvZOZeQ==} + dev: true + + /@tanstack/query-persist-client-core@4.35.3: + resolution: {integrity: sha512-UlUMsvmy12qgPzphIq8iyFtwxuv/vaEyFQEFDVVCvyrqj2G020qMZiCA1vj3+gasmCXh59EraiC2eY4Iqo0/PA==} + dependencies: + '@tanstack/query-core': 4.35.3 + dev: true + + /@tanstack/query-sync-storage-persister@4.35.3: + resolution: {integrity: sha512-q9axt4iJkRnhR9R9qou+Q2+T2S21jwgf/7carYs9DQGLoE9r9YnwxgbmDE72yQd1glcsGF26UqqO6WO8ziNCrQ==} + dependencies: + '@tanstack/query-persist-client-core': 4.35.3 + dev: true + + /@tanstack/vue-query@4.35.3(vue@3.3.4): + resolution: {integrity: sha512-0uRKL0+m/Wm/rxPxeaf4afi6raJZdTw7LPi32KRnRmXPFT2jWpfwrqO2PW9FYMuAtd0PCsSek0qg/YUgtsKbiQ==} + peerDependencies: + '@vue/composition-api': ^1.1.2 + vue: ^2.5.0 || ^3.0.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + dependencies: + '@tanstack/match-sorter-utils': 8.8.4 + '@tanstack/query-core': 4.35.3 + '@vue/devtools-api': 6.5.0 + vue: 3.3.4 + vue-demi: 0.13.11(vue@3.3.4) + dev: true + /@tootallnate/once@2.0.0: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -3454,6 +3507,10 @@ packages: resolution: {integrity: sha512-d0XxK3YTObnWVp6rZuev3c49+j4Lo8g4L1ZRm9z5L0xpoZycUPshHgczK5gsUMaZOstjVYYi09p5gYvUtfChYw==} dev: true + /@types/web-bluetooth@0.0.17: + resolution: {integrity: sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==} + dev: true + /@types/ws@8.5.5: resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} dependencies: @@ -4082,6 +4139,49 @@ packages: resolution: {integrity: sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==} dev: true + /@vueuse/core@10.4.1(vue@3.3.4): + resolution: {integrity: sha512-DkHIfMIoSIBjMgRRvdIvxsyboRZQmImofLyOHADqiVbQVilP8VVHDhBX2ZqoItOgu7dWa8oXiNnScOdPLhdEXg==} + dependencies: + '@types/web-bluetooth': 0.0.17 + '@vueuse/metadata': 10.4.1 + '@vueuse/shared': 10.4.1(vue@3.3.4) + vue-demi: 0.14.6(vue@3.3.4) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + dev: true + + /@vueuse/metadata@10.4.1: + resolution: {integrity: sha512-2Sc8X+iVzeuMGHr6O2j4gv/zxvQGGOYETYXEc41h0iZXIRnRbJZGmY/QP8dvzqUelf8vg0p/yEA5VpCEu+WpZg==} + dev: true + + /@vueuse/nuxt@10.4.1(nuxt@3.7.3)(rollup@2.79.1)(vue@3.3.4): + resolution: {integrity: sha512-tJ25KCkozZaQEy0qli4Ta8WXlbMIjSD7gPnVfLScZ2DpSSgImMB5R66PQEkrbSg4GfFj0OuoYc4+vCHQ/FqTsw==} + peerDependencies: + nuxt: ^3.0.0 + dependencies: + '@nuxt/kit': 3.7.3(rollup@2.79.1) + '@vueuse/core': 10.4.1(vue@3.3.4) + '@vueuse/metadata': 10.4.1 + local-pkg: 0.4.3 + nuxt: 3.7.3(eslint@8.49.0)(rollup@2.79.1)(typescript@5.2.2) + vue-demi: 0.14.6(vue@3.3.4) + transitivePeerDependencies: + - '@vue/composition-api' + - rollup + - supports-color + - vue + dev: true + + /@vueuse/shared@10.4.1(vue@3.3.4): + resolution: {integrity: sha512-vz5hbAM4qA0lDKmcr2y3pPdU+2EVw/yzfRsBdu+6+USGa4PxqSQRYIUC9/NcT06y+ZgaTsyURw2I9qOFaaXHAg==} + dependencies: + vue-demi: 0.14.6(vue@3.3.4) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + dev: true + /@webassemblyjs/ast@1.11.6: resolution: {integrity: sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==} dependencies: @@ -5858,8 +5958,8 @@ packages: - supports-color dev: true - /eslint-plugin-antfu@1.0.0-beta.3(eslint@8.49.0)(typescript@5.2.2): - resolution: {integrity: sha512-jP9wSGmfWbAUVqzZAdq314kNFTnKjZpiqdQhCbKDVdSL9m6aGuG94FOK5nbpwQJA5vKdYc3PSPiZy1yFjWDPwg==} + /eslint-plugin-antfu@1.0.0-beta.4(eslint@8.49.0)(typescript@5.2.2): + resolution: {integrity: sha512-SbN6Sp6t/4dyLgJkme+OHOifPp4pFCUXmhwY2cPBwUa1aC//olNl4Us9zuGDFoaHy0ArXDJu3SLPeqaFci1AoQ==} dependencies: '@typescript-eslint/utils': 6.7.2(eslint@8.49.0)(typescript@5.2.2) transitivePeerDependencies: @@ -9515,6 +9615,10 @@ packages: jsesc: 0.5.0 dev: true + /remove-accents@0.4.2: + resolution: {integrity: sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==} + dev: true + /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -11117,6 +11221,21 @@ packages: ufo: 1.3.0 dev: true + /vue-demi@0.13.11(vue@3.3.4): + resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + dependencies: + vue: 3.3.4 + dev: true + /vue-demi@0.14.6(vue@3.3.4): resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==} engines: {node: '>=12'} diff --git a/server/api/user/[id].get.ts b/server/api/user/[id].get.ts index b103550..59d05e2 100644 --- a/server/api/user/[id].get.ts +++ b/server/api/user/[id].get.ts @@ -10,4 +10,8 @@ export default defineProtectedEventHandler(async (event) => { } return event.context.user +}, { + cache: { + maxAge: 5, + }, }) diff --git a/server/utils/handlers.ts b/server/utils/handlers.ts index 1f37ee8..5cfab4c 100644 --- a/server/utils/handlers.ts +++ b/server/utils/handlers.ts @@ -1,8 +1,10 @@ // @ts-expect-error Bad types import { verifyIdToken } from 'web-auth-library/google' +import { defu } from 'defu' import type { UserToken } from 'web-auth-library/dist/google' import type { EventHandler, EventHandlerRequest } from 'h3' -import type { User } from '~/server/db/schema' +import type { CachedEventHandlerOptions } from 'nitropack' +import type { User } from '~/shared/types' declare module 'h3' { interface H3EventContext { @@ -12,13 +14,20 @@ declare module 'h3' { } export interface DefineProtectedEventHandlerOptions { - allowUnlinkedUser: boolean // Allow users which do not have a `firebaseId` linked in database + cache?: Pick + allowUnlinkedUser?: boolean // Allow users which do not have a `firebaseId` linked in database +} + +const defaultOptions: DefineProtectedEventHandlerOptions = { + allowUnlinkedUser: false, } export function defineProtectedEventHandler( handler: EventHandler, - options: DefineProtectedEventHandlerOptions = { allowUnlinkedUser: false }, + _options?: DefineProtectedEventHandlerOptions, ): EventHandler { + const options = defu(_options, defaultOptions) + return defineEventHandler(async (event) => { const authorization = getHeader(event, 'Authorization') ?? '' @@ -35,6 +44,9 @@ export function defineProtectedEventHandler( projectId: useRuntimeConfig().firebase.projectId, }) + if (options.cache?.maxAge) + setHeader(event, 'Cache-Control', `max-age=${options.cache.maxAge}`) + if (options.allowUnlinkedUser) return handler(event)