Initial commit

This commit is contained in:
Kyle 2026-02-15 16:13:41 +08:00
commit 1f6519635f
113 changed files with 7952 additions and 0 deletions

8
.env.example Normal file
View File

@ -0,0 +1,8 @@
# Database
POSTGRES_USER=lifemanager
POSTGRES_PASSWORD=changeme_in_production
POSTGRES_DB=lifemanager
# Backend
DATABASE_URL=postgresql+asyncpg://lifemanager:changeme_in_production@db:5432/lifemanager
SECRET_KEY=change-this-to-a-random-secret-key-in-production

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# Environment
.env
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Docker
docker-compose.override.yaml

113
CLAUDE.md Normal file
View File

@ -0,0 +1,113 @@
# CLAUDE.md - LifeManager
## Hard Rules
- **Naive datetimes only.** The DB uses `TIMESTAMP WITHOUT TIME ZONE`. Never send timezone-aware strings (no `Z` suffix, no `.toISOString()`). Use local datetime formatting helpers instead.
- **Eager load relationships in async SQLAlchemy.** Any `response_model` that includes a relationship (e.g. `ProjectResponse.tasks`) must use `selectinload()` when querying. Lazy loading raises `MissingGreenlet` in async context.
- **Never shadow SQLAlchemy names.** Model columns must not be named `relationship`, `column`, `metadata`, or other SQLAlchemy reserved names. The `person.py` model aliases the import as `sa_relationship` for this reason.
- **Frontend date inputs require exact formats.** `<input type="date">` needs `YYYY-MM-DD`, `<input type="datetime-local">` needs `YYYY-MM-DDThh:mm`. Backend may return `2026-02-15T00:00:00` which must be sliced/converted before binding.
- **All API routes are prefixed with `/api`.** Frontend axios base URL is `/api`. Nginx proxies `/api/` to backend:8000. Never duplicate the prefix.
## Tech Stack
### Backend
- **Python 3.12** (slim Docker image - no curl, use `urllib` for healthchecks)
- **FastAPI** with async lifespan, Pydantic v2 (`model_dump()`, `ConfigDict(from_attributes=True)`)
- **SQLAlchemy 2.0** async with `Mapped[]` type hints, `mapped_column()`, `async_sessionmaker`
- **PostgreSQL 16** (Alpine) via `asyncpg`
- **Alembic** for migrations
- **Auth:** PIN-based with bcrypt + itsdangerous signed cookies (not JWT)
### Frontend
- **React 18** + TypeScript + Vite 6
- **TanStack Query v5** for server state (`useQuery`, `useMutation`, `invalidateQueries`)
- **FullCalendar 6** (dayGrid, timeGrid, interaction plugins)
- **Tailwind CSS 3** with custom dark theme + accent color CSS variables
- **Sonner** for toast notifications
- **Lucide React** for icons
- Custom shadcn/ui-style components in `frontend/src/components/ui/`
### Infrastructure
- **Docker Compose** - 3 services: `db`, `backend`, `frontend`
- **Nginx** (Alpine) serves frontend SPA, proxies `/api/` to backend
- Frontend served on port **80**, backend on port **8000**
## Authority Links
- [progress.md](progress.md) - Project tracker, milestone status, fix history, outstanding items
## Project Structure
```
backend/app/
main.py # FastAPI app, router registration, health endpoint
config.py # Pydantic BaseSettings (DATABASE_URL, SECRET_KEY)
database.py # Async engine, session factory, get_db dependency
models/ # SQLAlchemy 2.0 models (Mapped[] style)
schemas/ # Pydantic v2 request/response schemas
routers/ # One router per feature (auth, todos, events, etc.)
frontend/src/
App.tsx # Routes + ProtectedRoute wrapper
lib/api.ts # Axios instance + getErrorMessage helper
hooks/ # useAuth, useSettings, useTheme
types/index.ts # TypeScript interfaces matching backend schemas
components/
ui/ # 12 base components (Button, Dialog, Input, etc.)
layout/ # AppLayout + Sidebar
auth/ # LockScreen (PIN setup/login)
dashboard/ # DashboardPage + widgets
calendar/ # CalendarPage + EventForm
todos/ # TodosPage + TodoList + TodoItem + TodoForm
reminders/ # RemindersPage + ReminderList + ReminderForm
projects/ # ProjectsPage + ProjectCard + ProjectDetail + forms
people/ # PeoplePage + PersonCard + PersonForm
locations/ # LocationsPage + LocationCard + LocationForm
settings/ # SettingsPage (accent color, PIN change)
```
## Essential Commands
```bash
# Build and run all services
docker-compose up --build
# Rebuild single service after changes
docker-compose up --build backend
docker-compose up --build frontend
# View logs
docker-compose logs -f
docker-compose logs -f backend
# Reset database (destroys all data)
docker-compose down -v && docker-compose up --build
# Stop everything
docker-compose down
```
## API Routes
All routes require auth (signed cookie) except `/api/auth/*` and `/health`.
| Prefix | Resource |
|--------------------|-----------------|
| `/api/auth` | PIN setup/login/logout/status |
| `/api/todos` | Todos CRUD + toggle |
| `/api/events` | Calendar events CRUD |
| `/api/reminders` | Reminders CRUD + dismiss |
| `/api/projects` | Projects + nested tasks CRUD |
| `/api/people` | People CRUD |
| `/api/locations` | Locations CRUD |
| `/api/settings` | Settings + PIN change |
| `/api/dashboard` | Dashboard aggregation |
| `/api/upcoming` | Unified upcoming items |
## Stop Conditions
- **Do not** add timezone info to datetime strings sent to the backend
- **Do not** use `datetime.utcnow()` - use `datetime.now(timezone.utc)` instead (deprecated in 3.12)
- **Do not** return relationships from async endpoints without `selectinload()`
- **Do not** use `curl` in backend Docker healthchecks (not available in python:slim)
- **Do not** use `git push --force` or destructive git operations without explicit approval

194
README.md Normal file
View File

@ -0,0 +1,194 @@
# LifeManager
A self-hosted personal life administration app with a dark-themed UI. Manage your todos, calendar events, projects, reminders, contacts, and locations from a single dashboard.
## Features
- **Dashboard** - At-a-glance overview with today's events, upcoming todos, active reminders, and project stats
- **Todos** - Task management with priorities, due dates, and completion tracking
- **Calendar** - Full interactive calendar (month/week/day views) with drag-and-drop event rescheduling
- **Projects** - Project boards with nested task lists, status tracking, and progress indicators
- **Reminders** - Time-based reminders with dismiss functionality
- **People** - Contact directory with relationship tracking and task assignment
- **Locations** - Location management with categories
- **Settings** - Customizable accent color, upcoming days range, and PIN management
## Screenshots
*Coming soon*
## Tech Stack
| Layer | Technology |
|--------------|------------|
| Frontend | React 18, TypeScript, Vite, Tailwind CSS |
| UI Components | Custom shadcn/ui-style components, FullCalendar, Lucide icons |
| State | TanStack Query v5, React Router v6 |
| Backend | FastAPI, Python 3.12, Pydantic v2 |
| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic |
| Auth | PIN-based with bcrypt + signed cookies |
| Deployment | Docker Compose (3 services), Nginx reverse proxy |
## Quick Start
### Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
### Setup
1. **Clone the repository**
```bash
git clone https://your-gitea-instance/youruser/lifemanager.git
cd lifemanager
```
2. **Configure environment variables**
```bash
cp .env.example .env
```
Edit `.env` and set secure values:
```env
POSTGRES_USER=lifemanager
POSTGRES_PASSWORD=your-secure-password
POSTGRES_DB=lifemanager
DATABASE_URL=postgresql+asyncpg://lifemanager:your-secure-password@db:5432/lifemanager
SECRET_KEY=your-random-secret-key
```
3. **Build and run**
```bash
docker-compose up --build
```
4. **Open the app**
Navigate to `http://localhost` in your browser. On first launch you'll be prompted to create a PIN.
## Architecture
```
+-----------+
| Browser |
+-----+-----+
|
port 80 (HTTP)
|
+-------+-------+
| Nginx |
| (frontend) |
+---+-------+---+
| |
static | | /api/*
files | |
v v
+---+-------+---+
| FastAPI |
| (backend) |
| port 8000 |
+-------+-------+
|
+-------+-------+
| PostgreSQL |
| (db) |
| port 5432 |
+---------------+
```
- **Frontend** is built as static files and served by Nginx. Nginx also reverse-proxies API requests to the backend.
- **Backend** runs Alembic migrations on startup, then serves the FastAPI application.
- **Database** uses a named Docker volume (`postgres_data`) for persistence.
## API Overview
All endpoints require authentication (signed session cookie) except auth routes and the health check.
| Endpoint | Description |
|--------------------|-------------|
| `GET /health` | Health check |
| `/api/auth/*` | PIN setup, login, logout, status |
| `/api/todos/*` | Todos CRUD + toggle completion |
| `/api/events/*` | Calendar events CRUD |
| `/api/reminders/*` | Reminders CRUD + dismiss |
| `/api/projects/*` | Projects + nested tasks CRUD |
| `/api/people/*` | People CRUD |
| `/api/locations/*` | Locations CRUD |
| `/api/settings/*` | App settings + PIN change |
| `/api/dashboard` | Dashboard aggregation |
| `/api/upcoming` | Unified upcoming items feed |
Full API documentation is available at `http://localhost:8000/docs` (Swagger UI) when the backend is running.
## Development
### Rebuild a single service
```bash
docker-compose up --build backend # Backend only
docker-compose up --build frontend # Frontend only
```
### View logs
```bash
docker-compose logs -f # All services
docker-compose logs -f backend # Backend only
```
### Reset database
```bash
docker-compose down -v && docker-compose up --build
```
### Stop all services
```bash
docker-compose down
```
## Project Structure
```
lifemanager/
├── docker-compose.yaml
├── .env / .env.example
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── start.sh
│ ├── alembic.ini
│ ├── alembic/ # Database migrations
│ └── app/
│ ├── main.py # FastAPI app entry point
│ ├── config.py # Environment settings
│ ├── database.py # Async SQLAlchemy setup
│ ├── models/ # SQLAlchemy ORM models
│ ├── schemas/ # Pydantic request/response schemas
│ └── routers/ # API route handlers
└── frontend/
├── Dockerfile
├── nginx.conf
├── package.json
└── src/
├── App.tsx # Routes and auth guard
├── lib/api.ts # Axios client
├── hooks/ # Auth, settings, theme hooks
├── types/ # TypeScript interfaces
└── components/
├── ui/ # Base UI components
├── layout/ # App shell and sidebar
├── auth/ # PIN login screen
├── dashboard/ # Dashboard and widgets
├── calendar/ # Calendar and event form
├── todos/ # Todo management
├── reminders/ # Reminder management
├── projects/ # Project boards and tasks
├── people/ # Contact directory
├── locations/ # Location management
└── settings/ # App settings
```
## License
This project is for personal use. Feel free to fork and adapt for your own needs.

2
backend/.env.example Normal file
View File

@ -0,0 +1,2 @@
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/lifemanager
SECRET_KEY=your-secret-key-change-in-production-use-a-long-random-string

47
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,47 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Environment variables
.env
.env.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
# OS
.DS_Store
Thumbs.db

22
backend/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Expose port
EXPOSE 8000
# Run migrations and start server
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]

200
backend/README.md Normal file
View File

@ -0,0 +1,200 @@
# LifeManager Backend
A complete FastAPI backend for the LifeManager application with async SQLAlchemy, PostgreSQL, authentication, and comprehensive CRUD operations.
## Features
- **FastAPI** with async/await support
- **SQLAlchemy 2.0** with async engine
- **PostgreSQL** with asyncpg driver
- **Alembic** for database migrations
- **bcrypt** for password hashing
- **itsdangerous** for session management
- **PIN-based authentication** with secure session cookies
- **Full CRUD operations** for all entities
- **Dashboard** with aggregated data
- **CORS enabled** for frontend integration
## Project Structure
```
backend/
├── alembic/ # Database migrations
│ ├── versions/ # Migration files
│ ├── env.py # Alembic environment
│ └── script.py.mako # Migration template
├── app/
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic schemas
│ ├── routers/ # API route handlers
│ ├── config.py # Configuration
│ ├── database.py # Database setup
│ └── main.py # FastAPI application
├── requirements.txt # Python dependencies
├── Dockerfile # Docker configuration
├── alembic.ini # Alembic configuration
└── start.sh # Startup script
```
## Setup
### 1. Install Dependencies
```bash
cd backend
pip install -r requirements.txt
```
### 2. Configure Environment
Create a `.env` file:
```bash
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/lifemanager
SECRET_KEY=your-secret-key-change-in-production
```
### 3. Create Database
```bash
createdb lifemanager
```
### 4. Run Migrations
```bash
alembic upgrade head
```
### 5. Start Server
```bash
# Using the start script
chmod +x start.sh
./start.sh
# Or directly with uvicorn
uvicorn app.main:app --reload
```
The API will be available at `http://localhost:8000`
## API Documentation
Interactive API documentation is available at:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
## API Endpoints
### Authentication
- `POST /api/auth/setup` - Initial PIN setup
- `POST /api/auth/login` - Login with PIN
- `POST /api/auth/logout` - Logout
- `GET /api/auth/status` - Check auth status
### Todos
- `GET /api/todos` - List todos (with filters)
- `POST /api/todos` - Create todo
- `GET /api/todos/{id}` - Get todo
- `PUT /api/todos/{id}` - Update todo
- `DELETE /api/todos/{id}` - Delete todo
- `PATCH /api/todos/{id}/toggle` - Toggle completion
### Calendar Events
- `GET /api/events` - List events (with date range)
- `POST /api/events` - Create event
- `GET /api/events/{id}` - Get event
- `PUT /api/events/{id}` - Update event
- `DELETE /api/events/{id}` - Delete event
### Reminders
- `GET /api/reminders` - List reminders (with filters)
- `POST /api/reminders` - Create reminder
- `GET /api/reminders/{id}` - Get reminder
- `PUT /api/reminders/{id}` - Update reminder
- `DELETE /api/reminders/{id}` - Delete reminder
- `PATCH /api/reminders/{id}/dismiss` - Dismiss reminder
### Projects
- `GET /api/projects` - List projects
- `POST /api/projects` - Create project
- `GET /api/projects/{id}` - Get project
- `PUT /api/projects/{id}` - Update project
- `DELETE /api/projects/{id}` - Delete project
- `GET /api/projects/{id}/tasks` - List project tasks
- `POST /api/projects/{id}/tasks` - Create project task
- `PUT /api/projects/{id}/tasks/{task_id}` - Update task
- `DELETE /api/projects/{id}/tasks/{task_id}` - Delete task
### People
- `GET /api/people` - List people (with search)
- `POST /api/people` - Create person
- `GET /api/people/{id}` - Get person
- `PUT /api/people/{id}` - Update person
- `DELETE /api/people/{id}` - Delete person
### Locations
- `GET /api/locations` - List locations (with category filter)
- `POST /api/locations` - Create location
- `GET /api/locations/{id}` - Get location
- `PUT /api/locations/{id}` - Update location
- `DELETE /api/locations/{id}` - Delete location
### Settings
- `GET /api/settings` - Get settings
- `PUT /api/settings` - Update settings
- `PUT /api/settings/pin` - Change PIN
### Dashboard
- `GET /api/dashboard` - Get dashboard data
- `GET /api/upcoming?days=7` - Get upcoming items
## Database Schema
The application uses the following tables:
- `settings` - Application settings and PIN
- `todos` - Task items
- `calendar_events` - Calendar events
- `reminders` - Reminders
- `projects` - Projects
- `project_tasks` - Tasks within projects
- `people` - Contacts/people
- `locations` - Physical locations
## Docker
Build and run with Docker:
```bash
docker build -t lifemanager-backend .
docker run -p 8000:8000 -e DATABASE_URL=... -e SECRET_KEY=... lifemanager-backend
```
## Development
### Create New Migration
```bash
alembic revision --autogenerate -m "Description of changes"
```
### Apply Migrations
```bash
alembic upgrade head
```
### Rollback Migration
```bash
alembic downgrade -1
```
## Security Notes
- Change `SECRET_KEY` in production
- Use strong PINs (minimum 4 digits recommended)
- Session cookies are httpOnly and last 30 days
- All API endpoints (except auth) require authentication
- PINs are hashed with bcrypt before storage

114
backend/alembic.ini Normal file
View File

@ -0,0 +1,114 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

99
backend/alembic/env.py Normal file
View File

@ -0,0 +1,99 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import app config and models
from app.config import settings
from app.database import Base
from app.models import (
Settings,
Todo,
CalendarEvent,
Reminder,
Project,
ProjectTask,
Person,
Location,
)
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# Set database URL from app config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,173 @@
"""Initial migration - create all tables
Revision ID: 001
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create settings table
op.create_table(
'settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('pin_hash', sa.String(length=255), nullable=False),
sa.Column('accent_color', sa.String(length=20), nullable=False, server_default='cyan'),
sa.Column('upcoming_days', sa.Integer(), nullable=False, server_default='7'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_settings_id'), 'settings', ['id'], unique=False)
# Create people table
op.create_table(
'people',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('birthday', sa.Date(), nullable=True),
sa.Column('relationship', sa.String(length=100), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_people_id'), 'people', ['id'], unique=False)
# Create locations table
op.create_table(
'locations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('address', sa.Text(), nullable=False),
sa.Column('category', sa.String(length=100), nullable=False, server_default='other'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_locations_id'), 'locations', ['id'], unique=False)
# Create projects table
op.create_table(
'projects',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False, server_default='not_started'),
sa.Column('color', sa.String(length=20), nullable=True),
sa.Column('due_date', sa.Date(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_projects_id'), 'projects', ['id'], unique=False)
# Create reminders table
op.create_table(
'reminders',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('remind_at', sa.DateTime(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('is_dismissed', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('recurrence_rule', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_reminders_id'), 'reminders', ['id'], unique=False)
# Create calendar_events table
op.create_table(
'calendar_events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('start_datetime', sa.DateTime(), nullable=False),
sa.Column('end_datetime', sa.DateTime(), nullable=False),
sa.Column('all_day', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('color', sa.String(length=20), nullable=True),
sa.Column('location_id', sa.Integer(), nullable=True),
sa.Column('recurrence_rule', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.ForeignKeyConstraint(['location_id'], ['locations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_calendar_events_id'), 'calendar_events', ['id'], unique=False)
# Create todos table
op.create_table(
'todos',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('priority', sa.String(length=20), nullable=False, server_default='medium'),
sa.Column('due_date', sa.Date(), nullable=True),
sa.Column('completed', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('category', sa.String(length=100), nullable=True),
sa.Column('recurrence_rule', sa.String(length=255), nullable=True),
sa.Column('project_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_todos_id'), 'todos', ['id'], unique=False)
# Create project_tasks table
op.create_table(
'project_tasks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False, server_default='pending'),
sa.Column('priority', sa.String(length=20), nullable=False, server_default='medium'),
sa.Column('due_date', sa.Date(), nullable=True),
sa.Column('person_id', sa.Integer(), nullable=True),
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.ForeignKeyConstraint(['person_id'], ['people.id'], ),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_project_tasks_id'), 'project_tasks', ['id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_project_tasks_id'), table_name='project_tasks')
op.drop_table('project_tasks')
op.drop_index(op.f('ix_todos_id'), table_name='todos')
op.drop_table('todos')
op.drop_index(op.f('ix_calendar_events_id'), table_name='calendar_events')
op.drop_table('calendar_events')
op.drop_index(op.f('ix_reminders_id'), table_name='reminders')
op.drop_table('reminders')
op.drop_index(op.f('ix_projects_id'), table_name='projects')
op.drop_table('projects')
op.drop_index(op.f('ix_locations_id'), table_name='locations')
op.drop_table('locations')
op.drop_index(op.f('ix_people_id'), table_name='people')
op.drop_table('people')
op.drop_index(op.f('ix_settings_id'), table_name='settings')
op.drop_table('settings')

0
backend/app/__init__.py Normal file
View File

15
backend/app/config.py Normal file
View File

@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/lifemanager"
SECRET_KEY: str = "your-secret-key-change-in-production"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True
)
settings = Settings()

33
backend/app/database.py Normal file
View File

@ -0,0 +1,33 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
from app.config import settings
# Create async engine
engine = create_async_engine(
settings.DATABASE_URL,
echo=True,
future=True
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False
)
# Base class for models
Base = declarative_base()
# Dependency to get database session
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise

54
backend/app/main.py Normal file
View File

@ -0,0 +1,54 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.database import engine, Base
from app.routers import auth, todos, events, reminders, projects, people, locations, settings as settings_router, dashboard
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown: Clean up resources
await engine.dispose()
app = FastAPI(
title="LifeManager API",
description="Backend API for LifeManager application",
version="1.0.0",
lifespan=lifespan
)
# CORS configuration for development
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers with /api prefix
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
app.include_router(todos.router, prefix="/api/todos", tags=["Todos"])
app.include_router(events.router, prefix="/api/events", tags=["Calendar Events"])
app.include_router(reminders.router, prefix="/api/reminders", tags=["Reminders"])
app.include_router(projects.router, prefix="/api/projects", tags=["Projects"])
app.include_router(people.router, prefix="/api/people", tags=["People"])
app.include_router(locations.router, prefix="/api/locations", tags=["Locations"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"])
app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
@app.get("/")
async def root():
return {"message": "LifeManager API is running"}
@app.get("/health")
async def health_check():
return {"status": "healthy"}

View File

@ -0,0 +1,19 @@
from app.models.settings import Settings
from app.models.todo import Todo
from app.models.calendar_event import CalendarEvent
from app.models.reminder import Reminder
from app.models.project import Project
from app.models.project_task import ProjectTask
from app.models.person import Person
from app.models.location import Location
__all__ = [
"Settings",
"Todo",
"CalendarEvent",
"Reminder",
"Project",
"ProjectTask",
"Person",
"Location",
]

View File

@ -0,0 +1,24 @@
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional
from app.database import Base
class CalendarEvent(Base):
__tablename__ = "calendar_events"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
start_datetime: Mapped[datetime] = mapped_column(nullable=False)
end_datetime: Mapped[datetime] = mapped_column(nullable=False)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
location_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("locations.id"), nullable=True)
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships
location: Mapped[Optional["Location"]] = relationship(back_populates="events")

View File

@ -0,0 +1,20 @@
from sqlalchemy import String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional, List
from app.database import Base
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
address: Mapped[str] = mapped_column(Text, nullable=False)
category: Mapped[str] = mapped_column(String(100), default="other")
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships
events: Mapped[List["CalendarEvent"]] = relationship(back_populates="location")

View File

@ -0,0 +1,23 @@
from sqlalchemy import String, Text, Date, func
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
from datetime import datetime, date
from typing import Optional, List
from app.database import Base
class Person(Base):
__tablename__ = "people"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
address: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
birthday: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
relationship: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships
assigned_tasks: Mapped[List["ProjectTask"]] = sa_relationship(back_populates="person")

View File

@ -0,0 +1,22 @@
from sqlalchemy import String, Text, Date, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, date
from typing import Optional, List
from app.database import Base
class Project(Base):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="not_started")
color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships
tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan")
todos: Mapped[List["Todo"]] = relationship(back_populates="project")

View File

@ -0,0 +1,25 @@
from sqlalchemy import String, Text, Integer, Date, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, date
from typing import Optional
from app.database import Base
class ProjectTask(Base):
__tablename__ = "project_tasks"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="pending")
priority: Mapped[str] = mapped_column(String(20), default="medium")
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
person_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("people.id"), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships
project: Mapped["Project"] = relationship(back_populates="tasks")
person: Mapped[Optional["Person"]] = relationship(back_populates="assigned_tasks")

View File

@ -0,0 +1,19 @@
from sqlalchemy import String, Text, Boolean, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
from app.database import Base
class Reminder(Base):
__tablename__ = "reminders"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
remind_at: Mapped[datetime] = mapped_column(nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_dismissed: Mapped[bool] = mapped_column(Boolean, default=False)
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -0,0 +1,15 @@
from sqlalchemy import String, Integer, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
class Settings(Base):
__tablename__ = "settings"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
pin_hash: Mapped[str] = mapped_column(String(255), nullable=False)
accent_color: Mapped[str] = mapped_column(String(20), default="cyan")
upcoming_days: Mapped[int] = mapped_column(Integer, default=7)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -0,0 +1,25 @@
from sqlalchemy import String, Text, Boolean, Date, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, date
from typing import Optional
from app.database import Base
class Todo(Base):
__tablename__ = "todos"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
priority: Mapped[str] = mapped_column(String(20), default="medium")
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
completed: Mapped[bool] = mapped_column(Boolean, default=False)
completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
project_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("projects.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships
project: Mapped[Optional["Project"]] = relationship(back_populates="todos")

View File

@ -0,0 +1,13 @@
from app.routers import auth, todos, events, reminders, projects, people, locations, settings, dashboard
__all__ = [
"auth",
"todos",
"events",
"reminders",
"projects",
"people",
"locations",
"settings",
"dashboard",
]

151
backend/app/routers/auth.py Normal file
View File

@ -0,0 +1,151 @@
from fastapi import APIRouter, Depends, HTTPException, Response, Cookie
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
import bcrypt
from itsdangerous import TimestampSigner, BadSignature
from app.database import get_db
from app.models.settings import Settings
from app.schemas.settings import SettingsCreate
from app.config import settings as app_settings
router = APIRouter()
# Initialize signer for session management
signer = TimestampSigner(app_settings.SECRET_KEY)
def hash_pin(pin: str) -> str:
"""Hash a PIN using bcrypt."""
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
def verify_pin(pin: str, hashed: str) -> bool:
"""Verify a PIN against its hash."""
return bcrypt.checkpw(pin.encode(), hashed.encode())
def create_session_token(user_id: int) -> str:
"""Create a signed session token."""
return signer.sign(str(user_id)).decode()
def verify_session_token(token: str) -> Optional[int]:
"""Verify and extract user ID from session token."""
try:
unsigned = signer.unsign(token, max_age=86400 * 30) # 30 days
return int(unsigned)
except (BadSignature, ValueError):
return None
async def get_current_session(
session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db)
) -> Settings:
"""Dependency to verify session and return current settings."""
if not session_cookie:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = verify_session_token(session_cookie)
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid or expired session")
result = await db.execute(select(Settings).where(Settings.id == user_id))
settings_obj = result.scalar_one_or_none()
if not settings_obj:
raise HTTPException(status_code=401, detail="Session invalid")
return settings_obj
@router.post("/setup")
async def setup_pin(
data: SettingsCreate,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""Create initial PIN. Only works if no settings exist."""
result = await db.execute(select(Settings))
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Setup already completed")
pin_hash = hash_pin(data.pin)
new_settings = Settings(pin_hash=pin_hash)
db.add(new_settings)
await db.commit()
await db.refresh(new_settings)
# Create session
token = create_session_token(new_settings.id)
response.set_cookie(
key="session",
value=token,
httponly=True,
max_age=86400 * 30, # 30 days
samesite="lax"
)
return {"message": "Setup completed successfully", "authenticated": True}
@router.post("/login")
async def login(
data: SettingsCreate,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""Verify PIN and create session."""
result = await db.execute(select(Settings))
settings_obj = result.scalar_one_or_none()
if not settings_obj:
raise HTTPException(status_code=400, detail="Setup required")
if not verify_pin(data.pin, settings_obj.pin_hash):
raise HTTPException(status_code=401, detail="Invalid PIN")
# Create session
token = create_session_token(settings_obj.id)
response.set_cookie(
key="session",
value=token,
httponly=True,
max_age=86400 * 30, # 30 days
samesite="lax"
)
return {"message": "Login successful", "authenticated": True}
@router.post("/logout")
async def logout(response: Response):
"""Clear session cookie."""
response.delete_cookie(key="session")
return {"message": "Logout successful"}
@router.get("/status")
async def auth_status(
session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db)
):
"""Check authentication status."""
result = await db.execute(select(Settings))
settings_obj = result.scalar_one_or_none()
setup_required = settings_obj is None
authenticated = False
if not setup_required and session_cookie:
user_id = verify_session_token(session_cookie)
authenticated = user_id is not None
return {
"authenticated": authenticated,
"setup_required": setup_required
}

View File

@ -0,0 +1,192 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import datetime, date, timedelta
from typing import Optional, List, Dict, Any
from app.database import get_db
from app.models.settings import Settings
from app.models.todo import Todo
from app.models.calendar_event import CalendarEvent
from app.models.reminder import Reminder
from app.models.project import Project
from app.models.person import Person
from app.models.location import Location
from app.routers.auth import get_current_session
router = APIRouter()
@router.get("/dashboard")
async def get_dashboard(
client_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get aggregated dashboard data."""
today = client_date or date.today()
upcoming_cutoff = today + timedelta(days=current_user.upcoming_days)
# Today's events
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
events_query = select(CalendarEvent).where(
CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= today_end
)
events_result = await db.execute(events_query)
todays_events = events_result.scalars().all()
# Upcoming todos (not completed, with due date within upcoming_days)
todos_query = select(Todo).where(
Todo.completed == False,
Todo.due_date.isnot(None),
Todo.due_date <= upcoming_cutoff
).order_by(Todo.due_date.asc())
todos_result = await db.execute(todos_query)
upcoming_todos = todos_result.scalars().all()
# Active reminders (not dismissed, is_active = true)
reminders_query = select(Reminder).where(
Reminder.is_active == True,
Reminder.is_dismissed == False
).order_by(Reminder.remind_at.asc())
reminders_result = await db.execute(reminders_query)
active_reminders = reminders_result.scalars().all()
# Project stats
total_projects_result = await db.execute(select(func.count(Project.id)))
total_projects = total_projects_result.scalar()
projects_by_status_query = select(
Project.status,
func.count(Project.id).label("count")
).group_by(Project.status)
projects_by_status_result = await db.execute(projects_by_status_query)
projects_by_status = {row[0]: row[1] for row in projects_by_status_result}
# Total people
total_people_result = await db.execute(select(func.count(Person.id)))
total_people = total_people_result.scalar()
# Total locations
total_locations_result = await db.execute(select(func.count(Location.id)))
total_locations = total_locations_result.scalar()
return {
"todays_events": [
{
"id": event.id,
"title": event.title,
"start_datetime": event.start_datetime,
"end_datetime": event.end_datetime,
"all_day": event.all_day,
"color": event.color
}
for event in todays_events
],
"upcoming_todos": [
{
"id": todo.id,
"title": todo.title,
"due_date": todo.due_date,
"priority": todo.priority,
"category": todo.category
}
for todo in upcoming_todos
],
"active_reminders": [
{
"id": reminder.id,
"title": reminder.title,
"remind_at": reminder.remind_at
}
for reminder in active_reminders
],
"project_stats": {
"total": total_projects,
"by_status": projects_by_status
},
"total_people": total_people,
"total_locations": total_locations
}
@router.get("/upcoming")
async def get_upcoming(
days: int = Query(default=7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get unified list of upcoming items (todos, events, reminders) sorted by date."""
today = date.today()
cutoff_date = today + timedelta(days=days)
cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time())
# Get upcoming todos with due dates
todos_query = select(Todo).where(
Todo.completed == False,
Todo.due_date.isnot(None),
Todo.due_date <= cutoff_date
)
todos_result = await db.execute(todos_query)
todos = todos_result.scalars().all()
# Get upcoming events
events_query = select(CalendarEvent).where(
CalendarEvent.start_datetime <= cutoff_datetime
)
events_result = await db.execute(events_query)
events = events_result.scalars().all()
# Get upcoming reminders
reminders_query = select(Reminder).where(
Reminder.is_active == True,
Reminder.is_dismissed == False,
Reminder.remind_at <= cutoff_datetime
)
reminders_result = await db.execute(reminders_query)
reminders = reminders_result.scalars().all()
# Combine into unified list
upcoming_items: List[Dict[str, Any]] = []
for todo in todos:
upcoming_items.append({
"type": "todo",
"id": todo.id,
"title": todo.title,
"date": todo.due_date.isoformat() if todo.due_date else None,
"datetime": None,
"priority": todo.priority,
"category": todo.category
})
for event in events:
upcoming_items.append({
"type": "event",
"id": event.id,
"title": event.title,
"date": event.start_datetime.date().isoformat(),
"datetime": event.start_datetime.isoformat(),
"all_day": event.all_day,
"color": event.color
})
for reminder in reminders:
upcoming_items.append({
"type": "reminder",
"id": reminder.id,
"title": reminder.title,
"date": reminder.remind_at.date().isoformat(),
"datetime": reminder.remind_at.isoformat()
})
# Sort by date/datetime
upcoming_items.sort(key=lambda x: x["datetime"] if x["datetime"] else x["date"])
return {
"items": upcoming_items,
"days": days,
"cutoff_date": cutoff_date.isoformat()
}

View File

@ -0,0 +1,122 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from datetime import date
from app.database import get_db
from app.models.calendar_event import CalendarEvent
from app.schemas.calendar_event import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse
from app.routers.auth import get_current_session
from app.models.settings import Settings
router = APIRouter()
@router.get("/", response_model=List[CalendarEventResponse])
async def get_events(
start: Optional[date] = Query(None),
end: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get all calendar events with optional date range filtering."""
query = select(CalendarEvent)
if start:
query = query.where(CalendarEvent.start_datetime >= start)
if end:
query = query.where(CalendarEvent.end_datetime <= end)
query = query.order_by(CalendarEvent.start_datetime.asc())
result = await db.execute(query)
events = result.scalars().all()
return events
@router.post("/", response_model=CalendarEventResponse, status_code=201)
async def create_event(
event: CalendarEventCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Create a new calendar event."""
if event.end_datetime < event.start_datetime:
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
new_event = CalendarEvent(**event.model_dump())
db.add(new_event)
await db.commit()
await db.refresh(new_event)
return new_event
@router.get("/{event_id}", response_model=CalendarEventResponse)
async def get_event(
event_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get a specific calendar event by ID."""
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
return event
@router.put("/{event_id}", response_model=CalendarEventResponse)
async def update_event(
event_id: int,
event_update: CalendarEventUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Update a calendar event."""
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
update_data = event_update.model_dump(exclude_unset=True)
# Validate datetime order if both are being updated
start = update_data.get("start_datetime", event.start_datetime)
end = update_data.get("end_datetime", event.end_datetime)
if end < start:
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
for key, value in update_data.items():
setattr(event, key, value)
await db.commit()
await db.refresh(event)
return event
@router.delete("/{event_id}", status_code=204)
async def delete_event(
event_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Delete a calendar event."""
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
await db.delete(event)
await db.commit()
return None

View File

@ -0,0 +1,107 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from app.database import get_db
from app.models.location import Location
from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse
from app.routers.auth import get_current_session
from app.models.settings import Settings
router = APIRouter()
@router.get("/", response_model=List[LocationResponse])
async def get_locations(
category: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get all locations with optional category filter."""
query = select(Location)
if category:
query = query.where(Location.category == category)
query = query.order_by(Location.name.asc())
result = await db.execute(query)
locations = result.scalars().all()
return locations
@router.post("/", response_model=LocationResponse, status_code=201)
async def create_location(
location: LocationCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Create a new location."""
new_location = Location(**location.model_dump())
db.add(new_location)
await db.commit()
await db.refresh(new_location)
return new_location
@router.get("/{location_id}", response_model=LocationResponse)
async def get_location(
location_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get a specific location by ID."""
result = await db.execute(select(Location).where(Location.id == location_id))
location = result.scalar_one_or_none()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
return location
@router.put("/{location_id}", response_model=LocationResponse)
async def update_location(
location_id: int,
location_update: LocationUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Update a location."""
result = await db.execute(select(Location).where(Location.id == location_id))
location = result.scalar_one_or_none()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
update_data = location_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(location, key, value)
await db.commit()
await db.refresh(location)
return location
@router.delete("/{location_id}", status_code=204)
async def delete_location(
location_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Delete a location."""
result = await db.execute(select(Location).where(Location.id == location_id))
location = result.scalar_one_or_none()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
await db.delete(location)
await db.commit()
return None

View File

@ -0,0 +1,107 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from app.database import get_db
from app.models.person import Person
from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse
from app.routers.auth import get_current_session
from app.models.settings import Settings
router = APIRouter()
@router.get("/", response_model=List[PersonResponse])
async def get_people(
search: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get all people with optional search."""
query = select(Person)
if search:
query = query.where(Person.name.ilike(f"%{search}%"))
query = query.order_by(Person.name.asc())
result = await db.execute(query)
people = result.scalars().all()
return people
@router.post("/", response_model=PersonResponse, status_code=201)
async def create_person(
person: PersonCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Create a new person."""
new_person = Person(**person.model_dump())
db.add(new_person)
await db.commit()
await db.refresh(new_person)
return new_person
@router.get("/{person_id}", response_model=PersonResponse)
async def get_person(
person_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get a specific person by ID."""
result = await db.execute(select(Person).where(Person.id == person_id))
person = result.scalar_one_or_none()
if not person:
raise HTTPException(status_code=404, detail="Person not found")
return person
@router.put("/{person_id}", response_model=PersonResponse)
async def update_person(
person_id: int,
person_update: PersonUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Update a person."""
result = await db.execute(select(Person).where(Person.id == person_id))
person = result.scalar_one_or_none()
if not person:
raise HTTPException(status_code=404, detail="Person not found")
update_data = person_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(person, key, value)
await db.commit()
await db.refresh(person)
return person
@router.delete("/{person_id}", status_code=204)
async def delete_person(
person_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Delete a person."""
result = await db.execute(select(Person).where(Person.id == person_id))
person = result.scalar_one_or_none()
if not person:
raise HTTPException(status_code=404, detail="Person not found")
await db.delete(person)
await db.commit()
return None

View File

@ -0,0 +1,208 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from typing import List
from app.database import get_db
from app.models.project import Project
from app.models.project_task import ProjectTask
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
from app.routers.auth import get_current_session
from app.models.settings import Settings
router = APIRouter()
@router.get("/", response_model=List[ProjectResponse])
async def get_projects(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get all projects with their tasks."""
query = select(Project).options(selectinload(Project.tasks)).order_by(Project.created_at.desc())
result = await db.execute(query)
projects = result.scalars().all()
return projects
@router.post("/", response_model=ProjectResponse, status_code=201)
async def create_project(
project: ProjectCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Create a new project."""
new_project = Project(**project.model_dump())
db.add(new_project)
await db.commit()
# Re-fetch with eagerly loaded tasks for response serialization
query = select(Project).options(selectinload(Project.tasks)).where(Project.id == new_project.id)
result = await db.execute(query)
return result.scalar_one()
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get a specific project by ID with its tasks."""
query = select(Project).options(selectinload(Project.tasks)).where(Project.id == project_id)
result = await db.execute(query)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@router.put("/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: int,
project_update: ProjectUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Update a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
update_data = project_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(project, key, value)
await db.commit()
# Re-fetch with eagerly loaded tasks for response serialization
query = select(Project).options(selectinload(Project.tasks)).where(Project.id == project_id)
result = await db.execute(query)
return result.scalar_one()
@router.delete("/{project_id}", status_code=204)
async def delete_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Delete a project and all its tasks."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
await db.delete(project)
await db.commit()
return None
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
async def get_project_tasks(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get all tasks for a specific project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
query = select(ProjectTask).where(ProjectTask.project_id == project_id).order_by(ProjectTask.sort_order.asc())
result = await db.execute(query)
tasks = result.scalars().all()
return tasks
@router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201)
async def create_project_task(
project_id: int,
task: ProjectTaskCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Create a new task for a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
task_data = task.model_dump()
task_data["project_id"] = project_id
new_task = ProjectTask(**task_data)
db.add(new_task)
await db.commit()
await db.refresh(new_task)
return new_task
@router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse)
async def update_project_task(
project_id: int,
task_id: int,
task_update: ProjectTaskUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Update a project task."""
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
ProjectTask.project_id == project_id
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
update_data = task_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(task, key, value)
await db.commit()
await db.refresh(task)
return task
@router.delete("/{project_id}/tasks/{task_id}", status_code=204)
async def delete_project_task(
project_id: int,
task_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Delete a project task."""
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
ProjectTask.project_id == project_id
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
await db.delete(task)
await db.commit()
return None

View File

@ -0,0 +1,132 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from app.database import get_db
from app.models.reminder import Reminder
from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse
from app.routers.auth import get_current_session
from app.models.settings import Settings
router = APIRouter()
@router.get("/", response_model=List[ReminderResponse])
async def get_reminders(
active: Optional[bool] = Query(None),
dismissed: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get all reminders with optional filters."""
query = select(Reminder)
if active is not None:
query = query.where(Reminder.is_active == active)
if dismissed is not None:
query = query.where(Reminder.is_dismissed == dismissed)
query = query.order_by(Reminder.remind_at.asc())
result = await db.execute(query)
reminders = result.scalars().all()
return reminders
@router.post("/", response_model=ReminderResponse, status_code=201)
async def create_reminder(
reminder: ReminderCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Create a new reminder."""
new_reminder = Reminder(**reminder.model_dump())
db.add(new_reminder)
await db.commit()
await db.refresh(new_reminder)
return new_reminder
@router.get("/{reminder_id}", response_model=ReminderResponse)
async def get_reminder(
reminder_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get a specific reminder by ID."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
reminder = result.scalar_one_or_none()
if not reminder:
raise HTTPException(status_code=404, detail="Reminder not found")
return reminder
@router.put("/{reminder_id}", response_model=ReminderResponse)
async def update_reminder(
reminder_id: int,
reminder_update: ReminderUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Update a reminder."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
reminder = result.scalar_one_or_none()
if not reminder:
raise HTTPException(status_code=404, detail="Reminder not found")
update_data = reminder_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(reminder, key, value)
await db.commit()
await db.refresh(reminder)
return reminder
@router.delete("/{reminder_id}", status_code=204)
async def delete_reminder(
reminder_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Delete a reminder."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
reminder = result.scalar_one_or_none()
if not reminder:
raise HTTPException(status_code=404, detail="Reminder not found")
await db.delete(reminder)
await db.commit()
return None
@router.patch("/{reminder_id}/dismiss", response_model=ReminderResponse)
async def dismiss_reminder(
reminder_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Dismiss a reminder."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
reminder = result.scalar_one_or_none()
if not reminder:
raise HTTPException(status_code=404, detail="Reminder not found")
reminder.is_dismissed = True
await db.commit()
await db.refresh(reminder)
return reminder

View File

@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.settings import Settings
from app.schemas.settings import SettingsUpdate, SettingsResponse, ChangePinRequest
from app.routers.auth import get_current_session, hash_pin, verify_pin
router = APIRouter()
@router.get("/", response_model=SettingsResponse)
async def get_settings(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get current settings (excluding PIN hash)."""
return current_user
@router.put("/", response_model=SettingsResponse)
async def update_settings(
settings_update: SettingsUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Update settings (accent color, upcoming days)."""
update_data = settings_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(current_user, key, value)
await db.commit()
await db.refresh(current_user)
return current_user
@router.put("/pin")
async def change_pin(
pin_change: ChangePinRequest,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Change PIN. Requires old PIN verification."""
if not verify_pin(pin_change.old_pin, current_user.pin_hash):
raise HTTPException(status_code=401, detail="Invalid old PIN")
current_user.pin_hash = hash_pin(pin_change.new_pin)
await db.commit()
return {"message": "PIN changed successfully"}

View File

@ -0,0 +1,149 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from datetime import datetime, timezone
from app.database import get_db
from app.models.todo import Todo
from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse
from app.routers.auth import get_current_session
from app.models.settings import Settings
router = APIRouter()
@router.get("/", response_model=List[TodoResponse])
async def get_todos(
completed: Optional[bool] = Query(None),
priority: Optional[str] = Query(None),
category: Optional[str] = Query(None),
search: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get all todos with optional filters."""
query = select(Todo)
if completed is not None:
query = query.where(Todo.completed == completed)
if priority:
query = query.where(Todo.priority == priority)
if category:
query = query.where(Todo.category == category)
if search:
query = query.where(Todo.title.ilike(f"%{search}%"))
query = query.order_by(Todo.created_at.desc())
result = await db.execute(query)
todos = result.scalars().all()
return todos
@router.post("/", response_model=TodoResponse, status_code=201)
async def create_todo(
todo: TodoCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Create a new todo."""
new_todo = Todo(**todo.model_dump())
db.add(new_todo)
await db.commit()
await db.refresh(new_todo)
return new_todo
@router.get("/{todo_id}", response_model=TodoResponse)
async def get_todo(
todo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get a specific todo by ID."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
return todo
@router.put("/{todo_id}", response_model=TodoResponse)
async def update_todo(
todo_id: int,
todo_update: TodoUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Update a todo."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
update_data = todo_update.model_dump(exclude_unset=True)
# Handle completion timestamp
if "completed" in update_data:
if update_data["completed"] and not todo.completed:
update_data["completed_at"] = datetime.now(timezone.utc)
elif not update_data["completed"]:
update_data["completed_at"] = None
for key, value in update_data.items():
setattr(todo, key, value)
await db.commit()
await db.refresh(todo)
return todo
@router.delete("/{todo_id}", status_code=204)
async def delete_todo(
todo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Delete a todo."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
await db.delete(todo)
await db.commit()
return None
@router.patch("/{todo_id}/toggle", response_model=TodoResponse)
async def toggle_todo(
todo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Toggle todo completion status."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
todo.completed = not todo.completed
todo.completed_at = datetime.now(timezone.utc) if todo.completed else None
await db.commit()
await db.refresh(todo)
return todo

View File

@ -0,0 +1,36 @@
from app.schemas.settings import SettingsCreate, SettingsUpdate, SettingsResponse, ChangePinRequest
from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse
from app.schemas.calendar_event import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse
from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse
from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse
__all__ = [
"SettingsCreate",
"SettingsUpdate",
"SettingsResponse",
"ChangePinRequest",
"TodoCreate",
"TodoUpdate",
"TodoResponse",
"CalendarEventCreate",
"CalendarEventUpdate",
"CalendarEventResponse",
"ReminderCreate",
"ReminderUpdate",
"ReminderResponse",
"ProjectCreate",
"ProjectUpdate",
"ProjectResponse",
"ProjectTaskCreate",
"ProjectTaskUpdate",
"ProjectTaskResponse",
"PersonCreate",
"PersonUpdate",
"PersonResponse",
"LocationCreate",
"LocationUpdate",
"LocationResponse",
]

View File

@ -0,0 +1,41 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional
class CalendarEventCreate(BaseModel):
title: str
description: Optional[str] = None
start_datetime: datetime
end_datetime: datetime
all_day: bool = False
color: Optional[str] = None
location_id: Optional[int] = None
recurrence_rule: Optional[str] = None
class CalendarEventUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
start_datetime: Optional[datetime] = None
end_datetime: Optional[datetime] = None
all_day: Optional[bool] = None
color: Optional[str] = None
location_id: Optional[int] = None
recurrence_rule: Optional[str] = None
class CalendarEventResponse(BaseModel):
id: int
title: str
description: Optional[str]
start_datetime: datetime
end_datetime: datetime
all_day: bool
color: Optional[str]
location_id: Optional[int]
recurrence_rule: Optional[str]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,29 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional
class LocationCreate(BaseModel):
name: str
address: str
category: str = "other"
notes: Optional[str] = None
class LocationUpdate(BaseModel):
name: Optional[str] = None
address: Optional[str] = None
category: Optional[str] = None
notes: Optional[str] = None
class LocationResponse(BaseModel):
id: int
name: str
address: str
category: str
notes: Optional[str]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,38 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime, date
from typing import Optional
class PersonCreate(BaseModel):
name: str
email: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
birthday: Optional[date] = None
relationship: Optional[str] = None
notes: Optional[str] = None
class PersonUpdate(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
birthday: Optional[date] = None
relationship: Optional[str] = None
notes: Optional[str] = None
class PersonResponse(BaseModel):
id: int
name: str
email: Optional[str]
phone: Optional[str]
address: Optional[str]
birthday: Optional[date]
relationship: Optional[str]
notes: Optional[str]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,34 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime, date
from typing import Optional, List
from app.schemas.project_task import ProjectTaskResponse
class ProjectCreate(BaseModel):
name: str
description: Optional[str] = None
status: str = "not_started"
color: Optional[str] = None
due_date: Optional[date] = None
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
color: Optional[str] = None
due_date: Optional[date] = None
class ProjectResponse(BaseModel):
id: int
name: str
description: Optional[str]
status: str
color: Optional[str]
due_date: Optional[date]
created_at: datetime
updated_at: datetime
tasks: List[ProjectTaskResponse] = []
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,39 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime, date
from typing import Optional
class ProjectTaskCreate(BaseModel):
title: str
description: Optional[str] = None
status: str = "pending"
priority: str = "medium"
due_date: Optional[date] = None
person_id: Optional[int] = None
sort_order: int = 0
class ProjectTaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
due_date: Optional[date] = None
person_id: Optional[int] = None
sort_order: Optional[int] = None
class ProjectTaskResponse(BaseModel):
id: int
project_id: int
title: str
description: Optional[str]
status: str
priority: str
due_date: Optional[date]
person_id: Optional[int]
sort_order: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,34 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional
class ReminderCreate(BaseModel):
title: str
description: Optional[str] = None
remind_at: datetime
is_active: bool = True
recurrence_rule: Optional[str] = None
class ReminderUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
remind_at: Optional[datetime] = None
is_active: Optional[bool] = None
is_dismissed: Optional[bool] = None
recurrence_rule: Optional[str] = None
class ReminderResponse(BaseModel):
id: int
title: str
description: Optional[str]
remind_at: datetime
is_active: bool
is_dismissed: bool
recurrence_rule: Optional[str]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,26 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime
class SettingsCreate(BaseModel):
pin: str
class SettingsUpdate(BaseModel):
accent_color: str | None = None
upcoming_days: int | None = None
class SettingsResponse(BaseModel):
id: int
accent_color: str
upcoming_days: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ChangePinRequest(BaseModel):
old_pin: str
new_pin: str

View File

@ -0,0 +1,41 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime, date
from typing import Optional
class TodoCreate(BaseModel):
title: str
description: Optional[str] = None
priority: str = "medium"
due_date: Optional[date] = None
category: Optional[str] = None
recurrence_rule: Optional[str] = None
project_id: Optional[int] = None
class TodoUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
priority: Optional[str] = None
due_date: Optional[date] = None
completed: Optional[bool] = None
category: Optional[str] = None
recurrence_rule: Optional[str] = None
project_id: Optional[int] = None
class TodoResponse(BaseModel):
id: int
title: str
description: Optional[str]
priority: str
due_date: Optional[date]
completed: bool
completed_at: Optional[datetime]
category: Optional[str]
recurrence_rule: Optional[str]
project_id: Optional[int]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

11
backend/requirements.txt Normal file
View File

@ -0,0 +1,11 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.1
pydantic==2.10.4
pydantic-settings==2.7.1
bcrypt==4.2.1
python-multipart==0.0.20
python-dateutil==2.9.0
itsdangerous==2.2.0

9
backend/start.sh Normal file
View File

@ -0,0 +1,9 @@
#!/bin/bash
# Run database migrations
echo "Running database migrations..."
alembic upgrade head
# Start the FastAPI application
echo "Starting FastAPI application..."
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

39
docker-compose.yaml Normal file
View File

@ -0,0 +1,39 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
env_file: .env
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 5
backend:
build: ./backend
restart: unless-stopped
ports:
- "8000:8000"
env_file: .env
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 10s
timeout: 5s
retries: 3
frontend:
build: ./frontend
restart: unless-stopped
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
volumes:
postgres_data:

32
frontend/.gitignore vendored Normal file
View File

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

31
frontend/Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source files
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

117
frontend/README.md Normal file
View File

@ -0,0 +1,117 @@
# LifeManager Frontend
A modern, dark-themed React application for managing your life - todos, calendar events, reminders, projects, people, and locations.
## Tech Stack
- **React 18** with TypeScript
- **Vite** for fast builds
- **Tailwind CSS v3** for styling
- **shadcn/ui** components (manually implemented)
- **React Router v6** for routing
- **TanStack Query v5** for data fetching
- **Axios** for HTTP requests
- **FullCalendar** for calendar view
- **Lucide React** for icons
- **date-fns** for date formatting
- **sonner** for toast notifications
## Features
- PIN-based authentication with setup wizard
- Dark theme with customizable accent colors (cyan, blue, purple, orange, green)
- Dashboard with stats and widgets
- Todos with priority, category, and recurrence
- Calendar with event management
- Reminders with dismiss functionality
- Projects with tasks and progress tracking
- People management with relationships
- Locations categorization
- Responsive design with collapsible sidebar
## Getting Started
### Prerequisites
- Node.js 20 or higher
- npm or yarn
### Installation
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
```
## Development
The application expects a backend API at `/api`. During development, Vite proxies `/api` requests to `http://localhost:8000`.
### Project Structure
```
frontend/
├── src/
│ ├── components/
│ │ ├── auth/ # Authentication components
│ │ ├── calendar/ # Calendar page and forms
│ │ ├── dashboard/ # Dashboard widgets
│ │ ├── layout/ # Layout components (sidebar, etc)
│ │ ├── locations/ # Locations management
│ │ ├── people/ # People management
│ │ ├── projects/ # Projects and tasks
│ │ ├── reminders/ # Reminders management
│ │ ├── settings/ # Settings page
│ │ ├── todos/ # Todos management
│ │ └── ui/ # shadcn/ui components
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities (API client, utils)
│ ├── types/ # TypeScript type definitions
│ ├── App.tsx # Main app with routing
│ ├── main.tsx # Entry point
│ └── index.css # Global styles
├── Dockerfile # Production Docker image
├── nginx.conf # Nginx configuration
└── package.json
```
## Docker Deployment
Build and run with Docker:
```bash
# Build image
docker build -t lifemanager-frontend .
# Run container
docker run -p 80:80 lifemanager-frontend
```
## Environment Variables
No environment variables required. API base URL is configured to `/api` and proxied by nginx in production.
## Customization
### Accent Colors
Accent colors are defined in `src/hooks/useTheme.ts` and can be changed in Settings. The application uses CSS custom properties for theming.
### Adding New Pages
1. Create component in `src/components/<feature>/`
2. Add route in `src/App.tsx`
3. Add navigation item in `src/components/layout/Sidebar.tsx`
## License
Private project - all rights reserved.

16
frontend/components.json Normal file
View File

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LifeManager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

41
frontend/nginx.conf Normal file
View File

@ -0,0 +1,41 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
# API proxy
location /api {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# SPA fallback - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

38
frontend/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "lifemanager",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9",
"@fullcalendar/react": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"lucide-react": "^0.468.0",
"date-fns": "^4.1.0",
"sonner": "^1.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^2.6.0",
"class-variance-authority": "^0.7.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

60
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,60 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import LockScreen from '@/components/auth/LockScreen';
import AppLayout from '@/components/layout/AppLayout';
import DashboardPage from '@/components/dashboard/DashboardPage';
import TodosPage from '@/components/todos/TodosPage';
import CalendarPage from '@/components/calendar/CalendarPage';
import RemindersPage from '@/components/reminders/RemindersPage';
import ProjectsPage from '@/components/projects/ProjectsPage';
import ProjectDetail from '@/components/projects/ProjectDetail';
import PeoplePage from '@/components/people/PeoplePage';
import LocationsPage from '@/components/locations/LocationsPage';
import SettingsPage from '@/components/settings/SettingsPage';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
}
if (!authStatus?.authenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function App() {
return (
<Routes>
<Route path="/login" element={<LockScreen />} />
<Route
path="/"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="todos" element={<TodosPage />} />
<Route path="calendar" element={<CalendarPage />} />
<Route path="reminders" element={<RemindersPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="people" element={<PeoplePage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
);
}
export default App;

View File

@ -0,0 +1,110 @@
import { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Lock } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
export default function LockScreen() {
const navigate = useNavigate();
const { authStatus, login, setup, isLoginPending, isSetupPending } = useAuth();
const [pin, setPin] = useState('');
const [confirmPin, setConfirmPin] = useState('');
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (authStatus?.setup_required) {
if (pin !== confirmPin) {
toast.error('PINs do not match');
return;
}
if (pin.length < 4) {
toast.error('PIN must be at least 4 characters');
return;
}
try {
await setup(pin);
toast.success('PIN created successfully');
navigate('/dashboard');
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to create PIN');
}
} else {
try {
await login(pin);
navigate('/dashboard');
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Invalid PIN');
setPin('');
}
}
};
const isSetup = authStatus?.setup_required;
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-4 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-accent/10">
<Lock className="h-8 w-8 text-accent" />
</div>
<CardTitle className="text-2xl">
{isSetup ? 'Welcome to LifeManager' : 'Enter PIN'}
</CardTitle>
<CardDescription>
{isSetup
? 'Create a PIN to secure your account'
: 'Enter your PIN to access your dashboard'}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="pin">{isSetup ? 'Create PIN' : 'PIN'}</Label>
<Input
id="pin"
type="password"
value={pin}
onChange={(e) => setPin(e.target.value)}
placeholder="Enter PIN"
required
autoFocus
className="text-center text-lg tracking-widest"
/>
</div>
{isSetup && (
<div className="space-y-2">
<Label htmlFor="confirm-pin">Confirm PIN</Label>
<Input
id="confirm-pin"
type="password"
value={confirmPin}
onChange={(e) => setConfirmPin(e.target.value)}
placeholder="Confirm PIN"
required
className="text-center text-lg tracking-widest"
/>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isLoginPending || isSetupPending}
>
{isLoginPending || isSetupPending
? 'Please wait...'
: isSetup
? 'Create PIN'
: 'Unlock'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,126 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { EventClickArg, DateSelectArg, EventDropArg } from '@fullcalendar/core';
import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent } from '@/types';
import EventForm from './EventForm';
export default function CalendarPage() {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const { data: events = [] } = useQuery({
queryKey: ['calendar-events'],
queryFn: async () => {
const { data } = await api.get<CalendarEvent[]>('/events');
return data;
},
});
const eventDropMutation = useMutation({
mutationFn: async ({ id, start, end, allDay }: { id: number; start: string; end: string; allDay: boolean }) => {
const response = await api.put(`/events/${id}`, {
start_datetime: start,
end_datetime: end,
all_day: allDay,
});
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.success('Event moved');
},
onError: (error) => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.error(getErrorMessage(error, 'Failed to move event'));
},
});
const calendarEvents = events.map((event) => ({
id: event.id.toString(),
title: event.title,
start: event.start_datetime,
end: event.end_datetime || undefined,
allDay: event.all_day,
backgroundColor: event.color || 'hsl(var(--accent-color))',
borderColor: event.color || 'hsl(var(--accent-color))',
}));
const handleEventClick = (info: EventClickArg) => {
const event = events.find((e) => e.id.toString() === info.event.id);
if (event) {
setEditingEvent(event);
setShowForm(true);
}
};
const toLocalDatetime = (d: Date): string => {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const handleEventDrop = (info: EventDropArg) => {
const id = parseInt(info.event.id);
const start = info.event.allDay
? info.event.startStr
: info.event.start ? toLocalDatetime(info.event.start) : info.event.startStr;
const end = info.event.allDay
? info.event.endStr || info.event.startStr
: info.event.end ? toLocalDatetime(info.event.end) : start;
eventDropMutation.mutate({ id, start, end, allDay: info.event.allDay });
};
const handleDateSelect = (selectInfo: DateSelectArg) => {
setSelectedDate(selectInfo.startStr);
setShowForm(true);
};
const handleCloseForm = () => {
setShowForm(false);
setEditingEvent(null);
setSelectedDate(null);
};
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<h1 className="text-3xl font-bold">Calendar</h1>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="bg-card rounded-lg border p-4">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay',
}}
events={calendarEvents}
editable={true}
selectable={true}
selectMirror={true}
dayMaxEvents={true}
weekends={true}
eventClick={handleEventClick}
eventDrop={handleEventDrop}
select={handleDateSelect}
height="auto"
/>
</div>
</div>
{showForm && (
<EventForm event={editingEvent} initialDate={selectedDate} onClose={handleCloseForm} />
)}
</div>
);
}

View File

@ -0,0 +1,260 @@
import { useState, FormEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent, Location } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
interface EventFormProps {
event: CalendarEvent | null;
initialDate?: string | null;
onClose: () => void;
}
const colorPresets = [
{ name: 'Accent', value: '' },
{ name: 'Red', value: '#ef4444' },
{ name: 'Orange', value: '#f97316' },
{ name: 'Yellow', value: '#eab308' },
{ name: 'Green', value: '#22c55e' },
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Purple', value: '#8b5cf6' },
{ name: 'Pink', value: '#ec4899' },
];
// Extract just the date portion (YYYY-MM-DD) from any date/datetime string
function toDateOnly(dt: string): string {
if (!dt) return '';
return dt.split('T')[0];
}
// Ensure a datetime string is in datetime-local format (YYYY-MM-DDThh:mm)
function toDatetimeLocal(dt: string, fallbackTime: string = '09:00'): string {
if (!dt) return '';
if (dt.includes('T')) return dt.slice(0, 16); // trim seconds/timezone
return `${dt}T${fallbackTime}`;
}
// Format a date/datetime string for the correct input type
function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09:00'): string {
if (!dt) return '';
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
}
export default function EventForm({ event, initialDate, onClose }: EventFormProps) {
const queryClient = useQueryClient();
const isAllDay = event?.all_day ?? false;
const rawStart = event?.start_datetime || initialDate || '';
const rawEnd = event?.end_datetime || initialDate || '';
const [formData, setFormData] = useState({
title: event?.title || '',
description: event?.description || '',
start_datetime: formatForInput(rawStart, isAllDay, '09:00'),
end_datetime: formatForInput(rawEnd, isAllDay, '10:00'),
all_day: isAllDay,
location_id: event?.location_id?.toString() || '',
color: event?.color || '',
recurrence_rule: event?.recurrence_rule || '',
});
const { data: locations = [] } = useQuery({
queryKey: ['locations'],
queryFn: async () => {
const { data } = await api.get<Location[]>('/locations');
return data;
},
});
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
const payload = {
...data,
location_id: data.location_id ? parseInt(data.location_id) : null,
};
if (event) {
const response = await api.put(`/events/${event.id}`, payload);
return response.data;
} else {
const response = await api.post('/events', payload);
return response.data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.success(event ? 'Event updated' : 'Event created');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, event ? 'Failed to update event' : 'Failed to create event'));
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/events/${event?.id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.success('Event deleted');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{event ? 'Edit Event' : 'New Event'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="all_day"
checked={formData.all_day}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked;
setFormData({
...formData,
all_day: checked,
start_datetime: formatForInput(formData.start_datetime, checked, '09:00'),
end_datetime: formatForInput(formData.end_datetime, checked, '10:00'),
});
}}
/>
<Label htmlFor="all_day">All day event</Label>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="start">Start</Label>
<Input
id="start"
type={formData.all_day ? 'date' : 'datetime-local'}
value={formData.start_datetime}
onChange={(e) => setFormData({ ...formData, start_datetime: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="end">End</Label>
<Input
id="end"
type={formData.all_day ? 'date' : 'datetime-local'}
value={formData.end_datetime}
onChange={(e) => setFormData({ ...formData, end_datetime: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="location">Location</Label>
<Select
id="location"
value={formData.location_id}
onChange={(e) => setFormData({ ...formData, location_id: e.target.value })}
>
<option value="">None</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>
{loc.name}
</option>
))}
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Select
id="color"
value={formData.color}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
>
{colorPresets.map((preset) => (
<option key={preset.name} value={preset.value}>
{preset.name}
</option>
))}
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="recurrence">Recurrence</Label>
<Select
id="recurrence"
value={formData.recurrence_rule}
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })}
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
<DialogFooter>
{event && (
<Button
type="button"
variant="destructive"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="mr-auto"
>
Delete
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : event ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,63 @@
import { format } from 'date-fns';
import { Calendar, Clock } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface DashboardEvent {
id: number;
title: string;
start_datetime: string;
end_datetime: string;
all_day: boolean;
color?: string;
}
interface CalendarWidgetProps {
events: DashboardEvent[];
}
export default function CalendarWidget({ events }: CalendarWidgetProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Today's Events
</CardTitle>
</CardHeader>
<CardContent>
{events.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No events scheduled for today
</p>
) : (
<div className="space-y-3">
{events.map((event) => (
<div
key={event.id}
className="flex items-start gap-3 p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors"
>
<div
className="w-1 h-full min-h-[2rem] rounded-full"
style={{ backgroundColor: event.color || 'hsl(var(--primary))' }}
/>
<div className="flex-1 min-w-0">
<p className="font-medium">{event.title}</p>
{!event.all_day && (
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-1">
<Clock className="h-3 w-3" />
{format(new Date(event.start_datetime), 'h:mm a')}
{event.end_datetime && ` - ${format(new Date(event.end_datetime), 'h:mm a')}`}
</div>
)}
{event.all_day && (
<p className="text-sm text-muted-foreground mt-1">All day</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,113 @@
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Bell } from 'lucide-react';
import api from '@/lib/api';
import type { DashboardData, UpcomingResponse } from '@/types';
import { useSettings } from '@/hooks/useSettings';
import StatsWidget from './StatsWidget';
import TodoWidget from './TodoWidget';
import CalendarWidget from './CalendarWidget';
import UpcomingWidget from './UpcomingWidget';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DashboardSkeleton } from '@/components/ui/skeleton';
export default function DashboardPage() {
const { settings } = useSettings();
const { data, isLoading } = useQuery({
queryKey: ['dashboard'],
queryFn: async () => {
const today = new Date().toISOString().split('T')[0];
const { data } = await api.get<DashboardData>(`/dashboard?client_date=${today}`);
return data;
},
});
const { data: upcomingData } = useQuery({
queryKey: ['upcoming', settings?.upcoming_days],
queryFn: async () => {
const days = settings?.upcoming_days || 7;
const { data } = await api.get<UpcomingResponse>(`/upcoming?days=${days}`);
return data;
},
});
if (isLoading) {
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground mt-1">Welcome back. Here's your overview.</p>
</div>
<div className="flex-1 overflow-y-auto p-6">
<DashboardSkeleton />
</div>
</div>
);
}
if (!data) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">Failed to load dashboard</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground mt-1">Welcome back. Here's your overview.</p>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-6">
<StatsWidget
projectStats={data.project_stats}
totalPeople={data.total_people}
totalLocations={data.total_locations}
/>
{upcomingData && upcomingData.items.length > 0 && (
<UpcomingWidget items={upcomingData.items} days={upcomingData.days} />
)}
<div className="grid gap-6 lg:grid-cols-2">
<TodoWidget todos={data.upcoming_todos} />
<CalendarWidget events={data.todays_events} />
</div>
{data.active_reminders.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Active Reminders
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.active_reminders.map((reminder) => (
<div
key={reminder.id}
className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors"
>
<Bell className="h-4 w-4 text-accent" />
<div className="flex-1">
<p className="font-medium">{reminder.title}</p>
<p className="text-xs text-muted-foreground">
{format(new Date(reminder.remind_at), 'MMM d, yyyy h:mm a')}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
import { useNavigate } from 'react-router-dom';
import type { Project } from '@/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
interface ProjectsWidgetProps {
projects: Project[];
}
const statusColors = {
not_started: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
in_progress: 'bg-accent/10 text-accent border-accent/20',
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
};
export default function ProjectsWidget({ projects }: ProjectsWidgetProps) {
const navigate = useNavigate();
return (
<Card>
<CardHeader>
<CardTitle>Active Projects</CardTitle>
</CardHeader>
<CardContent>
{projects.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No active projects. Create one to get started!
</p>
) : (
<div className="space-y-3">
{projects.map((project) => {
const completedTasks = project.tasks?.filter((t) => t.status === 'completed').length || 0;
const totalTasks = project.tasks?.length || 0;
const progress = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0;
return (
<div
key={project.id}
className="p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors cursor-pointer"
onClick={() => navigate(`/projects/${project.id}`)}
>
<div className="flex items-start justify-between mb-2">
<p className="font-medium">{project.name}</p>
<Badge className={statusColors[project.status]}>
{project.status.replace('_', ' ')}
</Badge>
</div>
{totalTasks > 0 && (
<div>
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>Progress</span>
<span>
{completedTasks}/{totalTasks} tasks
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-accent transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,58 @@
import { FolderKanban, Users, MapPin } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
interface StatsWidgetProps {
projectStats: {
total: number;
by_status: Record<string, number>;
};
totalPeople: number;
totalLocations: number;
}
export default function StatsWidget({ projectStats, totalPeople, totalLocations }: StatsWidgetProps) {
const statCards = [
{
label: 'Total Projects',
value: projectStats.total,
icon: FolderKanban,
color: 'text-blue-500',
},
{
label: 'In Progress',
value: projectStats.by_status['in_progress'] || 0,
icon: FolderKanban,
color: 'text-purple-500',
},
{
label: 'People',
value: totalPeople,
icon: Users,
color: 'text-green-500',
},
{
label: 'Locations',
value: totalLocations,
icon: MapPin,
color: 'text-orange-500',
},
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{statCards.map((stat) => (
<Card key={stat.label}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
<p className="text-3xl font-bold mt-2">{stat.value}</p>
</div>
<stat.icon className={`h-8 w-8 ${stat.color}`} />
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -0,0 +1,75 @@
import { format, isPast } from 'date-fns';
import { Calendar, CheckCircle2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface DashboardTodo {
id: number;
title: string;
due_date: string;
priority: string;
category?: string;
}
interface TodoWidgetProps {
todos: DashboardTodo[];
}
const priorityColors: Record<string, string> = {
low: 'bg-green-500/10 text-green-500 border-green-500/20',
medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
high: 'bg-red-500/10 text-red-500 border-red-500/20',
};
export default function TodoWidget({ todos }: TodoWidgetProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5" />
Upcoming Todos
</CardTitle>
</CardHeader>
<CardContent>
{todos.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No upcoming todos. You're all caught up!
</p>
) : (
<div className="space-y-3">
{todos.slice(0, 5).map((todo) => {
const isOverdue = isPast(new Date(todo.due_date));
return (
<div
key={todo.id}
className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors"
>
<div className="flex-1 min-w-0">
<p className="font-medium">{todo.title}</p>
<div className="flex items-center gap-2 mt-1">
<div className={cn(
"flex items-center gap-1 text-xs",
isOverdue ? "text-destructive" : "text-muted-foreground"
)}>
<Calendar className="h-3 w-3" />
{format(new Date(todo.due_date), 'MMM d, yyyy')}
{isOverdue && <span className="font-medium">(Overdue)</span>}
</div>
{todo.category && (
<Badge variant="outline" className="text-xs">{todo.category}</Badge>
)}
</div>
</div>
<Badge className={priorityColors[todo.priority] || priorityColors.medium}>
{todo.priority}
</Badge>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,76 @@
import { format } from 'date-fns';
import { CheckSquare, Calendar, Bell } from 'lucide-react';
import type { UpcomingItem } from '@/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
interface UpcomingWidgetProps {
items: UpcomingItem[];
days?: number;
}
const priorityColors: Record<string, string> = {
low: 'bg-green-500/10 text-green-500 border-green-500/20',
medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
high: 'bg-red-500/10 text-red-500 border-red-500/20',
};
export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) {
const getIcon = (type: string) => {
switch (type) {
case 'todo':
return <CheckSquare className="h-5 w-5 text-blue-500" />;
case 'event':
return <Calendar className="h-5 w-5 text-purple-500" />;
case 'reminder':
return <Bell className="h-5 w-5 text-orange-500" />;
default:
return null;
}
};
return (
<Card>
<CardHeader>
<CardTitle>Upcoming ({days} days)</CardTitle>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No upcoming items in the next few days
</p>
) : (
<ScrollArea className="h-[300px]">
<div className="space-y-3">
{items.map((item, index) => (
<div
key={`${item.type}-${item.id}-${index}`}
className="flex items-start gap-3 p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors"
>
{getIcon(item.type)}
<div className="flex-1 min-w-0">
<p className="font-medium">{item.title}</p>
<p className="text-sm text-muted-foreground">
{item.datetime
? format(new Date(item.datetime), 'MMM d, yyyy h:mm a')
: format(new Date(item.date), 'MMM d, yyyy')}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="capitalize">
{item.type}
</Badge>
{item.priority && (
<Badge className={priorityColors[item.priority]}>{item.priority}</Badge>
)}
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,35 @@
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Menu } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme';
import { Button } from '@/components/ui/button';
import Sidebar from './Sidebar';
export default function AppLayout() {
useTheme();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar
collapsed={collapsed}
onToggle={() => setCollapsed(!collapsed)}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mobile header */}
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-bold text-accent ml-3">LifeManager</h1>
</div>
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,114 @@
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard,
CheckSquare,
Calendar,
Bell,
FolderKanban,
Users,
MapPin,
Settings,
ChevronLeft,
ChevronRight,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
const navItems = [
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/todos', icon: CheckSquare, label: 'Todos' },
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
{ to: '/reminders', icon: Bell, label: 'Reminders' },
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
{ to: '/people', icon: Users, label: 'People' },
{ to: '/locations', icon: MapPin, label: 'Locations' },
];
interface SidebarProps {
collapsed: boolean;
onToggle: () => void;
mobileOpen: boolean;
onMobileClose: () => void;
}
export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/10 hover:text-accent'
);
const sidebarContent = (
<>
<div className="flex h-16 items-center justify-between border-b px-4">
{!collapsed && <h1 className="text-xl font-bold text-accent">LifeManager</h1>}
<Button
variant="ghost"
size="icon"
onClick={mobileOpen ? onMobileClose : onToggle}
className="ml-auto"
>
{mobileOpen ? (
<X className="h-5 w-5" />
) : collapsed ? (
<ChevronRight className="h-5 w-5" />
) : (
<ChevronLeft className="h-5 w-5" />
)}
</Button>
</div>
<nav className="flex-1 space-y-1 p-2">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
onClick={mobileOpen ? onMobileClose : undefined}
className={navLinkClass}
>
<item.icon className="h-5 w-5 shrink-0" />
{(!collapsed || mobileOpen) && <span>{item.label}</span>}
</NavLink>
))}
</nav>
<div className="border-t p-2">
<NavLink
to="/settings"
onClick={mobileOpen ? onMobileClose : undefined}
className={navLinkClass}
>
<Settings className="h-5 w-5 shrink-0" />
{(!collapsed || mobileOpen) && <span>Settings</span>}
</NavLink>
</div>
</>
);
return (
<>
{/* Desktop sidebar */}
<aside
className={cn(
'hidden md:flex flex-col border-r bg-card transition-all duration-300',
collapsed ? 'w-16' : 'w-64'
)}
>
{sidebarContent}
</aside>
{/* Mobile overlay */}
{mobileOpen && (
<div className="fixed inset-0 z-40 md:hidden">
<div className="absolute inset-0 bg-background/80" onClick={onMobileClose} />
<aside className="relative z-50 flex flex-col w-64 h-full bg-card border-r">
{sidebarContent}
</aside>
</div>
)}
</>
);
}

View File

@ -0,0 +1,75 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { MapPin, Trash2, Edit } from 'lucide-react';
import api from '@/lib/api';
import type { Location } from '@/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
interface LocationCardProps {
location: Location;
onEdit: (location: Location) => void;
}
const categoryColors: Record<string, string> = {
home: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
work: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
restaurant: 'bg-orange-500/10 text-orange-500 border-orange-500/20',
shop: 'bg-green-500/10 text-green-500 border-green-500/20',
other: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
};
export default function LocationCard({ location, onEdit }: LocationCardProps) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/locations/${location.id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
toast.success('Location deleted');
},
onError: () => {
toast.error('Failed to delete location');
},
});
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-xl flex items-center gap-2">
<MapPin className="h-5 w-5" />
{location.name}
</CardTitle>
<Badge className={categoryColors[location.category]} style={{ marginTop: '0.5rem' }}>
{location.category}
</Badge>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" onClick={() => onEdit(location)}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-2">{location.address}</p>
{location.notes && (
<p className="text-sm text-muted-foreground line-clamp-2">{location.notes}</p>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,124 @@
import { useState, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Location } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
interface LocationFormProps {
location: Location | null;
onClose: () => void;
}
export default function LocationForm({ location, onClose }: LocationFormProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: location?.name || '',
address: location?.address || '',
category: location?.category || 'other',
notes: location?.notes || '',
});
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
if (location) {
const response = await api.put(`/locations/${location.id}`, data);
return response.data;
} else {
const response = await api.post('/locations', data);
return response.data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
toast.success(location ? 'Location updated' : 'Location created');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, location ? 'Failed to update location' : 'Failed to create location'));
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{location ? 'Edit Location' : 'New Location'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="address">Address</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Select
id="category"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value as any })}
>
<option value="home">Home</option>
<option value="work">Work</option>
<option value="restaurant">Restaurant</option>
<option value="shop">Shop</option>
<option value="other">Other</option>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : location ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,92 @@
import { useState } from 'react';
import { Plus, MapPin } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Location } from '@/types';
import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { GridSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import LocationCard from './LocationCard';
import LocationForm from './LocationForm';
export default function LocationsPage() {
const [showForm, setShowForm] = useState(false);
const [editingLocation, setEditingLocation] = useState<Location | null>(null);
const [categoryFilter, setCategoryFilter] = useState('');
const { data: locations = [], isLoading } = useQuery({
queryKey: ['locations'],
queryFn: async () => {
const { data } = await api.get<Location[]>('/locations');
return data;
},
});
const filteredLocations = categoryFilter
? locations.filter((loc) => loc.category === categoryFilter)
: locations;
const handleEdit = (location: Location) => {
setEditingLocation(location);
setShowForm(true);
};
const handleCloseForm = () => {
setShowForm(false);
setEditingLocation(null);
};
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl font-bold">Locations</h1>
<Button onClick={() => setShowForm(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Location
</Button>
</div>
<div className="flex items-center gap-4">
<Label htmlFor="category-filter">Filter by category:</Label>
<Select
id="category-filter"
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="">All</option>
<option value="home">Home</option>
<option value="work">Work</option>
<option value="restaurant">Restaurant</option>
<option value="shop">Shop</option>
<option value="other">Other</option>
</Select>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<GridSkeleton cards={6} />
) : filteredLocations.length === 0 ? (
<EmptyState
icon={MapPin}
title="No locations yet"
description="Add locations to organise your favourite places, workspaces, and more."
actionLabel="Add Location"
onAction={() => setShowForm(true)}
/>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredLocations.map((location) => (
<LocationCard key={location.id} location={location} onEdit={handleEdit} />
))}
</div>
)}
</div>
{showForm && <LocationForm location={editingLocation} onClose={handleCloseForm} />}
</div>
);
}

View File

@ -0,0 +1,82 @@
import { useState } from 'react';
import { Plus, Users } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Person } from '@/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { GridSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import PersonCard from './PersonCard';
import PersonForm from './PersonForm';
export default function PeoplePage() {
const [showForm, setShowForm] = useState(false);
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
const [search, setSearch] = useState('');
const { data: people = [], isLoading } = useQuery({
queryKey: ['people'],
queryFn: async () => {
const { data } = await api.get<Person[]>('/people');
return data;
},
});
const filteredPeople = people.filter((person) =>
person.name.toLowerCase().includes(search.toLowerCase())
);
const handleEdit = (person: Person) => {
setEditingPerson(person);
setShowForm(true);
};
const handleCloseForm = () => {
setShowForm(false);
setEditingPerson(null);
};
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl font-bold">People</h1>
<Button onClick={() => setShowForm(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Person
</Button>
</div>
<Input
placeholder="Search people..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-md"
/>
</div>
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<GridSkeleton cards={6} />
) : filteredPeople.length === 0 ? (
<EmptyState
icon={Users}
title="No contacts yet"
description="Add people to your directory to keep track of contacts and relationships."
actionLabel="Add Person"
onAction={() => setShowForm(true)}
/>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredPeople.map((person) => (
<PersonCard key={person.id} person={person} onEdit={handleEdit} />
))}
</div>
)}
</div>
{showForm && <PersonForm person={editingPerson} onClose={handleCloseForm} />}
</div>
);
}

View File

@ -0,0 +1,91 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Mail, Phone, MapPin, Calendar, Trash2, Edit } from 'lucide-react';
import { format } from 'date-fns';
import api from '@/lib/api';
import type { Person } from '@/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
interface PersonCardProps {
person: Person;
onEdit: (person: Person) => void;
}
export default function PersonCard({ person, onEdit }: PersonCardProps) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/people/${person.id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] });
toast.success('Person deleted');
},
onError: () => {
toast.error('Failed to delete person');
},
});
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-xl">{person.name}</CardTitle>
{person.relationship && (
<Badge variant="outline" style={{ marginTop: '0.5rem' }}>
{person.relationship}
</Badge>
)}
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" onClick={() => onEdit(person)}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{person.email && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-4 w-4" />
<span className="truncate">{person.email}</span>
</div>
)}
{person.phone && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Phone className="h-4 w-4" />
{person.phone}
</div>
)}
{person.address && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="h-4 w-4" />
<span className="truncate">{person.address}</span>
</div>
)}
{person.birthday && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
Birthday: {format(new Date(person.birthday), 'MMM d, yyyy')}
</div>
)}
{person.notes && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">{person.notes}</p>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,154 @@
import { useState, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Person } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
interface PersonFormProps {
person: Person | null;
onClose: () => void;
}
export default function PersonForm({ person, onClose }: PersonFormProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: person?.name || '',
email: person?.email || '',
phone: person?.phone || '',
address: person?.address || '',
birthday: person?.birthday || '',
relationship: person?.relationship || '',
notes: person?.notes || '',
});
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
if (person) {
const response = await api.put(`/people/${person.id}`, data);
return response.data;
} else {
const response = await api.post('/people', data);
return response.data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] });
toast.success(person ? 'Person updated' : 'Person created');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, person ? 'Failed to update person' : 'Failed to create person'));
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{person ? 'Edit Person' : 'New Person'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address">Address</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="birthday">Birthday</Label>
<Input
id="birthday"
type="date"
value={formData.birthday}
onChange={(e) => setFormData({ ...formData, birthday: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="relationship">Relationship</Label>
<Input
id="relationship"
value={formData.relationship}
onChange={(e) => setFormData({ ...formData, relationship: e.target.value })}
placeholder="e.g., Friend, Family, Colleague"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : person ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,66 @@
import { useNavigate } from 'react-router-dom';
import { format } from 'date-fns';
import { Calendar } from 'lucide-react';
import type { Project } from '@/types';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
interface ProjectCardProps {
project: Project;
onEdit: (project: Project) => void;
}
const statusColors = {
not_started: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
in_progress: 'bg-accent/10 text-accent border-accent/20',
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
};
export default function ProjectCard({ project }: ProjectCardProps) {
const navigate = useNavigate();
const completedTasks = project.tasks?.filter((t) => t.status === 'completed').length || 0;
const totalTasks = project.tasks?.length || 0;
const progress = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0;
return (
<Card
className="cursor-pointer transition-colors hover:bg-accent/5"
onClick={() => navigate(`/projects/${project.id}`)}
>
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="text-xl">{project.name}</CardTitle>
<Badge className={statusColors[project.status]}>{project.status.replace('_', ' ')}</Badge>
</div>
{project.description && (
<CardDescription className="line-clamp-2">{project.description}</CardDescription>
)}
</CardHeader>
<CardContent>
{totalTasks > 0 && (
<div className="mb-3">
<div className="flex justify-between text-sm mb-1">
<span className="text-muted-foreground">Progress</span>
<span className="font-medium">
{completedTasks}/{totalTasks} tasks
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-accent transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{project.due_date && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
Due {format(new Date(project.due_date), 'MMM d, yyyy')}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,197 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
import api from '@/lib/api';
import type { Project, ProjectTask } from '@/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import TaskForm from './TaskForm';
import ProjectForm from './ProjectForm';
const statusColors = {
not_started: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
in_progress: 'bg-accent/10 text-accent border-accent/20',
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
};
const taskStatusColors = {
pending: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
in_progress: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
};
const priorityColors = {
low: 'bg-green-500/10 text-green-500 border-green-500/20',
medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
high: 'bg-red-500/10 text-red-500 border-red-500/20',
};
export default function ProjectDetail() {
const { id } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showTaskForm, setShowTaskForm] = useState(false);
const [showProjectForm, setShowProjectForm] = useState(false);
const [editingTask, setEditingTask] = useState<ProjectTask | null>(null);
const { data: project, isLoading } = useQuery({
queryKey: ['projects', id],
queryFn: async () => {
const { data } = await api.get<Project>(`/projects/${id}`);
return data;
},
});
const toggleTaskMutation = useMutation({
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
const newStatus = status === 'completed' ? 'pending' : 'completed';
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] });
},
});
const deleteTaskMutation = useMutation({
mutationFn: async (taskId: number) => {
await api.delete(`/projects/${id}/tasks/${taskId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] });
toast.success('Task deleted');
},
});
const deleteProjectMutation = useMutation({
mutationFn: async () => {
await api.delete(`/projects/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
toast.success('Project deleted');
navigate('/projects');
},
onError: () => {
toast.error('Failed to delete project');
},
});
if (isLoading) {
return <div className="p-6 text-center text-muted-foreground">Loading project...</div>;
}
if (!project) {
return <div className="p-6 text-center text-muted-foreground">Project not found</div>;
}
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<div className="flex items-center gap-4 mb-4">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="text-3xl font-bold flex-1">{project.name}</h1>
<Badge className={statusColors[project.status]}>{project.status.replace('_', ' ')}</Badge>
<Button variant="outline" onClick={() => setShowProjectForm(true)}>
Edit Project
</Button>
<Button
variant="destructive"
onClick={() => deleteProjectMutation.mutate()}
disabled={deleteProjectMutation.isPending}
>
Delete Project
</Button>
</div>
{project.description && (
<p className="text-muted-foreground mb-4">{project.description}</p>
)}
<Button onClick={() => setShowTaskForm(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Task
</Button>
</div>
<div className="flex-1 overflow-y-auto p-6">
{!project.tasks || project.tasks.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">No tasks yet. Add one to get started!</p>
</div>
) : (
<div className="space-y-2">
{project.tasks.map((task) => (
<Card key={task.id}>
<CardHeader>
<div className="flex items-start gap-3">
<Checkbox
checked={task.status === 'completed'}
onChange={() =>
toggleTaskMutation.mutate({ taskId: task.id, status: task.status })
}
disabled={toggleTaskMutation.isPending}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg">{task.title}</CardTitle>
{task.description && (
<CardDescription className="mt-1">{task.description}</CardDescription>
)}
<div className="flex items-center gap-2 mt-2">
<Badge className={taskStatusColors[task.status]}>
{task.status.replace('_', ' ')}
</Badge>
<Badge className={priorityColors[task.priority]}>{task.priority}</Badge>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
setEditingTask(task);
setShowTaskForm(true);
}}
>
Edit
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteTaskMutation.mutate(task.id)}
disabled={deleteTaskMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
</Card>
))}
</div>
)}
</div>
{showTaskForm && (
<TaskForm
projectId={parseInt(id!)}
task={editingTask}
onClose={() => {
setShowTaskForm(false);
setEditingTask(null);
}}
/>
)}
{showProjectForm && (
<ProjectForm
project={project}
onClose={() => setShowProjectForm(false)}
/>
)}
</div>
);
}

View File

@ -0,0 +1,138 @@
import { useState, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Project } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
interface ProjectFormProps {
project: Project | null;
onClose: () => void;
}
export default function ProjectForm({ project, onClose }: ProjectFormProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: project?.name || '',
description: project?.description || '',
status: project?.status || 'not_started',
color: project?.color || '',
due_date: project?.due_date || '',
});
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
if (project) {
const response = await api.put(`/projects/${project.id}`, data);
return response.data;
} else {
const response = await api.post('/projects', data);
return response.data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
if (project) {
queryClient.invalidateQueries({ queryKey: ['projects', project.id.toString()] });
}
toast.success(project ? 'Project updated' : 'Project created');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, project ? 'Failed to update project' : 'Failed to create project'));
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{project ? 'Edit Project' : 'New Project'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
id="status"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
>
<option value="not_started">Not Started</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Input
id="color"
type="color"
value={formData.color}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : project ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,90 @@
import { useState } from 'react';
import { Plus, FolderKanban } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Project } from '@/types';
import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { GridSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import ProjectCard from './ProjectCard';
import ProjectForm from './ProjectForm';
export default function ProjectsPage() {
const [showForm, setShowForm] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [statusFilter, setStatusFilter] = useState('');
const { data: projects = [], isLoading } = useQuery({
queryKey: ['projects'],
queryFn: async () => {
const { data } = await api.get<Project[]>('/projects');
return data;
},
});
const filteredProjects = statusFilter
? projects.filter((p) => p.status === statusFilter)
: projects;
const handleEdit = (project: Project) => {
setEditingProject(project);
setShowForm(true);
};
const handleCloseForm = () => {
setShowForm(false);
setEditingProject(null);
};
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl font-bold">Projects</h1>
<Button onClick={() => setShowForm(true)}>
<Plus className="mr-2 h-4 w-4" />
New Project
</Button>
</div>
<div className="flex items-center gap-4">
<Label htmlFor="status-filter">Filter by status:</Label>
<Select
id="status-filter"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">All</option>
<option value="not_started">Not Started</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</Select>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<GridSkeleton cards={6} />
) : filteredProjects.length === 0 ? (
<EmptyState
icon={FolderKanban}
title="No projects yet"
description="Create your first project to start tracking tasks and progress."
actionLabel="New Project"
onAction={() => setShowForm(true)}
/>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredProjects.map((project) => (
<ProjectCard key={project.id} project={project} onEdit={handleEdit} />
))}
</div>
)}
</div>
{showForm && <ProjectForm project={editingProject} onClose={handleCloseForm} />}
</div>
);
}

View File

@ -0,0 +1,168 @@
import { useState, FormEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { ProjectTask, Person } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
interface TaskFormProps {
projectId: number;
task: ProjectTask | null;
onClose: () => void;
}
export default function TaskForm({ projectId, task, onClose }: TaskFormProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
title: task?.title || '',
description: task?.description || '',
status: task?.status || 'pending',
priority: task?.priority || 'medium',
due_date: task?.due_date || '',
person_id: task?.person_id?.toString() || '',
});
const { data: people = [] } = useQuery({
queryKey: ['people'],
queryFn: async () => {
const { data } = await api.get<Person[]>('/people');
return data;
},
});
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
const payload = {
...data,
person_id: data.person_id ? parseInt(data.person_id) : null,
};
if (task) {
const response = await api.put(`/projects/${projectId}/tasks/${task.id}`, payload);
return response.data;
} else {
const response = await api.post(`/projects/${projectId}/tasks`, payload);
return response.data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
toast.success(task ? 'Task updated' : 'Task created');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, task ? 'Failed to update task' : 'Failed to create task'));
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{task ? 'Edit Task' : 'New Task'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
id="status"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="priority">Priority</Label>
<Select
id="priority"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as any })}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="person">Assign To</Label>
<Select
id="person"
value={formData.person_id}
onChange={(e) => setFormData({ ...formData, person_id: e.target.value })}
>
<option value="">Unassigned</option>
{people.map((person) => (
<option key={person.id} value={person.id}>
{person.name}
</option>
))}
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : task ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,124 @@
import { useState, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Reminder } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
interface ReminderFormProps {
reminder: Reminder | null;
onClose: () => void;
}
export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
title: reminder?.title || '',
description: reminder?.description || '',
remind_at: reminder?.remind_at || '',
recurrence_rule: reminder?.recurrence_rule || '',
});
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
if (reminder) {
const response = await api.put(`/reminders/${reminder.id}`, data);
return response.data;
} else {
const response = await api.post('/reminders', data);
return response.data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reminders'] });
toast.success(reminder ? 'Reminder updated' : 'Reminder created');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, reminder ? 'Failed to update reminder' : 'Failed to create reminder'));
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{reminder ? 'Edit Reminder' : 'New Reminder'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remind_at">Remind At</Label>
<Input
id="remind_at"
type="datetime-local"
value={formData.remind_at}
onChange={(e) => setFormData({ ...formData, remind_at: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="recurrence">Recurrence</Label>
<Select
id="recurrence"
value={formData.recurrence_rule}
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })}
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : reminder ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,114 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, isPast } from 'date-fns';
import { Bell, BellOff, Trash2, Calendar } from 'lucide-react';
import api from '@/lib/api';
import type { Reminder } from '@/types';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface ReminderListProps {
reminders: Reminder[];
onEdit: (reminder: Reminder) => void;
}
export default function ReminderList({ reminders, onEdit }: ReminderListProps) {
const queryClient = useQueryClient();
const dismissMutation = useMutation({
mutationFn: async (id: number) => {
const { data } = await api.patch(`/reminders/${id}/dismiss`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reminders'] });
toast.success('Reminder dismissed');
},
onError: () => {
toast.error('Failed to dismiss reminder');
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
await api.delete(`/reminders/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reminders'] });
toast.success('Reminder deleted');
},
onError: () => {
toast.error('Failed to delete reminder');
},
});
if (reminders.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">No reminders found.</p>
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{reminders.map((reminder) => {
const isOverdue = !reminder.is_dismissed && isPast(new Date(reminder.remind_at));
return (
<Card
key={reminder.id}
className={cn(
'cursor-pointer transition-colors hover:bg-accent/5',
isOverdue && 'border-destructive'
)}
onClick={() => onEdit(reminder)}
>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{reminder.is_dismissed ? (
<BellOff className="h-5 w-5 text-muted-foreground" />
) : (
<Bell className={cn('h-5 w-5', isOverdue && 'text-destructive')} />
)}
<CardTitle className="text-lg">{reminder.title}</CardTitle>
</div>
</div>
</CardHeader>
<CardContent>
{reminder.description && (
<p className="text-sm text-muted-foreground mb-3">{reminder.description}</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
<Calendar className="h-4 w-4" />
{format(new Date(reminder.remind_at), 'MMM d, yyyy h:mm a')}
{isOverdue && <span className="text-destructive font-medium">(Overdue)</span>}
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
{!reminder.is_dismissed && (
<Button
size="sm"
variant="outline"
onClick={() => dismissMutation.mutate(reminder.id)}
disabled={dismissMutation.isPending}
>
Dismiss
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => deleteMutation.mutate(reminder.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@ -0,0 +1,84 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Reminder } from '@/types';
import { Button } from '@/components/ui/button';
import { GridSkeleton } from '@/components/ui/skeleton';
import ReminderList from './ReminderList';
import ReminderForm from './ReminderForm';
export default function RemindersPage() {
const [showForm, setShowForm] = useState(false);
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
const [filter, setFilter] = useState<'all' | 'active' | 'dismissed'>('active');
const { data: reminders = [], isLoading } = useQuery({
queryKey: ['reminders'],
queryFn: async () => {
const { data } = await api.get<Reminder[]>('/reminders');
return data;
},
});
const filteredReminders = reminders.filter((reminder) => {
if (filter === 'active') return !reminder.is_dismissed;
if (filter === 'dismissed') return reminder.is_dismissed;
return true;
});
const handleEdit = (reminder: Reminder) => {
setEditingReminder(reminder);
setShowForm(true);
};
const handleCloseForm = () => {
setShowForm(false);
setEditingReminder(null);
};
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl font-bold">Reminders</h1>
<Button onClick={() => setShowForm(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Reminder
</Button>
</div>
<div className="flex gap-2">
<Button
variant={filter === 'active' ? 'default' : 'outline'}
onClick={() => setFilter('active')}
>
Active
</Button>
<Button
variant={filter === 'dismissed' ? 'default' : 'outline'}
onClick={() => setFilter('dismissed')}
>
Dismissed
</Button>
<Button
variant={filter === 'all' ? 'default' : 'outline'}
onClick={() => setFilter('all')}
>
All
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<GridSkeleton cards={6} />
) : (
<ReminderList reminders={filteredReminders} onEdit={handleEdit} />
)}
</div>
{showForm && <ReminderForm reminder={editingReminder} onClose={handleCloseForm} />}
</div>
);
}

View File

@ -0,0 +1,193 @@
import { useState, FormEvent, CSSProperties } from 'react';
import { toast } from 'sonner';
import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
const accentColors = [
{ name: 'cyan', label: 'Cyan', color: '#06b6d4' },
{ name: 'blue', label: 'Blue', color: '#3b82f6' },
{ name: 'purple', label: 'Purple', color: '#8b5cf6' },
{ name: 'orange', label: 'Orange', color: '#f97316' },
{ name: 'green', label: 'Green', color: '#22c55e' },
];
export default function SettingsPage() {
const { settings, updateSettings, changePin, isUpdating, isChangingPin } = useSettings();
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
const [pinForm, setPinForm] = useState({
oldPin: '',
newPin: '',
confirmPin: '',
});
const handleColorChange = async (color: string) => {
setSelectedColor(color);
try {
await updateSettings({ accent_color: color });
toast.success('Accent color updated');
} catch (error) {
toast.error('Failed to update accent color');
}
};
const handleUpcomingDaysSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
await updateSettings({ upcoming_days: upcomingDays });
toast.success('Settings updated');
} catch (error) {
toast.error('Failed to update settings');
}
};
const handlePinSubmit = async (e: FormEvent) => {
e.preventDefault();
if (pinForm.newPin !== pinForm.confirmPin) {
toast.error('New PINs do not match');
return;
}
if (pinForm.newPin.length < 4) {
toast.error('PIN must be at least 4 characters');
return;
}
try {
await changePin({ oldPin: pinForm.oldPin, newPin: pinForm.newPin });
toast.success('PIN changed successfully');
setPinForm({ oldPin: '', newPin: '', confirmPin: '' });
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to change PIN');
}
};
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<h1 className="text-3xl font-bold">Settings</h1>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl space-y-6">
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>Customize the look and feel of your application</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Accent Color</Label>
<div className="flex gap-3 mt-3">
{accentColors.map((color) => (
<button
key={color.name}
type="button"
onClick={() => handleColorChange(color.name)}
className={cn(
'h-12 w-12 rounded-full border-2 transition-all hover:scale-110',
selectedColor === color.name
? 'border-white ring-2 ring-offset-2 ring-offset-background'
: 'border-transparent'
)}
style={
{
backgroundColor: color.color,
'--tw-ring-color': color.color,
} as CSSProperties
}
title={color.label}
/>
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Dashboard</CardTitle>
<CardDescription>Configure your dashboard preferences</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUpcomingDaysSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="upcoming_days">Upcoming Days Range</Label>
<div className="flex gap-3 items-center">
<Input
id="upcoming_days"
type="number"
min="1"
max="30"
value={upcomingDays}
onChange={(e) => setUpcomingDays(parseInt(e.target.value))}
className="w-24"
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
<p className="text-sm text-muted-foreground">
How many days ahead to show in the upcoming items widget
</p>
</div>
<Button type="submit" disabled={isUpdating}>
{isUpdating ? 'Saving...' : 'Save'}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Security</CardTitle>
<CardDescription>Change your PIN</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handlePinSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="old_pin">Current PIN</Label>
<Input
id="old_pin"
type="password"
value={pinForm.oldPin}
onChange={(e) => setPinForm({ ...pinForm, oldPin: e.target.value })}
required
className="max-w-xs"
/>
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="new_pin">New PIN</Label>
<Input
id="new_pin"
type="password"
value={pinForm.newPin}
onChange={(e) => setPinForm({ ...pinForm, newPin: e.target.value })}
required
className="max-w-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm_pin">Confirm New PIN</Label>
<Input
id="confirm_pin"
type="password"
value={pinForm.confirmPin}
onChange={(e) => setPinForm({ ...pinForm, confirmPin: e.target.value })}
required
className="max-w-xs"
/>
</div>
<Button type="submit" disabled={isChangingPin}>
{isChangingPin ? 'Changing...' : 'Change PIN'}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,150 @@
import { useState, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Todo } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
interface TodoFormProps {
todo: Todo | null;
onClose: () => void;
}
export default function TodoForm({ todo, onClose }: TodoFormProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
title: todo?.title || '',
description: todo?.description || '',
priority: todo?.priority || 'medium',
due_date: todo?.due_date || '',
category: todo?.category || '',
recurrence_rule: todo?.recurrence_rule || '',
});
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
if (todo) {
const response = await api.put(`/todos/${todo.id}`, data);
return response.data;
} else {
const response = await api.post('/todos', data);
return response.data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
toast.success(todo ? 'Todo updated' : 'Todo created');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, todo ? 'Failed to update todo' : 'Failed to create todo'));
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{todo ? 'Edit Todo' : 'New Todo'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="priority">Priority</Label>
<Select
id="priority"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as any })}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Input
id="category"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
placeholder="e.g., Work, Personal, Shopping"
/>
</div>
<div className="space-y-2">
<Label htmlFor="recurrence">Recurrence</Label>
<Select
id="recurrence"
value={formData.recurrence_rule}
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })}
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : todo ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,93 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Trash2, Calendar } from 'lucide-react';
import { format } from 'date-fns';
import api from '@/lib/api';
import type { Todo } from '@/types';
import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
interface TodoItemProps {
todo: Todo;
onEdit: (todo: Todo) => void;
}
const priorityColors = {
low: 'bg-green-500/10 text-green-500 border-green-500/20',
medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
high: 'bg-red-500/10 text-red-500 border-red-500/20',
};
export default function TodoItem({ todo, onEdit }: TodoItemProps) {
const queryClient = useQueryClient();
const toggleMutation = useMutation({
mutationFn: async () => {
const { data } = await api.patch(`/todos/${todo.id}/toggle`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
toast.success(todo.completed ? 'Todo marked incomplete' : 'Todo completed!');
},
onError: () => {
toast.error('Failed to update todo');
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/todos/${todo.id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
toast.success('Todo deleted');
},
onError: () => {
toast.error('Failed to delete todo');
},
});
return (
<div className="flex items-center gap-3 rounded-lg border bg-card p-4 hover:bg-accent/5 transition-colors">
<Checkbox
checked={todo.completed}
onChange={() => toggleMutation.mutate()}
disabled={toggleMutation.isPending}
/>
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => onEdit(todo)}>
<h3
className={cn(
'font-medium',
todo.completed && 'line-through text-muted-foreground'
)}
>
{todo.title}
</h3>
{todo.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">{todo.description}</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge className={priorityColors[todo.priority]}>{todo.priority}</Badge>
{todo.category && <Badge variant="outline">{todo.category}</Badge>}
{todo.due_date && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
{format(new Date(todo.due_date), 'MMM d, yyyy')}
</div>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}

View File

@ -0,0 +1,25 @@
import type { Todo } from '@/types';
import TodoItem from './TodoItem';
interface TodoListProps {
todos: Todo[];
onEdit: (todo: Todo) => void;
}
export default function TodoList({ todos, onEdit }: TodoListProps) {
if (todos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">No todos found. Create one to get started!</p>
</div>
);
}
return (
<div className="space-y-2">
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onEdit={onEdit} />
))}
</div>
);
}

View File

@ -0,0 +1,110 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Todo } from '@/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { ListSkeleton } from '@/components/ui/skeleton';
import TodoList from './TodoList';
import TodoForm from './TodoForm';
export default function TodosPage() {
const [showForm, setShowForm] = useState(false);
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
const [filters, setFilters] = useState({
priority: '',
category: '',
showCompleted: true,
search: '',
});
const { data: todos = [], isLoading } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const { data } = await api.get<Todo[]>('/todos');
return data;
},
});
const filteredTodos = todos.filter((todo) => {
if (filters.priority && todo.priority !== filters.priority) return false;
if (filters.category && todo.category !== filters.category) return false;
if (!filters.showCompleted && todo.completed) return false;
if (filters.search && !todo.title.toLowerCase().includes(filters.search.toLowerCase()))
return false;
return true;
});
const handleEdit = (todo: Todo) => {
setEditingTodo(todo);
setShowForm(true);
};
const handleCloseForm = () => {
setShowForm(false);
setEditingTodo(null);
};
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl font-bold">Todos</h1>
<Button onClick={() => setShowForm(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Todo
</Button>
</div>
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="Search todos..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<Select
value={filters.priority}
onChange={(e) => setFilters({ ...filters, priority: e.target.value })}
>
<option value="">All Priorities</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
<Input
placeholder="Filter by category..."
value={filters.category}
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
className="w-48"
/>
<div className="flex items-center gap-2">
<Checkbox
id="show-completed"
checked={filters.showCompleted}
onChange={(e) =>
setFilters({ ...filters, showCompleted: (e.target as HTMLInputElement).checked })
}
/>
<Label htmlFor="show-completed">Show completed</Label>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<ListSkeleton rows={6} />
) : (
<TodoList todos={filteredTodos} onEdit={handleEdit} />
)}
</div>
{showForm && <TodoForm todo={editingTodo} onClose={handleCloseForm} />}
</div>
);
}

View File

@ -0,0 +1,30 @@
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-accent text-accent-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
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,48 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium 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-accent text-accent-foreground hover:bg-accent/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent/10 hover:text-accent',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent/10 hover:text-accent',
link: 'text-accent 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> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@ -0,0 +1,55 @@
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,27 @@
import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, ...props }, ref) => {
return (
<div className="relative inline-flex items-center">
<input
type="checkbox"
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 appearance-none checked:bg-accent checked:border-accent',
className
)}
ref={ref}
{...props}
/>
<Check className="absolute left-0 h-4 w-4 text-accent-foreground opacity-0 peer-checked:opacity-100 pointer-events-none" />
</div>
);
}
);
Checkbox.displayName = 'Checkbox';
export { Checkbox };

View File

@ -0,0 +1,106 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}
const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children }) => {
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onOpenChange(false);
};
if (open) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [open, onOpenChange]);
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="fixed inset-0 bg-background/80 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
/>
<div className="relative z-50">{children}</div>
</div>,
document.body
);
};
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
'relative grid w-full max-w-lg gap-4 border bg-card p-6 shadow-lg rounded-lg',
className
)}
{...props}
>
{children}
</div>
)
);
DialogContent.displayName = 'DialogContent';
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<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
DialogTitle.displayName = 'DialogTitle';
const DialogDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
DialogDescription.displayName = 'DialogDescription';
const DialogClose = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ className, ...props }, ref) => (
<button
ref={ref}
className={cn(
'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',
className
)}
{...props}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
)
);
DialogClose.displayName = 'DialogClose';
export { Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, DialogClose };

View File

@ -0,0 +1,67 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface DropdownMenuProps {
children: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const DropdownMenu: React.FC<DropdownMenuProps> = ({ children }) => {
return <div className="relative inline-block">{children}</div>;
};
const DropdownMenuTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, children, ...props }, ref) => (
<button ref={ref} className={cn(className)} {...props}>
{children}
</button>
));
DropdownMenuTrigger.displayName = 'DropdownMenuTrigger';
const DropdownMenuContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
'absolute right-0 z-50 mt-2 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
className
)}
{...props}
>
{children}
</div>
)
);
DropdownMenuContent.displayName = 'DropdownMenuContent';
const DropdownMenuItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground',
className
)}
{...props}
/>
)
);
DropdownMenuItem.displayName = 'DropdownMenuItem';
const DropdownMenuSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
)
);
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
};

View File

@ -0,0 +1,29 @@
import type { LucideIcon } from 'lucide-react';
import { Button } from './button';
import { Plus } from 'lucide-react';
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
}
export function EmptyState({ icon: Icon, title, description, actionLabel, onAction }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4">
<div className="rounded-full bg-muted p-4 mb-4">
<Icon className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-1">{title}</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">{description}</p>
{actionLabel && onAction && (
<Button onClick={onAction}>
<Plus className="mr-2 h-4 w-4" />
{actionLabel}
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,23 @@
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,20 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className
)}
{...props}
/>
)
);
Label.displayName = 'Label';
export { Label };

View File

@ -0,0 +1,13 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => (
<div ref={ref} className={cn('relative overflow-auto', className)} {...props}>
{children}
</div>
)
);
ScrollArea.displayName = 'ScrollArea';
export { ScrollArea };

View File

@ -0,0 +1,28 @@
import * as React from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, children, ...props }, ref) => {
return (
<div className="relative">
<select
className={cn(
'flex h-10 w-full appearance-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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}
>
{children}
</select>
<ChevronDown className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 opacity-50 pointer-events-none" />
</div>
);
}
);
Select.displayName = 'Select';
export { Select };

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: 'horizontal' | 'vertical';
decorative?: boolean;
}
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<div
ref={ref}
role={decorative ? 'none' : 'separator'}
aria-orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
);
Separator.displayName = 'Separator';
export { Separator };

View File

@ -0,0 +1,81 @@
import { cn } from '@/lib/utils';
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={cn(
'animate-pulse rounded-md bg-muted',
className
)}
/>
);
}
export function CardSkeleton() {
return (
<div className="rounded-lg border bg-card p-6 space-y-3">
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-1/2" />
</div>
);
}
export function ListSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="space-y-3">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border bg-card p-4">
<Skeleton className="h-5 w-5 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
);
}
export function GridSkeleton({ cards = 6 }: { cards?: number }) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: cards }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
);
}
export function DashboardSkeleton() {
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card p-6 space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-8 w-12" />
</div>
))}
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="rounded-lg border bg-card p-6 space-y-3">
<Skeleton className="h-5 w-32" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
<div className="rounded-lg border bg-card p-6 space-y-3">
<Skeleton className="h-5 w-32" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
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 };

Some files were not shown because too many files have changed in this diff Show More