diff --git a/engine/compose.prod.yaml b/engine/compose.prod.yaml index 62c4155..2a9c8d7 100644 --- a/engine/compose.prod.yaml +++ b/engine/compose.prod.yaml @@ -1,5 +1,4 @@ # runs the engine on port 3000 -version: "3.9" services: engine: image: ghcr.io/v3xlabs/v3x-property-engine:latest diff --git a/engine/compose.yaml b/engine/compose.yaml index 5c3a853..a53f85e 100644 --- a/engine/compose.yaml +++ b/engine/compose.yaml @@ -1,5 +1,4 @@ # runs the engine on port 3000 -version: "3.9" services: # Engine # engine: diff --git a/engine/src/database/mod.rs b/engine/src/database/mod.rs index ea15c13..43c4224 100644 --- a/engine/src/database/mod.rs +++ b/engine/src/database/mod.rs @@ -1,4 +1,5 @@ use sqlx::{postgres::PgPoolOptions, PgPool}; +use tracing::info; #[derive(Debug)] pub struct Database { @@ -14,11 +15,15 @@ impl Database { s.init().await?; + info!("Database initialized"); + Ok(s) } pub async fn init(&self) -> Result<(), sqlx::Error> { - sqlx::migrate!().run(&self.pool).await?; + let migrations = sqlx::migrate!(); + + migrations.run(&self.pool).await?; Ok(()) } diff --git a/web/src/api/core.ts b/web/src/api/core.ts index 398f57a..fc412d7 100644 --- a/web/src/api/core.ts +++ b/web/src/api/core.ts @@ -1,18 +1,42 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { QueryObserverOptions, QueryOptions, useMutation, useQuery } from '@tanstack/react-query'; import { useAuth } from './auth'; export const BASE_URL = 'http://localhost:3000'; -export function useHttp(key: string) { +export type HttpOptions = { + // Whether to include the token in the request + // - 'include' will include the token if available + // - 'ignore' will not include the token + // - 'required' will throw an error if the token is not available + auth?: 'include' | 'ignore' | 'required' | 'skip'; + skipIfUnauthed?: boolean; +}; + +export function useHttp( + key: string, + options?: HttpOptions, + queryOptions?: Partial +) { const { token, clearAuthToken } = useAuth(); + const { auth = 'ignore', skipIfUnauthed = false } = options || {}; return useQuery({ queryKey: [key], + enabled: true, queryFn: async () => { const headers = new Headers(); - headers.append('Authorization', 'Bearer ' + token); + if (auth === 'include' || auth === 'required') { + if (!token && auth === 'required') { + throw new Error( + 'No token available but endpoint requires it, key: ' + + key + ); + } + + headers.append('Authorization', 'Bearer ' + token); + } try { const response = await fetch(BASE_URL + key, { headers }); @@ -21,14 +45,16 @@ export function useHttp(key: string) { console.log('Token expired, clearing token'); clearAuthToken(); - return; + throw new Error('Token expired'); } return (await response.json()) as T; } catch (error) { console.error(error); + throw error; } }, + ...queryOptions, }); } diff --git a/web/src/api/generate_id.ts b/web/src/api/generate_id.ts new file mode 100644 index 0000000..fc06064 --- /dev/null +++ b/web/src/api/generate_id.ts @@ -0,0 +1 @@ +export const isValidId = (id?: string) => id && /^[\dA-Za-z]+$/.test(id); diff --git a/web/src/api/instance_settings.ts b/web/src/api/instance_settings.ts new file mode 100644 index 0000000..0f242ee --- /dev/null +++ b/web/src/api/instance_settings.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query'; + +export type IdCasingPreference = 'upper' | 'lower'; + +export type InstanceSettings = { + id_casing_preference: IdCasingPreference; +}; + +export const getInstanceSettings = () => { + return { + queryKey: ['instance_settings'], + queryFn: async () => { + return { + id_casing_preference: 'upper', + } as InstanceSettings; + }, + }; +}; + +export const useInstanceSettings = () => { + return useQuery(getInstanceSettings()); +}; + +export const formatIdCasing = ( + id: string | undefined, + id_casing_preference?: IdCasingPreference +) => { + if (!id) return; + + if (id_casing_preference === 'upper') { + return id.toUpperCase(); + } + + return id.toLowerCase(); +}; + +export const formatId = ( + id: string | undefined, + instanceSettings?: InstanceSettings +) => { + if (!id) return; + + // Trim leading zeros + const trimmedId = id.replace(/^0+/, ''); + + return formatIdCasing(trimmedId, instanceSettings?.id_casing_preference); +}; diff --git a/web/src/api/item.ts b/web/src/api/item.ts new file mode 100644 index 0000000..a511888 --- /dev/null +++ b/web/src/api/item.ts @@ -0,0 +1,42 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { BASE_URL } from './core'; + +export type ApiItemResponse = { + item_id: string; + owner_id: number; + product_id: number; + name?: string; + media?: number[]; + created?: string; + modified?: string; +}; + +// export const useApiItemById = (id: string) => +// useHttp('/api/item/' + id); + +// Mock data for now +const item: ApiItemResponse = { + item_id: '1', + owner_id: 1, + product_id: 1, + media: [1, 2, 3], +}; + +export const useApiItemById = (id: string) => { + return useQuery({ + queryKey: ['item', id], + queryFn: () => item, + }); +}; + +// Create item +// This endpoint provisions the desired item_id with a placeholder item +export const useApiCreateItem = () => { + return useMutation({ + mutationFn: async (item_id: string) => + fetch(BASE_URL + '/api/item/create?item_id=' + item_id, { + method: 'POST', + }).then((response) => response.ok), + }); +}; diff --git a/web/src/api/me.ts b/web/src/api/me.ts index 6ac280b..76d99fd 100644 --- a/web/src/api/me.ts +++ b/web/src/api/me.ts @@ -1,3 +1,4 @@ +import { useAuth } from './auth'; import { useHttp } from './core'; export type ApiMeResponse = { @@ -30,5 +31,16 @@ export type ApiMeResponse = { }; export function useApiMe() { - return useHttp('/api/me'); + const { token } = useAuth(); + + return useHttp( + '/api/me', + { + auth: 'required', + skipIfUnauthed: true, + }, + { + enabled: !!token, + } + ); } diff --git a/web/src/api/property.ts b/web/src/api/property.ts deleted file mode 100644 index 4610f36..0000000 --- a/web/src/api/property.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useHttp } from './core'; - -export type PropertyResponse = { - id: number; - owner_id: number; - product_id: number; - name?: string; - media?: number[]; - created?: string; - modified?: string; -}; - -export const useProperty = (id: string) => - useHttp('/api/property/' + id); diff --git a/web/src/components/Unauthorized.tsx b/web/src/components/Unauthorized.tsx new file mode 100644 index 0000000..646b5fa --- /dev/null +++ b/web/src/components/Unauthorized.tsx @@ -0,0 +1,9 @@ +export const UnauthorizedResourceModal = () => { + return ( +
+

Unauthorized

+

You are not authorized to access this resource.

+
Try logging in
+
+ ); +}; diff --git a/web/src/components/UserProfile.tsx b/web/src/components/UserProfile.tsx index 0b2abaa..15e1a30 100644 --- a/web/src/components/UserProfile.tsx +++ b/web/src/components/UserProfile.tsx @@ -4,11 +4,9 @@ import { Link } from '@tanstack/react-router'; import clsx from 'clsx'; import { FC } from 'react'; import { match } from 'ts-pattern'; -import { useQuery } from '@tanstack/react-query'; -import { ApiMeResponse, useApiMe } from '../api/me'; +import { ApiMeResponse } from '../api/me'; import { useApiUserById } from '../api/user'; -import { fetcher } from '../api/core'; type Properties = { user_id: string; @@ -31,10 +29,7 @@ export const AvatarHolder: FC<{ 'AvatarImage', size === 'compact' && '!size-6' )} - src={ - image || - 'https://images.unsplash.com/photo-1511485977113-f34c92461ad9?ixlib=rb-1.2.1&w=128&h=128&dpr=2&q=80' - } + src={image} alt={alt || 'User Avatar'} /> )} diff --git a/web/src/components/input/BaseInput.tsx b/web/src/components/input/BaseInput.tsx index 9cacf4f..fba6d6e 100644 --- a/web/src/components/input/BaseInput.tsx +++ b/web/src/components/input/BaseInput.tsx @@ -12,6 +12,7 @@ export type BaseInputProperties = { suffix?: ReactNode; value?: string; onChange?: (value: string) => void; + errorMessage?: string; }; export const BaseInput = ({ @@ -24,6 +25,7 @@ export const BaseInput = ({ suffix, value, onChange, + errorMessage, }: BaseInputProperties) => { return ( <> @@ -44,6 +46,7 @@ export const BaseInput = ({ {suffix} + {errorMessage &&

{errorMessage}

} ); }; diff --git a/web/src/components/input/NewItemIdInput.tsx b/web/src/components/input/NewItemIdInput.tsx index caa5517..a1487f3 100644 --- a/web/src/components/input/NewItemIdInput.tsx +++ b/web/src/components/input/NewItemIdInput.tsx @@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { FiRefreshCcw } from 'react-icons/fi'; +import { isValidId } from '../../api/generate_id'; import { BaseInput, BaseInputProperties } from './BaseInput'; const placeholderValues = ['000001', '123456', 'AA17C', 'ABCDEF', '000013']; @@ -22,6 +23,7 @@ export const NewItemIdInput = (properties: BaseInputProperties) => { }, onSuccess: (data) => { setValue(data); + properties.onChange?.(data); }, }); @@ -39,12 +41,22 @@ export const NewItemIdInput = (properties: BaseInputProperties) => { return () => clearInterval(interval); }, [value]); + const isValid = isValidId(value); + return ( { + setValue(value); + properties.onChange?.(value); + }} + errorMessage={ + !isValid + ? 'Identifier must be alphanumeric (a-z0-9)' + : undefined + } suffix={ <>
diff --git a/web/src/components/item/ItemPreview.tsx b/web/src/components/item/ItemPreview.tsx new file mode 100644 index 0000000..6b4d8a7 --- /dev/null +++ b/web/src/components/item/ItemPreview.tsx @@ -0,0 +1,163 @@ +import * as Avatar from '@radix-ui/react-avatar'; +import * as HoverCard from '@radix-ui/react-hover-card'; +import { Link } from '@tanstack/react-router'; +import clsx from 'clsx'; +import { FC } from 'react'; +import { match } from 'ts-pattern'; + +import { formatId, useInstanceSettings } from '../../api/instance_settings'; +import { ApiItemResponse, useApiItemById } from '../../api/item'; + +type Properties = { + item_id: string; + variant?: 'avatar' | 'full' | 'compact'; +}; + +export const AvatarHolder: FC<{ + image?: string; + initials?: string; + alt?: string; + size?: 'compact' | 'default'; +}> = ({ image, initials, alt, size }) => { + return ( + + {image && ( + + )} + + {initials || 'X'} + + + ); +}; + +export const ItemPreviewHoverCard: FC<{ + item?: ApiItemResponse; +}> = ({ item }) => { + const { data: instanceSettings } = useInstanceSettings(); + const formattedItemId = formatId(item?.item_id, instanceSettings); + + return ( + +
+ +
+
+
{item?.name}
+
#{formattedItemId}
+
+ {/*
+ Components, icons, colors, and templates + for building high-quality, accessible + UI. Free and open-source. +
*/} +
+
+
0
{' '} +
Following
+
+
+
2,900
{' '} +
Followers
+
+
+
+
+ + +
+ ); +}; + +const UNKNOWN_USER = 'Unknown User'; + +export const ItemPreview: FC = ({ item_id, variant }) => { + const { data: item, isLoading } = useApiItemById(item_id); + const { data: instanceSettings } = useInstanceSettings(); + const formattedItemId = formatId(item?.item_id, instanceSettings); + + if (isLoading) { + return
Loading...
; + } + + return ( +
+ {match({ variant }) + .with({ variant: 'avatar' }, () => ( + + + + + + + + + )) + .with({ variant: 'compact' }, () => ( + + + + + + {item?.name || UNKNOWN_USER} + + + + + + )) + .otherwise(() => ( + + + + +
+
+ {item?.name || UNKNOWN_USER} +
+
+ #{formattedItemId} +
+
+ +
+ +
+ ))} +
+ ); +}; diff --git a/web/src/components/media/MediaGallery.tsx b/web/src/components/media/MediaGallery.tsx new file mode 100644 index 0000000..eeb4c51 --- /dev/null +++ b/web/src/components/media/MediaGallery.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react'; + +import { MediaPreview } from './MediaPreview'; + +export const MediaGallery: FC<{ media_ids: number[] }> = ({ media_ids }) => { + return ( +
+
+ {media_ids.map((media_id) => ( + + ))} +
+
+ ); +}; diff --git a/web/src/components/media/MediaPreview.tsx b/web/src/components/media/MediaPreview.tsx new file mode 100644 index 0000000..eef4b0f --- /dev/null +++ b/web/src/components/media/MediaPreview.tsx @@ -0,0 +1,10 @@ +import { FC } from 'react'; + +export const MediaPreview: FC<{ media_id: number }> = ({ media_id }) => { + return ( +
+ MediaPreview + {media_id} +
+ ); +}; diff --git a/web/src/index.css b/web/src/index.css index f5aef21..7a1af88 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -4,6 +4,7 @@ .btn { @apply bg-white text-neutral-800 border border-neutral-200 px-3 py-1 h-fit hover:bg-neutral-50 rounded-md focus:outline focus:outline-2 outline-offset-2 outline-blue-500; + @apply disabled:bg-neutral-100 disabled:text-neutral-400 disabled:border-neutral-200 disabled:hover:bg-neutral-100 disabled:cursor-not-allowed; } .h1 { diff --git a/web/src/index.tsx b/web/src/index.tsx index 695a6b8..dcf5410 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -1,6 +1,6 @@ import './index.css'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { createRouter, RouterProvider } from '@tanstack/react-router'; import React from 'react'; import ReactDOM from 'react-dom/client'; @@ -8,6 +8,7 @@ import ReactDOM from 'react-dom/client'; import { preflightAuth } from './api/auth'; // Import the generated route tree import { routeTree } from './routeTree.gen'; +import { queryClient } from './util/query'; // Create a new router instance const router = createRouter({ routeTree }); @@ -21,8 +22,6 @@ declare module '@tanstack/react-router' { preflightAuth(); -const queryClient = new QueryClient(); - ReactDOM.createRoot(document.querySelector('#root')!).render( diff --git a/web/src/routes/create.lazy.tsx b/web/src/routes/create.lazy.tsx index 7b80e87..05068e9 100644 --- a/web/src/routes/create.lazy.tsx +++ b/web/src/routes/create.lazy.tsx @@ -1,9 +1,18 @@ -import { createLazyFileRoute } from '@tanstack/react-router'; +import { createLazyFileRoute, useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; import { FiArrowRight } from 'react-icons/fi'; +import { useApiCreateItem } from '../api/item'; import { NewItemIdInput } from '../components/input/NewItemIdInput'; +import { isValidId } from '../api/generate_id'; const component = () => { + const { mutate: createItem } = useApiCreateItem(); + const [item_id, setItemId] = useState(''); + const navigate = useNavigate(); + + const isDisabled = !isValidId(item_id); + return (

Create new Item

@@ -12,6 +21,8 @@ const component = () => {

@@ -21,7 +32,18 @@ const component = () => { (a-zA-Z0-9). Leading zeros will be trimmed.

- diff --git a/web/src/routes/index.lazy.tsx b/web/src/routes/index.lazy.tsx index 9826a34..a98ff47 100644 --- a/web/src/routes/index.lazy.tsx +++ b/web/src/routes/index.lazy.tsx @@ -1,6 +1,6 @@ import { createLazyFileRoute } from '@tanstack/react-router'; -import { StlPreview } from '../components/stl_preview/StlPreview'; +import { ItemPreview } from '../components/item/ItemPreview'; import { UserProfile } from '../components/UserProfile'; const component = () => { @@ -15,6 +15,9 @@ const component = () => {
+
+ +
); }; diff --git a/web/src/routes/item/$itemId/index.tsx b/web/src/routes/item/$itemId/index.tsx index df55ee4..03e4038 100644 --- a/web/src/routes/item/$itemId/index.tsx +++ b/web/src/routes/item/$itemId/index.tsx @@ -1,5 +1,43 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, redirect, useParams } from '@tanstack/react-router'; + +import { formatId, getInstanceSettings } from '../../../api/instance_settings'; +import { useApiItemById } from '../../../api/item'; +import { ItemPreview } from '../../../components/item/ItemPreview'; +import { MediaGallery } from '../../../components/media/MediaGallery'; +import { UnauthorizedResourceModal } from '../../../components/Unauthorized'; +import { queryClient } from '../../../util/query'; export const Route = createFileRoute('/item/$itemId/')({ - component: () =>
Hello /item/$itemId!
, + // if item_id is not formatId(item_id, instanceSettings), redirect to the formatted item_id + loader: async ({ context, params }) => { + const instanceSettings = await queryClient.ensureQueryData( + getInstanceSettings() + ); + const formattedItemId = formatId(params.itemId, instanceSettings); + + if (formattedItemId !== params.itemId) { + return redirect({ to: `/item/${formattedItemId}` }); + } + }, + component: () => { + const { itemId } = useParams({ from: '/item/$itemId/' }); + + const { data: item, error } = useApiItemById(itemId); + + if (error) { + return ; + } + + return ( +
+

Item {itemId}

+
+
+ +
+ +
+
+ ); + }, }); diff --git a/web/src/util/query.ts b/web/src/util/query.ts new file mode 100644 index 0000000..6d46de5 --- /dev/null +++ b/web/src/util/query.ts @@ -0,0 +1,3 @@ +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient();