diff --git a/apps/client/package-lock.json b/apps/client/package-lock.json index 7c8e807a..f5eeb961 100644 --- a/apps/client/package-lock.json +++ b/apps/client/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.1", "dependencies": { "@clerk/clerk-react": "^5.35.3", + "@clerk/types": "^4.90.0", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-form": "^1.15.0", "@tanstack/react-query": "^5.90.2", @@ -20,6 +21,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-icons": "^5.5.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11" }, @@ -576,9 +578,9 @@ } }, "node_modules/@clerk/types": { - "version": "4.68.0", - "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.68.0.tgz", - "integrity": "sha512-3+PoGGQgyzLZibzYleByPmx6dfPVoFUjLBy+kASHzF9g3o12WchDn1aI01vN7WNm1ik1Z5sbLhzJs8d9bz38EQ==", + "version": "4.90.0", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.90.0.tgz", + "integrity": "sha512-AnXeCkyFdM+3icdeKElajVKV73IvlQ6eeWTaLG700CzL+Zw9iD2YV4t8qAhNhwo0FFtkcI9EKDblw6G9Rn1Onw==", "license": "MIT", "dependencies": { "csstype": "3.1.3" @@ -5434,6 +5436,16 @@ "seroval-plugins": "~1.3.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", diff --git a/apps/client/package.json b/apps/client/package.json index dc273f2a..0d7f305e 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -11,11 +11,12 @@ "lint": "npx eslint .", "lint:fix": "npx eslint . --fix", "typecheck": "npx tsc --noEmit", - "generate": "npx openapi-typescript https://api.echo-social.app/openapi -o ./src/libs/api/v1.d.ts", + "openapi": "npx openapi-typescript https://api.echo-social.app/openapi -o ./src/libs/api/v1.d.ts", "preview": "vite preview" }, "dependencies": { "@clerk/clerk-react": "^5.35.3", + "@clerk/types": "^4.90.0", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-form": "^1.15.0", "@tanstack/react-query": "^5.90.2", @@ -27,6 +28,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-icons": "^5.5.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11" }, diff --git a/apps/client/src/libs/api/index.ts b/apps/client/src/libs/api/index.ts index 7ad69910..2b320736 100644 --- a/apps/client/src/libs/api/index.ts +++ b/apps/client/src/libs/api/index.ts @@ -1,15 +1,26 @@ -import createClient from "openapi-fetch" +import createOpenApiClient from "openapi-fetch" import type { components, paths } from "./v1" -const BASE_URL = import.meta.env.VITE_API_BASE_URL +export const createClient = (baseUrl: string) => createOpenApiClient({ baseUrl }) -// TODO: validate .env variables in one place -if (!BASE_URL) { - throw new Error("API base URL missing from .env file!") -} +export class ApiException extends Error { + public timestamp: string + public status: number + public path: string + public response: Response -export const client = createClient({ baseUrl: BASE_URL }) + constructor(error: schemas["ErrorResponse"], response: Response) { + super(error.message) + this.timestamp = error.timestamp + this.status = error.status + this.path = error.path + this.response = response + this.name = "ApiException" + } +} -export type { paths, operations } from "./v1" +export type { Middleware } from "openapi-fetch" +export type { operations, paths } from "./v1" export type schemas = components["schemas"] +export type Client = ReturnType diff --git a/apps/client/src/libs/api/v1.d.ts b/apps/client/src/libs/api/v1.d.ts index 8147e2f7..2090b36c 100644 --- a/apps/client/src/libs/api/v1.d.ts +++ b/apps/client/src/libs/api/v1.d.ts @@ -11,7 +11,9 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get your profile */ get: operations["getMe"]; + /** @description Update your profile */ put: operations["updateProfile"]; post?: never; delete?: never; @@ -29,7 +31,9 @@ export interface paths { }; get?: never; put?: never; + /** @description Follow a profile by ID */ post: operations["followById"]; + /** @description Unfollow a profile by ID */ delete: operations["unfollowById"]; options?: never; head?: never; @@ -45,6 +49,7 @@ export interface paths { }; get?: never; put?: never; + /** @description Create a post */ post: operations["create"]; delete?: never; options?: never; @@ -61,7 +66,9 @@ export interface paths { }; get?: never; put?: never; + /** @description Like a post by ID */ post: operations["like"]; + /** @description Unlike a post by ID */ delete: operations["unlike"]; options?: never; head?: never; @@ -77,6 +84,7 @@ export interface paths { }; get?: never; put?: never; + /** @description Sync the authenticated Clerk user to the local application */ post: operations["clerkOnboarding"]; delete?: never; options?: never; @@ -91,6 +99,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get a profile by username */ get: operations["getByUsername"]; put?: never; post?: never; @@ -107,6 +116,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get following by ID */ get: operations["getFollowing"]; put?: never; post?: never; @@ -123,6 +133,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get followers by ID */ get: operations["getFollowers"]; put?: never; post?: never; @@ -139,9 +150,11 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get a post by ID */ get: operations["getPostById"]; put?: never; post?: never; + /** @description Delete a post by ID */ delete: operations["delete"]; options?: never; head?: never; @@ -155,6 +168,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get post replies by ID */ get: operations["getRepliesByPostId"]; put?: never; post?: never; @@ -171,6 +185,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get replies by profile ID */ get: operations["getProfileReplies"]; put?: never; post?: never; @@ -187,6 +202,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get posts by profile ID */ get: operations["getProfilePosts"]; put?: never; post?: never; @@ -203,6 +219,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get mentions of profile ID */ get: operations["getProfileMentions"]; put?: never; post?: never; @@ -219,6 +236,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get likes by profile ID */ get: operations["getProfileLikes"]; put?: never; post?: never; @@ -235,6 +253,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get your homepage posts */ get: operations["getHomeFeed"]; put?: never; post?: never; @@ -251,6 +270,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get discover page posts */ get: operations["getDiscoverFeed"]; put?: never; post?: never; @@ -270,6 +290,14 @@ export interface components { bio?: string; location?: string; }; + ErrorResponse: { + /** Format: date-time */ + timestamp: string; + /** Format: int32 */ + status: number; + message: string; + path: string; + }; /** @description Represents the request body required to create a new post. */ CreatePostRequest: { /** Format: uuid */ @@ -426,13 +454,22 @@ export interface operations { }; }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description Invalid field(s) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; followById: { @@ -446,13 +483,31 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Already following profile with ID */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; unfollowById: { @@ -466,13 +521,22 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; create: { @@ -488,13 +552,31 @@ export interface operations { }; }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description Invalid field(s) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Parent ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; like: { @@ -508,13 +590,31 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Already liked post with ID */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; unlike: { @@ -528,8 +628,8 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -546,8 +646,8 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; @@ -577,13 +677,24 @@ export interface operations { "*/*": components["schemas"]["Profile"]; }; }; + /** @description Username not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getFollowing: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -602,13 +713,33 @@ export interface operations { "*/*": components["schemas"]["PagedSimplifiedProfile"]; }; }; + /** @description Invalid pagination parameter(s) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getFollowers: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -627,6 +758,24 @@ export interface operations { "*/*": components["schemas"]["PagedSimplifiedProfile"]; }; }; + /** @description Invalid pagination parameter(s) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getPostById: { @@ -649,6 +798,15 @@ export interface operations { "*/*": components["schemas"]["Post"]; }; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; delete: { @@ -662,20 +820,31 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description Cannot delete another user's post */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getRepliesByPostId: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -694,13 +863,24 @@ export interface operations { "*/*": components["schemas"]["PagedPost"]; }; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getProfileReplies: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -719,13 +899,24 @@ export interface operations { "*/*": components["schemas"]["PagedPost"]; }; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getProfilePosts: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -744,13 +935,24 @@ export interface operations { "*/*": components["schemas"]["PagedPost"]; }; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getProfileMentions: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -769,13 +971,24 @@ export interface operations { "*/*": components["schemas"]["PagedPost"]; }; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getProfileLikes: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -794,13 +1007,24 @@ export interface operations { "*/*": components["schemas"]["PagedPost"]; }; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getHomeFeed: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path?: never; @@ -822,8 +1046,10 @@ export interface operations { getDiscoverFeed: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path?: never; diff --git a/apps/client/src/providers/index.tsx b/apps/client/src/providers/index.tsx index beecf00f..3522ed37 100644 --- a/apps/client/src/providers/index.tsx +++ b/apps/client/src/providers/index.tsx @@ -1,5 +1,6 @@ import { EchoClerkProvider } from "./clerk-provider" import { EchoQueryClientProvider } from "./query-client-provider" +import SonnerProvider from "./sonner-provider" interface Props { children: React.ReactNode @@ -8,7 +9,10 @@ interface Props { export function AppProvider({ children }: Readonly) { return ( - {children} + + {children} + + ) } diff --git a/apps/client/src/providers/query-client-provider.tsx b/apps/client/src/providers/query-client-provider.tsx index 7cb94e30..7876bbc5 100644 --- a/apps/client/src/providers/query-client-provider.tsx +++ b/apps/client/src/providers/query-client-provider.tsx @@ -1,6 +1,6 @@ import { QueryClientProvider } from "@tanstack/react-query" -import { queryClient } from "@/utils/api" +import { queryClient } from "@/utils/query" interface Props { children: React.ReactNode diff --git a/apps/client/src/providers/sonner-provider.tsx b/apps/client/src/providers/sonner-provider.tsx new file mode 100644 index 00000000..49114933 --- /dev/null +++ b/apps/client/src/providers/sonner-provider.tsx @@ -0,0 +1,5 @@ +import { Toaster } from "sonner" + +export default function SonnerProvider() { + return +} diff --git a/apps/client/src/routes/(protected)/home.tsx b/apps/client/src/routes/(protected)/home.tsx index 4cf8f3ce..a3cf8f8a 100644 --- a/apps/client/src/routes/(protected)/home.tsx +++ b/apps/client/src/routes/(protected)/home.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react" import { UserButton, useAuth } from "@clerk/clerk-react" import { createFileRoute } from "@tanstack/react-router" -import { client } from "@/libs/api" +import { client } from "@/utils/api" export const Route = createFileRoute("/(protected)/home")({ component: HomePage diff --git a/apps/client/src/types/global.d.ts b/apps/client/src/types/global.d.ts index 6bafd05d..efc0a80f 100644 --- a/apps/client/src/types/global.d.ts +++ b/apps/client/src/types/global.d.ts @@ -1,4 +1,8 @@ +import type { Clerk } from "@clerk/clerk-js" + declare global { + var Clerk: Clerk + interface CustomJwtSessionClaims { echo_id: string | null onboarded: boolean diff --git a/apps/client/src/utils/api.ts b/apps/client/src/utils/api.ts index c73f1377..8173e30f 100644 --- a/apps/client/src/utils/api.ts +++ b/apps/client/src/utils/api.ts @@ -1,5 +1,41 @@ -import { QueryClient } from "@tanstack/react-query" +import { toast } from "sonner" -export const queryClient = new QueryClient({ - defaultOptions: {} -}) +import { ApiException, type Middleware, createClient } from "@/libs/api" + +const BASE_URL = import.meta.env.VITE_API_BASE_URL + +// TODO: validate .env variables in one place +if (!BASE_URL) { + throw new Error("API base URL missing from .env file!") +} + +const authMiddleware: Middleware = { + onRequest: async ({ request }) => { + const token = await globalThis.Clerk?.session?.getToken() + + if (token) { + request.headers.append("Authorization", `Bearer ${token}`) + } + } +} + +const errorMiddleware: Middleware = { + onResponse: async ({ response }) => { + if (response.ok) return response + + const error = await response.json() + throw new ApiException(error, response) // https://openapi-ts.dev/openapi-fetch/middleware-auth#throwing + }, + onError: () => { + toast.error("A network error occurred", { description: "Please try again later" }) + } +} + +function createClientWithMiddleware() { + const client = createClient(BASE_URL) + client.use(authMiddleware) + client.use(errorMiddleware) + return client +} + +export const client = createClientWithMiddleware() diff --git a/apps/client/src/utils/query.ts b/apps/client/src/utils/query.ts new file mode 100644 index 00000000..88b71a79 --- /dev/null +++ b/apps/client/src/utils/query.ts @@ -0,0 +1,8 @@ +import { QueryClient } from "@tanstack/react-query" + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: {}, + mutations: {} + } +}) diff --git a/apps/client/src/utils/router.ts b/apps/client/src/utils/router.ts index 29cdeee1..a6708213 100644 --- a/apps/client/src/utils/router.ts +++ b/apps/client/src/utils/router.ts @@ -2,7 +2,7 @@ import { createRouter } from "@tanstack/react-router" import { routeTree } from "@/routeTree.gen" -import { queryClient } from "./api" +import { queryClient } from "./query" declare module "@tanstack/react-router" { interface Register {