From 0fa02e4913b74caab6f1bb75a177c16f21bfd373 Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 19:49:07 +0800 Subject: [PATCH 01/18] fix: add frontend url to env var --- frontend/.env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/.env.example b/frontend/.env.example index 419cd7d0..3304aa4d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,4 @@ NEXT_PUBLIC_APP_NAME=TODO +NEXT_PUBLIC_FRONTEND_URL="http://localhost:3000" NEXT_PUBLIC_BACKEND_URL="http://localhost:8000" NEXT_PUBLIC_GOOGLE_CLIENT_ID= \ No newline at end of file From 163d81a83ac515d3a9c7b0f3876e323b1cbfd259 Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 19:49:32 +0800 Subject: [PATCH 02/18] chore: update workspace settings --- .vscode/assignment-3.code-workspace | 25 ++++++++++++++++++++++++- .vscode/settings.json | 17 ----------------- 2 files changed, 24 insertions(+), 18 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/assignment-3.code-workspace b/.vscode/assignment-3.code-workspace index b1b825a9..7d178dcb 100644 --- a/.vscode/assignment-3.code-workspace +++ b/.vscode/assignment-3.code-workspace @@ -4,5 +4,28 @@ "path": ".." } ], - "settings": {} + "settings": { + "editor.formatOnSave": true, + "eslint.workingDirectories": ["./frontend"], + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "javascript.preferences.importModuleSpecifier": "non-relative", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "files.exclude": { + "frontend/.next": true, + "frontend/next-env.d.ts": true, + "frontend/node_modules": true, + "frontend/next.config.mjs": true, + "frontend/postcss.config.mjs": true + } + } } diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 704d3cd7..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "editor.formatOnSave": true, - "eslint.workingDirectories": ["./frontend"], - "[jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "javascript.preferences.importModuleSpecifier": "non-relative", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" - } -} From e2ac192c7ee7ac2ffe2e5a6c951f0590a1a4886a Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 19:51:21 +0800 Subject: [PATCH 03/18] feat(axios): regenerate client --- frontend/.eslintrc.json | 4 +- frontend/client/client.ts | 15 ++ frontend/client/core/index.ts | 87 ++++++++ frontend/client/core/types.ts | 159 ++++++++++++++ frontend/client/core/utils.ts | 374 ++++++++++++++++++++++++++++++++ frontend/client/schemas.gen.ts | 1 + frontend/client/services.gen.ts | 3 +- frontend/components.json | 3 +- frontend/openapi-ts.config.ts | 7 + 9 files changed, 649 insertions(+), 4 deletions(-) create mode 100644 frontend/client/client.ts create mode 100644 frontend/client/core/index.ts create mode 100644 frontend/client/core/types.ts create mode 100644 frontend/client/core/utils.ts create mode 100644 frontend/openapi-ts.config.ts diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 4dd51cd9..b41e09fb 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -29,7 +29,9 @@ "alias": { "@": "./", "@/components": "./components/", - "@/lib": "./lib/" + "@/lib": "./lib/", + "@/hooks": "./hooks/", + "@/store": "./store/" }, "aliasForSubpaths": true } diff --git a/frontend/client/client.ts b/frontend/client/client.ts new file mode 100644 index 00000000..6d6722dd --- /dev/null +++ b/frontend/client/client.ts @@ -0,0 +1,15 @@ +export { createClient } from "./core/"; +export type { + Client, + Config, + Options, + RequestOptions, + RequestOptionsBase, + RequestResult, +} from "./core/types"; +export { + createConfig, + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from "./core/utils"; diff --git a/frontend/client/core/index.ts b/frontend/client/core/index.ts new file mode 100644 index 00000000..76dca954 --- /dev/null +++ b/frontend/client/core/index.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import type { AxiosError } from "axios"; +import axios from "axios"; + +import type { Client, Config, RequestOptions } from "./types"; +import { createConfig, getUrl, mergeConfigs, mergeHeaders } from "./utils"; + +export const createClient = (config: Config): Client => { + let _config = mergeConfigs(createConfig(), config); + + const instance = axios.create(_config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + instance.defaults = { + ...instance.defaults, + ..._config, + // @ts-expect-error + headers: mergeHeaders(instance.defaults.headers, _config.headers), + }; + return getConfig(); + }; + + // @ts-expect-error + const request: Client["request"] = async (options) => { + const opts: RequestOptions = { + ..._config, + ...options, + // @ts-expect-error + headers: mergeHeaders(_config.headers, options.headers), + }; + if (opts.body && opts.bodySerializer) { + opts.body = opts.bodySerializer(opts.body); + } + + const url = getUrl({ + path: opts.path, + url: opts.url, + }); + + const _axios = opts.axios || instance; + + try { + const response = await _axios({ + ...opts, + data: opts.body, + params: opts.query, + url, + }); + + let { data } = response; + + if (opts.responseType === "json" && opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + + return { + ...response, + data: data ?? {}, + }; + } catch (error) { + const e = error as AxiosError; + if (opts.throwOnError) { + throw e; + } + // @ts-expect-error + e.error = e.response?.data ?? {}; + return e; + } + }; + + return { + delete: (options) => request({ ...options, method: "delete" }), + get: (options) => request({ ...options, method: "get" }), + getConfig, + head: (options) => request({ ...options, method: "head" }), + instance, + options: (options) => request({ ...options, method: "options" }), + patch: (options) => request({ ...options, method: "patch" }), + post: (options) => request({ ...options, method: "post" }), + put: (options) => request({ ...options, method: "put" }), + request, + setConfig, + } as Client; +}; diff --git a/frontend/client/core/types.ts b/frontend/client/core/types.ts new file mode 100644 index 00000000..18a974f1 --- /dev/null +++ b/frontend/client/core/types.ts @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + AxiosError, + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + AxiosStatic, + CreateAxiosDefaults, +} from "axios"; + +import type { BodySerializer } from "./utils"; + +type OmitKeys = Pick>; + +export interface Config + extends Omit { + /** + * Axios implementation. You can use this option to provide a custom + * Axios instance. + * @default axios + */ + axios?: AxiosStatic; + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | CreateAxiosDefaults["headers"] + | Record< + string, + | string + | number + | boolean + | (string | number | boolean)[] + | null + | undefined + | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: + | "connect" + | "delete" + | "get" + | "head" + | "options" + | "patch" + | "post" + | "put" + | "trace"; + /** + * A function for transforming response data before it's returned to the + * caller function. This is an ideal place to post-process server data, + * e.g. convert date ISO strings into native Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * Throw an error instead of returning it in the response? + * @default false + */ + throwOnError?: ThrowOnError; +} + +export interface RequestOptionsBase + extends Config { + path?: Record; + query?: Record; + url: string; +} + +export type RequestResult< + Data = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, +> = ThrowOnError extends true + ? Promise> + : Promise< + | (AxiosResponse & { error: undefined }) + | (AxiosError & { data: undefined; error: TError }) + >; + +type MethodFn = < + Data = unknown, + TError = unknown, + ThrowOnError extends boolean = false, +>( + options: Omit, "method">, +) => RequestResult; + +type RequestFn = < + Data = unknown, + TError = unknown, + ThrowOnError extends boolean = false, +>( + options: Omit, "method"> & + Pick>, "method">, +) => RequestResult; + +export interface Client { + delete: MethodFn; + get: MethodFn; + getConfig: () => Config; + head: MethodFn; + instance: AxiosInstance; + options: MethodFn; + patch: MethodFn; + post: MethodFn; + put: MethodFn; + request: RequestFn; + setConfig: (config: Config) => Config; +} + +export type RequestOptions = RequestOptionsBase & + Config & { + headers: AxiosRequestConfig["headers"]; + }; + +type OptionsBase = Omit< + RequestOptionsBase, + "url" +> & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; +}; + +export type Options< + T = unknown, + ThrowOnError extends boolean = boolean, +> = T extends { body?: any } + ? T extends { headers?: any } + ? OmitKeys, "body" | "headers"> & T + : OmitKeys, "body"> & + T & + Pick, "headers"> + : T extends { headers?: any } + ? OmitKeys, "headers"> & + T & + Pick, "body"> + : OptionsBase & T; diff --git a/frontend/client/core/utils.ts b/frontend/client/core/utils.ts new file mode 100644 index 00000000..309135e0 --- /dev/null +++ b/frontend/client/core/utils.ts @@ -0,0 +1,374 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import type { Config } from "./types"; + +interface PathSerializer { + path: Record; + url: string; +} + +const PATH_PARAM_RE = /\{[^{}]+\}/g; + +type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited"; +type MatrixStyle = "label" | "matrix" | "simple"; +type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type ObjectStyle = "form" | "deepObject"; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +export type BodySerializer = (body: any) => any; + +interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +interface SerializeOptions + extends SerializePrimitiveOptions, + SerializerOptions {} +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ""; + } + + if (typeof value === "object") { + throw new Error( + "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "label": + return "."; + case "matrix": + return ";"; + case "simple": + return ","; + default: + return "&"; + } +}; + +const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "form": + return ","; + case "pipeDelimited": + return "|"; + case "spaceDelimited": + return "%20"; + default: + return ","; + } +}; + +const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case "label": + return "."; + case "matrix": + return ";"; + case "simple": + return ","; + default: + return "&"; + } +}; + +const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case "label": + return `.${joinedValues}`; + case "matrix": + return `;${name}=${joinedValues}`; + case "simple": + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === "label" || style === "simple") { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === "label" || style === "matrix" + ? separator + joinedValues + : joinedValues; +}; + +const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: Record; +}) => { + if (style !== "deepObject" && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [ + ...values, + key, + allowReserved ? (v as string) : encodeURIComponent(v as string), + ]; + }); + const joinedValues = values.join(","); + switch (style) { + case "form": + return `${name}=${joinedValues}`; + case "label": + return `.${joinedValues}`; + case "matrix": + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === "deepObject" ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === "label" || style === "matrix" + ? separator + joinedValues + : joinedValues; +}; + +const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = "simple"; + + if (name.endsWith("*")) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith(".")) { + name = name.substring(1); + style = "label"; + } else if (name.startsWith(";")) { + name = name.substring(1); + style = "matrix"; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === "object") { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + }), + ); + continue; + } + + if (style === "matrix") { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === "label" ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + path, + url, +}: { + path?: Record; + url: string; +}) => (path ? defaultPathSerializer({ path, url }) : url); + +const serializeFormDataPair = ( + formData: FormData, + key: string, + value: unknown, +) => { + if (typeof value === "string" || value instanceof Blob) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } +}; + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +export const mergeHeaders = ( + ...headers: Array["headers"] | undefined> +): Required["headers"] => { + const mergedHeaders: Required["headers"] = {}; + for (const header of headers) { + if (!header || typeof header !== "object") { + continue; + } + + const iterator = Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + // @ts-expect-error + delete mergedHeaders[key]; + } else if (Array.isArray(value)) { + for (const v of value) { + // @ts-expect-error + mergedHeaders[key] = [...(mergedHeaders[key] ?? []), v as string]; + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + // @ts-expect-error + mergedHeaders[key] = + typeof value === "object" ? JSON.stringify(value) : (value as string); + } + } + } + return mergedHeaders; +}; + +export const formDataBodySerializer = { + bodySerializer: | Array>>( + body: T, + ) => { + const formData = new FormData(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(formData, key, v)); + } else { + serializeFormDataPair(formData, key, value); + } + }); + + return formData; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: T) => JSON.stringify(body), +}; + +const serializeUrlSearchParamsPair = ( + data: URLSearchParams, + key: string, + value: unknown, +) => { + if (typeof value === "string") { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>( + body: T, + ) => { + const data = new URLSearchParams(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data; + }, +}; + +export const createConfig = (override: Config = {}): Config => ({ + baseURL: "", + ...override, +}); diff --git a/frontend/client/schemas.gen.ts b/frontend/client/schemas.gen.ts index 0decc188..b8e2a7ed 100644 --- a/frontend/client/schemas.gen.ts +++ b/frontend/client/schemas.gen.ts @@ -78,6 +78,7 @@ export const SignUpDataSchema = { }, password: { type: "string", + minLength: 6, title: "Password", }, }, diff --git a/frontend/client/services.gen.ts b/frontend/client/services.gen.ts index 3db4e0e6..8fffebce 100644 --- a/frontend/client/services.gen.ts +++ b/frontend/client/services.gen.ts @@ -5,8 +5,7 @@ import { createConfig, type Options, urlSearchParamsBodySerializer, -} from "@hey-api/client-axios"; - +} from "./client"; import type { AuthGoogleAuthGoogleGetData, AuthGoogleAuthGoogleGetError, diff --git a/frontend/components.json b/frontend/components.json index 481633df..27ecb296 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -15,6 +15,7 @@ "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", - "hooks": "@/hooks" + "hooks": "@/hooks", + "store": "@/store" } } \ No newline at end of file diff --git a/frontend/openapi-ts.config.ts b/frontend/openapi-ts.config.ts new file mode 100644 index 00000000..d6986418 --- /dev/null +++ b/frontend/openapi-ts.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "@hey-api/openapi-ts"; + +export default defineConfig({ + client: { bundle: true, name: "@hey-api/client-axios" }, + input: `${process.env.NEXT_PUBLIC_BACKEND_URL}/openapi.json`, + output: "client", +}); From 67ac15ff465307ac8aebada3dc75518188d1cc02 Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 19:52:12 +0800 Subject: [PATCH 04/18] feat: set up zustand and user store --- frontend/store/store-provider.tsx | 16 +++++++++++ frontend/store/user/user-store-provider.tsx | 31 ++++++++++++++++++++ frontend/store/user/user-store.ts | 32 +++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 frontend/store/store-provider.tsx create mode 100644 frontend/store/user/user-store-provider.tsx create mode 100644 frontend/store/user/user-store.ts diff --git a/frontend/store/store-provider.tsx b/frontend/store/store-provider.tsx new file mode 100644 index 00000000..06ecd943 --- /dev/null +++ b/frontend/store/store-provider.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { UserStoreProvider } from "./user/user-store-provider"; + +export function StoreProvider({ children }: { children: ReactNode }) { + const queryClient = new QueryClient(); + + return ( + + {children} + + ); +} diff --git a/frontend/store/user/user-store-provider.tsx b/frontend/store/user/user-store-provider.tsx new file mode 100644 index 00000000..ece2c1bb --- /dev/null +++ b/frontend/store/user/user-store-provider.tsx @@ -0,0 +1,31 @@ +import { createContext, ReactNode, useContext, useRef } from "react"; +import { StoreApi, useStore } from "zustand"; + +import { createUserStore, UserStore } from "@/store/user/user-store"; + +export type UserStoreApi = StoreApi; +export const UserStoreContext = createContext(null); + +export const UserStoreProvider = ({ children }: { children: ReactNode }) => { + const storeRef = useRef(); + + if (!storeRef.current) { + storeRef.current = createUserStore(); + } + + return ( + + {children} + + ); +}; + +export const useUserStore = (selector: (store: UserStore) => T): T => { + const userStoreContext = useContext(UserStoreContext); + + if (!userStoreContext) { + throw new Error(`useUserStore must be used within UserStoreProvider`); + } + + return useStore(userStoreContext, selector); +}; diff --git a/frontend/store/user/user-store.ts b/frontend/store/user/user-store.ts new file mode 100644 index 00000000..6ad7209d --- /dev/null +++ b/frontend/store/user/user-store.ts @@ -0,0 +1,32 @@ +import { createStore } from "zustand"; + +interface UserState { + isLoggedIn: boolean; + userId?: number; + email?: string; +} + +export const defaultUserState: UserState = { + isLoggedIn: false, +}; + +interface UserActions { + setLoggedIn: (userId?: number, email?: string) => void; + setNotLoggedIn: () => void; +} + +export type UserStore = UserState & UserActions; + +export const createUserStore = (initState: UserState = defaultUserState) => { + return createStore()((set) => ({ + ...initState, + setLoggedIn: (userId, email) => + set(() => ({ isLoggedIn: true, userId, email })), + setNotLoggedIn: () => + set(() => ({ + isLoggedIn: false, + userId: undefined, + email: undefined, + })), + })); +}; From d159161226004b831722c96d884255eef6dbc7ab Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 19:53:45 +0800 Subject: [PATCH 05/18] feat: set up react query --- frontend/app/layout.tsx | 12 +++--- frontend/package-lock.json | 59 +++++++++++++++++++++++++++- frontend/package.json | 6 ++- frontend/queries/user.ts | 14 +++++++ frontend/queries/utils/query-keys.ts | 3 ++ 5 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 frontend/queries/user.ts create mode 100644 frontend/queries/utils/query-keys.ts diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 7292fbf7..a54f15ee 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,9 +1,9 @@ import type { Metadata } from "next"; import { Inter as FontSans } from "next/font/google"; -import Navbar from "@/components/navigation/navbar"; -import { Toaster } from "@/components/ui/toaster"; +import AppLayout from "@/components/layout/app-layout"; import { cn } from "@/lib/utils"; +import { StoreProvider } from "@/store/store-provider"; import "./globals.css"; @@ -29,11 +29,9 @@ export default function RootLayout({ fontSans.variable, )} > -
- -
{children}
- -
+ + {children} + ); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e3e8542..a9cf3425 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.0", @@ -25,7 +26,8 @@ "react-hook-form": "^7.53.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zustand": "^5.0.0-rc.2" }, "devDependencies": { "@dword-design/eslint-plugin-import-alias": "https://github.com/chloeelim/eslint-plugin-import-alias/tarball/d5f7c350bc5400b8917c01fdd6b745afb6bcb35f", @@ -1023,6 +1025,32 @@ "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", "license": "MIT" }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.0.tgz", + "integrity": "sha512-Q/PbuSMk/vyAd/UoIShVGZ7StHHeRFYU7wXmi5GV+8cLXflZAEpHL/F697H1klrzxKXNtZ97vWiC0q3RKUH8UA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-checkbox": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.1.tgz", @@ -7446,6 +7474,35 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "5.0.0-rc.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0-rc.2.tgz", + "integrity": "sha512-o2Nwuvnk8vQBX7CcHL8WfFkZNJdxB/VKeWw0tNglw8p4cypsZ3tRT7rTRTDNeUPFS0qaMBRSKe+fVwL5xpcE3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 259369cd..fa0d204c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,10 +7,11 @@ "build": "next build", "start": "next start", "lint": "next lint", - "generate-client": "openapi-ts --input http://localhost:8000/openapi.json --output ./client --client @hey-api/client-axios" + "generate-client": "openapi-ts" }, "dependencies": { "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.0", @@ -27,7 +28,8 @@ "react-hook-form": "^7.53.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zustand": "^5.0.0-rc.2" }, "devDependencies": { "@dword-design/eslint-plugin-import-alias": "https://github.com/chloeelim/eslint-plugin-import-alias/tarball/d5f7c350bc5400b8917c01fdd6b745afb6bcb35f", diff --git a/frontend/queries/user.ts b/frontend/queries/user.ts new file mode 100644 index 00000000..0af6a57b --- /dev/null +++ b/frontend/queries/user.ts @@ -0,0 +1,14 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { getUserAuthSessionGet } from "@/client/services.gen"; + +import { QueryKeys } from "./utils/query-keys"; + +export const getUserProfile = () => + queryOptions({ + queryKey: [QueryKeys.UserProfile], + queryFn: () => + getUserAuthSessionGet({ withCredentials: true }).then( + (data) => data.data, + ), + }); diff --git a/frontend/queries/utils/query-keys.ts b/frontend/queries/utils/query-keys.ts new file mode 100644 index 00000000..7bba8004 --- /dev/null +++ b/frontend/queries/utils/query-keys.ts @@ -0,0 +1,3 @@ +export enum QueryKeys { + UserProfile = "user_profile", +} From cef3440b33bc0b2da17564645bf694124c49d07e Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 19:54:06 +0800 Subject: [PATCH 06/18] feat: persist user session in store --- frontend/components/layout/app-layout.tsx | 33 +++++++++++ frontend/components/navigation/navbar.tsx | 71 +++++++++++++++++------ frontend/components/ui/avatar.tsx | 50 ++++++++++++++++ 3 files changed, 135 insertions(+), 19 deletions(-) create mode 100644 frontend/components/layout/app-layout.tsx create mode 100644 frontend/components/ui/avatar.tsx diff --git a/frontend/components/layout/app-layout.tsx b/frontend/components/layout/app-layout.tsx new file mode 100644 index 00000000..15402218 --- /dev/null +++ b/frontend/components/layout/app-layout.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { ReactNode, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import Navbar from "@/components/navigation/navbar"; +import { Toaster } from "@/components/ui/toaster"; +import { getUserProfile } from "@/queries/user"; +import { useUserStore } from "@/store/user/user-store-provider"; + +const AppLayout = ({ children }: { children: ReactNode }) => { + const { setLoggedIn, setNotLoggedIn } = useUserStore((state) => state); + const { data: userProfile, isSuccess: isUserProfileSuccess } = + useQuery(getUserProfile()); + + useEffect(() => { + if (isUserProfileSuccess && userProfile) { + setLoggedIn(userProfile.id, userProfile.email); + } else { + setNotLoggedIn(); + } + }, [userProfile, isUserProfileSuccess, setLoggedIn, setNotLoggedIn]); + + return ( +
+ +
{children}
+ +
+ ); +}; + +export default AppLayout; diff --git a/frontend/components/navigation/navbar.tsx b/frontend/components/navigation/navbar.tsx index bb8be3af..9d673aeb 100644 --- a/frontend/components/navigation/navbar.tsx +++ b/frontend/components/navigation/navbar.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import Link from "next/link"; import { NavigationMenu, @@ -5,34 +6,66 @@ import { NavigationMenuLink, NavigationMenuList, } from "@radix-ui/react-navigation-menu"; +import { useQuery } from "@tanstack/react-query"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; +import { getUserProfile } from "@/queries/user"; +import { useUserStore } from "@/store/user/user-store-provider"; import { NavItem } from "@/types/navigation"; export const NavItems: NavItem[] = []; function Navbar() { + const { email, isLoggedIn, setLoggedIn, setNotLoggedIn } = useUserStore( + (state) => state, + ); + const { data: userProfile, isSuccess: isUserProfileSuccess } = + useQuery(getUserProfile()); + + useEffect(() => { + if (isUserProfileSuccess && userProfile) { + setLoggedIn(userProfile.id, userProfile.email); + } else { + setNotLoggedIn(); + } + }, [userProfile, isUserProfileSuccess, setLoggedIn, setNotLoggedIn]); + + console.log(useUserStore((state) => state)); + return (
-
- - - {process.env.NEXT_PUBLIC_APP_NAME} - - - - - {NavItems.map((navItem) => ( - - - - {navItem.label} - - - - ))} - - +
+
+ + + {process.env.NEXT_PUBLIC_APP_NAME} + + + + + {NavItems.map((navItem) => ( + + + + {navItem.label} + + + + ))} + + + +
+ {isLoggedIn && ( +
+ + {email?.charAt(0)} + +
+ )}
); diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx new file mode 100644 index 00000000..13fb9acd --- /dev/null +++ b/frontend/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarFallback, AvatarImage }; From 8e086be5dad63985249589ce399ec31b42170ddb Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 21:49:01 +0800 Subject: [PATCH 07/18] feat: add user avatar to navbar --- frontend/app/login/page.tsx | 2 +- frontend/app/register/page.tsx | 2 +- .../google-oauth-button.tsx | 0 .../components/auth/user-profile-button.tsx | 56 +++ frontend/components/navigation/navbar.tsx | 15 +- frontend/components/ui/button.tsx | 2 + frontend/components/ui/dropdown-menu.tsx | 200 ++++++++ frontend/components/ui/popover.tsx | 31 ++ frontend/package-lock.json | 445 ++++++++++++++++++ frontend/package.json | 2 + frontend/utils/string.ts | 7 + 11 files changed, 748 insertions(+), 14 deletions(-) rename frontend/components/{miscellaneous => auth}/google-oauth-button.tsx (100%) create mode 100644 frontend/components/auth/user-profile-button.tsx create mode 100644 frontend/components/ui/dropdown-menu.tsx create mode 100644 frontend/components/ui/popover.tsx create mode 100644 frontend/utils/string.ts diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index d5f183c1..d5126c2a 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -7,9 +7,9 @@ import { CircleAlert } from "lucide-react"; import { z } from "zod"; import { logInAuthLoginPost } from "@/client"; +import GoogleOAuthButton from "@/components/auth/google-oauth-button"; import PasswordField from "@/components/form/fields/password-field"; import TextField from "@/components/form/fields/text-field"; -import GoogleOAuthButton from "@/components/miscellaneous/google-oauth-button"; import Link from "@/components/navigation/link"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Box } from "@/components/ui/box"; diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx index 6c1c9589..d0e47b98 100644 --- a/frontend/app/register/page.tsx +++ b/frontend/app/register/page.tsx @@ -7,9 +7,9 @@ import { CircleAlert } from "lucide-react"; import { z } from "zod"; import { signUpAuthSignupPost } from "@/client"; +import GoogleOAuthButton from "@/components/auth/google-oauth-button"; import PasswordField from "@/components/form/fields/password-field"; import TextField from "@/components/form/fields/text-field"; -import GoogleOAuthButton from "@/components/miscellaneous/google-oauth-button"; import Link from "@/components/navigation/link"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Box } from "@/components/ui/box"; diff --git a/frontend/components/miscellaneous/google-oauth-button.tsx b/frontend/components/auth/google-oauth-button.tsx similarity index 100% rename from frontend/components/miscellaneous/google-oauth-button.tsx rename to frontend/components/auth/google-oauth-button.tsx diff --git a/frontend/components/auth/user-profile-button.tsx b/frontend/components/auth/user-profile-button.tsx new file mode 100644 index 00000000..7cc57e49 --- /dev/null +++ b/frontend/components/auth/user-profile-button.tsx @@ -0,0 +1,56 @@ +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { useUserStore } from "@/store/user/user-store-provider"; +import { getInitialFromEmail, getNameFromEmail } from "@/utils/string"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useRouter } from "next/navigation"; + +const UserProfileButton = () => { + const { email, setNotLoggedIn } = useUserStore((state) => state); + const router = useRouter(); + + return ( +
+ + + + {getInitialFromEmail(email)} + + + + + +
+

{getNameFromEmail(email)}

+

{email}

+
+
+ + + router.push("/user/profile")}> + Manage my profile + + + + + + Log out + + +
+
+
+ ); +}; + +export default UserProfileButton; diff --git a/frontend/components/navigation/navbar.tsx b/frontend/components/navigation/navbar.tsx index 9d673aeb..b39ed3bc 100644 --- a/frontend/components/navigation/navbar.tsx +++ b/frontend/components/navigation/navbar.tsx @@ -8,7 +8,7 @@ import { } from "@radix-ui/react-navigation-menu"; import { useQuery } from "@tanstack/react-query"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import UserProfileButton from "@/components/auth/user-profile-button"; import { navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; import { getUserProfile } from "@/queries/user"; import { useUserStore } from "@/store/user/user-store-provider"; @@ -17,7 +17,7 @@ import { NavItem } from "@/types/navigation"; export const NavItems: NavItem[] = []; function Navbar() { - const { email, isLoggedIn, setLoggedIn, setNotLoggedIn } = useUserStore( + const { isLoggedIn, setLoggedIn, setNotLoggedIn } = useUserStore( (state) => state, ); const { data: userProfile, isSuccess: isUserProfileSuccess } = @@ -31,8 +31,6 @@ function Navbar() { } }, [userProfile, isUserProfileSuccess, setLoggedIn, setNotLoggedIn]); - console.log(useUserStore((state) => state)); - return (
@@ -57,15 +55,8 @@ function Navbar() { ))} -
- {isLoggedIn && ( -
- - {email?.charAt(0)} - -
- )} + {isLoggedIn && }
); diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx index 2274e92b..c4f29333 100644 --- a/frontend/components/ui/button.tsx +++ b/frontend/components/ui/button.tsx @@ -14,6 +14,8 @@ const buttonVariants = cva( "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + destructive_outline: + "border border-destructive/80 text-destructive hover:bg-destructive/5", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", diff --git a/frontend/components/ui/dropdown-menu.tsx b/frontend/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..f69a0d64 --- /dev/null +++ b/frontend/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/frontend/components/ui/popover.tsx b/frontend/components/ui/popover.tsx new file mode 100644 index 00000000..a0ec48be --- /dev/null +++ b/frontend/components/ui/popover.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a9cf3425..9a173be6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,8 +11,10 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.0", + "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", "@tanstack/react-query": "^5.56.2", @@ -603,6 +605,44 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz", + "integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", + "license": "MIT" + }, "node_modules/@hey-api/client-axios": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@hey-api/client-axios/-/client-axios-0.2.4.tgz", @@ -1025,6 +1065,29 @@ "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", "license": "MIT" }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", + "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-avatar": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.0.tgz", @@ -1179,6 +1242,75 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.1.tgz", + "integrity": "sha512-y8E+x9fBq9qvteD2Zwa4397pUVhYsh9iq44b5RD5qu1GMJWBCBuVg1hMyItbc6+zH00TxGRqd9Iot4wzf3OoBQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz", + "integrity": "sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", + "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", @@ -1220,6 +1352,46 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.1.tgz", + "integrity": "sha512-oa3mXRRVjHi6DZu/ghuzdylyjaMXLymx83irM7hTxutQbD+7IhPKdMdRHD26Rm+kHRrWcrUkkRPv5pd47a2xFQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.0", + "@radix-ui/react-focus-guards": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.1", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-navigation-menu": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.0.tgz", @@ -1256,6 +1428,75 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", + "integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.0", + "@radix-ui/react-focus-guards": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.1", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", + "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-portal": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", @@ -1327,6 +1568,37 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", @@ -1460,6 +1732,24 @@ } } }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-size": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", @@ -1501,6 +1791,12 @@ } } }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1928,6 +2224,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", @@ -2818,6 +3126,12 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -3966,6 +4280,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-stream": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", @@ -4337,6 +4660,15 @@ "node": ">= 0.4" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -6137,6 +6469,76 @@ "dev": true, "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", + "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.4", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", + "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", + "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "invariant": "^2.2.4", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -7212,6 +7614,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", + "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", + "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index fa0d204c..cfaefd2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,8 +13,10 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.0", + "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", "@tanstack/react-query": "^5.56.2", diff --git a/frontend/utils/string.ts b/frontend/utils/string.ts new file mode 100644 index 00000000..4364fcaa --- /dev/null +++ b/frontend/utils/string.ts @@ -0,0 +1,7 @@ +export function getNameFromEmail(email?: string) { + return email?.split("@")[0]; +} + +export function getInitialFromEmail(email?: string) { + return email?.charAt(0); +} From 7100c5c9c6f7bd5f7b9371e0fab0c37e0055666e Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 21:49:14 +0800 Subject: [PATCH 08/18] fix: set navbar min height --- frontend/components/navigation/navbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/navigation/navbar.tsx b/frontend/components/navigation/navbar.tsx index b39ed3bc..780d53e7 100644 --- a/frontend/components/navigation/navbar.tsx +++ b/frontend/components/navigation/navbar.tsx @@ -33,7 +33,7 @@ function Navbar() { return (
-
+
From 4d3f9c7ad022d4189c946582c19a6b7fdfd29ba6 Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 21:55:08 +0800 Subject: [PATCH 09/18] chore: regenerate axios client --- frontend/client/schemas.gen.ts | 19 +++++++++++++++++++ frontend/client/services.gen.ts | 22 +++++++++++++++++++--- frontend/client/types.gen.ts | 12 +++++++++++- frontend/openapi-ts.config.ts | 2 +- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/frontend/client/schemas.gen.ts b/frontend/client/schemas.gen.ts index b8e2a7ed..e8916813 100644 --- a/frontend/client/schemas.gen.ts +++ b/frontend/client/schemas.gen.ts @@ -87,6 +87,25 @@ export const SignUpDataSchema = { title: "SignUpData", } as const; +export const TokenSchema = { + properties: { + access_token: { + type: "string", + title: "Access Token", + }, + token_type: { + type: "string", + title: "Token Type", + }, + user: { + $ref: "#/components/schemas/UserPublic", + }, + }, + type: "object", + required: ["access_token", "token_type", "user"], + title: "Token", +} as const; + export const UserPublicSchema = { properties: { id: { diff --git a/frontend/client/services.gen.ts b/frontend/client/services.gen.ts index 8fffebce..f65c9cd9 100644 --- a/frontend/client/services.gen.ts +++ b/frontend/client/services.gen.ts @@ -18,14 +18,14 @@ import type { LogInAuthLoginPostResponse, LoginGoogleAuthLoginGoogleGetError, LoginGoogleAuthLoginGoogleGetResponse, + LogoutAuthLogoutGetError, + LogoutAuthLogoutGetResponse, SignUpAuthSignupPostData, SignUpAuthSignupPostError, SignUpAuthSignupPostResponse, } from "./types.gen"; -export const client = createClient( - createConfig({ baseURL: process.env.NEXT_PUBLIC_BACKEND_URL }), -); +export const client = createClient(createConfig()); /** * Sign Up @@ -113,3 +113,19 @@ export const getUserAuthSessionGet = ( url: "/auth/session", }); }; + +/** + * Logout + */ +export const logoutAuthLogoutGet = ( + options?: Options, +) => { + return (options?.client ?? client).get< + LogoutAuthLogoutGetResponse, + LogoutAuthLogoutGetError, + ThrowOnError + >({ + ...options, + url: "/auth/logout", + }); +}; diff --git a/frontend/client/types.gen.ts b/frontend/client/types.gen.ts index 9d50e4a2..a4e57709 100644 --- a/frontend/client/types.gen.ts +++ b/frontend/client/types.gen.ts @@ -18,6 +18,12 @@ export type SignUpData = { password: string; }; +export type Token = { + access_token: string; + token_type: string; + user: UserPublic; +}; + export type UserPublic = { id: number; email: string; @@ -41,7 +47,7 @@ export type LogInAuthLoginPostData = { body: Body_log_in_auth_login_post; }; -export type LogInAuthLoginPostResponse = unknown; +export type LogInAuthLoginPostResponse = Token; export type LogInAuthLoginPostError = HTTPValidationError; @@ -64,3 +70,7 @@ export type GetUserAuthSessionGetData = unknown; export type GetUserAuthSessionGetResponse = UserPublic; export type GetUserAuthSessionGetError = HTTPValidationError; + +export type LogoutAuthLogoutGetResponse = unknown; + +export type LogoutAuthLogoutGetError = unknown; diff --git a/frontend/openapi-ts.config.ts b/frontend/openapi-ts.config.ts index d6986418..6102c105 100644 --- a/frontend/openapi-ts.config.ts +++ b/frontend/openapi-ts.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "@hey-api/openapi-ts"; export default defineConfig({ client: { bundle: true, name: "@hey-api/client-axios" }, - input: `${process.env.NEXT_PUBLIC_BACKEND_URL}/openapi.json`, + input: "http://localhost:8000/openapi.json", output: "client", }); From 60ea4ed6bb67c03df3b81d60855a2b9e7e54625b Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 21:55:38 +0800 Subject: [PATCH 10/18] style: fix linter errors --- .../components/auth/user-profile-button.tsx | 7 +- frontend/components/ui/dropdown-menu.tsx | 114 +++++++++--------- frontend/components/ui/popover.tsx | 24 ++-- 3 files changed, 73 insertions(+), 72 deletions(-) diff --git a/frontend/components/auth/user-profile-button.tsx b/frontend/components/auth/user-profile-button.tsx index 7cc57e49..aef934ce 100644 --- a/frontend/components/auth/user-profile-button.tsx +++ b/frontend/components/auth/user-profile-button.tsx @@ -1,6 +1,6 @@ +import { useRouter } from "next/navigation"; + import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { useUserStore } from "@/store/user/user-store-provider"; -import { getInitialFromEmail, getNameFromEmail } from "@/utils/string"; import { DropdownMenu, DropdownMenuContent, @@ -10,7 +10,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useRouter } from "next/navigation"; +import { useUserStore } from "@/store/user/user-store-provider"; +import { getInitialFromEmail, getNameFromEmail } from "@/utils/string"; const UserProfileButton = () => { const { email, setNotLoggedIn } = useUserStore((state) => state); diff --git a/frontend/components/ui/dropdown-menu.tsx b/frontend/components/ui/dropdown-menu.tsx index f69a0d64..76361110 100644 --- a/frontend/components/ui/dropdown-menu.tsx +++ b/frontend/components/ui/dropdown-menu.tsx @@ -1,60 +1,60 @@ -"use client" +"use client"; -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { Check, ChevronRight, Circle } from "lucide-react" +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const DropdownMenu = DropdownMenuPrimitive.Root +const DropdownMenu = DropdownMenuPrimitive.Root; -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -const DropdownMenuGroup = DropdownMenuPrimitive.Group +const DropdownMenuGroup = DropdownMenuPrimitive.Group; -const DropdownMenuPortal = DropdownMenuPrimitive.Portal +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; -const DropdownMenuSub = DropdownMenuPrimitive.Sub +const DropdownMenuSub = DropdownMenuPrimitive.Sub; -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, children, ...props }, ref) => ( {children} -)) +)); DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName + DropdownMenuPrimitive.SubTrigger.displayName; const DropdownMenuSubContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( -)) +)); DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName + DropdownMenuPrimitive.SubContent.displayName; const DropdownMenuContent = React.forwardRef< React.ElementRef, @@ -62,47 +62,47 @@ const DropdownMenuContent = React.forwardRef< >(({ className, sideOffset = 4, ...props }, ref) => ( -)) -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, checked, ...props }, ref) => ( @@ -112,20 +112,20 @@ const DropdownMenuCheckboxItem = React.forwardRef< {children} -)) +)); DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName + DropdownMenuPrimitive.CheckboxItem.displayName; const DropdownMenuRadioItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( @@ -135,38 +135,38 @@ const DropdownMenuRadioItem = React.forwardRef< {children} -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; const DropdownMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; const DropdownMenuSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( -)) -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ className, @@ -177,24 +177,24 @@ const DropdownMenuShortcut = ({ className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} /> - ) -} -DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; export { DropdownMenu, - DropdownMenuTrigger, + DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -} + DropdownMenuTrigger, +}; diff --git a/frontend/components/ui/popover.tsx b/frontend/components/ui/popover.tsx index a0ec48be..2283eeb4 100644 --- a/frontend/components/ui/popover.tsx +++ b/frontend/components/ui/popover.tsx @@ -1,13 +1,13 @@ -"use client" +"use client"; -import * as React from "react" -import * as PopoverPrimitive from "@radix-ui/react-popover" +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Popover = PopoverPrimitive.Root +const Popover = PopoverPrimitive.Root; -const PopoverTrigger = PopoverPrimitive.Trigger +const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverContent = React.forwardRef< React.ElementRef, @@ -15,17 +15,17 @@ const PopoverContent = React.forwardRef< >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( -)) -PopoverContent.displayName = PopoverPrimitive.Content.displayName +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent } +export { Popover, PopoverContent, PopoverTrigger }; From 8e0e5752507e33f585273e059cccc895983ddf32 Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 22:15:49 +0800 Subject: [PATCH 11/18] fix: client base url --- frontend/client/services.gen.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/client/services.gen.ts b/frontend/client/services.gen.ts index f65c9cd9..db187d3e 100644 --- a/frontend/client/services.gen.ts +++ b/frontend/client/services.gen.ts @@ -25,7 +25,9 @@ import type { SignUpAuthSignupPostResponse, } from "./types.gen"; -export const client = createClient(createConfig()); +export const client = createClient( + createConfig({ baseURL: process.env.NEXT_PUBLIC_BACKEND_URL }), +); /** * Sign Up From b3bcd995dafe33c770159359d4773820431e522a Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 22:16:06 +0800 Subject: [PATCH 12/18] feat: logout --- frontend/components/auth/user-profile-button.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/components/auth/user-profile-button.tsx b/frontend/components/auth/user-profile-button.tsx index aef934ce..6a5463fd 100644 --- a/frontend/components/auth/user-profile-button.tsx +++ b/frontend/components/auth/user-profile-button.tsx @@ -1,5 +1,6 @@ import { useRouter } from "next/navigation"; +import { logoutAuthLogoutGet } from "@/client"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { DropdownMenu, @@ -17,6 +18,11 @@ const UserProfileButton = () => { const { email, setNotLoggedIn } = useUserStore((state) => state); const router = useRouter(); + const signout = async () => { + await logoutAuthLogoutGet(); + setNotLoggedIn(); + }; + return (
@@ -43,7 +49,7 @@ const UserProfileButton = () => { Log out From 9d5fc584fed5b1eb0b3aea4dd450572b601507ba Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 22:16:13 +0800 Subject: [PATCH 13/18] fix: typo --- frontend/app/login/page.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index d5126c2a..41a85440 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -36,7 +36,7 @@ const loginFormDefault = { type LoginForm = z.infer; function LoginPage() { - const [isError, seIstError] = useState(false); + const [isError, setIsError] = useState(false); const form = useForm({ resolver: zodResolver(loginFormSchema), defaultValues: loginFormDefault, @@ -49,9 +49,9 @@ function LoginPage() { }); if (response.error) { - seIstError(true); + setIsError(true); } else { - seIstError(false); + setIsError(false); } }; From 9188a8e42b5d4bf7df3ed983312178492280bf07 Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 22:16:38 +0800 Subject: [PATCH 14/18] chore: change cookie expiration duration --- backend/src/auth/dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/auth/dependencies.py b/backend/src/auth/dependencies.py index a47cdcee..8eeaabf8 100644 --- a/backend/src/auth/dependencies.py +++ b/backend/src/auth/dependencies.py @@ -27,7 +27,7 @@ async def __call__(self, request: Request) -> Optional[str]: ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 30 +ACCESS_TOKEN_EXPIRE_MINUTES = 10080 ################## # Password utils # From ba8caa5a2ea331e17951be464970bfa24386a10e Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 23:07:26 +0800 Subject: [PATCH 15/18] fix: actually logout --- frontend/client/services.gen.ts | 5 ++++- frontend/components/auth/user-profile-button.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/client/services.gen.ts b/frontend/client/services.gen.ts index db187d3e..a00fdc58 100644 --- a/frontend/client/services.gen.ts +++ b/frontend/client/services.gen.ts @@ -26,7 +26,10 @@ import type { } from "./types.gen"; export const client = createClient( - createConfig({ baseURL: process.env.NEXT_PUBLIC_BACKEND_URL }), + createConfig({ + baseURL: process.env.NEXT_PUBLIC_BACKEND_URL, + withCredentials: true, + }), ); /** diff --git a/frontend/components/auth/user-profile-button.tsx b/frontend/components/auth/user-profile-button.tsx index 6a5463fd..f422e9e7 100644 --- a/frontend/components/auth/user-profile-button.tsx +++ b/frontend/components/auth/user-profile-button.tsx @@ -19,7 +19,7 @@ const UserProfileButton = () => { const router = useRouter(); const signout = async () => { - await logoutAuthLogoutGet(); + await logoutAuthLogoutGet({ withCredentials: true }); setNotLoggedIn(); }; From 3e71b7f4cd447f0e661dbb9968ff3a3a2980c4b3 Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 23:13:55 +0800 Subject: [PATCH 16/18] fix: client withCredentials --- frontend/client/services.gen.ts | 7 +------ frontend/store/store-provider.tsx | 6 ++++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/client/services.gen.ts b/frontend/client/services.gen.ts index a00fdc58..f65c9cd9 100644 --- a/frontend/client/services.gen.ts +++ b/frontend/client/services.gen.ts @@ -25,12 +25,7 @@ import type { SignUpAuthSignupPostResponse, } from "./types.gen"; -export const client = createClient( - createConfig({ - baseURL: process.env.NEXT_PUBLIC_BACKEND_URL, - withCredentials: true, - }), -); +export const client = createClient(createConfig()); /** * Sign Up diff --git a/frontend/store/store-provider.tsx b/frontend/store/store-provider.tsx index 06ecd943..f5a2f180 100644 --- a/frontend/store/store-provider.tsx +++ b/frontend/store/store-provider.tsx @@ -3,10 +3,16 @@ import { ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { client } from "@/client"; + import { UserStoreProvider } from "./user/user-store-provider"; export function StoreProvider({ children }: { children: ReactNode }) { const queryClient = new QueryClient(); + client.setConfig({ + baseURL: process.env.NEXT_PUBLIC_BACKEND_URL, + withCredentials: true, + }); return ( From a290438352cbb1cd2d3525bc836927722529770e Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 23:16:21 +0800 Subject: [PATCH 17/18] feat: redirect after auth --- frontend/app/login/page.tsx | 3 +++ frontend/app/register/page.tsx | 9 ++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 41a85440..d7f81357 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; +import { useRouter } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; import { CircleAlert } from "lucide-react"; import { z } from "zod"; @@ -36,6 +37,7 @@ const loginFormDefault = { type LoginForm = z.infer; function LoginPage() { + const router = useRouter(); const [isError, setIsError] = useState(false); const form = useForm({ resolver: zodResolver(loginFormSchema), @@ -52,6 +54,7 @@ function LoginPage() { setIsError(true); } else { setIsError(false); + router.push("/"); } }; diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx index d0e47b98..23bfbfa6 100644 --- a/frontend/app/register/page.tsx +++ b/frontend/app/register/page.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; +import { useRouter } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; import { CircleAlert } from "lucide-react"; import { z } from "zod"; @@ -29,7 +30,8 @@ const registerFormDefault = { type RegisterForm = z.infer; function RegisterPage() { - const [isError, seIstError] = useState(false); + const router = useRouter(); + const [isError, setIsError] = useState(false); const form = useForm({ resolver: zodResolver(registerFormSchema), defaultValues: registerFormDefault, @@ -42,9 +44,10 @@ function RegisterPage() { }); if (response.error) { - seIstError(true); + setIsError(true); } else { - seIstError(false); + setIsError(false); + router.push("/"); } }; From 3f7951cc3ce8937fcf75b4112fa90a174dad51f2 Mon Sep 17 00:00:00 2001 From: Chloe Lim Date: Sat, 21 Sep 2024 23:20:38 +0800 Subject: [PATCH 18/18] fix: redirect succesful registration to login --- frontend/app/register/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx index 23bfbfa6..86c97c06 100644 --- a/frontend/app/register/page.tsx +++ b/frontend/app/register/page.tsx @@ -47,7 +47,7 @@ function RegisterPage() { setIsError(true); } else { setIsError(false); - router.push("/"); + router.push("/login"); } };