feat: create initial web frontend and backend structure
This commit is contained in:
parent
5c1b972e8b
commit
85eb2a6371
46 changed files with 8072 additions and 0 deletions
34
web/Makefile
Normal file
34
web/Makefile
Normal 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
51
web/README.md
Normal 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
19
web/backend/.gitignore
vendored
Normal 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
17
web/backend/api/router.go
Normal 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)
|
||||
}
|
||||
22
web/backend/api/router_test.go
Normal file
22
web/backend/api/router_test.go
Normal 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
33
web/backend/api/status.go
Normal 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)
|
||||
}
|
||||
37
web/backend/api/status_test.go
Normal file
37
web/backend/api/status_test.go
Normal 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
29
web/backend/embed.go
Normal 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
3
web/backend/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/sipeed/picoclaw/web/backend
|
||||
|
||||
go 1.25.7
|
||||
23
web/backend/main.go
Normal file
23
web/backend/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
52
web/backend/middleware/middleware.go
Normal file
52
web/backend/middleware/middleware.go
Normal 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)
|
||||
})
|
||||
}
|
||||
8
web/backend/model/status.go
Normal file
8
web/backend/model/status.go
Normal 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
48
web/backend/server.go
Normal 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)
|
||||
}
|
||||
37
web/backend/server_test.go
Normal file
37
web/backend/server_test.go
Normal 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
23
web/backend/ws/handler.go
Normal 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")
|
||||
}
|
||||
25
web/backend/ws/handler_test.go
Normal file
25
web/backend/ws/handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
7
web/frontend/.editorconfig
Normal file
7
web/frontend/.editorconfig
Normal 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
24
web/frontend/.gitignore
vendored
Normal 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?
|
||||
5
web/frontend/.prettierignore
Normal file
5
web/frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
routeTree.gen.ts
|
||||
src/components/ui
|
||||
25
web/frontend/components.json
Normal file
25
web/frontend/components.json
Normal 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": {}
|
||||
}
|
||||
31
web/frontend/eslint.config.js
Normal file
31
web/frontend/eslint.config.js
Normal 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
18
web/frontend/index.html
Normal 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
51
web/frontend/package.json
Normal 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
6862
web/frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
17
web/frontend/prettier.config.js
Normal file
17
web/frontend/prettier.config.js
Normal 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
|
||||
BIN
web/frontend/public/apple-touch-icon.png
Normal file
BIN
web/frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
web/frontend/public/favicon-96x96.png
Normal file
BIN
web/frontend/public/favicon-96x96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
web/frontend/public/favicon.ico
Normal file
BIN
web/frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
web/frontend/public/favicon.svg
Normal file
1
web/frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 88 KiB |
21
web/frontend/public/site.webmanifest
Normal file
21
web/frontend/public/site.webmanifest
Normal 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"
|
||||
}
|
||||
BIN
web/frontend/public/web-app-manifest-192x192.png
Normal file
BIN
web/frontend/public/web-app-manifest-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
web/frontend/public/web-app-manifest-512x512.png
Normal file
BIN
web/frontend/public/web-app-manifest-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
21
web/frontend/src/api/status.ts
Normal file
21
web/frontend/src/api/status.ts
Normal 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")
|
||||
}
|
||||
67
web/frontend/src/components/ui/button.tsx
Normal file
67
web/frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
23
web/frontend/src/hooks/use-api-status.ts
Normal file
23
web/frontend/src/hooks/use-api-status.ts
Normal 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 }
|
||||
}
|
||||
47
web/frontend/src/hooks/use-websocket.ts
Normal file
47
web/frontend/src/hooks/use-websocket.ts
Normal 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
126
web/frontend/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
6
web/frontend/src/lib/utils.ts
Normal file
6
web/frontend/src/lib/utils.ts
Normal 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
24
web/frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
}
|
||||
59
web/frontend/src/routeTree.gen.ts
Normal file
59
web/frontend/src/routeTree.gen.ts
Normal 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>()
|
||||
11
web/frontend/src/routes/__root.tsx
Normal file
11
web/frontend/src/routes/__root.tsx
Normal 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 })
|
||||
59
web/frontend/src/routes/index.tsx
Normal file
59
web/frontend/src/routes/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
web/frontend/tsconfig.app.json
Normal file
32
web/frontend/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
13
web/frontend/tsconfig.json
Normal file
13
web/frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
26
web/frontend/tsconfig.node.json
Normal file
26
web/frontend/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
35
web/frontend/vite.config.ts
Normal file
35
web/frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue