feat: challenge registry-driven platform + XVI/XVII imports + admin toggle + domain rename

- Rename repo/domain: attack-defense-platform / attackdefense.imrnes.team (all refs replaced)
- challenge_registry.json: single source of truth (28 challs across gemastik18/xvi/xvii)
- teams.py: registry-driven CHALLENGES, set_challenge_enabled, sync_challenge_runtime
  (apply enable/disable to live teams: build/up or stop/remove + receiver restart)
- compose_gen.py: render per-team compose from canonical per-challenge templates
  (image reuse, per-team ports 30xxx, flag mounts, passwords)
- gen_canonical_composes.py: canonical docker-compose.yml for all services
- import_new_challenges.py: import XVI/XVII services + EOL base image fixes
  (debian:buster→bookworm, node:14→20, python:3.7-slim→3.11)
- receiver: xvi package (10 checkers) + xvii package (12 generic checkers),
  Challenge base reads PASSWORD_<team_port> from env; gen_receiver_main.py
  generates per-team main.py from registry
- main.py: /api/challenges returns full registry; PATCH /api/challenges/<name>
  toggles enabled + applies to live teams
- index.html: 🏗️ Challenge Manager tab (toggle per challenge, grouped by set)
- SLA bonus now dynamic (all enabled challenges, not hardcoded 6)
This commit is contained in:
MythEclipse committed 2026-09-25 14:04:33 +08:00
1 parent c35b23a37f
commit c6fd9ec268
1317 files changed
+306586 -128

No files matched your search

+24
View File
@@ -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?
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+39
View File
@@ -0,0 +1,39 @@
import js from "@eslint/js";
import globals from "globals";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
export default [
{ ignores: ["dist"] },
{
files: ["**/*.{js,jsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: "latest",
ecmaFeatures: { jsx: true },
sourceType: "module",
},
},
settings: { react: { version: "18.3" } },
plugins: {
react,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs["jsx-runtime"].rules,
...reactHooks.configs.recommended.rules,
"react/jsx-no-target-blank": "off",
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"react/prop-types": "off",
},
},
];
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FJB</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-router": "^1.58.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"ky": "^1.7.2",
"lucide-react": "^0.441.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@tanstack/router-devtools": "^1.58.3",
"@tanstack/router-plugin": "^1.58.4",
"@types/node": "^22.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.12",
"vite": "^5.4.1"
}
}
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f38ba8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-store"><path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"/><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4"/><path d="M2 7h20"/><path d="M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 Line truncated

After

Width:  |  Height:  |  Size: 690 B

@@ -0,0 +1,238 @@
import { useCallback, useState, useEffect } from "react";
import {
Loader2,
ShoppingBag,
ShoppingCart,
LogOut,
LogIn,
Plus,
Eye,
EyeOff,
} from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useLocation } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import useAuth from "@/hooks/useAuth";
import router from "@/lib/router";
import httpClient from "@/lib/httpClient";
function AuthButton() {
const navigate = useNavigate();
const { isPending, authSuccess } = useAuth();
const queryClient = useQueryClient();
const logout = useCallback(
(e) => {
e.preventDefault();
document.cookie = "session=";
queryClient.invalidateQueries({ queryKey: ["user"] });
router.invalidate();
navigate({ to: "/login" });
},
[queryClient, navigate],
);
if (isPending) {
return (
<Button className="flex items-center gap-2" disabled>
<Loader2 className="h-5 w-5 animate-spin" />
<span className="hidden sm:inline">Loading</span>
</Button>
);
}
if (authSuccess) {
return (
<Button className="flex items-center gap-2" onClick={logout}>
<LogOut className="h-5 w-5" />
<span className="hidden sm:inline">Logout</span>
</Button>
);
}
return (
<Link to="/login">
<Button className="flex items-center gap-2">
<LogIn className="h-5 w-5" />
<span className="hidden sm:inline">Login</span>
</Button>
</Link>
);
}
export default function Navigation() {
const [data, setData] = useState({
name: "",
description: "",
valuable: "",
price: "",
});
const [valueHidden, setValueHidden] = useState(true);
const { pathname } = useLocation();
const [dialogClass, setDialogClass] = useState(null);
const [open, setOpen] = useState(false);
const { isSuccess: authSuccess } = useAuth();
const queryClient = useQueryClient();
const { isPending, mutate } = useMutation({
mutationKey: ["post-add-item"],
mutationFn: (data) => httpClient.post("/api/catalog", { json: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
setOpen(false);
},
});
useEffect(() => {
if (pathname === "/" && authSuccess) {
setDialogClass("flex items-center gap-2");
} else {
setDialogClass("hidden");
}
}, [pathname, authSuccess]);
const handleChange = (event) => {
const { name, value } = event.target;
setData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (event) => {
event.preventDefault();
mutate(data);
};
const toggleValuableVisibility = () => {
setValueHidden((prev) => !prev);
};
return (
<div className="flex justify-between items-center mb-6 flex-wrap gap-4">
<Link to="/">
<h1 className="text-3xl font-bold">FJB</h1>
</Link>
<div className="flex gap-2">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className={dialogClass}>
<Plus className="h-5 w-5" />
<span className="hidden sm:inline">Add Item</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Item</DialogTitle>
<DialogDescription>
Enter the details of the item you want to add to the
marketplace.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
name="name"
value={data.name}
onChange={handleChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
</Label>
<Textarea
id="description"
name="description"
value={data.description}
onChange={handleChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="valuable" className="text-right">
Valuable
</Label>
<div className="col-span-3 flex">
<Input
id="valuable"
name="valuable"
type={valueHidden ? "text" : "password"}
value={data.valuable}
onChange={handleChange}
className="flex-grow"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={toggleValuableVisibility}
className="ml-2"
>
{valueHidden ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="price" className="text-right">
Price
</Label>
<Input
id="price"
name="price"
value={data.price}
onChange={handleChange}
type="number"
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isPending}>
{isPending && (
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-primary border-r-transparent" />
)}
Submit
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Link to="/">
<Button variant="outline" className="flex items-center gap-2">
<ShoppingBag className="h-5 w-5" />
<span className="hidden sm:inline">Marketplace</span>
</Button>
</Link>
<Link to="/cart">
<Button variant="outline" className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5" />
<span className="hidden sm:inline">Cart</span>
</Button>
</Link>
<AuthButton />
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
}
export { Badge, badgeVariants }
@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } 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 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />)
);
})
Button.displayName = "Button"
export { Button, buttonVariants }
@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,94 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props} />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[4Line truncated
className
)}
{...props}>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
(<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props} />)
);
})
Input.displayName = "Input"
export { Input }
@@ -0,0 +1,16 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
@@ -0,0 +1,86 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -0,0 +1,41 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props} />
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props} />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
return (
(<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props} />)
);
})
Textarea.displayName = "Textarea"
export { Textarea }
@@ -0,0 +1,24 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+9
View File
@@ -0,0 +1,9 @@
const PAGE = {
LOGIN: 0,
MARKETPLACE: 1,
CART: 2,
};
const TOKEN_KEY = "TOKEN";
export { PAGE, TOKEN_KEY };
@@ -0,0 +1,9 @@
import httpClient from "@/lib/httpClient";
import { useQuery } from "@tanstack/react-query";
export default function useAuth() {
return useQuery({
queryKey: ["user"],
queryFn: () => httpClient.get("/api/user").json(),
});
}
+67
View File
@@ -0,0 +1,67 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import router from "@/lib/router";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
createRoot(document.getElementById("root")).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
@@ -0,0 +1,159 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { Loader2, Trash2 } from "lucide-react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import httpClient from "@/lib/httpClient";
import useAuth from "@/hooks/useAuth";
export default function CartCheckout() {
const auth = useAuth();
const queryClient = useQueryClient();
const { isLoading, data: cart } = useQuery({
queryKey: ["cart"],
queryFn: () => httpClient("/api/cart").json(),
enabled: auth.isSuccess,
});
const { mutate } = useMutation({
mutationKey: ["delete-cart"],
mutationFn: (id) =>
httpClient
.delete("/api/cart", {
searchParams: new URLSearchParams([["id", id]]),
})
.json(),
onSuccess: async () => {
queryClient.invalidateQueries({
queryKey: ["cart"],
});
},
});
const calculateTotal = () => {
return cart ? cart.items.reduce((total, item) => total + item.price, 0) : 0;
};
const handleDelete = useCallback(
(index) => {
mutate(index);
},
[mutate],
);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<>
{!cart || !cart.items || cart.items.length === 0 ? (
<div className="flex justify-center items-center">
Your cart is empty
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Your Items</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Price</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cart.items.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>${item.price.toFixed(2)}</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle>Order Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span>Subtotal</span>
<span>${calculateTotal().toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span>Shipping</span>
<span>Free</span>
</div>
<div className="flex justify-between font-bold">
<span>Total</span>
<span>${calculateTotal().toFixed(2)}</span>
</div>
</div>
</CardContent>
<CardFooter>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<Button
variant="outline"
disabled
className="cursor-not-allowed w-full"
>
{/* TODO: create checkout */}
<span>Proceed to Payment</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>This action is currently unavailable</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardFooter>
</Card>
</div>
)}
</>
);
}
@@ -0,0 +1,142 @@
import { useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import httpClient from "@/lib/httpClient";
export default function LoginRegister() {
const usernameRef = useRef();
const passwordRef = useRef();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { isLoading, mutate } = useMutation({
mutationKey: ["login"],
mutationFn: (data) => httpClient.post("/api/login", { json: data }).json(),
onSettled(data, err) {
if (err || data?.error) {
queryClient.setQueryData(["user"], null);
return;
}
queryClient.setQueryData(["user"], data);
navigate({ to: "/" });
},
retry: false,
});
async function onSubmit(event) {
event.preventDefault();
const username = usernameRef.current.value;
const password = passwordRef.current.value;
mutate({ username, password });
}
return (
<Card className="w-[350px]">
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Login or create a new account.</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="login" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="login">Login</TabsTrigger>
<TabsTrigger value="register">Register</TabsTrigger>
</TabsList>
<TabsContent value="login">
<form onSubmit={onSubmit}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label htmlFor="username">Username</Label>
<Input
id="username"
placeholder=""
type="username"
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
disabled={isLoading}
ref={usernameRef}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="password">Password</Label>
<Input
id="password"
placeholder="********"
type="password"
autoCapitalize="none"
autoComplete="current-password"
disabled={isLoading}
ref={passwordRef}
/>
</div>
<Button disabled={isLoading}>
{isLoading && (
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-primary border-r-transparent" />
)}
Sign In
</Button>
</div>
</form>
</TabsContent>
<TabsContent value="register">
<form onSubmit={onSubmit}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label htmlFor="username">Username</Label>
<Input
id="username"
placeholder=""
type="username"
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
disabled={isLoading}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="password">Password</Label>
<Input
id="password"
placeholder="********"
type="password"
autoCapitalize="none"
autoComplete="new-password"
disabled={isLoading}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
placeholder="********"
type="password"
autoCapitalize="none"
autoComplete="new-password"
disabled={isLoading}
/>
</div>
<Button disabled={isLoading}>
{isLoading && (
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-primary border-r-transparent" />
)}
Create Account
</Button>
</div>
</form>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}
@@ -0,0 +1,94 @@
import { useCallback } from "react";
import { formatDistanceToNow } from "date-fns";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ShoppingBasket, Loader2 } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import httpClient from "@/lib/httpClient";
export default function MarketplaceListing() {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["items"],
queryFn: () => httpClient.get("/api/catalog").json(),
});
const { mutate } = useMutation({
mutationKey: ["post-cart"],
mutationFn: (data) => httpClient.post("/api/cart", { json: data }).json(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] });
},
});
const handleAddToCart = useCallback(
(id) => {
mutate({ item: id });
},
[mutate],
);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!data) {
return (
<div className="flex justify-center items-center h-screen">
No data available
</div>
);
}
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.items.map((item) => (
<Card key={item.id} className="flex flex-col">
<CardHeader>
<CardTitle>{item.name}</CardTitle>
<CardDescription>{item.description}</CardDescription>
</CardHeader>
<CardContent className="flex-grow">
<p className="text-2xl font-bold">${item.price.toFixed(2)}</p>
<p className="text-sm text-gray-500">
Posted {formatDistanceToNow(new Date(item.date_posted))} ago
</p>
</CardContent>
<CardFooter className="flex justify-between items-center">
<Badge variant="secondary">Owner ID: {item.owner}</Badge>
<Button
className="flex items-center gap-2"
onClick={(e) => {
e.preventDefault();
console.log(e.target);
e.target.setAttribute("disabled", "true");
handleAddToCart(item.id);
setTimeout(() => {
e.target.removeAttribute("disabled");
}, 1000);
}}
>
<ShoppingBasket className="h-5 w-5" />
<span className="hidden sm:inline">Add to Cart</span>
</Button>
</CardFooter>
</Card>
))}
</div>
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
/* 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 CartImport } from './routes/cart'
// Create Virtual Routes
const LoginLazyImport = createFileRoute('/login')()
const IndexLazyImport = createFileRoute('/')()
// Create/Update Routes
const LoginLazyRoute = LoginLazyImport.update({
path: '/login',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/login.lazy').then((d) => d.Route))
const CartRoute = CartImport.update({
path: '/cart',
getParentRoute: () => rootRoute,
} as any)
const IndexLazyRoute = IndexLazyImport.update({
path: '/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexLazyImport
parentRoute: typeof rootRoute
}
'/cart': {
id: '/cart'
path: '/cart'
fullPath: '/cart'
preLoaderRoute: typeof CartImport
parentRoute: typeof rootRoute
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginLazyImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export interface FileRoutesByFullPath {
'/': typeof IndexLazyRoute
'/cart': typeof CartRoute
'/login': typeof LoginLazyRoute
}
export interface FileRoutesByTo {
'/': typeof IndexLazyRoute
'/cart': typeof CartRoute
'/login': typeof LoginLazyRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexLazyRoute
'/cart': typeof CartRoute
'/login': typeof LoginLazyRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/cart' | '/login'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/cart' | '/login'
id: '__root__' | '/' | '/cart' | '/login'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexLazyRoute: typeof IndexLazyRoute
CartRoute: typeof CartRoute
LoginLazyRoute: typeof LoginLazyRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexLazyRoute: IndexLazyRoute,
CartRoute: CartRoute,
LoginLazyRoute: LoginLazyRoute,
}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* prettier-ignore-end */
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.jsx",
"children": [
"/",
"/cart",
"/login"
]
},
"/": {
"filePath": "index.lazy.jsx"
},
"/cart": {
"filePath": "cart.jsx"
},
"/login": {
"filePath": "login.lazy.jsx"
}
}
}
ROUTE_MANIFEST_END */
@@ -0,0 +1,15 @@
import Navigation from "@/components/navigation";
import { createRootRoute, Outlet } from "@tanstack/react-router";
function RootComponent() {
return (
<div className="container mx-auto py-8 px-4">
<Navigation />
<Outlet />
</div>
);
}
export const Route = createRootRoute({
component: () => <RootComponent />,
});
@@ -0,0 +1,6 @@
import CartCheckout from '@/pages/cart-checkout'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/cart')({
component: () => <CartCheckout />,
})
@@ -0,0 +1,6 @@
import MarketplaceListing from '@/pages/marketplace-lisiting'
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/')({
component: () => <MarketplaceListing />,
})
@@ -0,0 +1,10 @@
import LoginRegister from "@/pages/login-register";
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/login")({
component: () => (
<div className="flex justify-center bg-background">
<LoginRegister />
</div>
),
});
+57
View File
@@ -0,0 +1,57 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
},
},
},
plugins: [require("tailwindcss-animate")],
};
+19
View File
@@ -0,0 +1,19 @@
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [TanStackRouterVite(), react()],
server: {
proxy: {
"/api": "http://localhost:17000",
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});