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" - } -} 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 # 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 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/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/app/login/page.tsx b/frontend/app/login/page.tsx index d5f183c1..d7f81357 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -2,14 +2,15 @@ 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"; 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"; @@ -36,7 +37,8 @@ const loginFormDefault = { type LoginForm = z.infer; function LoginPage() { - const [isError, seIstError] = useState(false); + const router = useRouter(); + const [isError, setIsError] = useState(false); const form = useForm({ resolver: zodResolver(loginFormSchema), defaultValues: loginFormDefault, @@ -49,9 +51,10 @@ function LoginPage() { }); if (response.error) { - seIstError(true); + setIsError(true); } else { - seIstError(false); + setIsError(false); + router.push("/"); } }; diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx index 6c1c9589..86c97c06 100644 --- a/frontend/app/register/page.tsx +++ b/frontend/app/register/page.tsx @@ -2,14 +2,15 @@ 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"; 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"; @@ -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("/login"); } }; 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..e8916813 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", }, }, @@ -86,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 3db4e0e6..f65c9cd9 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, @@ -19,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 @@ -114,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/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/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..f422e9e7 --- /dev/null +++ b/frontend/components/auth/user-profile-button.tsx @@ -0,0 +1,63 @@ +import { useRouter } from "next/navigation"; + +import { logoutAuthLogoutGet } from "@/client"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useUserStore } from "@/store/user/user-store-provider"; +import { getInitialFromEmail, getNameFromEmail } from "@/utils/string"; + +const UserProfileButton = () => { + const { email, setNotLoggedIn } = useUserStore((state) => state); + const router = useRouter(); + + const signout = async () => { + await logoutAuthLogoutGet({ withCredentials: true }); + setNotLoggedIn(); + }; + + return ( +
+ + + + {getInitialFromEmail(email)} + + + + + +
+

{getNameFromEmail(email)}

+

{email}

+
+
+ + + router.push("/user/profile")}> + Manage my profile + + + + + + Log out + + +
+
+
+ ); +}; + +export default UserProfileButton; 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..780d53e7 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,57 @@ import { NavigationMenuLink, NavigationMenuList, } from "@radix-ui/react-navigation-menu"; +import { useQuery } from "@tanstack/react-query"; +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"; import { NavItem } from "@/types/navigation"; export const NavItems: NavItem[] = []; function Navbar() { + const { 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]); + return (
-
- - - {process.env.NEXT_PUBLIC_APP_NAME} - - - - - {NavItems.map((navItem) => ( - - - - {navItem.label} - - - - ))} - - +
+
+ + + {process.env.NEXT_PUBLIC_APP_NAME} + + + + + {NavItems.map((navItem) => ( + + + + {navItem.label} + + + + ))} + + +
+ {isLoggedIn && }
); 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 }; 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..76361110 --- /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, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/frontend/components/ui/popover.tsx b/frontend/components/ui/popover.tsx new file mode 100644 index 00000000..2283eeb4 --- /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, PopoverContent, PopoverTrigger }; diff --git a/frontend/openapi-ts.config.ts b/frontend/openapi-ts.config.ts new file mode 100644 index 00000000..6102c105 --- /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: "http://localhost:8000/openapi.json", + output: "client", +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e3e8542..9a173be6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,9 +9,12 @@ "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-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", @@ -25,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", @@ -601,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", @@ -1023,6 +1065,55 @@ "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", + "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", @@ -1151,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", @@ -1192,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", @@ -1228,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", @@ -1299,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", @@ -1432,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", @@ -1473,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", @@ -1900,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", @@ -2790,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", @@ -3938,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", @@ -4309,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", @@ -6109,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", @@ -7184,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", @@ -7446,6 +7919,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..cfaefd2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,13 +7,16 @@ "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-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", @@ -27,7 +30,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", +} diff --git a/frontend/store/store-provider.tsx b/frontend/store/store-provider.tsx new file mode 100644 index 00000000..f5a2f180 --- /dev/null +++ b/frontend/store/store-provider.tsx @@ -0,0 +1,22 @@ +"use client"; + +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 ( + + {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, + })), + })); +}; 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); +}