From c096b75a403015beac09008cac33d063421045aa Mon Sep 17 00:00:00 2001 From: Jakob Helgesson Date: Sun, 8 Dec 2024 12:35:49 +0100 Subject: [PATCH] Introduce query helpers --- web/src/api/core.ts | 183 +++++++++++++++- web/src/api/generate_id.ts | 15 +- web/src/api/instance_settings.ts | 24 +-- web/src/api/item/generate.ts | 7 +- web/src/api/item/index.ts | 200 +++++++----------- web/src/api/me.ts | 23 +- web/src/api/media/index.ts | 76 ++----- web/src/api/search/index.ts | 36 ++-- web/src/api/searchtasks.ts | 28 +-- web/src/api/sessions.ts | 23 +- web/src/api/user/index.ts | 34 ++- web/src/api/user/pat.ts | 98 +++++---- web/src/components/ActiveSessionsTable.tsx | 4 +- web/src/components/Navbar.tsx | 2 +- web/src/components/UserProfile.tsx | 6 +- web/src/components/input/NewItemIdInput.tsx | 2 +- web/src/components/item/ItemPreview.tsx | 21 +- web/src/components/logs/ItemLogEntry.tsx | 4 +- web/src/components/logs/ItemLogSection.tsx | 2 +- web/src/components/media/MediaPreview.tsx | 20 +- web/src/components/owned_elements.tsx | 2 +- web/src/components/search/SearchInput.tsx | 4 +- .../search_tasks/SearchTaskTable.tsx | 4 +- web/src/components/stl_preview/StlPreview.tsx | 9 +- .../user_api_keys/UserApiKeysTable.tsx | 10 +- web/src/routes/$itemId.tsx | 7 +- web/src/routes/create.lazy.tsx | 6 +- web/src/routes/item/$itemId/edit.tsx | 39 ++-- web/src/routes/item/$itemId/index.tsx | 33 ++- web/src/routes/settings/index.lazy.tsx | 2 +- web/src/routes/user/$userId.tsx | 4 +- web/src/types.d.ts | 1 + web/src/vite-env.d.ts | 12 ++ web/vite.config.ts | 3 +- 34 files changed, 530 insertions(+), 414 deletions(-) create mode 100644 web/src/types.d.ts create mode 100644 web/src/vite-env.d.ts diff --git a/web/src/api/core.ts b/web/src/api/core.ts index 8b0e42c..4a8825e 100644 --- a/web/src/api/core.ts +++ b/web/src/api/core.ts @@ -1,14 +1,24 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { + DefaultError, DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryFunctionContext, + QueryKey, QueryObserverOptions, + skipToken, + useMutation, + UseMutationOptions, useQuery, + UseQueryOptions, } from '@tanstack/react-query'; import { useAuth } from './auth'; import { paths } from './schema.gen'; -export const BASE_URL = 'http://localhost:3000'; +export const BASE_URL = + import.meta.env.VITE_API_URL || `${window.location.origin}/api/`; type HTTPMethod = | 'get' @@ -128,7 +138,7 @@ export class ApiError extends Error { } } -export type ApiRequest< +export type ApiRoute< TPath extends keyof paths, TMethod extends PathMethods, TRoute extends AnyRoute = paths[TPath][TMethod] extends AnyRoute @@ -256,7 +266,7 @@ export const apiRequest = async < } } - const url = new URL('/api' + path, BASE_URL); + const url = new URL(`.${path}`, BASE_URL); if (query) { for (const [key, value] of Object.entries(query)) { @@ -272,19 +282,30 @@ export const apiRequest = async < } } - const { token } = useAuth.getState(); + const { token, clearAuthToken } = useAuth.getState(); if (token) { headers.set('Authorization', 'Bearer ' + token); } + if (contentType) { + headers.set('Content-Type', contentType); + } + const response = await fetch(url, { - method, + method: method.toUpperCase(), headers, body: convertBody(data, contentType), ...fetchOptions, }); + if (response.status === 401) { + console.log('Token expired, clearing token'); + clearAuthToken(); + + throw new ApiError('Token expired', 401); + } + if (!response.ok) { throw ApiError.fromResponse(response); } @@ -311,6 +332,156 @@ export const apiRequest = async < } }; +/// Query Creators + +const getFullKey = (queryKey: QueryKey, variables?: any): QueryKey => { + return variables === undefined ? queryKey : [...queryKey, variables]; +}; + +export type CreateQueryOptions< + TFunctionData, + TVariables = void, + TError = DefaultError +> = Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'select' +> & { + fetcher: ( + variables: TVariables, + context: QueryFunctionContext + ) => TFunctionData | Promise; + queryKey: QueryKey; + variables?: TVariables; +}; + +export type QueryHookOptions< + TFunctionData, + TVariables = void, + TError = DefaultError, + TData = TFunctionData +> = Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'queryKeyHashFn' +> & + (TVariables extends void + ? { + variables?: undefined; + } + : { + variables: TVariables; + }); + +export const createQuery = < + TFunctionData, + TVariables = void, + TError = DefaultError +>( + defaultOptions: CreateQueryOptions +) => { + const getQueryOptions = ( + fetcherFunction: ( + variables: TVariables, + context: QueryFunctionContext + ) => TFunctionData | Promise, + variables: TVariables + ) => { + return { + queryFn: + variables && variables === skipToken + ? (skipToken as any) + : (context: QueryFunctionContext) => + fetcherFunction(variables, context), + queryKey: getFullKey(defaultOptions.queryKey, variables), + } as { + queryFn: QueryFunction; + queryKey: QueryKey; + }; + }; + + const getKey = (variables?: TVariables) => + getFullKey(defaultOptions.queryKey, variables); + + const getOptions = (variables: TVariables) => { + return { + ...defaultOptions, + ...getQueryOptions(defaultOptions.fetcher, variables), + }; + }; + + const getFetchOptions = (variables: TVariables) => { + return { + ...getQueryOptions(defaultOptions.fetcher, variables), + queryKeyHashFn: defaultOptions.queryKeyHashFn, + }; + }; + + const useTQ = ( + options: QueryHookOptions, + queryClient?: QueryClient + ) => { + return useQuery( + { + ...defaultOptions, + ...options, + ...getQueryOptions( + defaultOptions.fetcher, + // @ts-ignore + options.variables ?? defaultOptions.variables + ), + }, + queryClient + ); + }; + + useTQ.getOptions = getOptions; + useTQ.getKey = getKey; + useTQ.getFetchOptions = getFetchOptions; + useTQ.fetcher = defaultOptions.fetcher; + + return useTQ as typeof useTQ & { + /** Type helper to infer the data type returned by this query. **Does not exist at runtime.** */ + $inferData: TFunctionData; + /** Type helper to infer the variables type accepted by this query. **Does not exist at runtime.** */ + $inferVariables: TVariables; + }; +}; + +export const createMutation = < + TData = unknown, + TVariables = void, + TError = DefaultError, + TContext = unknown +>( + defaultOptions: UseMutationOptions +) => { + const useTM = ( + options: Omit< + UseMutationOptions, + 'mutationFn' + >, + queryClient?: QueryClient + ) => { + return useMutation( + { + ...defaultOptions, + ...options, + }, + queryClient + ); + }; + + useTM.getKey = () => defaultOptions.mutationKey; + useTM.getOptions = () => defaultOptions; + useTM.mutationFn = defaultOptions.mutationFn; + + return useTM as typeof useTM & { + /** Type helper to infer the data type returned by this mutation. **Does not exist at runtime.** */ + $inferData: TData; + /** Type helper to infer the variables type accepted by this mutation. **Does not exist at runtime.** */ + $inferVariables: TVariables; + }; +}; + /** * @deprecated Use `ApiRequest` instead */ @@ -374,7 +545,7 @@ export const getHttp = } try { - const response = await fetch(BASE_URL + url, { headers }); + const response = await fetch(new URL(url, BASE_URL), { headers }); if (response.status === 401) { console.log('Token expired, clearing token'); diff --git a/web/src/api/generate_id.ts b/web/src/api/generate_id.ts index 2d69b7b..8c824fa 100644 --- a/web/src/api/generate_id.ts +++ b/web/src/api/generate_id.ts @@ -1,9 +1,14 @@ -import { useHttp } from './core'; +import { apiRequest, createQuery } from './core'; export const isValidId = (id?: string) => id && /^[\dA-Za-z]+$/.test(id); -export type ItemIdResponse = { - item_id: string; -}; +export type ItemIdResponse = typeof useGenerateId.$inferData; -export const useGenerateId = () => useHttp('/api/item/next'); +export const useGenerateId = createQuery({ + queryKey: ['generate-id'], + fetcher: async () => { + const response = await apiRequest('/item/next', 'get', {}); + + return response.data; + }, +}); diff --git a/web/src/api/instance_settings.ts b/web/src/api/instance_settings.ts index 573157d..e6e4d43 100644 --- a/web/src/api/instance_settings.ts +++ b/web/src/api/instance_settings.ts @@ -1,23 +1,17 @@ -import { queryOptions, useQuery } from '@tanstack/react-query'; +import { apiRequest, createQuery } from './core'; -import { getHttp } from './core'; +export type InstanceSettings = typeof useInstanceSettings.$inferData; -export type IdCasingPreference = 'upper' | 'lower'; +export type IdCasingPreference = InstanceSettings['id_casing_preference']; -export type InstanceSettings = { - id_casing_preference: IdCasingPreference; -}; - -export const instanceSettingsQueryOptions = queryOptions({ +export const useInstanceSettings = createQuery({ queryKey: ['instance_settings'], - queryFn: getHttp('/api/instance/settings', { - auth: 'include', - }), -}); + fetcher: async () => { + const response = await apiRequest('/instance/settings', 'get', {}); -export const useInstanceSettings = () => { - return useQuery(instanceSettingsQueryOptions); -}; + return response.data; + }, +}); export const formatIdCasing = ( id: string | undefined, diff --git a/web/src/api/item/generate.ts b/web/src/api/item/generate.ts index 68dbb9c..9c9eec3 100644 --- a/web/src/api/item/generate.ts +++ b/web/src/api/item/generate.ts @@ -1,11 +1,8 @@ -import { ApiRequest, useHttp } from '../core'; +import { ApiRoute, useHttp } from '../core'; export const isValidId = (id?: string) => id && /^[\dA-Za-z]+$/.test(id); -export type ItemIdResponse = ApiRequest< - '/item/next', - 'get' ->['response']['data']; +export type ItemIdResponse = ApiRoute<'/item/next', 'get'>['response']['data']; // TODO: migrate away from useHttp (to mutation) export const useGenerateId = () => useHttp('/api/item/next'); diff --git a/web/src/api/item/index.ts b/web/src/api/item/index.ts index 4e74220..a97b06f 100644 --- a/web/src/api/item/index.ts +++ b/web/src/api/item/index.ts @@ -1,149 +1,91 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { - queryOptions, - useMutation, - UseMutationOptions, - useQuery, - UseQueryOptions, -} from '@tanstack/react-query'; +import { apiRequest, ApiRoute, createMutation, createQuery } from '../core'; -import { useAuth } from '../auth'; -import { apiRequest, BASE_URL, getHttp } from '../core'; -import { paths } from '../schema.gen'; +export const useItemById = createQuery({ + queryKey: ['item'], + fetcher: async ({ item_id }: { item_id: string }) => { + const response = await apiRequest('/item/{item_id}', 'get', { + path: { item_id }, + }); -export type ApiItemResponse = { - item_id: string; - owner_id: number; - product_id: number; - name?: string; - media?: number[]; - created?: string; - modified?: string; -}; - -export const getItemById = (item_id: string) => - queryOptions({ - queryKey: ['item', '{item_id}', item_id], - queryFn: async () => { - const response = await apiRequest('/item/{item_id}', 'get', { - path: { item_id }, - }); - - return response.data; - }, - retry: false, - }); - -export const useItemById = (item_id: string) => { - return useQuery(getItemById(item_id)); -}; - -export const getOwnedItems = () => - queryOptions({ - queryKey: ['item', 'owned'], - queryFn: async () => { - const response = await apiRequest('/item/owned', 'get', {}); + return response.data; + }, +}); - return response.data; - }, - }); +export const useOwnedItems = createQuery({ + queryKey: ['item', 'owned'], + fetcher: async () => { + const response = await apiRequest('/item/owned', 'get', {}); -export const useOwnedItems = () => { - return useQuery(getOwnedItems()); -}; + return response.data; + }, + retry: false, +}); -export const getItemMedia = (item_id: string) => - queryOptions({ - queryKey: ['item', item_id, 'media'], - queryFn: getHttp('/api/item/' + item_id + '/media', { - auth: 'include', - }), - }); +export const useItemMedia = createQuery({ + queryKey: ['item', 'media'], + fetcher: async ({ item_id }: { item_id: string }) => { + const response = await apiRequest('/item/{item_id}/media', 'get', { + path: { item_id }, + }); -export const useItemMedia = (item_id: string) => { - return useQuery(getItemMedia(item_id)); -}; + return response.data; + }, +}); -export type ApiLogResponse = - paths['/item/{item_id}/logs']['get']['responses']['200']['content']['application/json; charset=utf-8']; +export const useItemLogs = createQuery({ + queryKey: ['item', 'logs'], + fetcher: async ({ item_id }: { item_id: string }) => { + const response = await apiRequest('/item/{item_id}/logs', 'get', { + path: { item_id }, + }); -export const getItemLogs = ( - item_id: string -): UseQueryOptions => ({ - queryKey: ['item', item_id, 'logs'], - queryFn: getHttp('/api/item/' + item_id + '/logs', { - auth: 'include', - }), + return response.data; + }, }); -export const useItemLogs = (item_id: string) => { - return useQuery(getItemLogs(item_id)); -}; - // Create item // This endpoint provisions the desired item_id with a placeholder item -export const useCreateItem = () => { - return useMutation({ - mutationFn: async (item_id: string) => - fetch(BASE_URL + '/api/item?item_id=' + item_id, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + useAuth.getState().token, - }, - }).then((response) => response.ok), - }); -}; +export const useCreateItem = createMutation({ + mutationKey: ['item', 'create'], + mutationFn: async ({ item_id }: { item_id: string }) => { + const response = await apiRequest('/item', 'post', { + query: { item_id }, + }); + + return response.data; + }, +}); // Delete item // This endpoint deletes the item from the database and the search index. -export const useDeleteItem = ( - options?: UseMutationOptions -) => { - const { clearAuthToken } = useAuth(); - - return useMutation({ - mutationFn: async (item_id: string) => { - const response = await fetch(BASE_URL + '/api/item/' + item_id, { - method: 'DELETE', - headers: { - Authorization: 'Bearer ' + useAuth.getState().token, - }, - }); - - if (response.status === 401) { - console.log('Token expired, clearing token'); - clearAuthToken(); - - throw new Error('Token expired'); - } - - return response.ok; - }, - ...options, - }); -}; +export const useDeleteItem = createMutation({ + mutationKey: ['item', 'delete'], + mutationFn: async ({ item_id }: { item_id: string }) => { + await apiRequest('/item/{item_id}', 'delete', { + path: { item_id }, + }); + + return true; + }, +}); // Edit item -export const useEditItem = () => { - return useMutation({ - mutationFn: async ({ - item_id, +export const useEditItem = createMutation({ + mutationKey: ['item', 'edit'], + mutationFn: async ({ + item_id, + data, + }: { + item_id: string; + data: ApiRoute<'/item/{item_id}', 'patch'>['body']['data']; + }) => { + await apiRequest('/item/{item_id}', 'patch', { + path: { item_id }, + contentType: 'application/json; charset=utf-8', data, - }: { - item_id: string; - data: paths['/item/{item_id}']['patch']['requestBody']['content']['application/json; charset=utf-8']; - }) => { - const response = await fetch(BASE_URL + '/api/item/' + item_id, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + useAuth.getState().token, - }, - body: JSON.stringify(data), - }); + }); - return response.ok; - }, - }); -}; + return true; + }, +}); diff --git a/web/src/api/me.ts b/web/src/api/me.ts index 086f3e7..27d7b3c 100644 --- a/web/src/api/me.ts +++ b/web/src/api/me.ts @@ -1,17 +1,12 @@ -import { queryOptions, useQuery } from '@tanstack/react-query'; +import { apiRequest, createQuery } from './core'; -import { ApiRequest, apiRequest } from './core'; +export type ApiMeResponse = typeof useMe.$inferData; -export type ApiMeResponse = ApiRequest<'/me', 'get'>['response']['data']; +export const useMe = createQuery({ + queryKey: ['me'], + fetcher: async () => { + const response = await apiRequest('/me', 'get', {}); -export const getMe = () => - queryOptions({ - queryKey: ['me'], - queryFn: async () => { - const response = await apiRequest('/me', 'get', {}); - - return response.data; - }, - }); - -export const useMe = () => useQuery(getMe()); + return response.data; + }, +}); diff --git a/web/src/api/media/index.ts b/web/src/api/media/index.ts index 015b849..a94a2f0 100644 --- a/web/src/api/media/index.ts +++ b/web/src/api/media/index.ts @@ -1,53 +1,23 @@ -import { queryOptions, useQuery } from '@tanstack/react-query'; - -import { ApiRequest, apiRequest } from '../core'; - -export type MediaResponse = ApiRequest< - '/media/{media_id}', - 'get' ->['response']['data']; - -export const getMedia = (media_id: number | undefined) => - queryOptions({ - queryKey: ['media', media_id], - queryFn: async () => { - if (!media_id) return; - - const response = await apiRequest('/media/{media_id}', 'get', { - path: { media_id }, - }); - - return response.data; - }, - enabled: !!media_id, - }); - -export const useMedia = (media_id: number | undefined) => - useQuery(getMedia(media_id)); - -export type LinkedItem = ApiRequest< - '/media/{media_id}/items', - 'get' ->['response']['data']; - -export const getLinkedItems = (media_id: number | undefined) => - queryOptions({ - queryKey: ['media', media_id, 'items'], - queryFn: async () => { - if (!media_id) return; - - const response = await apiRequest( - '/media/{media_id}/items', - 'get', - { - path: { media_id }, - } - ); - - return response.data; - }, - enabled: !!media_id, - }); - -export const useLinkedItems = (media_id: number | undefined) => - useQuery(getLinkedItems(media_id)); +import { apiRequest, createQuery } from '../core'; + +export const useMedia = createQuery({ + queryKey: ['media'], + fetcher: async ({ media_id }: { media_id: number }) => { + const response = await apiRequest('/media/{media_id}', 'get', { + path: { media_id }, + }); + + return response.data; + }, +}); + +export const useLinkedItems = createQuery({ + queryKey: ['media', 'items'], + fetcher: async ({ media_id }: { media_id: number }) => { + const response = await apiRequest('/media/{media_id}/items', 'get', { + path: { media_id }, + }); + + return response.data; + }, +}); diff --git a/web/src/api/search/index.ts b/web/src/api/search/index.ts index 66958fd..f520750 100644 --- a/web/src/api/search/index.ts +++ b/web/src/api/search/index.ts @@ -1,26 +1,16 @@ -import { queryOptions, useQuery } from '@tanstack/react-query'; +import { apiRequest, createQuery } from '../core'; -import { ApiRequest, apiRequest } from '../core'; +export type SearchableItem = (typeof useSearch.$inferData)[number]; -export type SearchableItem = ApiRequest< - '/search', - 'get' ->['response']['data'][number]; +export const useSearch = createQuery({ + queryKey: ['search'], + fetcher: async ({ query }: { query: string }) => { + const response = await apiRequest('/search', 'get', { + query: { + query, + }, + }); -export type SearchApiResponse = SearchableItem[]; - -export const getSearch = (query: string) => - queryOptions({ - queryKey: ['search', query], - queryFn: async () => { - const response = await apiRequest('/search', 'get', { - query: { - query, - }, - }); - - return response.data; - }, - }); - -export const useSearch = (query: string) => useQuery(getSearch(query)); + return response.data; + }, +}); diff --git a/web/src/api/searchtasks.ts b/web/src/api/searchtasks.ts index c9b2e38..754f6e8 100644 --- a/web/src/api/searchtasks.ts +++ b/web/src/api/searchtasks.ts @@ -1,22 +1,14 @@ -import { useHttp } from './core'; +import { apiRequest, createQuery } from './core'; -export type SearchTasksApiResponse = SearchTask[]; +export type SearchTasksApiResponse = typeof useSearchTasks.$inferData; -export type SearchTask = { - task_id: number; - external_task_id: number; - status: SearchTaskStatus; - details: string | null; - updated_at: string; -}; +export type SearchTask = SearchTasksApiResponse[number]; -export type SearchTaskStatus = - | 'enqueued' - | 'processing' - | 'succeeded' - | 'failed'; +export const useSearchTasks = createQuery({ + queryKey: ['search_tasks'], + fetcher: async () => { + const response = await apiRequest('/search/tasks', 'get', {}); -export const useTasks = () => - useHttp('/api/search/tasks', { - auth: 'required', - }); + return response.data; + }, +}); diff --git a/web/src/api/sessions.ts b/web/src/api/sessions.ts index 9264e21..4f949bc 100644 --- a/web/src/api/sessions.ts +++ b/web/src/api/sessions.ts @@ -1,15 +1,12 @@ -import { useHttp } from './core'; +import { apiRequest, createQuery } from './core'; -export type SessionResponse = { - session_id: string; - user_id: number; - user_agent: string; - user_ip: string; - last_access: string; - // valid: boolean; -}; +export type SessionResponse = (typeof useSessions.$inferData)[number]; -export const useSessions = () => - useHttp('/api/sessions', { - auth: 'required', - }); +export const useSessions = createQuery({ + queryKey: ['sessions'], + fetcher: async () => { + const response = await apiRequest('/sessions', 'get', {}); + + return response.data; + }, +}); diff --git a/web/src/api/user/index.ts b/web/src/api/user/index.ts index a7c4bc8..01bb62e 100644 --- a/web/src/api/user/index.ts +++ b/web/src/api/user/index.ts @@ -1,22 +1,12 @@ -import { queryOptions, useQuery } from '@tanstack/react-query'; - -import { ApiRequest, apiRequest } from '../core'; - -export type ApiUserByIdResponse = ApiRequest< - '/user/{user_id}', - 'get' ->['response']; - -export const getUserById = (user_id: number) => - queryOptions({ - queryKey: ['user', user_id], - queryFn: async () => { - const response = await apiRequest('/user/{user_id}', 'get', { - path: { user_id }, - }); - - return response.data; - }, - }); - -export const useUserById = (user_id: number) => useQuery(getUserById(user_id)); +import { apiRequest, createQuery } from '../core'; + +export const useUserById = createQuery({ + queryKey: ['user', 'by-id'], + fetcher: async ({ user_id }: { user_id: number }) => { + const response = await apiRequest('/user/{user_id}', 'get', { + path: { user_id }, + }); + + return response.data; + }, +}); diff --git a/web/src/api/user/pat.ts b/web/src/api/user/pat.ts index 265af49..76bab21 100644 --- a/web/src/api/user/pat.ts +++ b/web/src/api/user/pat.ts @@ -1,45 +1,53 @@ -import { queryOptions, useMutation, useQuery } from '@tanstack/react-query'; - -import { apiRequest } from '../core'; - -export const getUserPats = (user_id: number) => - queryOptions({ - queryKey: ['user_pats'], - queryFn: async () => { - const response = await apiRequest('/user/{user_id}/keys', 'get', { - path: { user_id }, - }); - - return response.data; - }, - }); - -export const useUserPats = (user_id: number) => useQuery(getUserPats(user_id)); - -export const useCreateUserPat = (user_id: number) => - useMutation({ - mutationFn: async (data: { name: string; permissions: string }) => { - const response = await apiRequest('/user/{user_id}/keys', 'post', { - path: { user_id }, - contentType: 'application/json; charset=utf-8', - data, - }); - - return response.data; - }, - }); - -export const useDeleteUserPat = (user_id: number, pat_id: number) => - useMutation({ - mutationFn: async () => { - const response = await apiRequest( - '/user/{user_id}/keys/{token_id}', - 'delete', - { - path: { user_id, token_id: pat_id }, - } - ); - - return response.data; - }, - }); +import { apiRequest, createMutation, createQuery } from '../core'; + +export const useUserPats = createQuery({ + queryKey: ['user', 'pats'], + fetcher: async ({ user_id }: { user_id: number }) => { + const response = await apiRequest('/user/{user_id}/keys', 'get', { + path: { user_id }, + }); + + return response.data; + }, +}); + +export const useCreateUserPat = createMutation({ + mutationKey: ['user', 'pat', 'create'], + mutationFn: async ({ + user_id, + ...data + }: { + user_id: number; + name: string; + permissions: string; + }) => { + const response = await apiRequest('/user/{user_id}/keys', 'post', { + path: { user_id }, + contentType: 'application/json; charset=utf-8', + data, + }); + + return response.data; + }, +}); + +export const useDeleteUserPat = createMutation({ + mutationKey: ['user', 'pat', 'delete'], + mutationFn: async ({ + user_id, + pat_id, + }: { + user_id: number; + pat_id: number; + }) => { + const response = await apiRequest( + '/user/{user_id}/keys/{token_id}', + 'delete', + { + path: { user_id, token_id: pat_id }, + } + ); + + return response.data; + }, +}); diff --git a/web/src/components/ActiveSessionsTable.tsx b/web/src/components/ActiveSessionsTable.tsx index aac6fb5..e7b63ed 100644 --- a/web/src/components/ActiveSessionsTable.tsx +++ b/web/src/components/ActiveSessionsTable.tsx @@ -11,7 +11,7 @@ import { LeafletPreview } from './LeafletPreview'; import { Button } from './ui/Button'; const ActiveSession: FC<{ session: SessionResponse }> = ({ session }) => { - const { refetch: updateSessions } = useSessions(); + const { refetch: updateSessions } = useSessions({}); const { token } = useAuth(); const { data: geoip } = useGeoIp(session.user_ip); const user_agent = UAParser(session.user_agent); @@ -101,7 +101,7 @@ const ActiveSession: FC<{ session: SessionResponse }> = ({ session }) => { }; export const ActiveSessionsTable: FC = () => { - const { data: sessions } = useSessions(); + const { data: sessions } = useSessions({}); return (
diff --git a/web/src/components/Navbar.tsx b/web/src/components/Navbar.tsx index 8f85e25..77f61c3 100644 --- a/web/src/components/Navbar.tsx +++ b/web/src/components/Navbar.tsx @@ -13,7 +13,7 @@ const LOGIN_URL = 'http://localhost:3000/api/login'; export const Navbar = () => { const { token, clearAuthToken } = useAuth(); - const { data: meData, isLoading: isVerifyingAuth } = useMe(); + const { data: meData, isLoading: isVerifyingAuth } = useMe({}); const login_here_url = LOGIN_URL + '?redirect=' + encodeURIComponent(window.location.href); diff --git a/web/src/components/UserProfile.tsx b/web/src/components/UserProfile.tsx index 5315fff..0e2ad8e 100644 --- a/web/src/components/UserProfile.tsx +++ b/web/src/components/UserProfile.tsx @@ -100,8 +100,10 @@ export const getInitials = (name?: string) => { const UNKNOWN_USER = 'Unknown User'; export const UserProfile: FC = ({ user_id, variant }) => { - const { data: user, isLoading } = useUserById(user_id); - const { data: me } = useMe(); + const { data: user, isLoading } = useUserById({ + variables: { user_id }, + }); + const { data: me } = useMe({}); if (isLoading) { return
Loading...
; diff --git a/web/src/components/input/NewItemIdInput.tsx b/web/src/components/input/NewItemIdInput.tsx index 028c4d6..de9d8dd 100644 --- a/web/src/components/input/NewItemIdInput.tsx +++ b/web/src/components/input/NewItemIdInput.tsx @@ -16,7 +16,7 @@ export const NewItemIdInput = (properties: BaseInputProperties) => { ); const { mutate: generateId } = useMutation({ mutationFn: async () => { - const response = await fetch(`${BASE_URL}/api/item/next`); + const response = await fetch(new URL('/api/item/next', BASE_URL)); const data = await response.json(); return data.item_id; diff --git a/web/src/components/item/ItemPreview.tsx b/web/src/components/item/ItemPreview.tsx index d1e0fcf..756b398 100644 --- a/web/src/components/item/ItemPreview.tsx +++ b/web/src/components/item/ItemPreview.tsx @@ -6,7 +6,7 @@ import { FC } from 'react'; import { match } from 'ts-pattern'; import { formatId, useInstanceSettings } from '@/api/instance_settings'; -import { ApiItemResponse, useItemById, useItemMedia } from '@/api/item'; +import { useItemById, useItemMedia } from '@/api/item'; import { useMedia } from '@/api/media'; type Properties = { @@ -52,9 +52,9 @@ export const AvatarHolder: FC<{ }; export const ItemPreviewHoverCard: FC<{ - item?: ApiItemResponse; + item: typeof useItemById.$inferData; }> = ({ item }) => { - const { data: instanceSettings } = useInstanceSettings(); + const { data: instanceSettings } = useInstanceSettings({}); const formattedItemId = formatId(item?.item_id, instanceSettings); return ( @@ -92,10 +92,17 @@ export const ItemPreviewHoverCard: FC<{ const UNKNOWN_ITEM = 'Unknown Item'; export const ItemPreview: FC = ({ item_id, variant }) => { - const { data: item, isLoading, isError } = useItemById(item_id); - const { data: media } = useItemMedia(item_id); - const { data: mediaData } = useMedia(media?.[0]); - const { data: instanceSettings } = useInstanceSettings(); + const { + data: item, + isLoading, + isError, + } = useItemById({ variables: { item_id } }); + const { data: media } = useItemMedia({ variables: { item_id } }); + const { data: mediaData } = useMedia({ + variables: { media_id: media?.[0]! }, + enabled: !!media?.[0], + }); + const { data: instanceSettings } = useInstanceSettings({}); const formattedItemId = formatId(item?.item_id, instanceSettings); const mediaUrl = (() => { diff --git a/web/src/components/logs/ItemLogEntry.tsx b/web/src/components/logs/ItemLogEntry.tsx index 144feb9..c1a0d10 100644 --- a/web/src/components/logs/ItemLogEntry.tsx +++ b/web/src/components/logs/ItemLogEntry.tsx @@ -2,12 +2,12 @@ import TimeAgo from 'javascript-time-ago'; import en from 'javascript-time-ago/locale/en'; import { match } from 'ts-pattern'; -import { ApiLogResponse } from '@/api/item'; +import { type useItemLogs } from '@/api/item'; import { UserProfile } from '@/components/UserProfile'; import { ItemPreview } from '../item/ItemPreview'; -export type ApiLogEntry = ApiLogResponse[number]; +export type ApiLogEntry = (typeof useItemLogs.$inferData)[number]; TimeAgo.addDefaultLocale(en); const timeAgo = new TimeAgo('en-US'); diff --git a/web/src/components/logs/ItemLogSection.tsx b/web/src/components/logs/ItemLogSection.tsx index 8688169..dfa365c 100644 --- a/web/src/components/logs/ItemLogSection.tsx +++ b/web/src/components/logs/ItemLogSection.tsx @@ -3,7 +3,7 @@ import { useItemLogs } from '@/api/item'; import { ItemLogEntry } from './ItemLogEntry'; export const ItemLogSection = ({ item_id }: { item_id: string }) => { - const { data: logs } = useItemLogs(item_id); + const { data: logs } = useItemLogs({ variables: { item_id } }); return (
diff --git a/web/src/components/media/MediaPreview.tsx b/web/src/components/media/MediaPreview.tsx index 30488fa..6743807 100644 --- a/web/src/components/media/MediaPreview.tsx +++ b/web/src/components/media/MediaPreview.tsx @@ -23,7 +23,10 @@ export const MediaPreview: FC<{ update_media_id?: (_media_id: number) => void; delete_media?: (_media_id: number) => void; }> = ({ media_id, url, name, kind, status, update_media_id, delete_media }) => { - const { data: media } = useMedia(media_id); + const { data: media } = useMedia({ + variables: { media_id: media_id! }, + enabled: !!media_id, + }); const fileType = kind ?? media?.url.split('.').pop(); const { token } = useAuth(); @@ -156,7 +159,10 @@ export const VideoPreview: FC<{ media_id?: number; url?: string }> = ({ media_id, url, }) => { - const { data: media } = useMedia(media_id); + const { data: media } = useMedia({ + variables: { media_id: media_id! }, + enabled: !!media_id, + }); return (
@@ -169,7 +175,10 @@ export const ImagePreview: FC<{ media_id?: number; url?: string }> = ({ media_id, url, }) => { - const { data: media } = useMedia(media_id); + const { data: media } = useMedia({ + variables: { media_id: media_id! }, + enabled: !!media_id, + }); const [imageNotFound, setImageNotFound] = useState(false); return ( @@ -198,7 +207,10 @@ export const StlPreview: FC<{ media_id?: number; url?: string }> = ({ media_id, url, }) => { - const { data: media } = useMedia(media_id); + const { data: media } = useMedia({ + variables: { media_id: media_id! }, + enabled: !!media_id, + }); return ( diff --git a/web/src/components/owned_elements.tsx b/web/src/components/owned_elements.tsx index 37b53e2..203154c 100644 --- a/web/src/components/owned_elements.tsx +++ b/web/src/components/owned_elements.tsx @@ -5,7 +5,7 @@ import { useOwnedItems } from '@/api/item'; import { Button } from './ui/Button'; export const AllOwnedItems = () => { - const { data } = useOwnedItems(); + const { data } = useOwnedItems({}); return (
diff --git a/web/src/components/search/SearchInput.tsx b/web/src/components/search/SearchInput.tsx index ebbe0b1..573f15b 100644 --- a/web/src/components/search/SearchInput.tsx +++ b/web/src/components/search/SearchInput.tsx @@ -8,7 +8,9 @@ import { Button } from '../ui/Button'; export const SearchInput = () => { const [query, setQuery] = useState(''); - const { data: searchResults } = useSearch(query); + const { data: searchResults } = useSearch({ + variables: { query }, + }); return (
diff --git a/web/src/components/search_tasks/SearchTaskTable.tsx b/web/src/components/search_tasks/SearchTaskTable.tsx index c00da34..82eb427 100644 --- a/web/src/components/search_tasks/SearchTaskTable.tsx +++ b/web/src/components/search_tasks/SearchTaskTable.tsx @@ -7,7 +7,7 @@ import { FiLoader } from 'react-icons/fi'; import { useAuth } from '@/api/auth'; import { BASE_URL } from '@/api/core'; -import { SearchTask, useTasks } from '@/api/searchtasks'; +import { SearchTask, useSearchTasks } from '@/api/searchtasks'; import { Button } from '../ui/Button'; @@ -85,7 +85,7 @@ const TaskTableEntry = ({ }; export const SearchTaskTable = () => { - const { data, refetch } = useTasks(); + const { data, refetch } = useSearchTasks({}); return (
diff --git a/web/src/components/stl_preview/StlPreview.tsx b/web/src/components/stl_preview/StlPreview.tsx index 93d360b..980dc71 100644 --- a/web/src/components/stl_preview/StlPreview.tsx +++ b/web/src/components/stl_preview/StlPreview.tsx @@ -1,3 +1,4 @@ +/* eslint-disable unicorn/number-literal-case */ import { OrbitControls, Stage } from '@react-three/drei'; import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber'; import { Suspense, useEffect, useRef, useState } from 'react'; @@ -22,15 +23,15 @@ function Measurements({ geometry }: { geometry: THREE.BufferGeometry }) { // Create lines and labels for each dimension with different colors and thickness const lineMaterials = [ new THREE.LineBasicMaterial({ - color: 0xFF_00_00, + color: 0xff_00_00, linewidth: 2, }), // Red for width new THREE.LineBasicMaterial({ - color: 0x00_FF_00, + color: 0x00_ff_00, linewidth: 2, }), // Green for height new THREE.LineBasicMaterial({ - color: 0x00_00_FF, + color: 0x00_00_ff, linewidth: 2, }), // Blue for depth ]; @@ -68,7 +69,7 @@ function Measurements({ geometry }: { geometry: THREE.BufferGeometry }) { // Create a background plane const backgroundGeometry = new THREE.PlaneGeometry(12, 6); const backgroundMaterial = new THREE.MeshBasicMaterial({ - color: 0xFF_FF_FF, + color: 0xff_ff_ff, opacity: 0.5, transparent: true, depthTest: false, diff --git a/web/src/components/user_api_keys/UserApiKeysTable.tsx b/web/src/components/user_api_keys/UserApiKeysTable.tsx index a24ffaf..1a64052 100644 --- a/web/src/components/user_api_keys/UserApiKeysTable.tsx +++ b/web/src/components/user_api_keys/UserApiKeysTable.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx'; import { FiLoader } from 'react-icons/fi'; import { useAuth } from '@/api/auth'; -import { ApiRequest, BASE_URL } from '@/api/core'; +import { ApiRoute, BASE_URL } from '@/api/core'; import { useMe } from '@/api/me'; import { useUserPats } from '@/api/user/pat'; @@ -14,7 +14,7 @@ import { Button } from '../ui/Button'; // TimeAgo.addDefaultLocale(en); // const timeAgo = new TimeAgo('en-US'); -type UserApiKey = ApiRequest< +type UserApiKey = ApiRoute< '/user/{user_id}/keys', 'get' >['response']['data'][number]; @@ -70,8 +70,10 @@ const TokenTableEntry = ({ }; export const UserApiKeysTable = () => { - const { data: me } = useMe(); - const { data, refetch } = useUserPats(me?.user_id ?? 0); + const { data: me } = useMe({}); + const { data, refetch } = useUserPats({ + variables: { user_id: me?.user_id ?? 0 }, + }); return (
diff --git a/web/src/routes/$itemId.tsx b/web/src/routes/$itemId.tsx index 3045877..5014a68 100644 --- a/web/src/routes/$itemId.tsx +++ b/web/src/routes/$itemId.tsx @@ -1,16 +1,13 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; -import { - formatId, - instanceSettingsQueryOptions, -} from '@/api/instance_settings'; +import { formatId, useInstanceSettings } from '@/api/instance_settings'; import { queryClient } from '@/util/query'; export const Route = createFileRoute('/$itemId')({ component: () =>
Hello /$itemId!
, loader: async ({ context, params }) => { const instanceSettings = await queryClient.ensureQueryData( - instanceSettingsQueryOptions + useInstanceSettings.getFetchOptions() ); const formattedItemId = formatId(params.itemId, instanceSettings); diff --git a/web/src/routes/create.lazy.tsx b/web/src/routes/create.lazy.tsx index 2ebc772..b2b5214 100644 --- a/web/src/routes/create.lazy.tsx +++ b/web/src/routes/create.lazy.tsx @@ -10,9 +10,9 @@ import { Button } from '@/components/ui/Button'; import { SCPage } from '@/layouts/SimpleCenterPage'; const component = () => { - const { data: instanceSettings } = useInstanceSettings(); + const { data: instanceSettings } = useInstanceSettings({}); const navigate = useNavigate(); - const { mutateAsync: createItem } = useCreateItem(); + const { mutateAsync: createItem } = useCreateItem({}); const { Field, Subscribe, handleSubmit } = useForm({ defaultValues: { @@ -23,7 +23,7 @@ const component = () => { if (!formattedItemId) return; - await createItem(formattedItemId); + await createItem({ item_id: formattedItemId }); // navigate to the new item navigate({ diff --git a/web/src/routes/item/$itemId/edit.tsx b/web/src/routes/item/$itemId/edit.tsx index 3d46c1c..b445a6f 100644 --- a/web/src/routes/item/$itemId/edit.tsx +++ b/web/src/routes/item/$itemId/edit.tsx @@ -10,15 +10,12 @@ import { import { FC } from 'react'; import { toast } from 'sonner'; +import { formatId, useInstanceSettings } from '@/api/instance_settings'; import { - formatId, - instanceSettingsQueryOptions, -} from '@/api/instance_settings'; -import { - getItemById, - getItemMedia, useDeleteItem, useEditItem, + useItemById, + useItemMedia, } from '@/api/item'; import { BaseInput } from '@/components/input/BaseInput'; import { EditMediaGallery } from '@/components/media/EditMediaGallery'; @@ -63,7 +60,7 @@ export const DeleteItemModal: FC<{ itemId: string }> = ({ itemId }) => { variant="destructive" onClick={(event) => { event.preventDefault(); - deleteItem.mutate(itemId); + deleteItem.mutate({ item_id: itemId }); }} disabled={deleteItem.isPending} > @@ -80,7 +77,7 @@ export const Route = createFileRoute('/item/$itemId/edit')({ loader: async ({ params }) => { // Ensure instance settings are loaded const instanceSettings = await queryClient.ensureQueryData( - instanceSettingsQueryOptions + useInstanceSettings.getFetchOptions() ); const formattedItemId = formatId(params.itemId, instanceSettings); @@ -94,15 +91,31 @@ export const Route = createFileRoute('/item/$itemId/edit')({ // Preload item and media return Promise.all([ - queryClient.ensureQueryData(getItemById(params.itemId)), - queryClient.ensureQueryData(getItemMedia(params.itemId)), + queryClient.ensureQueryData( + useItemById.getFetchOptions({ + item_id: params.itemId, + }) + ), + queryClient.ensureQueryData( + useItemMedia.getFetchOptions({ + item_id: params.itemId, + }) + ), ]); }, component: () => { const { itemId } = useParams({ from: '/item/$itemId/edit' }); - const { data: item, refetch } = useSuspenseQuery(getItemById(itemId)); - const { data: media } = useSuspenseQuery(getItemMedia(itemId)); - const { mutateAsync: editItem } = useEditItem(); + const { data: item, refetch } = useSuspenseQuery( + useItemById.getOptions({ + item_id: itemId, + }) + ); + const { data: media } = useSuspenseQuery( + useItemMedia.getOptions({ + item_id: itemId, + }) + ); + const { mutateAsync: editItem } = useEditItem({}); const navigate = useNavigate(); const { Field, Subscribe, handleSubmit } = useForm({ diff --git a/web/src/routes/item/$itemId/index.tsx b/web/src/routes/item/$itemId/index.tsx index 896c54f..e0b9311 100644 --- a/web/src/routes/item/$itemId/index.tsx +++ b/web/src/routes/item/$itemId/index.tsx @@ -10,11 +10,8 @@ import { } from '@tanstack/react-router'; import { useEffect } from 'react'; -import { - formatId, - instanceSettingsQueryOptions, -} from '@/api/instance_settings'; -import { getItemById, getItemMedia } from '@/api/item'; +import { formatId, useInstanceSettings } from '@/api/instance_settings'; +import { useItemById, useItemMedia } from '@/api/item'; import { ItemLogSection } from '@/components/logs/ItemLogSection'; import { MediaGallery } from '@/components/media/MediaGallery'; import { Button } from '@/components/ui/Button'; @@ -28,7 +25,7 @@ export const Route = createFileRoute('/item/$itemId/')({ loader: async ({ params }) => { // Ensure instance settings are loaded const instanceSettings = await queryClient.ensureQueryData( - instanceSettingsQueryOptions + useInstanceSettings.getFetchOptions() ); const formattedItemId = formatId(params.itemId, instanceSettings); @@ -42,15 +39,31 @@ export const Route = createFileRoute('/item/$itemId/')({ // Preload item and media return Promise.all([ - queryClient.ensureQueryData(getItemById(params.itemId)), - queryClient.ensureQueryData(getItemMedia(params.itemId)), + queryClient.ensureQueryData( + useItemById.getFetchOptions({ + item_id: params.itemId, + }) + ), + queryClient.ensureQueryData( + useItemMedia.getFetchOptions({ + item_id: params.itemId, + }) + ), ]); }, component: () => { const { itemId } = Route.useParams(); - const item = useSuspenseQuery(getItemById(itemId)); - const media = useSuspenseQuery(getItemMedia(itemId)); + const item = useSuspenseQuery( + useItemById.getOptions({ + item_id: itemId, + }) + ); + const media = useSuspenseQuery( + useItemMedia.getOptions({ + item_id: itemId, + }) + ); if (item.error) { return ; diff --git a/web/src/routes/settings/index.lazy.tsx b/web/src/routes/settings/index.lazy.tsx index 8f921ab..ed4b2fa 100644 --- a/web/src/routes/settings/index.lazy.tsx +++ b/web/src/routes/settings/index.lazy.tsx @@ -11,7 +11,7 @@ import { SCPage } from '@/layouts/SimpleCenterPage'; export const Route = createLazyFileRoute('/settings/')({ component: () => { - const { data: instanceSettings } = useInstanceSettings(); + const { data: instanceSettings } = useInstanceSettings({}); const { token } = useAuth(); const { mutate: indexAllItems } = useMutation({ mutationFn: async () => { diff --git a/web/src/routes/user/$userId.tsx b/web/src/routes/user/$userId.tsx index 109899f..0bf8847 100644 --- a/web/src/routes/user/$userId.tsx +++ b/web/src/routes/user/$userId.tsx @@ -7,7 +7,9 @@ export const Route = createFileRoute('/user/$userId')({ component: () => { const { userId } = useParams({ from: '/user/$userId' }); const user_id = Number.parseInt(userId); - const { data: user, isLoading } = useUserById(user_id); + const { data: user, isLoading } = useUserById({ + variables: { user_id }, + }); return ( diff --git a/web/src/types.d.ts b/web/src/types.d.ts new file mode 100644 index 0000000..b9e4ab4 --- /dev/null +++ b/web/src/types.d.ts @@ -0,0 +1 @@ +declare module 'troika-three-text'; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..9581ad5 --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/* eslint-disable unused-imports/no-unused-vars */ +/* eslint-disable unicorn/prevent-abbreviations */ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL?: string; + // more env variables... +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index e0501d7..81a6d2f 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -11,9 +11,10 @@ export default defineConfig({ }, }, server: { + host: '0.0.0.0', proxy: { '/api': { - target: 'http://localhost:3000', + target: process.env.VITE_API_URL || 'http://localhost:3000', changeOrigin: true, }, },