build: Build a full-stack task management application with the following str...

This commit is contained in:
rdev-worker 2026-01-31 17:58:53 +00:00
parent 5a35b14281
commit 1fa47060e7
31 changed files with 1880 additions and 6 deletions

View File

@ -1,9 +1,11 @@
steps:
docker:
build-backend:
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai
repo: "testfix"
repo: "testfix-backend"
dockerfile: backend/Dockerfile
context: backend
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
@ -13,10 +15,34 @@ steps:
- event: push
branch: main
deploy:
image: bitnami/kubectl:latest
commands:
- kubectl set image deployment/testfix testfix=registry.threesix.ai/testfix:${CI_COMMIT_SHA:0:8} -n projects
build-frontend:
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai
repo: "testfix-frontend"
dockerfile: frontend/Dockerfile
context: frontend
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
cache: true
skip-tls-verify: true
when:
- event: push
branch: main
deploy-backend:
image: bitnami/kubectl:latest
commands:
- kubectl set image deployment/testfix-backend backend=registry.threesix.ai/testfix-backend:${CI_COMMIT_SHA:0:8} -n projects
when:
- event: push
branch: main
deploy-frontend:
image: bitnami/kubectl:latest
commands:
- kubectl set image deployment/testfix-frontend frontend=registry.threesix.ai/testfix-frontend:${CI_COMMIT_SHA:0:8} -n projects
when:
- event: push
branch: main

31
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
main
taskmanager
# Test binary
*.test
# Output of go coverage
*.out
# Dependency directories
vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local

36
backend/Dockerfile Normal file
View File

@ -0,0 +1,36 @@
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Install dependencies
RUN apk add --no-cache git
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Final stage
FROM alpine:3.19
WORKDIR /app
# Install ca-certificates for HTTPS
RUN apk --no-cache add ca-certificates
# Copy binary from builder
COPY --from=builder /app/main .
# Expose port
EXPOSE 8080
# Run the application
CMD ["./main"]

7
backend/go.mod Normal file
View File

@ -0,0 +1,7 @@
module taskmanager
go 1.22
require github.com/go-chi/chi/v5 v5.0.12
require github.com/go-chi/cors v1.2.1

4
backend/go.sum Normal file
View File

@ -0,0 +1,4 @@
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=

224
backend/main.go Normal file
View File

@ -0,0 +1,224 @@
package main
import (
"encoding/json"
"log"
"net/http"
"strconv"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type TaskStore struct {
mu sync.RWMutex
tasks map[int]Task
nextID int
}
func NewTaskStore() *TaskStore {
store := &TaskStore{
tasks: make(map[int]Task),
nextID: 1,
}
// Add some sample tasks
now := time.Now()
store.tasks[1] = Task{
ID: 1,
Title: "Set up development environment",
Description: "Install all necessary tools and dependencies for the project",
Status: "completed",
Priority: "high",
CreatedAt: now.Add(-48 * time.Hour),
UpdatedAt: now.Add(-24 * time.Hour),
}
store.tasks[2] = Task{
ID: 2,
Title: "Design database schema",
Description: "Create the initial database schema for task management",
Status: "in_progress",
Priority: "high",
CreatedAt: now.Add(-24 * time.Hour),
UpdatedAt: now,
}
store.tasks[3] = Task{
ID: 3,
Title: "Implement user authentication",
Description: "Add JWT-based authentication system",
Status: "pending",
Priority: "medium",
CreatedAt: now.Add(-12 * time.Hour),
UpdatedAt: now.Add(-12 * time.Hour),
}
store.nextID = 4
return store
}
func (s *TaskStore) GetAll() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
tasks := make([]Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
}
return tasks
}
func (s *TaskStore) Get(id int) (Task, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
task, ok := s.tasks[id]
return task, ok
}
func (s *TaskStore) Create(task Task) Task {
s.mu.Lock()
defer s.mu.Unlock()
task.ID = s.nextID
task.CreatedAt = time.Now()
task.UpdatedAt = time.Now()
if task.Status == "" {
task.Status = "pending"
}
if task.Priority == "" {
task.Priority = "medium"
}
s.tasks[task.ID] = task
s.nextID++
return task
}
func (s *TaskStore) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[id]; ok {
delete(s.tasks, id)
return true
}
return false
}
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
func respondJSON(w http.ResponseWriter, status int, payload interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(payload)
}
func main() {
store := NewTaskStore()
r := chi.NewRouter()
// Middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000", "http://localhost:8080", "*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: "healthy"})
})
// API routes
r.Route("/api", func(r chi.Router) {
r.Route("/tasks", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
tasks := store.GetAll()
respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
})
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
var task Task
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
respondJSON(w, http.StatusBadRequest, APIResponse{
Success: false,
Error: "Invalid request body",
})
return
}
if task.Title == "" {
respondJSON(w, http.StatusBadRequest, APIResponse{
Success: false,
Error: "Title is required",
})
return
}
created := store.Create(task)
respondJSON(w, http.StatusCreated, APIResponse{Success: true, Data: created})
})
r.Get("/{id}", func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
respondJSON(w, http.StatusBadRequest, APIResponse{
Success: false,
Error: "Invalid task ID",
})
return
}
task, ok := store.Get(id)
if !ok {
respondJSON(w, http.StatusNotFound, APIResponse{
Success: false,
Error: "Task not found",
})
return
}
respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: task})
})
r.Delete("/{id}", func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
respondJSON(w, http.StatusBadRequest, APIResponse{
Success: false,
Error: "Invalid task ID",
})
return
}
if !store.Delete(id) {
respondJSON(w, http.StatusNotFound, APIResponse{
Success: false,
Error: "Task not found",
})
return
}
respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: "Task deleted"})
})
})
})
log.Println("Starting server on :8080")
if err := http.ListenAndServe(":8080", r); err != nil {
log.Fatal(err)
}
}

35
docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- BACKEND_URL=http://backend:8080
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
networks:
default:
driver: bridge

34
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# Dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# Testing
/coverage
# Next.js
/.next/
/out/
# Production
/build
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
# Vercel
.vercel
# Typescript
*.tsbuildinfo
next-env.d.ts

51
frontend/Dockerfile Normal file
View File

@ -0,0 +1,51 @@
# Dependencies stage
FROM node:20-alpine AS deps
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm ci || npm install
# Builder stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependencies
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set environment variables for build
ENV NEXT_TELEMETRY_DISABLED=1
# Build the application
RUN npm run build
# Runner stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

46
frontend/app/globals.css Normal file
View File

@ -0,0 +1,46 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

33
frontend/app/layout.tsx Normal file
View File

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Task Manager",
description: "A modern task management application",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body className={inter.className}>
<div className="min-h-screen bg-background">
<header className="border-b border-border">
<div className="container mx-auto px-4 py-4">
<h1 className="text-2xl font-bold text-foreground">
Task Manager
</h1>
</div>
</header>
<main className="container mx-auto px-4 py-8">{children}</main>
</div>
</body>
</html>
);
}

216
frontend/app/page.tsx Normal file
View File

@ -0,0 +1,216 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Plus, Trash2, CheckCircle, Clock, Circle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Task, fetchTasks, deleteTask } from "@/lib/api";
const statusIcons = {
pending: Circle,
in_progress: Clock,
completed: CheckCircle,
};
const statusColors = {
pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
in_progress: "bg-blue-500/20 text-blue-400 border-blue-500/30",
completed: "bg-green-500/20 text-green-400 border-green-500/30",
};
const priorityColors = {
low: "bg-slate-500/20 text-slate-400 border-slate-500/30",
medium: "bg-orange-500/20 text-orange-400 border-orange-500/30",
high: "bg-red-500/20 text-red-400 border-red-500/30",
};
export default function Dashboard() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadTasks = async () => {
try {
setLoading(true);
const data = await fetchTasks();
setTasks(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load tasks");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTasks();
}, []);
const handleDelete = async (id: number) => {
try {
await deleteTask(id);
setTasks(tasks.filter((t) => t.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete task");
}
};
const stats = {
total: tasks.length,
pending: tasks.filter((t) => t.status === "pending").length,
inProgress: tasks.filter((t) => t.status === "in_progress").length,
completed: tasks.filter((t) => t.status === "completed").length,
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground">Loading tasks...</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Tasks
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Pending
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-yellow-400">
{stats.pending}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
In Progress
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-blue-400">
{stats.inProgress}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Completed
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-green-400">
{stats.completed}
</div>
</CardContent>
</Card>
</div>
{/* Actions */}
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">Tasks</h2>
<Link href="/tasks/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Task
</Button>
</Link>
</div>
{/* Error Message */}
{error && (
<div className="bg-destructive/20 text-destructive-foreground border border-destructive/30 rounded-lg p-4">
{error}
</div>
)}
{/* Task List */}
{tasks.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground mb-4">No tasks yet</p>
<Link href="/tasks/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create your first task
</Button>
</Link>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{tasks.map((task) => {
const StatusIcon = statusIcons[task.status];
return (
<Card
key={task.id}
className="hover:border-primary/50 transition-colors"
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<StatusIcon className="h-5 w-5 mt-0.5 text-muted-foreground" />
<div className="flex-1 min-w-0">
<Link
href={`/tasks/${task.id}`}
className="font-medium hover:text-primary transition-colors"
>
{task.title}
</Link>
{task.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{task.description}
</p>
)}
<div className="flex gap-2 mt-2">
<Badge
variant="outline"
className={statusColors[task.status]}
>
{task.status.replace("_", " ")}
</Badge>
<Badge
variant="outline"
className={priorityColors[task.priority]}
>
{task.priority}
</Badge>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(task.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,212 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Trash2, CheckCircle, Clock, Circle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Task, fetchTask, deleteTask } from "@/lib/api";
const statusIcons = {
pending: Circle,
in_progress: Clock,
completed: CheckCircle,
};
const statusColors = {
pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
in_progress: "bg-blue-500/20 text-blue-400 border-blue-500/30",
completed: "bg-green-500/20 text-green-400 border-green-500/30",
};
const priorityColors = {
low: "bg-slate-500/20 text-slate-400 border-slate-500/30",
medium: "bg-orange-500/20 text-orange-400 border-orange-500/30",
high: "bg-red-500/20 text-red-400 border-red-500/30",
};
export default function TaskDetail() {
const params = useParams();
const router = useRouter();
const [task, setTask] = useState<Task | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
useEffect(() => {
const loadTask = async () => {
try {
setLoading(true);
const id = parseInt(params.id as string);
const data = await fetchTask(id);
setTask(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load task");
} finally {
setLoading(false);
}
};
if (params.id) {
loadTask();
}
}, [params.id]);
const handleDelete = async () => {
if (!task) return;
try {
await deleteTask(task.id);
router.push("/");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete task");
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
});
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground">Loading task...</div>
</div>
);
}
if (error || !task) {
return (
<div className="max-w-2xl mx-auto">
<Link
href="/"
className="inline-flex items-center text-muted-foreground hover:text-foreground mb-6"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-destructive mb-4">{error || "Task not found"}</p>
<Link href="/">
<Button variant="outline">Go to Dashboard</Button>
</Link>
</CardContent>
</Card>
</div>
);
}
const StatusIcon = statusIcons[task.status];
return (
<div className="max-w-2xl mx-auto">
<Link
href="/"
className="inline-flex items-center text-muted-foreground hover:text-foreground mb-6"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<StatusIcon className="h-6 w-6 mt-1 text-muted-foreground" />
<div>
<CardTitle className="text-2xl">{task.title}</CardTitle>
<CardDescription className="mt-2">
Created {formatDate(task.createdAt)}
</CardDescription>
</div>
</div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-destructive">
<Trash2 className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Task</DialogTitle>
<DialogDescription>
Are you sure you want to delete this task? This action cannot
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex gap-2">
<Badge variant="outline" className={statusColors[task.status]}>
{task.status.replace("_", " ")}
</Badge>
<Badge variant="outline" className={priorityColors[task.priority]}>
{task.priority} priority
</Badge>
</div>
{task.description && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Description
</h3>
<p className="text-foreground whitespace-pre-wrap">
{task.description}
</p>
</div>
)}
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border">
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">
Created
</h3>
<p className="text-foreground">{formatDate(task.createdAt)}</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">
Last Updated
</h3>
<p className="text-foreground">{formatDate(task.updatedAt)}</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,153 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { createTask } from "@/lib/api";
export default function NewTask() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
title: "",
description: "",
status: "pending" as const,
priority: "medium" as const,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.title.trim()) {
setError("Title is required");
return;
}
try {
setLoading(true);
setError(null);
await createTask(formData);
router.push("/");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create task");
} finally {
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto">
<Link
href="/"
className="inline-flex items-center text-muted-foreground hover:text-foreground mb-6"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>
<Card>
<CardHeader>
<CardTitle>Create New Task</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-destructive/20 text-destructive-foreground border border-destructive/30 rounded-lg p-4">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
placeholder="Enter task title"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Enter task description (optional)"
rows={4}
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(value: "pending" | "in_progress" | "completed") =>
setFormData({ ...formData, status: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="priority">Priority</Label>
<Select
value={formData.priority}
onValueChange={(value: "low" | "medium" | "high") =>
setFormData({ ...formData, priority: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-4">
<Button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create Task"}
</Button>
<Link href="/">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

17
frontend/components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,56 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,79 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -0,0 +1,122 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@ -0,0 +1,25 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };

52
frontend/lib/api.ts Normal file
View File

@ -0,0 +1,52 @@
export interface Task {
id: number;
title: string;
description: string;
status: "pending" | "in_progress" | "completed";
priority: "low" | "medium" | "high";
createdAt: string;
updatedAt: string;
}
export interface APIResponse<T> {
success: boolean;
data?: T;
error?: string;
}
const API_BASE = "/api";
export async function fetchTasks(): Promise<Task[]> {
const res = await fetch(`${API_BASE}/tasks`);
const json: APIResponse<Task[]> = await res.json();
if (!json.success) throw new Error(json.error || "Failed to fetch tasks");
return json.data || [];
}
export async function fetchTask(id: number): Promise<Task> {
const res = await fetch(`${API_BASE}/tasks/${id}`);
const json: APIResponse<Task> = await res.json();
if (!json.success) throw new Error(json.error || "Failed to fetch task");
return json.data!;
}
export async function createTask(
task: Pick<Task, "title" | "description" | "priority" | "status">
): Promise<Task> {
const res = await fetch(`${API_BASE}/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(task),
});
const json: APIResponse<Task> = await res.json();
if (!json.success) throw new Error(json.error || "Failed to create task");
return json.data!;
}
export async function deleteTask(id: number): Promise<void> {
const res = await fetch(`${API_BASE}/tasks/${id}`, {
method: "DELETE",
});
const json: APIResponse<string> = await res.json();
if (!json.success) throw new Error(json.error || "Failed to delete task");
}

6
frontend/lib/utils.ts Normal file
View File

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

16
frontend/next.config.js Normal file
View File

@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
async rewrites() {
return [
{
source: '/api/:path*',
destination: process.env.BACKEND_URL
? `${process.env.BACKEND_URL}/api/:path*`
: 'http://backend:8080/api/:path*',
},
];
},
};
module.exports = nextConfig;

35
frontend/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "task-manager-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.3.0",
"lucide-react": "^0.378.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-toast": "^1.1.5"
},
"devDependencies": {
"@types/node": "^20.12.12",
"@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.4.5"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

0
frontend/public/.gitkeep Normal file
View File

View File

@ -0,0 +1,80 @@
import type { Config } from "tailwindcss";
const config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
export default config;

26
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}