create tasko and manage users

This commit is contained in:
dvirlabs 2025-12-10 15:19:07 +02:00
parent 63dafdb65b
commit 7ed3a4730a
25 changed files with 5070 additions and 0 deletions

2
backend/.env.example Normal file
View File

@ -0,0 +1,2 @@
PORT=8001
HOST=0.0.0.0

108
backend/POSTGRES_SETUP.md Normal file
View File

@ -0,0 +1,108 @@
# PostgreSQL Setup for Tasko
## Prerequisites
- PostgreSQL installed and running on your PC
- psql CLI access
## Setup Instructions
### 1. Run the schema script as postgres superuser:
```bash
psql -U postgres -f schema.sql
```
**OR** manually in psql:
```bash
# Connect as postgres superuser
psql -U postgres
# Then paste the contents of schema.sql
```
### 2. Verify the setup:
```sql
-- Connect to the database
\c tasko_db
-- List tables
\dt
-- List users
\du
-- Verify tasko_user has correct privileges
\dp
```
### 3. Test connection:
```bash
psql -U tasko_user -d tasko_db -h localhost
# Password: tasko_password
```
## Configuration
### Default credentials (change in production):
- **Database**: `tasko_db`
- **User**: `tasko_user`
- **Password**: `tasko_password`
- **Host**: `localhost`
- **Port**: `5432`
### Environment Variable (optional):
You can override the connection string by setting:
```bash
export DATABASE_URL="postgresql://tasko_user:tasko_password@localhost:5432/tasko_db"
```
Or on Windows:
```cmd
set DATABASE_URL=postgresql://tasko_user:tasko_password@localhost:5432/tasko_db
```
## Security Best Practices Applied
**Dedicated database user**: Created `tasko_user` instead of using `postgres` superuser
**Limited privileges**: Only granted necessary permissions (SELECT, INSERT, UPDATE, DELETE)
**No superuser access**: `tasko_user` cannot create/drop databases or modify system tables
**Schema isolation**: Uses public schema with controlled access
**Cascade deletes**: Foreign keys properly handle data integrity
## Install Python Dependencies
```bash
cd backend
pip install -r requirements.txt
```
This will install:
- psycopg2-binary (PostgreSQL adapter)
- sqlalchemy
- fastapi
- uvicorn
- pydantic
## Start the Backend
```bash
cd backend
python main.py
```
The server will start on http://localhost:8001
## Troubleshooting
### Connection refused:
- Ensure PostgreSQL service is running
- Check if port 5432 is open
- Verify pg_hba.conf allows local connections
### Authentication failed:
- Double-check username and password
- Ensure user was created: `\du` in psql
### Permission denied:
- Re-run the GRANT statements in schema.sql
- Verify with: `\dp` in psql

29
backend/README.md Normal file
View File

@ -0,0 +1,29 @@
# Tasko Backend
FastAPI backend for task management application.
## Setup
1. Create a virtual environment:
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run the server:
```bash
python main.py
```
Or using uvicorn directly:
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
The API will be available at http://localhost:8000
API documentation at http://localhost:8000/docs

Binary file not shown.

89
backend/database.py Normal file
View File

@ -0,0 +1,89 @@
from sqlalchemy import create_engine, Column, String, Boolean, DateTime, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
from datetime import datetime
import os
# PostgreSQL connection string
# Format: postgresql://username:password@localhost:5432/database_name
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://tasko_user:tasko_password@localhost:5432/tasko_db"
)
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
email = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
lists = relationship("TaskList", back_populates="user", cascade="all, delete-orphan")
tasks = relationship("Task", back_populates="user", cascade="all, delete-orphan")
tokens = relationship("Token", back_populates="user", cascade="all, delete-orphan")
class Token(Base):
__tablename__ = "tokens"
token = Column(String, primary_key=True, index=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="tokens")
class TaskList(Base):
__tablename__ = "task_lists"
id = Column(String, primary_key=True, index=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
name = Column(String, nullable=False)
icon = Column(String, default="📝")
color = Column(String, default="#667eea")
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="lists")
tasks = relationship("Task", back_populates="task_list", cascade="all, delete-orphan")
class Task(Base):
__tablename__ = "tasks"
id = Column(String, primary_key=True, index=True)
list_id = Column(String, ForeignKey("task_lists.id"), nullable=False)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
title = Column(String, nullable=False)
description = Column(String, nullable=True)
completed = Column(Boolean, default=False)
priority = Column(String, default="medium")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="tasks")
task_list = relationship("TaskList", back_populates="tasks")
def init_db():
"""Initialize the database"""
Base.metadata.create_all(bind=engine)
def get_db():
"""Dependency for getting database session"""
db = SessionLocal()
try:
yield db
finally:
db.close()

415
backend/main.py Normal file
View File

@ -0,0 +1,415 @@
from fastapi import FastAPI, HTTPException, Header, Depends
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from sqlalchemy.orm import Session
import uuid
import hashlib
from database import init_db, get_db
import database as db_models
app = FastAPI(title="Tasko API", version="1.0.0")
# Initialize database
init_db()
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Pydantic Models for API
class UserResponse(BaseModel):
id: str
username: str
email: str
created_at: datetime
class Config:
from_attributes = True
class UserRegister(BaseModel):
username: str
email: str
password: str
class UserLogin(BaseModel):
email: str
password: str
class AuthResponse(BaseModel):
user: UserResponse
token: str
class TaskListResponse(BaseModel):
id: str
user_id: str
name: str
icon: str = "📝"
color: str = "#667eea"
created_at: datetime
class Config:
from_attributes = True
class TaskListCreate(BaseModel):
name: str
icon: Optional[str] = "📝"
color: Optional[str] = "#667eea"
class TaskListUpdate(BaseModel):
name: Optional[str] = None
icon: Optional[str] = None
color: Optional[str] = None
class TaskResponse(BaseModel):
id: str
list_id: str
user_id: str
title: str
description: Optional[str] = None
completed: bool = False
priority: str = "medium"
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TaskCreate(BaseModel):
title: str
list_id: str
description: Optional[str] = None
priority: str = "medium"
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
completed: Optional[bool] = None
priority: Optional[str] = None
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def verify_token(authorization: Optional[str], db: Session) -> str:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Not authenticated")
token_str = authorization.replace("Bearer ", "")
token = db.query(db_models.Token).filter(db_models.Token.token == token_str).first()
if not token:
raise HTTPException(status_code=401, detail="Invalid token")
return token.user_id
@app.get("/")
def read_root():
return {"message": "Welcome to Tasko API"}
# Auth endpoints
@app.post("/register", response_model=AuthResponse)
def register(user_data: UserRegister, db: Session = Depends(get_db)):
"""Register a new user"""
# Check if username or email exists
existing_user = db.query(db_models.User).filter(
(db_models.User.username == user_data.username) |
(db_models.User.email == user_data.email)
).first()
if existing_user:
if existing_user.username == user_data.username:
raise HTTPException(status_code=400, detail="Username already exists")
else:
raise HTTPException(status_code=400, detail="Email already exists")
# Create user
user_id = str(uuid.uuid4())
new_user = db_models.User(
id=user_id,
username=user_data.username,
email=user_data.email,
password_hash=hash_password(user_data.password)
)
db.add(new_user)
# Create token
token_str = str(uuid.uuid4())
new_token = db_models.Token(token=token_str, user_id=user_id)
db.add(new_token)
# Create default lists for new user
default_lists = [
{"name": "Personal", "icon": "🏠", "color": "#667eea"},
{"name": "Work", "icon": "💼", "color": "#f093fb"},
{"name": "Shopping", "icon": "🛒", "color": "#4facfe"},
]
for list_data in default_lists:
list_id = str(uuid.uuid4())
new_list = db_models.TaskList(
id=list_id,
user_id=user_id,
name=list_data["name"],
icon=list_data["icon"],
color=list_data["color"]
)
db.add(new_list)
db.commit()
db.refresh(new_user)
return AuthResponse(
user=UserResponse.model_validate(new_user),
token=token_str
)
@app.post("/login", response_model=AuthResponse)
def login(user_data: UserLogin, db: Session = Depends(get_db)):
"""Login a user"""
password_hash = hash_password(user_data.password)
user = db.query(db_models.User).filter(
db_models.User.email == user_data.email,
db_models.User.password_hash == password_hash
).first()
if not user:
raise HTTPException(status_code=401, detail="Invalid email or password")
# Create new token
token_str = str(uuid.uuid4())
new_token = db_models.Token(token=token_str, user_id=user.id)
db.add(new_token)
db.commit()
return AuthResponse(
user=UserResponse.model_validate(user),
token=token_str
)
@app.post("/logout")
def logout(authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
"""Logout a user"""
if authorization and authorization.startswith("Bearer "):
token_str = authorization.replace("Bearer ", "")
token = db.query(db_models.Token).filter(db_models.Token.token == token_str).first()
if token:
db.delete(token)
db.commit()
return {"message": "Logged out successfully"}
# Task List endpoints
@app.get("/lists", response_model=List[TaskListResponse])
@app.get("/api/lists", response_model=List[TaskListResponse])
def get_task_lists(authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
"""Get all task lists for the authenticated user"""
user_id = verify_token(authorization, db)
task_lists = db.query(db_models.TaskList).filter(db_models.TaskList.user_id == user_id).all()
return [TaskListResponse.model_validate(tl) for tl in task_lists]
@app.get("/lists/{list_id}", response_model=TaskListResponse)
@app.get("/api/lists/{list_id}", response_model=TaskListResponse)
def get_task_list(list_id: str, authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
"""Get a specific task list by ID"""
user_id = verify_token(authorization, db)
task_list = db.query(db_models.TaskList).filter(
db_models.TaskList.id == list_id,
db_models.TaskList.user_id == user_id
).first()
if not task_list:
raise HTTPException(status_code=404, detail="Task list not found")
return TaskListResponse.model_validate(task_list)
@app.post("/lists", response_model=TaskListResponse, status_code=201)
@app.post("/api/lists", response_model=TaskListResponse, status_code=201)
def create_task_list(task_list: TaskListCreate, authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
"""Create a new task list"""
user_id = verify_token(authorization, db)
list_id = str(uuid.uuid4())
new_list = db_models.TaskList(
id=list_id,
user_id=user_id,
name=task_list.name,
icon=task_list.icon or "📝",
color=task_list.color or "#667eea"
)
db.add(new_list)
db.commit()
db.refresh(new_list)
return TaskListResponse.model_validate(new_list)
@app.put("/lists/{list_id}", response_model=TaskListResponse)
@app.put("/api/lists/{list_id}", response_model=TaskListResponse)
def update_task_list(list_id: str, list_update: TaskListUpdate, authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
"""Update an existing task list"""
user_id = verify_token(authorization, db)
task_list = db.query(db_models.TaskList).filter(
db_models.TaskList.id == list_id,
db_models.TaskList.user_id == user_id
).first()
if not task_list:
raise HTTPException(status_code=404, detail="Task list not found")
update_data = list_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(task_list, field, value)
db.commit()
db.refresh(task_list)
return TaskListResponse.model_validate(task_list)
@app.delete("/lists/{list_id}", status_code=204)
@app.delete("/api/lists/{list_id}", status_code=204)
def delete_task_list(list_id: str, authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
"""Delete a task list and all its tasks"""
user_id = verify_token(authorization, db)
task_list = db.query(db_models.TaskList).filter(
db_models.TaskList.id == list_id,
db_models.TaskList.user_id == user_id
).first()
if not task_list:
raise HTTPException(status_code=404, detail="Task list not found")
# Tasks will be deleted automatically due to cascade relationship
db.delete(task_list)
db.commit()
return None
# Task endpoints
@app.get("/tasks", response_model=List[TaskResponse])
@app.get("/api/tasks", response_model=List[TaskResponse])
def get_tasks(list_id: Optional[str] = None, authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
"""Get all tasks for authenticated user, optionally filtered by list_id"""
user_id = verify_token(authorization, db)
query = db.query(db_models.Task).filter(db_models.Task.user_id == user_id)
if list_id:
# Verify list belongs to user
task_list = db.query(db_models.TaskList).filter(
db_models.TaskList.id == list_id,
db_models.TaskList.user_id == user_id
).first()
if not task_list:
raise HTTPException(status_code=403, detail="Access denied")
query = query.filter(db_models.Task.list_id == list_id)
tasks = query.all()
return [TaskResponse.model_validate(t) for t in tasks]
@app.get("/tasks/{task_id}", response_model=TaskResponse)
@app.get("/api/tasks/{task_id}", response_model=TaskResponse)
def get_task(task_id: str, authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
"""Get a specific task by ID"""
user_id = verify_token(authorization, db)
task = db.query(db_models.Task).filter(
db_models.Task.id == task_id,
db_models.Task.user_id == user_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return TaskResponse.model_validate(task)
@app.post("/tasks", response_model=TaskResponse, status_code=201)
@app.post("/api/tasks", response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate, authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
"""Create a new task"""
user_id = verify_token(authorization, db)
# Verify list exists and belongs to user
task_list = db.query(db_models.TaskList).filter(
db_models.TaskList.id == task.list_id,
db_models.TaskList.user_id == user_id
).first()
if not task_list:
raise HTTPException(status_code=404, detail="Task list not found")
task_id = str(uuid.uuid4())
new_task = db_models.Task(
id=task_id,
list_id=task.list_id,
user_id=user_id,
title=task.title,
description=task.description,
completed=False,
priority=task.priority
)
db.add(new_task)
db.commit()
db.refresh(new_task)
return TaskResponse.model_validate(new_task)
@app.put("/tasks/{task_id}", response_model=TaskResponse)
@app.put("/api/tasks/{task_id}", response_model=TaskResponse)
def update_task(task_id: str, task_update: TaskUpdate, authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
"""Update an existing task"""
user_id = verify_token(authorization, db)
task = db.query(db_models.Task).filter(
db_models.Task.id == task_id,
db_models.Task.user_id == user_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
update_data = task_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(task, field, value)
task.updated_at = datetime.now()
db.commit()
db.refresh(task)
return TaskResponse.model_validate(task)
@app.delete("/tasks/{task_id}", status_code=204)
@app.delete("/api/tasks/{task_id}", status_code=204)
def delete_task(task_id: str, authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
"""Delete a task"""
user_id = verify_token(authorization, db)
task = db.query(db_models.Task).filter(
db_models.Task.id == task_id,
db_models.Task.user_id == user_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
db.delete(task)
db.commit()
return None
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)

5
backend/requirements.txt Normal file
View File

@ -0,0 +1,5 @@
fastapi==0.109.0
uvicorn==0.27.0
pydantic==2.5.3
sqlalchemy==2.0.25
psycopg2-binary==2.9.9

81
backend/schema.sql Normal file
View File

@ -0,0 +1,81 @@
-- Tasko Database Schema for PostgreSQL
-- Run this script as postgres superuser, then connect as tasko_user
-- Create database
CREATE DATABASE tasko_db;
-- Connect to the database
\c tasko_db
-- Create application user with limited privileges (best practice)
CREATE USER tasko_user WITH PASSWORD 'tasko_password';
-- Grant connection privilege
GRANT CONNECT ON DATABASE tasko_db TO tasko_user;
-- Create schema
CREATE SCHEMA IF NOT EXISTS public;
-- Grant schema usage
GRANT USAGE ON SCHEMA public TO tasko_user;
-- Create tables
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(64) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE tokens (
token VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE task_lists (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
name VARCHAR(200) NOT NULL,
icon VARCHAR(10) DEFAULT '📝',
color VARCHAR(7) DEFAULT '#667eea',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE tasks (
id VARCHAR(36) PRIMARY KEY,
list_id VARCHAR(36) NOT NULL,
user_id VARCHAR(36) NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
completed BOOLEAN DEFAULT FALSE NOT NULL,
priority VARCHAR(20) DEFAULT 'medium',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (list_id) REFERENCES task_lists(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Create indexes for better query performance
CREATE INDEX idx_tokens_user_id ON tokens(user_id);
CREATE INDEX idx_task_lists_user_id ON task_lists(user_id);
CREATE INDEX idx_tasks_list_id ON tasks(list_id);
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
CREATE INDEX idx_tasks_completed ON tasks(completed);
-- Grant privileges to tasko_user
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO tasko_user;
-- Grant sequence privileges (for any future auto-increment columns)
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO tasko_user;
-- Set default privileges for future tables
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO tasko_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO tasko_user;
-- Display summary
\dt
\du

BIN
backend/tasko.db Normal file

Binary file not shown.

76
backend/test_db.py Normal file
View File

@ -0,0 +1,76 @@
"""Test PostgreSQL connection and database setup"""
import sys
# Test 1: Check if psycopg2 is installed
try:
import psycopg2
print("✅ psycopg2 is installed")
except ImportError:
print("❌ psycopg2 is NOT installed")
print(" Run: pip install psycopg2-binary")
sys.exit(1)
# Test 2: Check SQLAlchemy import
try:
from sqlalchemy import create_engine
print("✅ SQLAlchemy is installed")
except ImportError:
print("❌ SQLAlchemy is NOT installed")
sys.exit(1)
# Test 3: Try to connect to PostgreSQL
try:
from database import engine, SessionLocal, User
print("✅ Database module imported successfully")
# Test connection
connection = engine.connect()
print("✅ Connected to PostgreSQL database")
connection.close()
except Exception as e:
print(f"❌ Failed to connect to database: {e}")
print("\nCheck:")
print(" 1. PostgreSQL is running")
print(" 2. Database 'tasko_db' exists")
print(" 3. User 'tasko_user' exists with correct password")
print(" 4. Run schema.sql first: psql -U postgres -f schema.sql")
sys.exit(1)
# Test 4: Check if tables exist
try:
from sqlalchemy import inspect
inspector = inspect(engine)
tables = inspector.get_table_names()
expected_tables = ['users', 'tokens', 'task_lists', 'tasks']
print(f"\n📋 Tables found: {tables}")
for table in expected_tables:
if table in tables:
print(f"✅ Table '{table}' exists")
else:
print(f"❌ Table '{table}' is missing")
if all(table in tables for table in expected_tables):
print("\n✅ All tables are present!")
else:
print("\n❌ Some tables are missing. Run schema.sql")
except Exception as e:
print(f"❌ Error checking tables: {e}")
sys.exit(1)
# Test 5: Try a simple query
try:
db = SessionLocal()
from database import User
user_count = db.query(User).count()
print(f"\n👥 Users in database: {user_count}")
db.close()
print("\n✅ Database is ready to use!")
except Exception as e:
print(f"❌ Error querying database: {e}")
sys.exit(1)

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
frontend/README.md Normal file
View File

@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
frontend/index.html Normal file
View 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>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2669
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^7.2.4"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

663
frontend/src/App.css Normal file
View File

@ -0,0 +1,663 @@
.app {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: 280px;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
box-shadow: 2px 0 20px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
padding: 1.5rem;
overflow-y: auto;
}
.sidebar-header {
margin-bottom: 2rem;
}
.sidebar-title {
font-size: 1.8rem;
font-weight: 700;
color: #667eea;
margin: 0 0 0.75rem 0;
}
.user-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: #f5f5f5;
border-radius: 10px;
}
.username {
font-size: 0.9rem;
font-weight: 600;
color: #666;
}
.logout-btn {
width: 32px;
height: 32px;
background: #ff4757;
color: white;
border: none;
border-radius: 8px;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.logout-btn:hover {
background: #ff3838;
transform: scale(1.1);
}
.lists-container {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: white;
border: 2px solid #f0f0f0;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
}
.list-item:hover {
border-color: #667eea;
transform: translateX(4px);
}
.list-item.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: transparent;
color: white;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.list-info {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.list-icon {
font-size: 1.5rem;
}
.list-item.active .list-icon {
filter: brightness(1.2);
}
.list-name {
font-size: 1rem;
font-weight: 600;
}
.list-delete-btn {
width: 24px;
height: 24px;
background: transparent;
border: none;
font-size: 1.5rem;
color: #999;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
padding: 0;
}
.list-delete-btn:hover {
background: rgba(255, 71, 87, 0.1);
color: #ff4757;
}
.list-item.active .list-delete-btn {
color: rgba(255, 255, 255, 0.7);
}
.list-item.active .list-delete-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.add-list-btn {
width: 100%;
padding: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.add-list-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.new-list-form {
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 60vh;
overflow-y: auto;
padding: 0.5rem;
}
.new-list-input {
padding: 0.75rem;
border: 2px solid #667eea;
border-radius: 8px;
font-size: 0.95rem;
outline: none;
}
.icon-picker-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.picker-label {
font-size: 0.85rem;
font-weight: 600;
color: #666;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 0.4rem;
}
.icon-option {
width: 32px;
height: 32px;
border: 2px solid #e0e0e0;
border-radius: 6px;
background: white;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.icon-option:hover {
border-color: #667eea;
transform: scale(1.1);
}
.icon-option.selected {
border-color: #667eea;
background: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
transform: scale(1.15);
}
.new-list-actions {
display: flex;
gap: 0.5rem;
}
.new-list-save,
.new-list-cancel {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.new-list-save {
background: #667eea;
color: white;
}
.new-list-save:hover {
background: #5568d3;
}
.new-list-cancel {
background: #f0f0f0;
color: #666;
}
.new-list-cancel:hover {
background: #e0e0e0;
}
/* Main Content */
.main-content {
flex: 1;
padding: 2rem;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.content-header {
margin-bottom: 2rem;
}
.content-title {
font-size: 2.5rem;
font-weight: 700;
color: white;
margin: 0;
display: flex;
align-items: center;
gap: 1rem;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.content-title span {
font-size: 3rem;
}
.task-form {
display: flex;
gap: 0.75rem;
margin-bottom: 2rem;
}
.task-input {
flex: 1;
padding: 1rem 1.25rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
font-size: 1rem;
transition: all 0.3s;
background: rgba(255, 255, 255, 0.95);
color: #333;
}
.task-input:focus {
outline: none;
border-color: white;
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
background: white;
}
.task-input::placeholder {
color: #999;
}
.add-button {
padding: 1rem 2rem;
background: white;
color: #667eea;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.add-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
.add-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.filter-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
background: rgba(255, 255, 255, 0.2);
padding: 0.5rem;
border-radius: 12px;
backdrop-filter: blur(10px);
}
.filter-tabs button {
flex: 1;
padding: 0.75rem 1rem;
background: transparent;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.3s;
}
.filter-tabs button.active {
background: white;
color: #667eea;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.task-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem;
background: rgba(255, 255, 255, 0.95);
border: 2px solid transparent;
border-radius: 12px;
transition: all 0.3s;
backdrop-filter: blur(10px);
}
.task-item:hover {
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateX(4px);
}
.task-content {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
}
.task-checkbox {
width: 24px;
height: 24px;
cursor: pointer;
accent-color: #667eea;
}
.task-title {
font-size: 1rem;
color: #333;
font-weight: 500;
cursor: pointer;
flex: 1;
}
.task-title:hover {
color: #667eea;
}
.task-item.completed .task-title {
text-decoration: line-through;
color: #999;
}
.task-edit-input {
flex: 1;
padding: 0.5rem;
border: 2px solid #667eea;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
outline: none;
background: white;
}
.task-actions {
display: flex;
gap: 0.5rem;
}
.edit-button,
.save-button,
.cancel-button,
.delete-button {
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
font-size: 1.2rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.edit-button {
background: #4facfe;
color: white;
}
.edit-button:hover {
background: #3a9ae5;
transform: scale(1.1);
}
.save-button {
background: #43e97b;
color: white;
}
.save-button:hover {
background: #32d66a;
transform: scale(1.1);
}
.cancel-button {
background: #feca57;
color: white;
}
.cancel-button:hover {
background: #e5b14f;
transform: scale(1.1);
}
.delete-button {
background: #ff4757;
color: white;
font-size: 1.5rem;
font-weight: 300;
}
.delete-button:hover {
background: #ff3838;
transform: scale(1.1);
}
.empty-state {
text-align: center;
color: rgba(255, 255, 255, 0.8);
padding: 3rem 1rem;
font-size: 1rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
backdrop-filter: blur(10px);
}
.empty-state-main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
text-align: center;
}
.empty-state-main h2 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.empty-state-main p {
font-size: 1.1rem;
opacity: 0.8;
}
/* Confirmation Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-content {
background: white;
border-radius: 20px;
padding: 2rem;
max-width: 400px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-title {
font-size: 1.5rem;
font-weight: 700;
color: #667eea;
margin: 0 0 1rem 0;
text-align: center;
}
.modal-message {
font-size: 1rem;
color: #666;
margin: 0 0 2rem 0;
text-align: center;
line-height: 1.6;
}
.modal-actions {
display: flex;
gap: 1rem;
}
.modal-confirm-btn,
.modal-cancel-btn {
flex: 1;
padding: 1rem;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.modal-confirm-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.modal-confirm-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
}
.modal-cancel-btn {
background: #f0f0f0;
color: #666;
}
.modal-cancel-btn:hover {
background: #e0e0e0;
}
@media (max-width: 768px) {
.app {
flex-direction: column;
}
.sidebar {
width: 100%;
max-height: 30vh;
}
.main-content {
padding: 1.5rem;
}
.content-title {
font-size: 2rem;
}
.content-title span {
font-size: 2.5rem;
}
.task-form {
flex-direction: column;
}
.add-button {
width: 100%;
}
}

500
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,500 @@
import { useState, useEffect } from 'react'
import Auth from './Auth'
import './App.css'
const API_URL = 'http://localhost:8001'
const AVAILABLE_ICONS = [
'📝', '💼', '🏠', '🛒', '🎯', '💪', '📚', '✈️',
'🎨', '🎮', '🎵', '🎬', '📱', '💻', '⚽', '🏃',
'🍕', '☕', '🌟', '❤️', '🔥', '✨', '🌈', '🎉',
'📖', '✍️', '🎓', '💡', '🔧', '🏆', '🎪', '🎭'
]
function App() {
const [user, setUser] = useState(null)
const [token, setToken] = useState(null)
const [lists, setLists] = useState([])
const [selectedList, setSelectedList] = useState(null)
const [tasks, setTasks] = useState([])
const [newTask, setNewTask] = useState('')
const [newListName, setNewListName] = useState('')
const [selectedIcon, setSelectedIcon] = useState('📝')
const [showNewListForm, setShowNewListForm] = useState(false)
const [filter, setFilter] = useState('all')
const [loading, setLoading] = useState(false)
const [editingTaskId, setEditingTaskId] = useState(null)
const [editingTaskTitle, setEditingTaskTitle] = useState('')
const [confirmModal, setConfirmModal] = useState({ show: false, message: '', onConfirm: null })
useEffect(() => {
// Check for stored token on mount
const storedToken = localStorage.getItem('token')
const storedUser = localStorage.getItem('user')
if (storedToken && storedUser) {
setToken(storedToken)
setUser(JSON.parse(storedUser))
}
}, [])
useEffect(() => {
if (token) {
fetchLists()
}
}, [token])
useEffect(() => {
if (selectedList && token) {
fetchTasks(selectedList.id)
}
}, [selectedList, token])
const getAuthHeaders = () => ({
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
})
const handleLogin = (userData, userToken) => {
setUser(userData)
setToken(userToken)
}
const handleLogout = async () => {
try {
await fetch(`${API_URL}/logout`, {
method: 'POST',
headers: getAuthHeaders()
})
} catch (error) {
console.error('Logout error:', error)
}
localStorage.removeItem('token')
localStorage.removeItem('user')
setUser(null)
setToken(null)
setLists([])
setTasks([])
setSelectedList(null)
}
const fetchLists = async () => {
try {
const response = await fetch(`${API_URL}/lists`, {
headers: getAuthHeaders()
})
const data = await response.json()
setLists(data)
if (data.length > 0 && !selectedList) {
setSelectedList(data[0])
}
} catch (error) {
console.error('Error fetching lists:', error)
}
}
const fetchTasks = async (listId) => {
try {
const response = await fetch(`${API_URL}/tasks?list_id=${listId}`, {
headers: getAuthHeaders()
})
const data = await response.json()
setTasks(data)
} catch (error) {
console.error('Error fetching tasks:', error)
}
}
const addList = async (e) => {
e.preventDefault()
if (!newListName.trim()) return
const colors = ['#667eea', '#f093fb', '#4facfe', '#43e97b', '#fa709a', '#feca57', '#ff6b6b', '#48dbfb']
try {
const response = await fetch(`${API_URL}/lists`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
name: newListName,
icon: selectedIcon,
color: colors[Math.floor(Math.random() * colors.length)]
})
})
const data = await response.json()
setLists([...lists, data])
setNewListName('')
setSelectedIcon('📝')
setShowNewListForm(false)
setSelectedList(data)
} catch (error) {
console.error('Error adding list:', error)
}
}
const deleteList = async (listId) => {
setConfirmModal({
show: true,
message: 'Delete this list and all its tasks?',
onConfirm: async () => {
try {
await fetch(`${API_URL}/lists/${listId}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
const updatedLists = lists.filter(list => list.id !== listId)
setLists(updatedLists)
if (selectedList?.id === listId) {
setSelectedList(updatedLists[0] || null)
setTasks([])
}
} catch (error) {
console.error('Error deleting list:', error)
}
setConfirmModal({ show: false, message: '', onConfirm: null })
}
})
}
const addTask = async (e) => {
e.preventDefault()
if (!newTask.trim() || !selectedList) return
setLoading(true)
try {
const response = await fetch(`${API_URL}/tasks`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ title: newTask, list_id: selectedList.id })
})
const data = await response.json()
setTasks([...tasks, data])
setNewTask('')
} catch (error) {
console.error('Error adding task:', error)
} finally {
setLoading(false)
}
}
const toggleTask = async (id, completed) => {
try {
const response = await fetch(`${API_URL}/tasks/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ completed: !completed })
})
const data = await response.json()
setTasks(tasks.map(task => task.id === id ? data : task))
} catch (error) {
console.error('Error updating task:', error)
}
}
const deleteTask = async (id) => {
try {
await fetch(`${API_URL}/tasks/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
setTasks(tasks.filter(task => task.id !== id))
} catch (error) {
console.error('Error deleting task:', error)
}
}
const startEditTask = (task) => {
setEditingTaskId(task.id)
setEditingTaskTitle(task.title)
}
const saveEditTask = async (id) => {
if (!editingTaskTitle.trim()) return
try {
const response = await fetch(`${API_URL}/tasks/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ title: editingTaskTitle })
})
const data = await response.json()
setTasks(tasks.map(task => task.id === id ? data : task))
setEditingTaskId(null)
setEditingTaskTitle('')
} catch (error) {
console.error('Error updating task:', error)
}
}
const cancelEditTask = () => {
setEditingTaskId(null)
setEditingTaskTitle('')
}
const filteredTasks = tasks.filter(task => {
if (filter === 'active') return !task.completed
if (filter === 'completed') return task.completed
return true
})
const activeTasks = tasks.filter(t => !t.completed).length
if (!user || !token) {
return <Auth onLogin={handleLogin} />
}
return (
<div className="app">
<div className="sidebar">
<div className="sidebar-header">
<h2 className="sidebar-title"> Tasko</h2>
<div className="user-info">
<span className="username">{user.username}</span>
<button onClick={handleLogout} className="logout-btn" title="Logout">
🚪
</button>
</div>
</div>
<div className="lists-container">
{lists.map(list => (
<div
key={list.id}
className={`list-item ${selectedList?.id === list.id ? 'active' : ''}`}
onClick={() => setSelectedList(list)}
>
<div className="list-info">
<span className="list-icon" style={{ color: list.color }}>{list.icon}</span>
<span className="list-name">{list.name}</span>
</div>
<button
onClick={(e) => {
e.stopPropagation()
deleteList(list.id)
}}
className="list-delete-btn"
title="Delete list"
>
×
</button>
</div>
))}
</div>
{showNewListForm ? (
<form onSubmit={addList} className="new-list-form">
<input
type="text"
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
placeholder="List name..."
className="new-list-input"
autoFocus
/>
<div className="icon-picker-section">
<label className="picker-label">Choose Icon:</label>
<div className="icon-grid">
{AVAILABLE_ICONS.map(icon => (
<button
key={icon}
type="button"
className={`icon-option ${selectedIcon === icon ? 'selected' : ''}`}
onClick={() => setSelectedIcon(icon)}
>
{icon}
</button>
))}
</div>
</div>
<div className="new-list-actions">
<button type="submit" className="new-list-save">
{selectedIcon} Save
</button>
<button
type="button"
onClick={() => {
setShowNewListForm(false)
setNewListName('')
setSelectedIcon('📝')
}}
className="new-list-cancel"
>
Cancel
</button>
</div>
</form>
) : (
<button
onClick={() => setShowNewListForm(true)}
className="add-list-btn"
>
+ New List
</button>
)}
</div>
<div className="main-content">
{selectedList ? (
<>
<div className="content-header">
<h1 className="content-title">
<span style={{ color: selectedList.color }}>{selectedList.icon}</span>
{selectedList.name}
</h1>
</div>
<form onSubmit={addTask} className="task-form">
<input
type="text"
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="What needs to be done?"
className="task-input"
disabled={loading}
/>
<button type="submit" className="add-button" disabled={loading}>
{loading ? '...' : 'Add Task'}
</button>
</form>
<div className="filter-tabs">
<button
className={filter === 'all' ? 'active' : ''}
onClick={() => setFilter('all')}
>
All ({tasks.length})
</button>
<button
className={filter === 'active' ? 'active' : ''}
onClick={() => setFilter('active')}
>
Active ({activeTasks})
</button>
<button
className={filter === 'completed' ? 'active' : ''}
onClick={() => setFilter('completed')}
>
Completed ({tasks.length - activeTasks})
</button>
</div>
<div className="task-list">
{filteredTasks.length === 0 ? (
<p className="empty-state">
{filter === 'completed' ? 'No completed tasks yet' :
filter === 'active' ? 'No active tasks' :
'No tasks yet. Add one above!'}
</p>
) : (
filteredTasks.map(task => (
<div key={task.id} className={`task-item ${task.completed ? 'completed' : ''}`}>
<div className="task-content">
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id, task.completed)}
className="task-checkbox"
/>
{editingTaskId === task.id ? (
<input
type="text"
value={editingTaskTitle}
onChange={(e) => setEditingTaskTitle(e.target.value)}
className="task-edit-input"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') saveEditTask(task.id)
if (e.key === 'Escape') cancelEditTask()
}}
/>
) : (
<span
className="task-title"
onDoubleClick={() => startEditTask(task)}
title="Double-click to edit"
>
{task.title}
</span>
)}
</div>
<div className="task-actions">
{editingTaskId === task.id ? (
<>
<button
onClick={() => saveEditTask(task.id)}
className="save-button"
title="Save"
>
</button>
<button
onClick={cancelEditTask}
className="cancel-button"
title="Cancel"
>
</button>
</>
) : (
<>
<button
onClick={() => startEditTask(task)}
className="edit-button"
title="Edit task"
>
</button>
<button
onClick={() => deleteTask(task.id)}
className="delete-button"
title="Delete task"
>
×
</button>
</>
)}
</div>
</div>
))
)}
</div>
</>
) : (
<div className="empty-state-main">
<h2>No lists yet</h2>
<p>Create a new list to get started!</p>
</div>
)}
</div>
{/* Confirmation Modal */}
{confirmModal.show && (
<div className="modal-overlay" onClick={() => setConfirmModal({ show: false, message: '', onConfirm: null })}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3 className="modal-title">Confirm Action</h3>
<p className="modal-message">{confirmModal.message}</p>
<div className="modal-actions">
<button
className="modal-confirm-btn"
onClick={confirmModal.onConfirm}
>
Confirm
</button>
<button
className="modal-cancel-btn"
onClick={() => setConfirmModal({ show: false, message: '', onConfirm: null })}
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default App

142
frontend/src/Auth.css Normal file
View File

@ -0,0 +1,142 @@
.auth-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.auth-box {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 3rem;
width: 100%;
max-width: 450px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.auth-title {
font-size: 3rem;
font-weight: 700;
color: #667eea;
margin: 0 0 0.5rem 0;
text-align: center;
}
.auth-subtitle {
text-align: center;
color: #666;
margin: 0 0 2rem 0;
font-size: 1rem;
font-weight: 500;
}
.auth-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
background: #f5f5f5;
padding: 0.5rem;
border-radius: 12px;
}
.auth-tabs button {
flex: 1;
padding: 0.75rem 1rem;
background: transparent;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
color: #666;
cursor: pointer;
transition: all 0.3s;
}
.auth-tabs button.active {
background: white;
color: #667eea;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.auth-error {
padding: 1rem;
background: #ffe0e0;
border: 2px solid #ff4757;
border-radius: 10px;
color: #c0392b;
text-align: center;
font-weight: 500;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.9rem;
font-weight: 600;
color: #333;
}
.form-group input {
padding: 1rem;
border: 2px solid #e0e0e0;
border-radius: 12px;
font-size: 1rem;
transition: all 0.3s;
outline: none;
}
.form-group input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.auth-submit {
padding: 1rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.auth-submit:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
}
.auth-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 640px) {
.auth-box {
padding: 2rem;
}
.auth-title {
font-size: 2.5rem;
}
}

138
frontend/src/Auth.jsx Normal file
View File

@ -0,0 +1,138 @@
import { useState } from 'react'
import './Auth.css'
const API_URL = 'http://localhost:8001'
function Auth({ onLogin }) {
const [isLogin, setIsLogin] = useState(true)
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (isLogin) {
if (!email.trim() || !password.trim()) {
setError('Please fill in all fields')
return
}
} else {
if (!username.trim() || !email.trim() || !password.trim()) {
setError('Please fill in all fields')
return
}
}
setLoading(true)
try {
const endpoint = isLogin ? '/login' : '/register'
const payload = isLogin
? { email, password }
: { username, email, password }
const response = await fetch(`${API_URL}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const data = await response.json()
if (!response.ok) {
setError(data.detail || 'An error occurred')
return
}
// Store token and user info
localStorage.setItem('token', data.token)
localStorage.setItem('user', JSON.stringify(data.user))
onLogin(data.user, data.token)
} catch (error) {
setError('Connection error. Please try again.')
console.error('Auth error:', error)
} finally {
setLoading(false)
}
}
return (
<div className="auth-container">
<div className="auth-box">
<h1 className="auth-title"> Tasko</h1>
<p className="auth-subtitle">Modern Task Management</p>
<div className="auth-tabs">
<button
className={isLogin ? 'active' : ''}
onClick={() => {
setIsLogin(true)
setError('')
}}
>
Login
</button>
<button
className={!isLogin ? 'active' : ''}
onClick={() => {
setIsLogin(false)
setError('')
}}
>
Register
</button>
</div>
<form onSubmit={handleSubmit} className="auth-form">
{error && <div className="auth-error">{error}</div>}
{!isLogin && (
<div className="form-group">
<label>Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
disabled={loading}
/>
</div>
)}
<div className="form-group">
<label>Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
disabled={loading}
/>
</div>
<div className="form-group">
<label>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
disabled={loading}
/>
</div>
<button type="submit" className="auth-submit" disabled={loading}>
{loading ? 'Please wait...' : (isLogin ? 'Login' : 'Create Account')}
</button>
</form>
</div>
</div>
)
}
export default Auth

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

25
frontend/src/index.css Normal file
View File

@ -0,0 +1,25 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
}
#root {
min-height: 100vh;
}

10
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

7
frontend/vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})