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 (
+
+
+
+
+ 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 (
+
+ );
+};
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"),
+ },
+ },
+});