feat: create initial web frontend and backend structure

This commit is contained in:
wenjie 2026-03-03 17:18:47 +08:00
parent 5c1b972e8b
commit 85eb2a6371
46 changed files with 8072 additions and 0 deletions

34
web/Makefile Normal file
View file

@ -0,0 +1,34 @@
.PHONY: dev dev-frontend dev-backend build test lint clean
# Run both frontend and backend dev servers
dev:
@echo "Starting backend and frontend dev servers..."
@$(MAKE) dev-backend & $(MAKE) dev-frontend
# Start frontend dev server (Vite, with proxy to backend)
dev-frontend:
cd frontend && pnpm dev
# Start backend dev server
dev-backend:
cd backend && go run .
# Build frontend and embed into Go binary
build:
cd frontend && pnpm build:backend
cd backend && go build -o picoclaw-web .
# Run all tests
test:
cd backend && go test ./...
cd frontend && pnpm lint
# Lint and format
lint:
cd backend && go vet ./...
cd frontend && pnpm check
# Clean build artifacts
clean:
rm -rf frontend/dist backend/dist backend/picoclaw-web
mkdir -p backend/dist && touch backend/dist/.gitkeep

51
web/README.md Normal file
View file

@ -0,0 +1,51 @@
# Picoclaw Web
This directory contains the standalone web service for `picoclaw`.
It provides a complete unified web interface, acting as a dashboard, configuration center, and interactive console (channel client) for the core `picoclaw` engine.
## Architecture
The service is structured as a monorepo containing both the backend and frontend code to ensure high cohesion and simplify deployment.
* **`backend/`**: The Go-based web server. It provides RESTful APIs, manages WebSocket connections for chat, and handles the lifecycle of the `picoclaw` process. It eventually embeds the compiled frontend assets into a single executable.
* **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface.
## Getting Started
### Prerequisites
* Go 1.25+
* Node.js 20+ with pnpm
### Development
Run both the frontend dev server and the Go backend simultaneously:
```bash
make dev
```
Or run them separately:
```bash
make dev-frontend # Vite dev server
make dev-backend # Go backend
```
### Build
Build the frontend and embed it into a single Go binary:
```bash
make build
```
The output binary is `backend/picoclaw-web`.
### Other Commands
```bash
make test # Run backend tests and frontend lint
make lint # Run go vet and prettier/eslint
make clean # Remove all build artifacts
```

19
web/backend/.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# Go build output
*.exe
*.dll
*.so
*.dylib
*.test
*.out
picoclaw-web
# Frontend build artifacts (embedded by Go)
dist/*
!dist/.gitkeep
# OS
.DS_Store
# Editors
.vscode/
.idea/

17
web/backend/api/router.go Normal file
View file

@ -0,0 +1,17 @@
package api
import "net/http"
// Handler serves HTTP API requests.
type Handler struct{}
// NewHandler creates an instance of the API handler.
func NewHandler() *Handler {
return &Handler{}
}
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
// All routes are registered under the /api/ prefix.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/status", h.handleStatus)
}

View file

@ -0,0 +1,22 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestRegisterRoutes(t *testing.T) {
handler := NewHandler()
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
// Verify that registered routes respond correctly
req := httptest.NewRequest("GET", "/api/status", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("RegisterRoutes: /api/status returned status %d, want %d", status, http.StatusOK)
}
}

33
web/backend/api/status.go Normal file
View file

@ -0,0 +1,33 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/sipeed/picoclaw/web/backend/model"
)
// startTime records when the server was started, used to calculate uptime.
var startTime = time.Now()
// Version is set at build time via -ldflags.
var Version = "dev"
// handleStatus returns the current server status, version, and uptime.
//
// GET /api/status
// Response: 200 OK
// {
// "status": "online",
// "version": "dev",
// "uptime": "2h30m15s"
// }
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
resp := model.StatusResponse{
Status: "online",
Version: Version,
Uptime: time.Since(startTime).Round(time.Second).String(),
}
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,37 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/sipeed/picoclaw/web/backend/model"
)
func TestHandleStatus(t *testing.T) {
handler := NewHandler()
req := httptest.NewRequest("GET", "/api/status", nil)
rr := httptest.NewRecorder()
handler.handleStatus(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handleStatus returned status %d, want %d", status, http.StatusOK)
}
var resp model.StatusResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to decode response JSON: %v", err)
}
if resp.Status != "online" {
t.Errorf("Expected status 'online', got %q", resp.Status)
}
if resp.Version == "" {
t.Error("Expected non-empty version")
}
if resp.Uptime == "" {
t.Error("Expected non-empty uptime")
}
}

29
web/backend/embed.go Normal file
View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"io/fs"
"log"
"net/http"
)
//go:embed all:dist
var frontendFS embed.FS
// registerEmbedRoutes sets up the HTTP handler to serve the embedded frontend files
func registerEmbedRoutes(mux *http.ServeMux) {
// Attempt to get the subdirectory 'dist' where Vite usually builds
subFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
// Log a warning if dist doesn't exist yet (e.g., during development before a frontend build)
log.Printf(
"Warning: no 'dist' folder found in embedded frontend. " +
"Ensure you run `pnpm build:backend` in the frontend directory " +
"before building the Go backend.",
)
return
}
// Serve the static files at the root route
mux.Handle("/", http.FileServer(http.FS(subFS)))
}

3
web/backend/go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/sipeed/picoclaw/web/backend
go 1.25.7

23
web/backend/main.go Normal file
View file

@ -0,0 +1,23 @@
package main
import (
"log"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/ws"
)
func main() {
log.Println("Starting picoclaw Web Console...")
// Initialize Server components
srv := NewServer(
api.NewHandler(),
ws.NewHandler(),
)
// Start the Server
if err := srv.Start(":8080"); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}

View file

@ -0,0 +1,52 @@
package middleware
import (
"log"
"net/http"
"runtime/debug"
"time"
)
// JSONContentType sets the Content-Type header to application/json for all
// requests handled by the wrapped handler.
func JSONContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
next.ServeHTTP(w, r)
})
}
// responseRecorder wraps http.ResponseWriter to capture the status code.
type responseRecorder struct {
http.ResponseWriter
statusCode int
}
func (rr *responseRecorder) WriteHeader(code int) {
rr.statusCode = code
rr.ResponseWriter.WriteHeader(code)
}
// Logger logs each HTTP request with method, path, status code, and duration.
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rec, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start))
})
}
// Recoverer recovers from panics in downstream handlers and returns a 500
// Internal Server Error response.
func Recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic recovered: %v\n%s", err, debug.Stack())
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,8 @@
package model
// StatusResponse represents the response payload for the GET /api/status endpoint.
type StatusResponse struct {
Status string `json:"status"`
Version string `json:"version"`
Uptime string `json:"uptime"`
}

48
web/backend/server.go Normal file
View file

@ -0,0 +1,48 @@
package main
import (
"fmt"
"net/http"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/middleware"
"github.com/sipeed/picoclaw/web/backend/ws"
)
// Server holds the components necessary to run the web UI backend.
type Server struct {
apiHandler *api.Handler
wsHandler *ws.Handler
}
// NewServer initializes a new Server instance.
func NewServer(apiHandler *api.Handler, wsHandler *ws.Handler) *Server {
return &Server{
apiHandler: apiHandler,
wsHandler: wsHandler,
}
}
// Start attaches the routes and begins listening on the specified address.
func (s *Server) Start(addr string) error {
mux := http.NewServeMux()
// API Routes (e.g. /api/status)
s.apiHandler.RegisterRoutes(mux)
// WebSocket Routes
s.wsHandler.RegisterRoutes(mux)
// Frontend Embedded Assets
registerEmbedRoutes(mux)
// Apply middleware stack
handler := middleware.Recoverer(
middleware.Logger(
middleware.JSONContentType(mux),
),
)
fmt.Printf("WebUI listening on %s\n", addr)
return http.ListenAndServe(addr, handler)
}

View file

@ -0,0 +1,37 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/ws"
)
func TestNewServer(t *testing.T) {
apiHandler := api.NewHandler()
wsHandler := ws.NewHandler()
srv := NewServer(apiHandler, wsHandler)
if srv == nil {
t.Fatal("Expected NewServer to return a valid instance, got nil")
}
if srv.apiHandler == nil || srv.wsHandler == nil {
t.Error("Not all server components were correctly initialized")
}
}
func TestEmbedRoutes(t *testing.T) {
mux := http.NewServeMux()
registerEmbedRoutes(mux)
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
}
}

23
web/backend/ws/handler.go Normal file
View file

@ -0,0 +1,23 @@
package ws
import (
"fmt"
"net/http"
)
// Handler serves WebSocket requests.
type Handler struct{}
// NewHandler creates an instance of the WebSocket handler.
func NewHandler() *Handler {
return &Handler{}
}
// RegisterRoutes binds the WebSocket routes to the ServeMux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/ws/chat", h.handleWebSocket)
}
func (h *Handler) handleWebSocket(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "WebSocket chat functionality placeholder")
}

View file

@ -0,0 +1,25 @@
package ws
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandleWebSocket(t *testing.T) {
handler := NewHandler()
req := httptest.NewRequest("GET", "/ws/chat", nil)
rr := httptest.NewRecorder()
handler.handleWebSocket(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handleWebSocket returned status %d, want %d", status, http.StatusOK)
}
body := rr.Body.String()
if !strings.Contains(body, "WebSocket chat functionality placeholder") {
t.Errorf("Response body did not contain placeholder text, got: %s", body)
}
}

View file

@ -0,0 +1,7 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf

24
web/frontend/.gitignore vendored Normal file
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?

View file

@ -0,0 +1,5 @@
package-lock.json
pnpm-lock.yaml
yarn.lock
routeTree.gen.ts
src/components/ui

View file

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-vega",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "tabler",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View file

@ -0,0 +1,31 @@
import js from "@eslint/js"
import eslintConfigPrettier from "eslint-config-prettier"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import { defineConfig, globalIgnores } from "eslint/config"
import globals from "globals"
import tseslint from "typescript-eslint"
export default defineConfig([
globalIgnores(["dist", "src/components/ui", "src/routeTree.gen.ts"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
eslintConfigPrettier,
],
languageOptions: {
ecmaVersion: "latest",
globals: globals.browser,
},
rules: {
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
])

18
web/frontend/index.html Normal file
View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PicoClaw</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

51
web/frontend/package.json Normal file
View file

@ -0,0 +1,51 @@
{
"name": "picoclaw-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:backend": "tsc -b && vite build --outDir ../backend/dist --emptyOutDir",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --check .",
"check": "prettier --write . && eslint --fix"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@tabler/icons-react": "^3.38.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-router": "^1.163.3",
"@tanstack/react-router-devtools": "^1.163.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"shadcn": "^3.8.5",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tanstack/router-plugin": "^1.164.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

6862
web/frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
// @ts-check
/** @type {import('prettier').Config} */
const config = {
semi: false,
printWidth: 80,
tabWidth: 2,
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/", "^[./]"],
importOrderSeparation: true,
importOrderSortSpecifiers: true,
plugins: [
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss",
],
}
export default config

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB

View file

@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View file

@ -0,0 +1,21 @@
// API client for the picoclaw web backend.
const BASE_URL = ""
interface StatusResponse {
status: string
version: string
uptime: string
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
return res.json() as Promise<T>
}
export async function getStatus(): Promise<StatusResponse> {
return request<StatusResponse>("/api/status")
}

View file

@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,23 @@
import { useCallback, useState } from "react"
import { getStatus } from "@/api/status"
export function useApiStatus() {
const [status, setStatus] = useState<string>("Unknown")
const [loading, setLoading] = useState(false)
const check = useCallback(async () => {
setLoading(true)
try {
const data = await getStatus()
setStatus(data.status || "Success")
} catch (err) {
setStatus("Fetch failed")
console.error(err)
} finally {
setLoading(false)
}
}, [])
return { status, loading, check }
}

View file

@ -0,0 +1,47 @@
import { useCallback, useEffect, useRef, useState } from "react"
export function useWebSocket(path: string) {
const [message, setMessage] = useState<string>("No messages yet")
const [connected, setConnected] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const connect = useCallback(() => {
if (wsRef.current) {
wsRef.current.close()
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const url = `${protocol}//${window.location.host}${path}`
const socket = new WebSocket(url)
socket.onopen = () => {
setConnected(true)
setMessage("Connected to WebSocket server.")
}
socket.onmessage = (event) => {
setMessage(event.data)
}
socket.onclose = () => {
setConnected(false)
setMessage("WebSocket connection closed.")
}
socket.onerror = (error) => {
setConnected(false)
setMessage("WebSocket error occurred.")
console.error("WebSocket Error:", error)
}
wsRef.current = socket
}, [path])
useEffect(() => {
return () => {
wsRef.current?.close()
}
}, [])
return { message, connected, connect }
}

126
web/frontend/src/index.css Normal file
View file

@ -0,0 +1,126 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--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.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--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.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--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;
}
}

View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

24
web/frontend/src/main.tsx Normal file
View file

@ -0,0 +1,24 @@
import { RouterProvider, createRouter } from "@tanstack/react-router"
import { StrictMode } from "react"
import ReactDOM from "react-dom/client"
import "./index.css"
import { routeTree } from "./routeTree.gen"
const router = createRouter({ routeTree })
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}
const rootElement = document.getElementById("root")!
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement)
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
}

View file

@ -0,0 +1,59 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View file

@ -0,0 +1,11 @@
import { Outlet, createRootRoute } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
const RootLayout = () => (
<>
<Outlet />
<TanStackRouterDevtools />
</>
)
export const Route = createRootRoute({ component: RootLayout })

View file

@ -0,0 +1,59 @@
import { IconMessageCircle, IconServer } from "@tabler/icons-react"
import { createFileRoute } from "@tanstack/react-router"
import { Button } from "@/components/ui/button"
import { useApiStatus } from "@/hooks/use-api-status"
import { useWebSocket } from "@/hooks/use-websocket"
export const Route = createFileRoute("/")({
component: Index,
})
function Index() {
const { status: apiStatus, loading, check: checkApiStatus } = useApiStatus()
const { message: wsMessage, connect: connectWebSocket } =
useWebSocket("/ws/chat")
return (
<div className="flex w-full flex-col items-center justify-center gap-10 py-20">
<div className="bg-card w-full max-w-sm rounded-xl border p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold tracking-tight">
Backend API Status
</h2>
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-2 text-sm">
Status:{" "}
<span className="text-foreground font-medium">{apiStatus}</span>
</span>
<Button
onClick={checkApiStatus}
size="sm"
variant="secondary"
disabled={loading}
>
<IconServer className="mr-2 h-4 w-4" /> Check
</Button>
</div>
</div>
<div className="bg-card w-full max-w-sm rounded-xl border p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold tracking-tight">
WebSocket Chat
</h2>
<div className="flex flex-col gap-4">
<div className="bg-muted text-muted-foreground min-h-24 rounded-md p-3 text-sm whitespace-pre-wrap">
{wsMessage}
</div>
<Button
onClick={connectWebSocket}
variant="outline"
className="w-full"
disabled
>
<IconMessageCircle className="mr-2 h-4 w-4" /> Connect to Chat
</Button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View file

@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,35 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import { tanstackRouter } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [
tanstackRouter({
target: "react",
autoCodeSplitting: true,
}),
react(),
tailwindcss(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:8080",
ws: true,
},
},
},
})