img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..d9aecca
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,168 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({
+ ...props
+}: React.ComponentProps
) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+
+
+ )}
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..d763cd9
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000..f752f82
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+import { Label as LabelPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..0118624
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..9772eb2
--- /dev/null
+++ b/src/components/ui/sonner.tsx
@@ -0,0 +1,47 @@
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+
+ ),
+ info: (
+
+ ),
+ warning: (
+
+ ),
+ error: (
+
+ ),
+ loading: (
+
+ ),
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx
new file mode 100644
index 0000000..abeaced
--- /dev/null
+++ b/src/components/ui/table.tsx
@@ -0,0 +1,116 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ |
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ |
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/hooks/usePlatform.tsx b/src/hooks/usePlatform.tsx
new file mode 100644
index 0000000..ab55f0e
--- /dev/null
+++ b/src/hooks/usePlatform.tsx
@@ -0,0 +1,48 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
+
+export type Platform = "web" | "desktop" | "mobile"
+
+const PlatformContext = createContext("web")
+
+export function PlatformProvider({ children }: { children: ReactNode }) {
+ const [platform, setPlatform] = useState("web")
+
+ useEffect(() => {
+ let cancelled = false
+ ;(async () => {
+ const p = await detectPlatform()
+ if (!cancelled) {
+ setPlatform(p)
+ if (p !== "web") {
+ document.documentElement.classList.add("no-select")
+ }
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function usePlatform(): Platform {
+ return useContext(PlatformContext)
+}
+
+async function detectPlatform(): Promise {
+ const w = window as unknown as Record
+ if (!w.__TAURI_INTERNALS__) return "web"
+
+ try {
+ const { platform } = await import("@tauri-apps/plugin-os")
+ const p = platform()
+ return p === "android" || p === "ios" ? "mobile" : "desktop"
+ } catch {
+ return "desktop"
+ }
+}
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..17b76d0
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,140 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+@import "@fontsource-variable/geist";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --font-heading: var(--font-sans);
+ --font-sans: 'Geist Variable', sans-serif;
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-foreground: var(--foreground);
+ --color-background: var(--background);
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
+}
+
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.87 0 0);
+ --chart-2: oklch(0.556 0 0);
+ --chart-3: oklch(0.439 0 0);
+ --chart-4: oklch(0.371 0 0);
+ --chart-5: oklch(0.269 0 0);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.87 0 0);
+ --chart-2: oklch(0.556 0 0);
+ --chart-3: oklch(0.439 0 0);
+ --chart-4: oklch(0.371 0 0);
+ --chart-5: oklch(0.269 0 0);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+ html {
+ @apply font-sans;
+ }
+}
+
+.no-select * {
+ user-select: none;
+}
+
+.no-select input,
+.no-select textarea,
+.no-select [data-selectable] {
+ user-select: text;
+}
\ No newline at end of file
diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts
new file mode 100644
index 0000000..6ec5085
--- /dev/null
+++ b/src/lib/formatters.ts
@@ -0,0 +1,7 @@
+export function formatDateTime(iso: string): string {
+ return new Date(iso).toLocaleString()
+}
+
+export function formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString()
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/main.tsx b/src/main.tsx
index 2be325e..0191160 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,9 +1,21 @@
-import React from "react";
-import ReactDOM from "react-dom/client";
-import App from "./App";
+import React from "react"
+import ReactDOM from "react-dom/client"
+import { RouterProvider } from "react-router-dom"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { Toaster } from "@/components/ui/sonner"
+import { PlatformProvider } from "@/hooks/usePlatform"
+import { router } from "@/router"
+import "./index.css"
+
+const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
- ,
-);
+
+
+
+
+
+
+
+)
diff --git a/src/router/guards.tsx b/src/router/guards.tsx
new file mode 100644
index 0000000..f980366
--- /dev/null
+++ b/src/router/guards.tsx
@@ -0,0 +1,16 @@
+import { Navigate, Outlet } from "react-router-dom"
+import { useAuthStore } from "@/store/authStore"
+
+export function ProtectedRoute() {
+ const isLoggedIn = useAuthStore((s) => !!s.token)
+ if (!isLoggedIn) return
+ return
+}
+
+export function AdminRoute() {
+ const isLoggedIn = useAuthStore((s) => !!s.token)
+ const isAdmin = useAuthStore((s) => s.role === "ADMIN")
+ if (!isLoggedIn) return
+ if (!isAdmin) return
+ return
+}
diff --git a/src/router/index.tsx b/src/router/index.tsx
new file mode 100644
index 0000000..5706e91
--- /dev/null
+++ b/src/router/index.tsx
@@ -0,0 +1,63 @@
+import { createBrowserRouter, Navigate } from "react-router-dom"
+import { ProtectedRoute, AdminRoute } from "./guards"
+
+// Placeholder components — real pages will replace these
+function LoginPage() {
+ return Login
+}
+function BooksPage() {
+ return Books
+}
+function MyBorrowsPage() {
+ return My Borrows
+}
+function AdminBooksPage() {
+ return Admin / Books
+}
+function AdminBorrowsPage() {
+ return Admin / Borrows
+}
+function AdminIndexPage() {
+ return
+}
+
+export const router = createBrowserRouter([
+ {
+ path: "/login",
+ element: ,
+ },
+ {
+ path: "/",
+ element: ,
+ },
+ {
+ path: "/books",
+ element: ,
+ },
+ {
+ element: ,
+ children: [
+ {
+ path: "/my-borrows",
+ element: ,
+ },
+ ],
+ },
+ {
+ element: ,
+ children: [
+ {
+ path: "/admin",
+ element: ,
+ },
+ {
+ path: "/admin/books",
+ element: ,
+ },
+ {
+ path: "/admin/borrows",
+ element: ,
+ },
+ ],
+ },
+])
diff --git a/src/store/authStore.ts b/src/store/authStore.ts
new file mode 100644
index 0000000..f7b0f58
--- /dev/null
+++ b/src/store/authStore.ts
@@ -0,0 +1,54 @@
+import { create } from "zustand"
+
+interface AuthState {
+ token: string | null
+ username: string | null
+ role: string | null
+
+ setAuth: (auth: { token: string; username: string; role: string }) => void
+ clearAuth: () => void
+ isLoggedIn: () => boolean
+ isAdmin: () => boolean
+}
+
+function loadAuthFromStorage() {
+ try {
+ const token = localStorage.getItem("token")
+ const username = localStorage.getItem("username")
+ const role = localStorage.getItem("role")
+ if (token && username && role) return { token, username, role }
+ } catch {
+ // localStorage may be unavailable
+ }
+ return { token: null, username: null, role: null }
+}
+
+export const useAuthStore = create((set, get) => {
+ const stored = loadAuthFromStorage()
+ return {
+ token: stored.token,
+ username: stored.username,
+ role: stored.role,
+
+ setAuth: ({ token, username, role }) => {
+ localStorage.setItem("token", token)
+ localStorage.setItem("username", username)
+ localStorage.setItem("role", role)
+ set({ token, username, role })
+ },
+
+ clearAuth: () => {
+ localStorage.removeItem("token")
+ localStorage.removeItem("username")
+ localStorage.removeItem("role")
+ set({ token: null, username: null, role: null })
+ },
+
+ isLoggedIn: () => !!get().token,
+ isAdmin: () => get().role === "ADMIN",
+ }
+})
+
+export function getToken(): string | null {
+ return useAuthStore.getState().token
+}
diff --git a/src/types/api.ts b/src/types/api.ts
new file mode 100644
index 0000000..be06c09
--- /dev/null
+++ b/src/types/api.ts
@@ -0,0 +1,82 @@
+// ---- Generic API result wrapper ----
+export interface ApiResult {
+ code: number
+ message: string
+ data: T | null
+}
+
+// ---- Auth ----
+export interface UserLoginDto {
+ username: string
+ password: string
+}
+
+export interface LoginVo {
+ token: string
+ username: string
+ role: string
+}
+
+export type ApiResultLoginVo = ApiResult
+
+// ---- Book ----
+export interface Book {
+ id: number | null
+ name: string
+ author: string
+ stock: number
+}
+
+export type ApiResultBook = ApiResult
+export type ApiResultListBook = ApiResult
+
+export interface BookAddDto {
+ name: string
+ author: string
+ stock: number
+}
+
+export interface BookUpdateDto {
+ name: string
+ author: string
+}
+
+// ---- Borrow / MyBorrow ----
+export interface BookBorrowVo {
+ id: number
+ name: string
+ author: string
+}
+
+export interface MyBorrowVo {
+ id: number
+ bookBorrowVo: BookBorrowVo
+ borrowTime: string
+ returnTime: string | null
+ status: string
+}
+
+export type ApiResultMyBorrowVo = ApiResult
+export type ApiResultListMyBorrowVo = ApiResult
+
+// ---- Admin Borrow ----
+export interface UserBorrowVo {
+ id: number
+ username: string
+ role: string
+}
+
+export interface BorrowInfoVo {
+ id: number
+ bookBorrowVo: BookBorrowVo
+ userBorrowVo: UserBorrowVo
+ borrowTime: string
+ returnTime: string | null
+ status: string
+}
+
+export type ApiResultBorrowInfoVo = ApiResult
+export type ApiResultListBorrowInfoVo = ApiResult
+
+// ---- Utils ----
+export type ApiResultString = ApiResult
diff --git a/tsconfig.json b/tsconfig.json
index a7fc6fb..33514fa 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,7 +18,13 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true
+ "noFallthroughCasesInSwitch": true,
+
+ /* Path aliases */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
diff --git a/vite.config.ts b/vite.config.ts
index ddad22a..9caa640 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,32 +1,37 @@
-import { defineConfig } from "vite";
-import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite"
+import react from "@vitejs/plugin-react"
+import tailwindcss from "@tailwindcss/vite"
+import path from "path"
// @ts-expect-error process is a nodejs global
-const host = process.env.TAURI_DEV_HOST;
+const host = process.env.TAURI_DEV_HOST
+const apiTarget = process.env.VITE_API_BASE || "http://localhost:8080"
-// https://vite.dev/config/
export default defineConfig(async () => ({
- plugins: [react()],
+ plugins: [react(), tailwindcss()],
+
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
- // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
- //
- // 1. prevent Vite from obscuring rust errors
clearScreen: false,
- // 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
- ? {
- protocol: "ws",
- host,
- port: 1421,
- }
+ ? { protocol: "ws", host, port: 1421 }
: undefined,
watch: {
- // 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
+ proxy: {
+ "/api": {
+ target: apiTarget,
+ changeOrigin: true,
+ },
+ },
},
-}));
+}))