diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf70988 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/node_modules diff --git a/backend/bun.lockb b/backend/bun.lockb index 193c880..6e4dc88 100755 Binary files a/backend/bun.lockb and b/backend/bun.lockb differ diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..d6c9537 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..0d6babe --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,30 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default { + // other rules... + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +} +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/frontend/bun.lockb b/frontend/bun.lockb new file mode 100755 index 0000000..33b978b Binary files /dev/null and b/frontend/bun.lockb differ diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..1c6facd --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c4d351b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Meiro + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..56563a4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,50 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", + "@tanstack/react-query": "^5.20.4", + "@tanstack/react-router": "^1.16.0", + "@tanstack/react-table": "^8.12.0", + "@tanstack/react-virtual": "^3.0.4", + "@tanstack/router-devtools": "^1.16.0", + "@tanstack/router-vite-plugin": "^1.16.1", + "@types/react-helmet-async": "^1.0.3", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.330.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-helmet-async": "^2.0.4", + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/node": "^20.11.17", + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "autoprefixer": "^10.4.17", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.2.2", + "vite": "^5.1.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/RouterErrorFallback.tsx b/frontend/src/components/RouterErrorFallback.tsx new file mode 100644 index 0000000..1a4fd60 --- /dev/null +++ b/frontend/src/components/RouterErrorFallback.tsx @@ -0,0 +1,28 @@ +import { Button } from "@/components/ui/button"; +import { AlertTriangleIcon, RefreshCw } from "lucide-react"; + +export const RouterErrorFallaback = () => { + return ( +
+
+
+ +
+ Error +
+
+
+ Oops! Something went wrong. Please try refreshing the page. +
+ +
+
+ ); +}; diff --git a/frontend/src/components/navigation-menu/RootNavigationMenu.tsx b/frontend/src/components/navigation-menu/RootNavigationMenu.tsx new file mode 100644 index 0000000..034abeb --- /dev/null +++ b/frontend/src/components/navigation-menu/RootNavigationMenu.tsx @@ -0,0 +1,55 @@ +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuList, + navigationMenuTriggerStyle, +} from "@/components/ui/navigation-menu"; +import { Link } from "@tanstack/react-router"; +import { HomeIcon, ListIcon } from "lucide-react"; +import { ReactNode } from "react"; + +type NavigationConfig = { + title: string; + route: string; + icon?: ReactNode; +}; + +const NAVIGATION_CONFIG: NavigationConfig[] = [ + { + title: "Home", + route: "/", + icon: , + }, + { + title: "Attributes", + route: "/attributes", + icon: , + }, +] as const; + +export const RootNavigationMenu = () => { + return ( +
+ + + {NAVIGATION_CONFIG.map(({ title, route, icon }) => ( + + +
+ {icon} + {title} +
+ + +
+ ))} +
+
+
+ ); +}; diff --git a/frontend/src/components/navigation-menu/index.ts b/frontend/src/components/navigation-menu/index.ts new file mode 100644 index 0000000..74d64d4 --- /dev/null +++ b/frontend/src/components/navigation-menu/index.ts @@ -0,0 +1 @@ +export * from "./RootNavigationMenu"; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..0ba4277 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..677d05f --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/src/components/ui/navigation-menu.tsx b/frontend/src/components/ui/navigation-menu.tsx new file mode 100644 index 0000000..1e2a0fc --- /dev/null +++ b/frontend/src/components/ui/navigation-menu.tsx @@ -0,0 +1,100 @@ +import * as React from "react"; +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"; +import { cva } from "class-variance-authority"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const NavigationMenu = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + +)); +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName; + +const NavigationMenuList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName; + +const NavigationMenuItem = NavigationMenuPrimitive.Item; + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50", +); + +const NavigationMenuTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children}{" "} + +)); +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName; + +const NavigationMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName; + +const NavigationMenuViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ +
+)); +NavigationMenuViewport.displayName = + NavigationMenuPrimitive.Viewport.displayName; + +export { + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuViewport, +}; diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..cf253cf --- /dev/null +++ b/frontend/src/components/ui/scroll-area.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..12b6ff7 --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,70 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); + +export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell }; diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx new file mode 100644 index 0000000..a822477 --- /dev/null +++ b/frontend/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/frontend/src/components/ui/toaster.tsx b/frontend/src/components/ui/toaster.tsx new file mode 100644 index 0000000..a2209ba --- /dev/null +++ b/frontend/src/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" +import { useToast } from "@/components/ui/use-toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/frontend/src/components/ui/use-toast.ts b/frontend/src/components/ui/use-toast.ts new file mode 100644 index 0000000..1671307 --- /dev/null +++ b/frontend/src/components/ui/use-toast.ts @@ -0,0 +1,192 @@ +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/frontend/src/components/virtualized-table/VirtualTable.hook.ts b/frontend/src/components/virtualized-table/VirtualTable.hook.ts new file mode 100644 index 0000000..7c03e93 --- /dev/null +++ b/frontend/src/components/virtualized-table/VirtualTable.hook.ts @@ -0,0 +1,50 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useRef, useEffect, useCallback } from "react"; + +type HookParams = { + hasNextPage: boolean; + allItems: T[]; + isFetchingNextPage: boolean; + fetchNextPage: () => void; +}; + +export const useVirtualScroll = ({ + hasNextPage, + allItems, + isFetchingNextPage, + fetchNextPage, +}: HookParams) => { + const scrollerRef = useRef(null); + const virtualizer = useVirtualizer({ + count: hasNextPage ? allItems.length + 1 : allItems.length, + getScrollElement: () => scrollerRef.current, + estimateSize: () => 80, + measureElement: + typeof window !== "undefined" && + navigator.userAgent.indexOf("Firefox") === -1 + ? (element) => element?.getBoundingClientRect().height + : undefined, + }); + + const fetchMoreOnBottomReached = useCallback( + (containerRefElement?: HTMLDivElement | null) => { + if (containerRefElement) { + const { scrollHeight, scrollTop, clientHeight } = containerRefElement; + if ( + scrollHeight - scrollTop - clientHeight < 80 && + !isFetchingNextPage && + hasNextPage + ) { + fetchNextPage(); + } + } + }, + [fetchNextPage, hasNextPage, isFetchingNextPage], + ); + + useEffect(() => { + fetchMoreOnBottomReached(scrollerRef.current); + }, [fetchMoreOnBottomReached]); + + return { virtualizer, scrollerRef, fetchMoreOnBottomReached }; +}; diff --git a/frontend/src/components/virtualized-table/VirtualTable.types.ts b/frontend/src/components/virtualized-table/VirtualTable.types.ts new file mode 100644 index 0000000..cfe98ac --- /dev/null +++ b/frontend/src/components/virtualized-table/VirtualTable.types.ts @@ -0,0 +1,15 @@ +import { ColumnDef, Row } from "@tanstack/react-table"; + +export type TableSortState = { id: keyof T; desc: boolean }; + +export type DataTableProps = { + columns: ColumnDef[]; + data: TData[]; + hasNextPage: boolean; + className?: string; + fetchNextPage: () => void; + isFetchingNextPage: boolean; + onSort?: (sort?: TableSortState) => void; + initialSort?: TableSortState; + onRowClick?: (row: Row) => void; +}; diff --git a/frontend/src/components/virtualized-table/VirtualizedTable.tsx b/frontend/src/components/virtualized-table/VirtualizedTable.tsx new file mode 100644 index 0000000..2283314 --- /dev/null +++ b/frontend/src/components/virtualized-table/VirtualizedTable.tsx @@ -0,0 +1,173 @@ +import { + ColumnSort, + OnChangeFn, + Row, + SortingState, + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import { useVirtualScroll } from "./VirtualTable.hook"; +import { ArrowDownWideNarrowIcon, ArrowUpNarrowWideIcon } from "lucide-react"; +import { useState } from "react"; +import { DataTableProps, TableSortState } from "./VirtualTable.types"; + +export function VirtualizedDataTable({ + columns, + data, + hasNextPage, + className, + fetchNextPage, + isFetchingNextPage, + onSort, + initialSort, + onRowClick, +}: DataTableProps) { + const [sorting, setSorting] = useState( + initialSort?.id ? [initialSort as ColumnSort] : [], + ); + const table = useReactTable({ + data, + columns, + state: { + sorting, + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + manualSorting: true, + }); + + const handleSortingChange: OnChangeFn = (updater) => { + if (table.getRowModel().rows.length) { + virtualizer.scrollToIndex?.(0); + } + + if (typeof updater === "function") { + const update = updater(table.getState().sorting); + setSorting(update); + if (onSort) { + const data = update[0] as ColumnSort; + onSort(data as TableSortState | undefined); + } + } + }; + + table.setOptions((prev) => ({ + ...prev, + onSortingChange: handleSortingChange, + })); + + const { rows } = table.getRowModel(); + + const { virtualizer, scrollerRef, fetchMoreOnBottomReached } = + useVirtualScroll>({ + allItems: rows, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + }); + + return ( +
fetchMoreOnBottomReached(e.target as HTMLDivElement)} + ref={scrollerRef} + > + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {{ + desc: , + asc: , + }[header.column.getIsSorted() as string] ?? null} +
+
+ ); + })} +
+ ))} +
+ + {virtualizer.getVirtualItems().map((virtualRow) => { + const isLoaderRow = virtualRow.index > rows.length - 1; + const row = rows[virtualRow.index] as Row; + + if (!row) { + return null; + } + + return ( + onRowClick?.(row)} + data-index={virtualRow.index} //needed for dynamic row height measurement + ref={(node) => virtualizer.measureElement(node)} //measure dynamic row height + key={row.id} + className={`flex absolute w-full h-[80px] ${onRowClick ? "cursor-pointer" : ""}`} // h-[80px] does not need to be there (I added it there cos I did not have enaugh data to make the infinite loading nicer expereinec since there is not enaugh data) + style={{ + transform: `translateY(${virtualRow.start}px)`, + }} + > + {isLoaderRow + ? "Is Loading" + : row.getVisibleCells().map((cell) => { + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })} + + ); + })} + +
+
+ ); +} diff --git a/frontend/src/components/virtualized-table/index.ts b/frontend/src/components/virtualized-table/index.ts new file mode 100644 index 0000000..b6c069c --- /dev/null +++ b/frontend/src/components/virtualized-table/index.ts @@ -0,0 +1 @@ +export * from "./VirtualizedTable"; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..2ada6b8 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,59 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/src/lib/api.service.ts b/frontend/src/lib/api.service.ts new file mode 100644 index 0000000..64154eb --- /dev/null +++ b/frontend/src/lib/api.service.ts @@ -0,0 +1,12 @@ +const BASE_URL = "http://localhost:3000"; + +export const api = { + get(endpoint: string) { + return fetch(`${BASE_URL}${endpoint}`, { method: "GET" }); + }, + delete(endpoint: string) { + return fetch(`${BASE_URL}${endpoint}`, { + method: "DELETE", + }); + }, +}; diff --git a/frontend/src/lib/debounce-state.hook.ts b/frontend/src/lib/debounce-state.hook.ts new file mode 100644 index 0000000..8021667 --- /dev/null +++ b/frontend/src/lib/debounce-state.hook.ts @@ -0,0 +1,26 @@ +import { useState, useEffect } from "react"; + +type HookReturnType = { + debounced: [T, React.Dispatch>]; + original: T; +}; + +export const useDebouncedState = ( + initialState: T, + delay: number, +): HookReturnType => { + const [state, setState] = useState(initialState); + const [debouncedState, setDebouncedState] = useState(initialState); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedState(state); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [state, delay]); + + return { debounced: [debouncedState, setState], original: state }; +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..a279967 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./index.css"; +import { RouterProvider, createRouter } from "@tanstack/react-router"; + +import { routeTree } from "./routeTree.gen"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "./react-query"; +import { HelmetProvider } from "react-helmet-async"; + +const router = createRouter({ routeTree, context: { queryClient } }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +const rootElement = document.getElementById("app")!; +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement); + root.render( + + + + + + + , + ); +} diff --git a/frontend/src/pages/attribute/AttributeCard.tsx b/frontend/src/pages/attribute/AttributeCard.tsx new file mode 100644 index 0000000..5d2bd9c --- /dev/null +++ b/frontend/src/pages/attribute/AttributeCard.tsx @@ -0,0 +1,45 @@ +import { Button } from "@/components/ui/button"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from "@/components/ui/card"; +import { AttributeType } from "@/types/attributes"; + +type Props = { + attribute: AttributeType; + onDelete: (attributeId: string) => void; +}; + +export const AttributeCard = ({ attribute, onDelete }: Props) => { + return ( + + + {attribute.name} + Attribute Detail + + +
+

Labels

+

{(attribute.labels || attribute.labelIds).join(", ")}

+
+
+

Created At

+

{new Date(attribute.createdAt).toLocaleDateString()}

+
+
+ + + +
+ ); +}; diff --git a/frontend/src/pages/attribute/AttributePage.tsx b/frontend/src/pages/attribute/AttributePage.tsx new file mode 100644 index 0000000..94e72b6 --- /dev/null +++ b/frontend/src/pages/attribute/AttributePage.tsx @@ -0,0 +1,43 @@ +import { Button } from "@/components/ui/button"; +import { Route } from "@/routes/attributes.$attributeId"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useRouter } from "@tanstack/react-router"; +import { attributeQueryOptions } from "./api"; +import { AttributeCard } from "./AttributeCard"; +import { ArrowLeftIcon } from "lucide-react"; +import { queryClient, useDeleteAttributeByIdQuery } from "@/react-query"; +import { ATTRIBUTES_QUERY_KEY } from "../attributes"; +import { labelsQueryOptions } from "@/react-query"; +import { useMemo } from "react"; + +export const AttributePage = () => { + const { attributeId } = Route.useParams(); + const { data: labels } = useSuspenseQuery(labelsQueryOptions); + const { data } = useSuspenseQuery(attributeQueryOptions(attributeId, labels)); + const router = useRouter(); + + const attribute = useMemo(() => { + data.data.labels = data.data.labelIds.map( + (id) => labels.data.find((label) => label.id === id)?.name, + ); + return data.data; + }, [data.data, labels.data]); + + const { mutate } = useDeleteAttributeByIdQuery(() => { + router.history.back(); + queryClient.invalidateQueries({ + queryKey: [ATTRIBUTES_QUERY_KEY], + }); + }); + + return ( +
+
+ +
+ +
+ ); +}; diff --git a/frontend/src/pages/attribute/api.ts b/frontend/src/pages/attribute/api.ts new file mode 100644 index 0000000..b32d0f3 --- /dev/null +++ b/frontend/src/pages/attribute/api.ts @@ -0,0 +1,26 @@ +import { api } from "@/lib/api.service"; +import { AttributeType } from "@/types/attributes"; +import { LabelsQuery } from "@/types/labels"; +import { queryOptions } from "@tanstack/react-query"; + +export const ATTRIBUTE_QUERY_KEY = "attribute"; + +export const fetchAttributeById = async (attributeId: string) => { + const response = await api.get(`/attributes/${attributeId}`); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + return response.json(); +}; + +export const attributeQueryOptions = ( + attributeId: string, + labels?: LabelsQuery, +) => + queryOptions<{ data: AttributeType }>({ + queryKey: [ATTRIBUTE_QUERY_KEY, attributeId, labels], + queryFn: () => fetchAttributeById(attributeId), + enabled: !!labels, + }); diff --git a/frontend/src/pages/attribute/index.ts b/frontend/src/pages/attribute/index.ts new file mode 100644 index 0000000..be62c18 --- /dev/null +++ b/frontend/src/pages/attribute/index.ts @@ -0,0 +1 @@ +export * from "./AttributePage"; diff --git a/frontend/src/pages/attributes/AttributesPage.tsx b/frontend/src/pages/attributes/AttributesPage.tsx new file mode 100644 index 0000000..5c1ed21 --- /dev/null +++ b/frontend/src/pages/attributes/AttributesPage.tsx @@ -0,0 +1,97 @@ +import { Input } from "@/components/ui/input"; +import { + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { attributesQueryOptions } from "./api"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/attributes.index"; +import { useEffect, useMemo } from "react"; +import { useDebouncedState } from "@/lib/debounce-state.hook"; +import { AttributesTable } from "./AttributesTable/AttributesTable"; +import { AttributesQueryOptions } from "./AttributesPage.types"; +import { AttributeType } from "@/types/attributes"; +import { labelsQueryOptions } from "@/react-query"; + +export const Attributes = () => { + const navigate = useNavigate({ from: Route.fullPath }); + const { searchText, sortBy, sortDir } = Route.useSearch(); + const { data: labels } = useSuspenseQuery(labelsQueryOptions); + const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = + useSuspenseInfiniteQuery( + attributesQueryOptions(Route.useLoaderDeps(), labels), + ); + + const attributes = useMemo(() => { + return data.pages.flatMap((page) => { + page.data.forEach((attribute) => { + attribute.labels = attribute.labelIds.map( + (id) => labels.data.find((label) => label.id === id)?.name, + ); + }); + return page.data; + }); + }, [data.pages, labels.data]); + + const { + debounced: [searchDraft, setSearchDraft], + original: originalSearchDraft, + } = useDebouncedState(searchText ?? "", 300); + + const setSearchParams = (params: AttributesQueryOptions) => { + navigate({ + search: (old) => { + return { + ...old, + ...params, + }; + }, + replace: true, + }); + }; + + const handleSort = (sort?: { id: keyof AttributeType; desc: boolean }) => { + if (!sort) { + setSearchParams({ sortBy: undefined, sortDir: undefined }); + return; + } + + if (sort.id !== "name" && sort.id !== "createdAt") { + return; + } + setSearchParams({ sortBy: sort.id, sortDir: sort.desc ? "desc" : "asc" }); + }; + + useEffect(() => { + setSearchParams({ searchText: searchDraft || undefined }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchDraft]); + + return ( +
+ setSearchDraft(e.target.value)} + /> + { + navigate({ + to: "/attributes/$attributeId", + params: { attributeId: row.original.id }, + }); + }} + onSort={handleSort} + data={attributes} + fetchNextPage={fetchNextPage} + isFetchingNextPage={isFetchingNextPage} + hasNextPage={hasNextPage} + /> +
+ ); +}; diff --git a/frontend/src/pages/attributes/AttributesPage.types.ts b/frontend/src/pages/attributes/AttributesPage.types.ts new file mode 100644 index 0000000..d9436b0 --- /dev/null +++ b/frontend/src/pages/attributes/AttributesPage.types.ts @@ -0,0 +1,5 @@ +export type AttributesQueryOptions = { + searchText?: string; + sortBy?: "name" | "createdAt"; + sortDir?: "asc" | "desc"; +}; diff --git a/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx b/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx new file mode 100644 index 0000000..ed6bf4f --- /dev/null +++ b/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx @@ -0,0 +1,27 @@ +import { VirtualizedDataTable } from "@/components/virtualized-table"; +import { AttributeType } from "@/types/attributes"; +import { getColumns } from "./AttributesTables.config"; +import { ComponentProps } from "react"; +import { queryClient, useDeleteAttributeByIdQuery } from "@/react-query"; +import { ATTRIBUTES_QUERY_KEY } from ".."; + +type Props = Omit< + ComponentProps>, + "columns" | "className" +>; + +export const AttributesTable = (props: Props) => { + const { mutate } = useDeleteAttributeByIdQuery(() => { + queryClient.invalidateQueries({ + queryKey: [ATTRIBUTES_QUERY_KEY], + }); + }); + + return ( + + ); +}; diff --git a/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx b/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx new file mode 100644 index 0000000..1865d63 --- /dev/null +++ b/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx @@ -0,0 +1,41 @@ +import { Button } from "@/components/ui/button"; +import { AttributeType } from "@/types/attributes"; +import { ColumnDef } from "@tanstack/react-table"; +import { TrashIcon } from "lucide-react"; + +export const getColumns = ( + onDelete: (id: string) => void, +): ColumnDef[] => [ + { + accessorKey: "name", + header: "Name", + }, + { + header: "Labels", + cell: ({ row }) => + (row.original.labels || row.original.labelIds).join(", "), + }, + { + accessorKey: "createdAt", + header: "Created At", + cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(), + }, + { + accessorKey: "delete", + header: "", + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/frontend/src/pages/attributes/api.ts b/frontend/src/pages/attributes/api.ts new file mode 100644 index 0000000..554aaf4 --- /dev/null +++ b/frontend/src/pages/attributes/api.ts @@ -0,0 +1,55 @@ +import { AttributeQuery } from "@/types/attributes"; +import { infiniteQueryOptions, keepPreviousData } from "@tanstack/react-query"; +import { AttributesQueryOptions as AttributesQueryDeps } from "./AttributesPage.types"; +import { api } from "@/lib/api.service"; +import { LabelsQuery } from "@/types/labels"; + +export const ATTRIBUTES_QUERY_KEY = "attributes"; +const DEFAULT_LIMIT = 10; + +export const fetchAttributes = async ({ + pageParam, + deps, +}: { + pageParam: unknown; + deps: AttributesQueryDeps; +}) => { + const params = new URLSearchParams({ + offset: typeof pageParam === "number" ? pageParam.toString() : "0", + limit: DEFAULT_LIMIT.toString(), + }); + + for (const key in deps) { + if (Object.prototype.hasOwnProperty.call(deps, key)) { + const value = (deps as Record)[key]; + if (value) { + params.set(key, encodeURIComponent(value)); + } + } + } + + const response = await api.get(`/attributes?${params.toString()}`); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + return response.json(); +}; + +export const attributesQueryOptions = ( + deps: AttributesQueryDeps, + labels: LabelsQuery, +) => + infiniteQueryOptions({ + queryKey: [ATTRIBUTES_QUERY_KEY, deps, labels], + queryFn: ({ pageParam }) => fetchAttributes({ pageParam, deps }), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + const nextOffset = lastPage.meta.offset + lastPage.meta.limit; + + return lastPage.meta.hasNextPage ? nextOffset : undefined; + }, + placeholderData: keepPreviousData, + enabled: !!labels, + }); diff --git a/frontend/src/pages/attributes/index.ts b/frontend/src/pages/attributes/index.ts new file mode 100644 index 0000000..96df41c --- /dev/null +++ b/frontend/src/pages/attributes/index.ts @@ -0,0 +1,2 @@ +export * from "./AttributesPage"; +export * from "./api"; diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts new file mode 100644 index 0000000..f927f7e --- /dev/null +++ b/frontend/src/pages/index.ts @@ -0,0 +1 @@ +export * from "./attributes"; diff --git a/frontend/src/react-query/client.ts b/frontend/src/react-query/client.ts new file mode 100644 index 0000000..6c7b9de --- /dev/null +++ b/frontend/src/react-query/client.ts @@ -0,0 +1,3 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient(); diff --git a/frontend/src/react-query/common-api.ts b/frontend/src/react-query/common-api.ts new file mode 100644 index 0000000..342ca5a --- /dev/null +++ b/frontend/src/react-query/common-api.ts @@ -0,0 +1,65 @@ +import { queryOptions, useMutation } from "@tanstack/react-query"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/api.service"; +import { LabelsQuery } from "@/types/labels"; + +/* + * API + Query to delete attribute by id + */ +const deleteAttribute = async (id: string) => { + const response = await api.delete(`/attributes/${id}`); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + return response.json(); +}; + +export const useDeleteAttributeByIdQuery = (onSuccess?: () => void) => { + const mutation = useMutation({ + mutationFn: deleteAttribute, + onSuccess: () => { + onSuccess?.(); + toast({ + title: "Attribute has been successfully deleted", + variant: "destructive", + }); + }, + }); + + return { + mutate: mutation.mutate, + }; +}; +/* + * END of attribute delete query + */ + +/* + * Fetch all labels + */ +const fetchAllLabels = async () => { + let offset = 0; + const limit = 10; + const response = await api.get(`/labels?offset=${offset}&limit=${limit}`); + const data = (await response.json()) as LabelsQuery; + + while (data.meta.hasNextPage) { + offset += limit; + const res = await api.get(`/labels?offset=${offset}&limit=${limit}`); + const jsonRes = (await res.json()) as LabelsQuery; + data.meta = jsonRes.meta; + data.data.push(...jsonRes.data); + } + + return data; +}; + +export const labelsQueryOptions = queryOptions({ + queryKey: ["labels"], + queryFn: fetchAllLabels, +}); +/* + * END of fetch all albels + */ diff --git a/frontend/src/react-query/index.ts b/frontend/src/react-query/index.ts new file mode 100644 index 0000000..77145de --- /dev/null +++ b/frontend/src/react-query/index.ts @@ -0,0 +1,2 @@ +export * from "./client"; +export * from "./common-api"; diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts new file mode 100644 index 0000000..9f49a1b --- /dev/null +++ b/frontend/src/routeTree.gen.ts @@ -0,0 +1,67 @@ +/* prettier-ignore-start */ + +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file is auto-generated by TanStack Router + +import { createFileRoute } from '@tanstack/react-router' + +// Import Routes + +import { Route as rootRoute } from './routes/__root' +import { Route as AttributesIndexImport } from './routes/attributes.index' +import { Route as AttributesAttributeIdImport } from './routes/attributes.$attributeId' + +// Create Virtual Routes + +const IndexLazyImport = createFileRoute('/')() + +// Create/Update Routes + +const IndexLazyRoute = IndexLazyImport.update({ + path: '/', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route)) + +const AttributesIndexRoute = AttributesIndexImport.update({ + path: '/attributes/', + getParentRoute: () => rootRoute, +} as any) + +const AttributesAttributeIdRoute = AttributesAttributeIdImport.update({ + path: '/attributes/$attributeId', + getParentRoute: () => rootRoute, +} as any) + +// Populate the FileRoutesByPath interface + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + preLoaderRoute: typeof IndexLazyImport + parentRoute: typeof rootRoute + } + '/attributes/$attributeId': { + preLoaderRoute: typeof AttributesAttributeIdImport + parentRoute: typeof rootRoute + } + '/attributes/': { + preLoaderRoute: typeof AttributesIndexImport + parentRoute: typeof rootRoute + } + } +} + +// Create and export the route tree + +export const routeTree = rootRoute.addChildren([ + IndexLazyRoute, + AttributesAttributeIdRoute, + AttributesIndexRoute, +]) + +/* prettier-ignore-end */ diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx new file mode 100644 index 0000000..c2d17c1 --- /dev/null +++ b/frontend/src/routes/__root.tsx @@ -0,0 +1,43 @@ +import { + createRootRouteWithContext, + Outlet, + ScrollRestoration, +} from "@tanstack/react-router"; +import React, { Suspense } from "react"; +import { RootNavigationMenu } from "@/components/navigation-menu"; +import { queryClient } from "@/react-query"; +import { Toaster } from "@/components/ui/toaster"; + +const TanStackRouterDevtools = + process.env.NODE_ENV === "production" + ? () => null + : React.lazy(() => + import("@tanstack/router-devtools").then((res) => ({ + default: res.TanStackRouterDevtools, + })), + ); + +export const Route = createRootRouteWithContext<{ + queryClient: typeof queryClient; +}>()({ + component: () => ( +
+ +
+ { + const paths = ["/attributes"]; + return paths.includes(location.pathname) + ? location.pathname + : location.hash; + }} + /> + +
+ + + + +
+ ), +}); diff --git a/frontend/src/routes/attributes.$attributeId.tsx b/frontend/src/routes/attributes.$attributeId.tsx new file mode 100644 index 0000000..1d0e5a7 --- /dev/null +++ b/frontend/src/routes/attributes.$attributeId.tsx @@ -0,0 +1,28 @@ +import { RouterErrorFallaback } from "@/components/RouterErrorFallback"; +import { AttributePage } from "@/pages/attribute"; +import { attributeQueryOptions } from "@/pages/attribute/api"; +import { createFileRoute } from "@tanstack/react-router"; +import { Helmet } from "react-helmet-async"; +import { labelsQueryOptions } from "@/react-query"; +import { z } from "zod"; + +export const Route = createFileRoute("/attributes/$attributeId")({ + parseParams: (params) => ({ + attributeId: z.string().parse(params.attributeId), + }), + loader: async ({ context: { queryClient }, params }) => { + const labels = await queryClient.ensureQueryData(labelsQueryOptions); + return queryClient.ensureQueryData( + attributeQueryOptions(params.attributeId, labels), + ); + }, + component: () => ( + <> + + Attribute Detail + + + + ), + errorComponent: RouterErrorFallaback, +}); diff --git a/frontend/src/routes/attributes.index.tsx b/frontend/src/routes/attributes.index.tsx new file mode 100644 index 0000000..471e134 --- /dev/null +++ b/frontend/src/routes/attributes.index.tsx @@ -0,0 +1,39 @@ +import { RouterErrorFallaback } from "@/components/RouterErrorFallback"; +import { Attributes, attributesQueryOptions } from "@/pages"; +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { Helmet } from "react-helmet-async"; +import { labelsQueryOptions } from "@/react-query"; + +export const Route = createFileRoute("/attributes/")({ + validateSearch: z.object({ + searchText: z.string().optional(), + sortBy: z.enum(["name", "createdAt"]).optional(), + sortDir: z.enum(["asc", "desc"]).optional(), + }).parse, + loaderDeps: ({ search: { searchText, sortBy, sortDir } }) => ({ + searchText, + sortBy, + sortDir, + }), + loader: async ({ context: { queryClient }, deps }) => { + const labels = await queryClient.ensureQueryData(labelsQueryOptions); + + const options = attributesQueryOptions(deps, labels); + + const data = + queryClient.getQueryData(options.queryKey) ?? + (await queryClient.fetchInfiniteQuery(options)); + + return data; + }, + component: () => ( + <> + + Attributes + + + + ), + errorComponent: RouterErrorFallaback, +}); diff --git a/frontend/src/routes/index.lazy.tsx b/frontend/src/routes/index.lazy.tsx new file mode 100644 index 0000000..e212a6f --- /dev/null +++ b/frontend/src/routes/index.lazy.tsx @@ -0,0 +1,19 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import { Helmet } from "react-helmet-async"; + +export const Route = createLazyFileRoute("/")({ + component: Index, +}); + +function Index() { + return ( + <> + + Meiro + +
+

Welcome Home!

+
+ + ); +} diff --git a/frontend/src/types/attributes.ts b/frontend/src/types/attributes.ts new file mode 100644 index 0000000..69ecfd5 --- /dev/null +++ b/frontend/src/types/attributes.ts @@ -0,0 +1,22 @@ +export type AttributeType = { + id: string; + name: string; + createdAt: string; // ISO8601 string + labelIds: string[]; + labels?: (string | undefined)[]; + deleted: boolean; +}; + +export type MetaType = { + offset: number; + limit: number; + searchText: string; + sortBy: string; + sortDir: string; + hasNextPage: boolean; +}; + +export type AttributeQuery = { + data: AttributeType[]; + meta: MetaType; +}; diff --git a/frontend/src/types/labels.ts b/frontend/src/types/labels.ts new file mode 100644 index 0000000..c83c929 --- /dev/null +++ b/frontend/src/types/labels.ts @@ -0,0 +1,15 @@ +export type LabelType = { + id: string; + name: string; +}; + +export type MetaType = { + offset: number; + limit: number; + hasNextPage: boolean; +}; + +export type LabelsQuery = { + data: LabelType[]; + meta: MetaType; +}; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..7cb7e37 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,77 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + ], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..96b13eb --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + }, + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }], +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6177c6b --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,13 @@ +import path from "path"; +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; +import { TanStackRouterVite } from "@tanstack/router-vite-plugin"; + +export default defineConfig({ + plugins: [react(), TanStackRouterVite()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +});