first commit
This commit is contained in:
commit
78d9777689
3
.bolt/config.json
Normal file
3
.bolt/config.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"template": "bolt-vite-react-ts"
|
||||||
|
}
|
5
.bolt/prompt
Normal file
5
.bolt/prompt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
|
||||||
|
|
||||||
|
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
|
||||||
|
|
||||||
|
Use icons from lucide-react for logos.
|
19
.dockerignore
Normal file
19
.dockerignore
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.nyc_output
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
.vscode
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.next
|
||||||
|
.nuxt
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
.eslintcache
|
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# 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?
|
||||||
|
.env
|
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Multi-stage build for production
|
||||||
|
FROM node:18-alpine AS frontend-build
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Backend build stage
|
||||||
|
FROM node:18-alpine AS backend-build
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
COPY server/ ./server/
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:18-alpine AS production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install backend dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
|
# Copy backend files
|
||||||
|
COPY server/ ./server/
|
||||||
|
|
||||||
|
# Copy built frontend files
|
||||||
|
COPY --from=frontend-build /app/dist ./public
|
||||||
|
|
||||||
|
# Create uploads directory
|
||||||
|
RUN mkdir -p uploads
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
|
# Change ownership of app directory
|
||||||
|
RUN chown -R nodejs:nodejs /app
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
CMD ["node", "server/index.js"]
|
26
Dockerfile.dev
Normal file
26
Dockerfile.dev
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Development Dockerfile
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create uploads directory
|
||||||
|
RUN mkdir -p uploads
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
|
# Change ownership of app directory
|
||||||
|
RUN chown -R nodejs:nodejs /app
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
EXPOSE 3001 5173
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
39
docker-compose.dev.yml
Normal file
39
docker-compose.dev.yml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
container_name: lawn-scheduler-dev
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
- "5173:5173"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- CHOKIDAR_USEPOLLING=true
|
||||||
|
networks:
|
||||||
|
- lawn-scheduler-dev-network
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: webdevops/nginx:alpine
|
||||||
|
container_name: lawn-scheduler-nginx-dev
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./uploads:/var/www/uploads:ro
|
||||||
|
networks:
|
||||||
|
- lawn-scheduler-dev-network
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
|
||||||
|
networks:
|
||||||
|
lawn-scheduler-dev-network:
|
||||||
|
driver: bridge
|
42
docker-compose.yml
Normal file
42
docker-compose.yml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: lawn-scheduler-app
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
- ./lawn_scheduler.db:/app/lawn_scheduler.db
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
networks:
|
||||||
|
- lawn-scheduler-network
|
||||||
|
depends_on:
|
||||||
|
- nginx
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: webdevops/nginx:alpine
|
||||||
|
container_name: lawn-scheduler-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- ./uploads:/var/www/uploads:ro
|
||||||
|
networks:
|
||||||
|
- lawn-scheduler-network
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
|
||||||
|
networks:
|
||||||
|
lawn-scheduler-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
uploads:
|
||||||
|
database:
|
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Lawn Mowing Schedule Manager - Full Stack Application</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
109
nginx/nginx.conf
Normal file
109
nginx/nginx.conf
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
use epoll;
|
||||||
|
multi_accept on;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/json
|
||||||
|
application/javascript
|
||||||
|
application/xml+rss
|
||||||
|
application/atom+xml
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||||
|
|
||||||
|
upstream backend {
|
||||||
|
server app:3001;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /var/www/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# API routes
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
location /uploads/ {
|
||||||
|
alias /var/www/uploads/;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frontend routes (SPA)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
add_header Expires "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
75
nginx/nginx.dev.conf
Normal file
75
nginx/nginx.dev.conf
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
upstream frontend {
|
||||||
|
server app:5173;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream backend {
|
||||||
|
server app:3001;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# API routes
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
location /uploads/ {
|
||||||
|
alias /var/www/uploads/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vite HMR WebSocket
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://frontend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frontend (Vite dev server)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
6064
package-lock.json
generated
Normal file
6064
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "lawn-mowing-scheduler",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||||
|
"client": "vite",
|
||||||
|
"server": "nodemon server/index.js",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.344.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0",
|
||||||
|
"@libsql/client": "^0.4.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"multer": "^1.4.5-lts.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.9.1",
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"eslint": "^9.9.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"typescript-eslint": "^8.3.0",
|
||||||
|
"vite": "^5.4.2",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
18
scripts/start-dev.sh
Normal file
18
scripts/start-dev.sh
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Starting Lawn Scheduler in Development Mode..."
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
mkdir -p uploads
|
||||||
|
|
||||||
|
# Build and start services
|
||||||
|
docker-compose -f docker-compose.dev.yml down
|
||||||
|
docker-compose -f docker-compose.dev.yml build --no-cache
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
echo "Development environment started!"
|
||||||
|
echo "Frontend (Vite): http://localhost:8080"
|
||||||
|
echo "Backend API: http://localhost:3001"
|
||||||
|
echo ""
|
||||||
|
echo "To view logs: docker-compose -f docker-compose.dev.yml logs -f"
|
||||||
|
echo "To stop: docker-compose -f docker-compose.dev.yml down"
|
18
scripts/start-prod.sh
Normal file
18
scripts/start-prod.sh
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Starting Lawn Scheduler in Production Mode..."
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
mkdir -p nginx/conf.d
|
||||||
|
mkdir -p uploads
|
||||||
|
|
||||||
|
# Build and start services
|
||||||
|
docker-compose down
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
echo "Application started!"
|
||||||
|
echo "Access the application at: http://localhost"
|
||||||
|
echo ""
|
||||||
|
echo "To view logs: docker-compose logs -f"
|
||||||
|
echo "To stop: docker-compose down"
|
344
server/index.js
Normal file
344
server/index.js
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { createClient } from '@libsql/client';
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Serve static files in production
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.use(express.static(path.join(__dirname, '../public')));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve uploads
|
||||||
|
app.use('/uploads', express.static('uploads'));
|
||||||
|
|
||||||
|
// Ensure uploads directory exists
|
||||||
|
const uploadsDir = 'uploads';
|
||||||
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
|
fs.mkdirSync(uploadsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure multer for file uploads
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, 'uploads/');
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (file.mimetype.startsWith('image/')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only image files are allowed!'), false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024 // 5MB limit
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize libsql database
|
||||||
|
const db = createClient({
|
||||||
|
url: 'file:lawn_scheduler.db'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create zones table with area field
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS zones (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
imagePath TEXT,
|
||||||
|
lastMowedDate TEXT,
|
||||||
|
intervalDays INTEGER NOT NULL DEFAULT 7,
|
||||||
|
area REAL DEFAULT 0,
|
||||||
|
createdAt TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add area column to existing table if it doesn't exist
|
||||||
|
try {
|
||||||
|
await db.execute(`ALTER TABLE zones ADD COLUMN area REAL DEFAULT 0`);
|
||||||
|
} catch (error) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert sample data if table is empty
|
||||||
|
const countResult = await db.execute('SELECT COUNT(*) as count FROM zones');
|
||||||
|
const count = countResult.rows[0].count;
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
const today = new Date();
|
||||||
|
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const twoWeeksAgo = new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await db.execute({
|
||||||
|
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)',
|
||||||
|
args: ['Front Yard', weekAgo.toISOString(), 7, 150.5]
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.execute({
|
||||||
|
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)',
|
||||||
|
args: ['Back Yard', twoWeeksAgo.toISOString(), 10, 280.0]
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.execute({
|
||||||
|
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)',
|
||||||
|
args: ['Side Garden', today.toISOString(), 14, 75.25]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to calculate zone status
|
||||||
|
function calculateZoneStatus(zone) {
|
||||||
|
const today = new Date();
|
||||||
|
const lastMowed = new Date(zone.lastMowedDate);
|
||||||
|
const daysSinceLastMow = Math.floor((today - lastMowed) / (1000 * 60 * 60 * 24));
|
||||||
|
const daysUntilNext = zone.intervalDays - daysSinceLastMow;
|
||||||
|
|
||||||
|
let status = 'ok';
|
||||||
|
if (daysUntilNext < 0) {
|
||||||
|
status = 'overdue';
|
||||||
|
} else if (daysUntilNext <= 0) {
|
||||||
|
status = 'due';
|
||||||
|
} else if (daysUntilNext <= 1) {
|
||||||
|
status = 'due';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...zone,
|
||||||
|
daysSinceLastMow,
|
||||||
|
daysUntilNext,
|
||||||
|
status,
|
||||||
|
isOverdue: daysUntilNext < 0,
|
||||||
|
isDueToday: daysUntilNext <= 0 && daysUntilNext >= -1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.get('/api/zones', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.execute('SELECT * FROM zones ORDER BY name');
|
||||||
|
const zones = result.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
imagePath: row.imagePath,
|
||||||
|
lastMowedDate: row.lastMowedDate,
|
||||||
|
intervalDays: row.intervalDays,
|
||||||
|
area: row.area || 0,
|
||||||
|
createdAt: row.createdAt
|
||||||
|
}));
|
||||||
|
const zonesWithStatus = zones.map(calculateZoneStatus);
|
||||||
|
res.json(zonesWithStatus);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/zones/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.execute({
|
||||||
|
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||||
|
args: [req.params.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Zone not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const zone = {
|
||||||
|
id: result.rows[0].id,
|
||||||
|
name: result.rows[0].name,
|
||||||
|
imagePath: result.rows[0].imagePath,
|
||||||
|
lastMowedDate: result.rows[0].lastMowedDate,
|
||||||
|
intervalDays: result.rows[0].intervalDays,
|
||||||
|
area: result.rows[0].area || 0,
|
||||||
|
createdAt: result.rows[0].createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneWithStatus = calculateZoneStatus(zone);
|
||||||
|
res.json(zoneWithStatus);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/zones', upload.single('image'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, intervalDays, area } = req.body;
|
||||||
|
const imagePath = req.file ? `/uploads/${req.file.filename}` : null;
|
||||||
|
const lastMowedDate = new Date().toISOString();
|
||||||
|
|
||||||
|
const result = await db.execute({
|
||||||
|
sql: 'INSERT INTO zones (name, imagePath, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0]
|
||||||
|
});
|
||||||
|
|
||||||
|
const newZoneResult = await db.execute({
|
||||||
|
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||||
|
args: [result.lastInsertRowid]
|
||||||
|
});
|
||||||
|
|
||||||
|
const newZone = {
|
||||||
|
id: newZoneResult.rows[0].id,
|
||||||
|
name: newZoneResult.rows[0].name,
|
||||||
|
imagePath: newZoneResult.rows[0].imagePath,
|
||||||
|
lastMowedDate: newZoneResult.rows[0].lastMowedDate,
|
||||||
|
intervalDays: newZoneResult.rows[0].intervalDays,
|
||||||
|
area: newZoneResult.rows[0].area || 0,
|
||||||
|
createdAt: newZoneResult.rows[0].createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneWithStatus = calculateZoneStatus(newZone);
|
||||||
|
res.status(201).json(zoneWithStatus);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, intervalDays, lastMowedDate, area } = req.body;
|
||||||
|
|
||||||
|
const existingResult = await db.execute({
|
||||||
|
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||||
|
args: [req.params.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Zone not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingZone = existingResult.rows[0];
|
||||||
|
const imagePath = req.file ? `/uploads/${req.file.filename}` : existingZone.imagePath;
|
||||||
|
|
||||||
|
await db.execute({
|
||||||
|
sql: 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, intervalDays = ?, area = ? WHERE id = ?',
|
||||||
|
args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0, req.params.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete old image if new one was provided
|
||||||
|
if (req.file && existingZone.imagePath) {
|
||||||
|
const oldImagePath = path.join(process.cwd(), existingZone.imagePath.substring(1));
|
||||||
|
if (fs.existsSync(oldImagePath)) {
|
||||||
|
fs.unlinkSync(oldImagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedResult = await db.execute({
|
||||||
|
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||||
|
args: [req.params.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedZone = {
|
||||||
|
id: updatedResult.rows[0].id,
|
||||||
|
name: updatedResult.rows[0].name,
|
||||||
|
imagePath: updatedResult.rows[0].imagePath,
|
||||||
|
lastMowedDate: updatedResult.rows[0].lastMowedDate,
|
||||||
|
intervalDays: updatedResult.rows[0].intervalDays,
|
||||||
|
area: updatedResult.rows[0].area || 0,
|
||||||
|
createdAt: updatedResult.rows[0].createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneWithStatus = calculateZoneStatus(updatedZone);
|
||||||
|
res.json(zoneWithStatus);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/zones/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.execute({
|
||||||
|
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||||
|
args: [req.params.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Zone not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const zone = result.rows[0];
|
||||||
|
|
||||||
|
// Delete associated image
|
||||||
|
if (zone.imagePath) {
|
||||||
|
const imagePath = path.join(process.cwd(), zone.imagePath.substring(1));
|
||||||
|
if (fs.existsSync(imagePath)) {
|
||||||
|
fs.unlinkSync(imagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute({
|
||||||
|
sql: 'DELETE FROM zones WHERE id = ?',
|
||||||
|
args: [req.params.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/zones/:id/mow', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString();
|
||||||
|
|
||||||
|
await db.execute({
|
||||||
|
sql: 'UPDATE zones SET lastMowedDate = ? WHERE id = ?',
|
||||||
|
args: [today, req.params.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedResult = await db.execute({
|
||||||
|
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||||
|
args: [req.params.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedZone = {
|
||||||
|
id: updatedResult.rows[0].id,
|
||||||
|
name: updatedResult.rows[0].name,
|
||||||
|
imagePath: updatedResult.rows[0].imagePath,
|
||||||
|
lastMowedDate: updatedResult.rows[0].lastMowedDate,
|
||||||
|
intervalDays: updatedResult.rows[0].intervalDays,
|
||||||
|
area: updatedResult.rows[0].area || 0,
|
||||||
|
createdAt: updatedResult.rows[0].createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneWithStatus = calculateZoneStatus(updatedZone);
|
||||||
|
res.json(zoneWithStatus);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve React app in production
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Server running on http://0.0.0.0:${PORT}`);
|
||||||
|
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
});
|
8
src/App.tsx
Normal file
8
src/App.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Dashboard from './components/Dashboard';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <Dashboard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
355
src/components/Dashboard.tsx
Normal file
355
src/components/Dashboard.tsx
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map } from 'lucide-react';
|
||||||
|
import { Zone } from '../types/zone';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
import ZoneCard from './ZoneCard';
|
||||||
|
import ZoneForm from './ZoneForm';
|
||||||
|
import SitePlan from './SitePlan';
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'due' | 'overdue';
|
||||||
|
type ViewType = 'dashboard' | 'sitePlan';
|
||||||
|
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const [zones, setZones] = useState<Zone[]>([]);
|
||||||
|
const [filteredZones, setFilteredZones] = useState<Zone[]>([]);
|
||||||
|
const [filter, setFilter] = useState<FilterType>('all');
|
||||||
|
const [view, setView] = useState<ViewType>('dashboard');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingZone, setEditingZone] = useState<Zone | null>(null);
|
||||||
|
const [selectedZoneId, setSelectedZoneId] = useState<number | undefined>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadZones();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyFilter();
|
||||||
|
}, [zones, filter]);
|
||||||
|
|
||||||
|
const loadZones = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getZones();
|
||||||
|
setZones(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load zones:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFilter = () => {
|
||||||
|
let filtered = zones;
|
||||||
|
switch (filter) {
|
||||||
|
case 'due':
|
||||||
|
filtered = zones.filter(zone => zone.isDueToday);
|
||||||
|
break;
|
||||||
|
case 'overdue':
|
||||||
|
filtered = zones.filter(zone => zone.isOverdue);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
filtered = zones;
|
||||||
|
}
|
||||||
|
setFilteredZones(filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAsMowed = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await api.markAsMowed(id);
|
||||||
|
loadZones();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark as mowed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteZone = async (id: number) => {
|
||||||
|
if (window.confirm('Are you sure you want to delete this zone?')) {
|
||||||
|
try {
|
||||||
|
await api.deleteZone(id);
|
||||||
|
loadZones();
|
||||||
|
if (selectedZoneId === id) {
|
||||||
|
setSelectedZoneId(undefined);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete zone:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = async () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingZone(null);
|
||||||
|
loadZones();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoneSelect = (zone: Zone) => {
|
||||||
|
setSelectedZoneId(zone.id);
|
||||||
|
setView('dashboard');
|
||||||
|
// Scroll to the zone card
|
||||||
|
setTimeout(() => {
|
||||||
|
const element = document.getElementById(`zone-card-${zone.id}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate area statistics
|
||||||
|
const overdueCount = zones.filter(zone => zone.isOverdue).length;
|
||||||
|
const dueCount = zones.filter(zone => zone.isDueToday).length;
|
||||||
|
const okCount = zones.filter(zone => zone.status === 'ok').length;
|
||||||
|
const totalArea = zones.reduce((sum, zone) => sum + zone.area, 0);
|
||||||
|
|
||||||
|
// Calculate mowed vs remaining area based on status
|
||||||
|
const mowedArea = zones
|
||||||
|
.filter(zone => zone.status === 'ok')
|
||||||
|
.reduce((sum, zone) => sum + zone.area, 0);
|
||||||
|
|
||||||
|
const remainingArea = zones
|
||||||
|
.filter(zone => zone.status === 'due' || zone.status === 'overdue')
|
||||||
|
.reduce((sum, zone) => sum + zone.area, 0);
|
||||||
|
|
||||||
|
const mowedPercentage = totalArea > 0 ? (mowedArea / totalArea) * 100 : 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
|
||||||
|
<Scissors className="h-8 w-8 text-green-600" />
|
||||||
|
Lawn Care Manager
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-gray-600">Keep track of your lawn mowing schedule</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setView('dashboard')}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
|
||||||
|
view === 'dashboard'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Calendar className="h-4 w-4 inline mr-2" />
|
||||||
|
Dashboard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('sitePlan')}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
|
||||||
|
view === 'sitePlan'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Map className="h-4 w-4 inline mr-2" />
|
||||||
|
Site Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 transition-colors duration-200 shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
Add Zone
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'sitePlan' ? (
|
||||||
|
<SitePlan
|
||||||
|
zones={zones}
|
||||||
|
onZoneSelect={handleZoneSelect}
|
||||||
|
selectedZoneId={selectedZoneId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Total Zones</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{zones.length}</p>
|
||||||
|
</div>
|
||||||
|
<Calendar className="h-8 w-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Total Area</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{totalArea.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-gray-500">sq ft</p>
|
||||||
|
</div>
|
||||||
|
<Square className="h-8 w-8 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-emerald-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Mowed Area</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{mowedArea.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-gray-500">sq ft ({mowedPercentage.toFixed(1)}%)</p>
|
||||||
|
</div>
|
||||||
|
<CheckCircle className="h-8 w-8 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-amber-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Needs Mowing</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{remainingArea.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-gray-500">sq ft ({(100 - mowedPercentage).toFixed(1)}%)</p>
|
||||||
|
</div>
|
||||||
|
<Clock className="h-8 w-8 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-orange-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Due Today</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{dueCount}</p>
|
||||||
|
</div>
|
||||||
|
<Scissors className="h-8 w-8 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-red-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Overdue</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{overdueCount}</p>
|
||||||
|
</div>
|
||||||
|
<AlertTriangle className="h-8 w-8 text-red-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{totalArea > 0 && (
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Mowing Progress</h3>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{mowedArea.toLocaleString()} / {totalArea.toLocaleString()} sq ft
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-4 mb-2">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-green-500 to-emerald-600 h-4 rounded-full transition-all duration-500 ease-out"
|
||||||
|
style={{ width: `${mowedPercentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle className="h-4 w-4 text-emerald-500" />
|
||||||
|
{mowedPercentage.toFixed(1)}% Complete
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-4 w-4 text-amber-500" />
|
||||||
|
{(100 - mowedPercentage).toFixed(1)}% Remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter Buttons */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-5 w-5 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">Filter:</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ key: 'all' as FilterType, label: 'All Zones', count: zones.length },
|
||||||
|
{ key: 'due' as FilterType, label: 'Due Today', count: dueCount },
|
||||||
|
{ key: 'overdue' as FilterType, label: 'Overdue', count: overdueCount },
|
||||||
|
].map(({ key, label, count }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setFilter(key)}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors duration-200 ${
|
||||||
|
filter === key
|
||||||
|
? 'bg-green-600 text-white shadow-md'
|
||||||
|
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label} ({count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zones Grid */}
|
||||||
|
{filteredZones.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Scissors className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No zones found</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
{filter === 'all' ? 'Get started by adding your first lawn zone.' : `No zones match the "${filter}" filter.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{filteredZones.map(zone => (
|
||||||
|
<div
|
||||||
|
key={zone.id}
|
||||||
|
id={`zone-card-${zone.id}`}
|
||||||
|
className={`transition-all duration-300 ${
|
||||||
|
selectedZoneId === zone.id
|
||||||
|
? 'ring-4 ring-blue-300 ring-opacity-50 scale-105'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ZoneCard
|
||||||
|
zone={zone}
|
||||||
|
onMarkAsMowed={handleMarkAsMowed}
|
||||||
|
onEdit={(zone) => {
|
||||||
|
setEditingZone(zone);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
onDelete={handleDeleteZone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Zone Form Modal */}
|
||||||
|
{showForm && (
|
||||||
|
<ZoneForm
|
||||||
|
zone={editingZone}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingZone(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
304
src/components/SitePlan.tsx
Normal file
304
src/components/SitePlan.tsx
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { MapPin, Upload, X, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { Zone } from '../types/zone';
|
||||||
|
|
||||||
|
interface ZoneMarker {
|
||||||
|
id: number;
|
||||||
|
x: number; // Percentage from left
|
||||||
|
y: number; // Percentage from top
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SitePlanProps {
|
||||||
|
zones: Zone[];
|
||||||
|
onZoneSelect: (zone: Zone) => void;
|
||||||
|
selectedZoneId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId }) => {
|
||||||
|
const [sitePlanImage, setSitePlanImage] = useState<string | null>(
|
||||||
|
localStorage.getItem('sitePlanImage')
|
||||||
|
);
|
||||||
|
const [zoneMarkers, setZoneMarkers] = useState<ZoneMarker[]>(() => {
|
||||||
|
const saved = localStorage.getItem('zoneMarkers');
|
||||||
|
return saved ? JSON.parse(saved) : [];
|
||||||
|
});
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
const [showMarkers, setShowMarkers] = useState(true);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
|
// Save markers to localStorage whenever they change
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('zoneMarkers', JSON.stringify(zoneMarkers));
|
||||||
|
}, [zoneMarkers]);
|
||||||
|
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const imageUrl = e.target?.result as string;
|
||||||
|
setSitePlanImage(imageUrl);
|
||||||
|
localStorage.setItem('sitePlanImage', imageUrl);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageClick = (e: React.MouseEvent<HTMLImageElement>) => {
|
||||||
|
if (!isEditMode || !imageRef.current) return;
|
||||||
|
|
||||||
|
const rect = imageRef.current.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||||
|
|
||||||
|
// Find a zone that doesn't have a marker yet
|
||||||
|
const unmarkedZone = zones.find(zone =>
|
||||||
|
!zoneMarkers.some(marker => marker.id === zone.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unmarkedZone) {
|
||||||
|
const newMarker: ZoneMarker = {
|
||||||
|
id: unmarkedZone.id,
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
};
|
||||||
|
setZoneMarkers(prev => [...prev, newMarker]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMarker = (zoneId: number) => {
|
||||||
|
setZoneMarkers(prev => prev.filter(marker => marker.id !== zoneId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSitePlan = () => {
|
||||||
|
if (window.confirm('Are you sure you want to remove the site plan and all markers?')) {
|
||||||
|
setSitePlanImage(null);
|
||||||
|
setZoneMarkers([]);
|
||||||
|
localStorage.removeItem('sitePlanImage');
|
||||||
|
localStorage.removeItem('zoneMarkers');
|
||||||
|
setIsEditMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getZoneById = (id: number) => zones.find(zone => zone.id === id);
|
||||||
|
|
||||||
|
const getMarkerColor = (zone: Zone) => {
|
||||||
|
switch (zone.status) {
|
||||||
|
case 'overdue':
|
||||||
|
return 'bg-red-500 border-red-600 shadow-red-200';
|
||||||
|
case 'due':
|
||||||
|
return 'bg-orange-500 border-orange-600 shadow-orange-200';
|
||||||
|
default:
|
||||||
|
return 'bg-green-500 border-green-600 shadow-green-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sitePlanImage) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-6">
|
||||||
|
<MapPin className="mx-auto h-16 w-16 text-gray-400" />
|
||||||
|
<h3 className="mt-4 text-lg font-semibold text-gray-900">Site Plan</h3>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
Upload an image of your property to visualize zone locations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 mx-auto transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<Upload className="h-5 w-5" />
|
||||||
|
Upload Site Plan
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm text-gray-500">
|
||||||
|
Supported formats: JPG, PNG, GIF (max 10MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b bg-gray-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MapPin className="h-6 w-6 text-blue-600" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Site Plan</h3>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
({zoneMarkers.length} of {zones.length} zones marked)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMarkers(!showMarkers)}
|
||||||
|
className={`p-2 rounded-md transition-colors duration-200 ${
|
||||||
|
showMarkers
|
||||||
|
? 'bg-blue-100 text-blue-600'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
title={showMarkers ? 'Hide markers' : 'Show markers'}
|
||||||
|
>
|
||||||
|
{showMarkers ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditMode(!isEditMode)}
|
||||||
|
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
|
||||||
|
isEditMode
|
||||||
|
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEditMode ? 'Done Editing' : 'Edit Markers'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="px-3 py-2 bg-blue-100 text-blue-700 rounded-md text-sm font-medium hover:bg-blue-200 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Change Image
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={clearSitePlan}
|
||||||
|
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors duration-200"
|
||||||
|
title="Remove site plan"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditMode && (
|
||||||
|
<div className="mt-3 p-3 bg-blue-50 rounded-md">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
<strong>Edit Mode:</strong> Click on the image to place markers for zones.
|
||||||
|
Zones without markers: {zones.filter(zone => !zoneMarkers.some(marker => marker.id === zone.id)).map(zone => zone.name).join(', ') || 'None'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Site Plan Image */}
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
ref={imageRef}
|
||||||
|
src={sitePlanImage}
|
||||||
|
alt="Site Plan"
|
||||||
|
className={`w-full h-auto max-h-[600px] object-contain ${
|
||||||
|
isEditMode ? 'cursor-crosshair' : 'cursor-default'
|
||||||
|
}`}
|
||||||
|
onClick={handleImageClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Zone Markers */}
|
||||||
|
{showMarkers && zoneMarkers.map(marker => {
|
||||||
|
const zone = getZoneById(marker.id);
|
||||||
|
if (!zone) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={marker.id}
|
||||||
|
className="absolute transform -translate-x-1/2 -translate-y-1/2 group"
|
||||||
|
style={{
|
||||||
|
left: `${marker.x}%`,
|
||||||
|
top: `${marker.y}%`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Marker */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isEditMode) {
|
||||||
|
onZoneSelect(zone);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`w-6 h-6 rounded-full border-2 shadow-lg transition-all duration-200 hover:scale-110 ${
|
||||||
|
getMarkerColor(zone)
|
||||||
|
} ${
|
||||||
|
selectedZoneId === zone.id
|
||||||
|
? 'ring-4 ring-blue-300 scale-110'
|
||||||
|
: ''
|
||||||
|
} ${
|
||||||
|
isEditMode ? 'cursor-pointer' : 'cursor-pointer hover:shadow-xl'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="sr-only">{zone.name}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10">
|
||||||
|
<div className="bg-gray-900 text-white text-xs rounded-md px-2 py-1 whitespace-nowrap">
|
||||||
|
<div className="font-medium">{zone.name}</div>
|
||||||
|
<div className="text-gray-300">
|
||||||
|
{zone.isOverdue ? 'Overdue' : zone.isDueToday ? 'Due today' : `${zone.daysUntilNext} days`}
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove button in edit mode */}
|
||||||
|
{isEditMode && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeMarker(zone.id);
|
||||||
|
}}
|
||||||
|
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors duration-200 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
{showMarkers && zoneMarkers.length > 0 && (
|
||||||
|
<div className="p-4 border-t bg-gray-50">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-3">Zone Status Legend</h4>
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-500 border border-green-600"></div>
|
||||||
|
<span className="text-gray-700">Up to date</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-orange-500 border border-orange-600"></div>
|
||||||
|
<span className="text-gray-700">Due today</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-500 border border-red-600"></div>
|
||||||
|
<span className="text-gray-700">Overdue</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SitePlan;
|
143
src/components/ZoneCard.tsx
Normal file
143
src/components/ZoneCard.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Scissors, Edit, Trash2, Calendar, Camera, Square } from 'lucide-react';
|
||||||
|
import { Zone } from '../types/zone';
|
||||||
|
|
||||||
|
interface ZoneCardProps {
|
||||||
|
zone: Zone;
|
||||||
|
onMarkAsMowed: (id: number) => void;
|
||||||
|
onEdit: (zone: Zone) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZoneCard: React.FC<ZoneCardProps> = ({
|
||||||
|
zone,
|
||||||
|
onMarkAsMowed,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'overdue':
|
||||||
|
return 'border-red-500 bg-red-50';
|
||||||
|
case 'due':
|
||||||
|
return 'border-orange-500 bg-orange-50';
|
||||||
|
default:
|
||||||
|
return 'border-green-500 bg-green-50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (zone: Zone) => {
|
||||||
|
if (zone.isOverdue) {
|
||||||
|
return `${Math.abs(zone.daysUntilNext)} days overdue`;
|
||||||
|
} else if (zone.isDueToday) {
|
||||||
|
return 'Due today';
|
||||||
|
} else {
|
||||||
|
return `${zone.daysUntilNext} days remaining`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusTextColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'overdue':
|
||||||
|
return 'text-red-700';
|
||||||
|
case 'due':
|
||||||
|
return 'text-orange-700';
|
||||||
|
default:
|
||||||
|
return 'text-green-700';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatArea = (area: number) => {
|
||||||
|
if (area === 0) return 'Not specified';
|
||||||
|
return `${area.toLocaleString()} sq ft`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-lg shadow-md border-l-4 ${getStatusColor(zone.status)} hover:shadow-lg transition-shadow duration-200`}>
|
||||||
|
{/* Zone Image */}
|
||||||
|
{zone.imagePath ? (
|
||||||
|
<div className="relative h-48 overflow-hidden rounded-t-lg">
|
||||||
|
<img
|
||||||
|
src={`http://localhost:3001${zone.imagePath}`}
|
||||||
|
alt={zone.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black bg-opacity-20"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-48 bg-gray-100 rounded-t-lg flex items-center justify-center">
|
||||||
|
<div className="text-center text-gray-400">
|
||||||
|
<Camera className="h-12 w-12 mx-auto mb-2" />
|
||||||
|
<p className="text-sm">No image</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Zone Name and Status */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">{zone.name}</h3>
|
||||||
|
<div className={`text-sm font-medium ${getStatusTextColor(zone.status)}`}>
|
||||||
|
{getStatusText(zone)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone Details */}
|
||||||
|
<div className="space-y-2 mb-4 text-sm text-gray-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>Last mowed: {formatDate(zone.lastMowedDate)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Scissors className="h-4 w-4" />
|
||||||
|
<span>Every {zone.intervalDays} days</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
<span>Area: {formatArea(zone.area)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onMarkAsMowed(zone.id)}
|
||||||
|
disabled={zone.status === 'ok' && zone.daysUntilNext > 1}
|
||||||
|
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors duration-200 ${
|
||||||
|
zone.status === 'ok' && zone.daysUntilNext > 1
|
||||||
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-green-600 hover:bg-green-700 text-white shadow-sm hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Scissors className="h-4 w-4 inline mr-1" />
|
||||||
|
Mowed
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(zone)}
|
||||||
|
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(zone.id)}
|
||||||
|
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZoneCard;
|
216
src/components/ZoneForm.tsx
Normal file
216
src/components/ZoneForm.tsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { X, Upload, Camera } from 'lucide-react';
|
||||||
|
import { Zone, ZoneFormData } from '../types/zone';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
|
||||||
|
interface ZoneFormProps {
|
||||||
|
zone: Zone | null;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
|
||||||
|
const [formData, setFormData] = useState<ZoneFormData>({
|
||||||
|
name: zone?.name || '',
|
||||||
|
intervalDays: zone?.intervalDays || 7,
|
||||||
|
lastMowedDate: zone ? zone.lastMowedDate.split('T')[0] : new Date().toISOString().split('T')[0],
|
||||||
|
area: zone?.area || 0,
|
||||||
|
});
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(
|
||||||
|
zone?.imagePath ? `http://localhost:3001${zone.imagePath}` : null
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setFormData(prev => ({ ...prev, image: file }));
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
setImagePreview(e.target?.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (zone) {
|
||||||
|
await api.updateZone(zone.id, formData);
|
||||||
|
} else {
|
||||||
|
await api.createZone(formData);
|
||||||
|
}
|
||||||
|
onSubmit();
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
|
{zone ? 'Edit Zone' : 'Add New Zone'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Zone Name */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Zone Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
|
required
|
||||||
|
placeholder="e.g., Front Yard, Back Garden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Area */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="area" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Area (square feet)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="area"
|
||||||
|
value={formData.area}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, area: parseFloat(e.target.value) || 0 }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="e.g., 150.5"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Optional - helps track total lawn area</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mowing Interval */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="intervalDays" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Mowing Interval (days)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="intervalDays"
|
||||||
|
value={formData.intervalDays}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, intervalDays: parseInt(e.target.value) }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
|
required
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Mowed Date */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lastMowedDate" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Last Mowed Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="lastMowedDate"
|
||||||
|
value={formData.lastMowedDate}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, lastMowedDate: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone Image */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Zone Image (optional)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{imagePreview ? (
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={imagePreview}
|
||||||
|
alt="Zone preview"
|
||||||
|
className="w-full h-48 object-cover rounded-md border"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setImagePreview(null);
|
||||||
|
setFormData(prev => ({ ...prev, image: undefined }));
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 bg-red-500 text-white p-1.5 rounded-full hover:bg-red-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="border-2 border-dashed border-gray-300 rounded-md p-6 text-center cursor-pointer hover:border-green-500 hover:bg-green-50 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<Upload className="mx-auto h-8 w-8 text-gray-400 mb-2" />
|
||||||
|
<p className="text-sm text-gray-600">Click to upload zone image</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">PNG, JPG up to 5MB</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Actions */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 py-2 px-4 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{loading ? 'Saving...' : zone ? 'Update Zone' : 'Create Zone'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZoneForm;
|
3
src/index.css
Normal file
3
src/index.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
67
src/services/api.ts
Normal file
67
src/services/api.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { Zone, ZoneFormData } from '../types/zone';
|
||||||
|
|
||||||
|
const API_BASE = 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
async getZones(): Promise<Zone[]> {
|
||||||
|
const response = await fetch(`${API_BASE}/zones`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch zones');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getZone(id: number): Promise<Zone> {
|
||||||
|
const response = await fetch(`${API_BASE}/zones/${id}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch zone');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createZone(data: ZoneFormData): Promise<Zone> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', data.name);
|
||||||
|
formData.append('intervalDays', data.intervalDays.toString());
|
||||||
|
formData.append('area', data.area.toString());
|
||||||
|
if (data.image) {
|
||||||
|
formData.append('image', data.image);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/zones`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to create zone');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateZone(id: number, data: ZoneFormData): Promise<Zone> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', data.name);
|
||||||
|
formData.append('intervalDays', data.intervalDays.toString());
|
||||||
|
formData.append('lastMowedDate', data.lastMowedDate);
|
||||||
|
formData.append('area', data.area.toString());
|
||||||
|
if (data.image) {
|
||||||
|
formData.append('image', data.image);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/zones/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to update zone');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteZone(id: number): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/zones/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to delete zone');
|
||||||
|
},
|
||||||
|
|
||||||
|
async markAsMowed(id: number): Promise<Zone> {
|
||||||
|
const response = await fetch(`${API_BASE}/zones/${id}/mow`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to mark as mowed');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
};
|
22
src/types/zone.ts
Normal file
22
src/types/zone.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
export interface Zone {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
imagePath?: string;
|
||||||
|
lastMowedDate: string;
|
||||||
|
intervalDays: number;
|
||||||
|
area: number;
|
||||||
|
createdAt: string;
|
||||||
|
daysSinceLastMow: number;
|
||||||
|
daysUntilNext: number;
|
||||||
|
status: 'ok' | 'due' | 'overdue';
|
||||||
|
isOverdue: boolean;
|
||||||
|
isDueToday: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoneFormData {
|
||||||
|
name: string;
|
||||||
|
intervalDays: number;
|
||||||
|
lastMowedDate: string;
|
||||||
|
area: number;
|
||||||
|
image?: File;
|
||||||
|
}
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
8
tailwind.config.js
Normal file
8
tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
18
vite.config.ts
Normal file
18
vite.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['lucide-react'],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user