First commit
This commit is contained in:
commit
19bba1a0d1
65
.gitignore
vendored
Normal file
65
.gitignore
vendored
Normal file
@ -0,0 +1,65 @@
|
||||
# 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
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Generated ICS files
|
||||
*.ics
|
||||
|
||||
# SQLite database
|
||||
data/
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
.npm
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Helm examples
|
||||
tasko/
|
||||
tasko-chart/
|
||||
121
.woodpecker.yml
Normal file
121
.woodpecker.yml
Normal file
@ -0,0 +1,121 @@
|
||||
steps:
|
||||
build-frontend:
|
||||
name: Build & Push Frontend
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
when:
|
||||
branch: [ master, develop ]
|
||||
event: [ push, pull_request, tag ]
|
||||
path:
|
||||
include: [ frontend/** ]
|
||||
settings:
|
||||
registry: harbor.dvirlabs.com
|
||||
repo: my-apps/${CI_REPO_NAME}-frontend
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}}
|
||||
username:
|
||||
from_secret: DOCKER_USERNAME
|
||||
password:
|
||||
from_secret: DOCKER_PASSWORD
|
||||
|
||||
build-backend:
|
||||
name: Build & Push Backend
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
when:
|
||||
branch: [ master, develop ]
|
||||
event: [ push, pull_request, tag ]
|
||||
path:
|
||||
include: [ backend/** ]
|
||||
settings:
|
||||
registry: harbor-core.dev-tools.svc.cluster.local
|
||||
repo: my-apps/${CI_REPO_NAME}-backend
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}}
|
||||
username:
|
||||
from_secret: DOCKER_USERNAME
|
||||
password:
|
||||
from_secret: DOCKER_PASSWORD
|
||||
|
||||
update-values-frontend:
|
||||
name: Update frontend tag in values.yaml
|
||||
image: alpine:3.19
|
||||
when:
|
||||
branch: [ master, develop ]
|
||||
event: [ push ]
|
||||
path:
|
||||
include: [ frontend/** ]
|
||||
environment:
|
||||
GIT_USERNAME:
|
||||
from_secret: GIT_USERNAME
|
||||
GIT_TOKEN:
|
||||
from_secret: GIT_TOKEN
|
||||
commands:
|
||||
- apk add --no-cache git yq
|
||||
- git config --global user.name "woodpecker-bot"
|
||||
- git config --global user.email "ci@dvirlabs.com"
|
||||
- git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git"
|
||||
- cd my-apps
|
||||
- |
|
||||
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||
echo "💡 Setting frontend tag to: $TAG"
|
||||
yq -i ".frontend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||
git add manifests/${CI_REPO_NAME}/values.yaml
|
||||
git commit -m "frontend: update tag to $TAG" || echo "No changes"
|
||||
git push origin HEAD
|
||||
|
||||
update-values-backend:
|
||||
name: Update backend tag in values.yaml
|
||||
image: alpine:3.19
|
||||
when:
|
||||
branch: [ master, develop ]
|
||||
event: [ push ]
|
||||
path:
|
||||
include: [ backend/** ]
|
||||
environment:
|
||||
GIT_USERNAME:
|
||||
from_secret: GIT_USERNAME
|
||||
GIT_TOKEN:
|
||||
from_secret: GIT_TOKEN
|
||||
commands:
|
||||
- apk add --no-cache git yq
|
||||
- git config --global user.name "woodpecker-bot"
|
||||
- git config --global user.email "ci@dvirlabs.com"
|
||||
- |
|
||||
if [ ! -d "my-apps" ]; then
|
||||
git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git"
|
||||
fi
|
||||
- cd my-apps
|
||||
- |
|
||||
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||
echo "💡 Setting backend tag to: $TAG"
|
||||
yq -i ".backend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||
git add manifests/${CI_REPO_NAME}/values.yaml
|
||||
git commit -m "backend: update tag to $TAG" || echo "No changes"
|
||||
git push origin HEAD
|
||||
|
||||
|
||||
trigger-gitops-via-push:
|
||||
when:
|
||||
branch: [ master, develop ]
|
||||
event: [ push ]
|
||||
name: Trigger apps-gitops via Git push
|
||||
image: alpine/git
|
||||
environment:
|
||||
GIT_USERNAME:
|
||||
from_secret: GIT_USERNAME
|
||||
GIT_TOKEN:
|
||||
from_secret: GIT_TOKEN
|
||||
commands: |
|
||||
git config --global user.name "woodpecker-bot"
|
||||
git config --global user.email "ci@dvirlabs.com"
|
||||
git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/apps-gitops.git"
|
||||
cd apps-gitops
|
||||
echo "# trigger at $(date) by $${CI_REPO_NAME}" >> .trigger
|
||||
git add .trigger
|
||||
git commit -m "ci: trigger apps-gitops build" || echo "no changes"
|
||||
git push origin HEAD
|
||||
170
QUICKSTART.md
Normal file
170
QUICKSTART.md
Normal file
@ -0,0 +1,170 @@
|
||||
# Calink Quick Start Guide
|
||||
|
||||
Get Calink running in under 5 minutes!
|
||||
|
||||
## Option 1: Docker Compose (Recommended)
|
||||
|
||||
**Requirements:** Docker and Docker Compose
|
||||
|
||||
```bash
|
||||
# Clone or navigate to the project
|
||||
cd ics-generator
|
||||
|
||||
# Start everything
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Access the app
|
||||
open http://localhost
|
||||
```
|
||||
|
||||
That's it! Calink is now running on http://localhost
|
||||
|
||||
To stop:
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## Option 2: Local Development
|
||||
|
||||
**Requirements:** Python 3.11+, Node.js 18+
|
||||
|
||||
### Terminal 1 - Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
Backend runs on: http://localhost:8000
|
||||
|
||||
### Terminal 2 - Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend runs on: http://localhost:5173
|
||||
|
||||
## Option 3: Kubernetes with Helm
|
||||
|
||||
**Requirements:** Kubernetes cluster, Helm 3+
|
||||
|
||||
```bash
|
||||
# Install Calink
|
||||
helm install calink ./helm/calink
|
||||
|
||||
# Check status
|
||||
helm status calink
|
||||
|
||||
# Get access info
|
||||
kubectl get ingress
|
||||
```
|
||||
|
||||
## First Steps
|
||||
|
||||
1. **Create an Event**
|
||||
- Open the web UI
|
||||
- Fill in event title and start date/time
|
||||
- Add reminders using quick buttons or custom values
|
||||
- Click "Download .ics"
|
||||
|
||||
2. **Use History**
|
||||
- Switch to "History" tab
|
||||
- Click "Load" on any past event to reuse it
|
||||
- Click "Delete" to remove old events
|
||||
|
||||
3. **Create Templates**
|
||||
- Set up your favorite reminder configuration
|
||||
- Switch to "Templates" tab
|
||||
- Click "Save Current Reminders"
|
||||
- Name your template
|
||||
- Apply it anytime in future events
|
||||
|
||||
## API Examples
|
||||
|
||||
### Generate ICS File
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/api/ics" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Team Meeting",
|
||||
"start_dt": "2026-03-01T10:00:00Z",
|
||||
"reminders": [
|
||||
{"amount": 10, "unit": "minutes"}
|
||||
]
|
||||
}' \
|
||||
--output meeting.ics
|
||||
```
|
||||
|
||||
### Get History
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/history
|
||||
```
|
||||
|
||||
### Create Template
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/api/templates" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Standard Reminders",
|
||||
"reminders": [
|
||||
{"amount": 10, "unit": "minutes"},
|
||||
{"amount": 1, "unit": "hours"},
|
||||
{"amount": 1, "unit": "days"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Failed to fetch" in UI:**
|
||||
- Ensure backend is running on port 8000
|
||||
- Check browser console for errors
|
||||
- Verify CORS is enabled (it should be by default)
|
||||
|
||||
**Docker: "Port already allocated":**
|
||||
```bash
|
||||
# Stop conflicting service
|
||||
docker-compose down
|
||||
|
||||
# Or change ports in docker-compose.yml
|
||||
```
|
||||
|
||||
**Backend: "No module named 'fastapi'":**
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**Frontend: Build errors:**
|
||||
```bash
|
||||
cd frontend
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read the full [README.md](README.md) for detailed documentation
|
||||
- Explore API documentation at http://localhost:8000/docs
|
||||
- Customize Helm values for production deployment
|
||||
- Set up ingress for custom domain
|
||||
|
||||
## Support
|
||||
|
||||
- API Docs: http://localhost:8000/docs (when backend is running)
|
||||
- Health Check: http://localhost:8000/health
|
||||
- Full Documentation: [README.md](README.md)
|
||||
|
||||
---
|
||||
|
||||
**Happy event creating! 🎉**
|
||||
422
README.md
Normal file
422
README.md
Normal file
@ -0,0 +1,422 @@
|
||||
# Calink
|
||||
|
||||
**Calink** lets you create and share calendar events with custom reminders using downloadable .ics files.
|
||||
|
||||
## Features
|
||||
|
||||
- 📅 Generate valid iCalendar (.ics) files
|
||||
- 🔔 Customizable reminders (minutes, hours, days, weeks)
|
||||
- 📋 Event history for quick reuse
|
||||
- ⚡ Reminder templates for common setups
|
||||
- 🌍 Timezone support
|
||||
- 🐳 Docker & Docker Compose ready
|
||||
- ☸️ Kubernetes Helm chart included
|
||||
- 🎨 Modern, responsive UI
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Local Development
|
||||
|
||||
#### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
Backend will run on: http://localhost:8000
|
||||
- API docs: http://localhost:8000/docs
|
||||
- Health check: http://localhost:8000/health
|
||||
|
||||
#### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend will run on: http://localhost:5173
|
||||
|
||||
## Docker Usage
|
||||
|
||||
### Build Images
|
||||
|
||||
```bash
|
||||
# Build backend
|
||||
docker build -t calink-backend:latest ./backend
|
||||
|
||||
# Build frontend
|
||||
docker build -t calink-frontend:latest ./frontend
|
||||
```
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This will start:
|
||||
- Backend on port 8000
|
||||
- Frontend on port 80
|
||||
- Persistent storage volume for SQLite database
|
||||
|
||||
Access the application at: http://localhost
|
||||
|
||||
To stop:
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
To view logs:
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## Kubernetes Deployment
|
||||
|
||||
### Using Helm
|
||||
|
||||
1. **Install the chart:**
|
||||
|
||||
```bash
|
||||
helm install calink ./helm/calink
|
||||
```
|
||||
|
||||
2. **With custom values:**
|
||||
|
||||
```bash
|
||||
helm install calink ./helm/calink -f custom-values.yaml
|
||||
```
|
||||
|
||||
3. **Upgrade release:**
|
||||
|
||||
```bash
|
||||
helm upgrade calink ./helm/calink
|
||||
```
|
||||
|
||||
4. **Uninstall:**
|
||||
|
||||
```bash
|
||||
helm uninstall calink
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Edit `helm/calink/values.yaml` to customize:
|
||||
|
||||
- **Images**: Change repository and tags
|
||||
- **Resources**: CPU/memory limits
|
||||
- **Persistence**: Storage size and class
|
||||
- **Ingress**: Domain and TLS settings
|
||||
- **Replicas**: Scale deployments
|
||||
|
||||
Example custom values:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
image:
|
||||
repository: your-registry/calink-backend
|
||||
tag: v1.0.0
|
||||
replicas: 2
|
||||
persistence:
|
||||
size: 5Gi
|
||||
|
||||
frontend:
|
||||
image:
|
||||
repository: your-registry/calink-frontend
|
||||
tag: v1.0.0
|
||||
replicas: 3
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
hosts:
|
||||
- host: calink.yourdomain.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: calink-tls
|
||||
hosts:
|
||||
- calink.yourdomain.com
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Backend API
|
||||
|
||||
#### Health Check
|
||||
```
|
||||
GET /health
|
||||
```
|
||||
|
||||
#### Generate ICS File
|
||||
```
|
||||
POST /api/ics
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "Team Meeting",
|
||||
"description": "Monthly sync",
|
||||
"location": "Conference Room A",
|
||||
"start_dt": "2026-03-01T10:00:00Z",
|
||||
"end_dt": "2026-03-01T11:00:00Z",
|
||||
"timezone": "UTC",
|
||||
"reminders": [
|
||||
{"amount": 10, "unit": "minutes"},
|
||||
{"amount": 1, "unit": "hours"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Preview ICS Content
|
||||
```
|
||||
POST /api/preview
|
||||
Content-Type: application/json
|
||||
```
|
||||
(Same body as `/api/ics`)
|
||||
|
||||
#### Get History
|
||||
```
|
||||
GET /api/history?limit=20
|
||||
```
|
||||
|
||||
#### Get Specific Event
|
||||
```
|
||||
GET /api/history/{event_id}
|
||||
```
|
||||
|
||||
#### Delete Event
|
||||
```
|
||||
DELETE /api/history/{event_id}
|
||||
```
|
||||
|
||||
#### Get Templates
|
||||
```
|
||||
GET /api/templates
|
||||
```
|
||||
|
||||
#### Create Template
|
||||
```
|
||||
POST /api/templates
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Quick Reminders",
|
||||
"reminders": [
|
||||
{"amount": 10, "unit": "minutes"},
|
||||
{"amount": 1, "unit": "hours"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Delete Template
|
||||
```
|
||||
DELETE /api/templates/{template_id}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
│ (React + TypeScript + Tailwind CSS) │
|
||||
│ Nginx (Port 80) │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
│ /api/*
|
||||
│
|
||||
┌────────────────────▼────────────────────────────────────┐
|
||||
│ Backend │
|
||||
│ (FastAPI + Python + SQLite) │
|
||||
│ Port 8000 │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
│
|
||||
┌────────────────────▼────────────────────────────────────┐
|
||||
│ SQLite Database │
|
||||
│ /data/app.db (Persistent) │
|
||||
│ │
|
||||
│ Tables: │
|
||||
│ - events_history │
|
||||
│ - reminder_templates │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_PATH` | `./data/app.db` | Path to SQLite database |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `VITE_API_BASE_URL` | `/api` | Backend API base URL |
|
||||
|
||||
For local development, create `frontend/.env.development`:
|
||||
```
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run with auto-reload
|
||||
python main.py
|
||||
|
||||
# Or use uvicorn directly
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run development server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Features Guide
|
||||
|
||||
### Event Creation
|
||||
|
||||
1. Fill in event details (title, description, location)
|
||||
2. Set start and end date/time
|
||||
3. Select timezone
|
||||
4. Add custom reminders or use quick-add buttons
|
||||
5. Click "Download .ics" to save the calendar file
|
||||
|
||||
### History Management
|
||||
|
||||
- All created events are automatically saved to history
|
||||
- Click "Load" to reuse an event
|
||||
- Click "Delete" to remove from history
|
||||
- View up to 20 recent events
|
||||
|
||||
### Template Management
|
||||
|
||||
- Save current reminder setup as a template
|
||||
- Name your template for easy identification
|
||||
- Apply templates to quickly set reminders
|
||||
- Delete templates you no longer need
|
||||
|
||||
## Database Schema
|
||||
|
||||
### events_history
|
||||
```sql
|
||||
CREATE TABLE events_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
location TEXT,
|
||||
start_dt TEXT NOT NULL,
|
||||
end_dt TEXT,
|
||||
timezone TEXT NOT NULL,
|
||||
reminders_json TEXT,
|
||||
last_downloaded_at TEXT
|
||||
);
|
||||
```
|
||||
|
||||
### reminder_templates
|
||||
```sql
|
||||
CREATE TABLE reminder_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
reminders_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend Issues
|
||||
|
||||
**Port 8000 already in use:**
|
||||
```bash
|
||||
# Find and kill the process
|
||||
lsof -ti:8000 | xargs kill -9
|
||||
|
||||
# Or use a different port
|
||||
uvicorn main:app --port 8001
|
||||
```
|
||||
|
||||
**Database not found:**
|
||||
- Check DATABASE_PATH environment variable
|
||||
- Ensure /data directory has write permissions
|
||||
- Database is auto-created on first run
|
||||
|
||||
### Frontend Issues
|
||||
|
||||
**Failed to fetch:**
|
||||
- Verify backend is running on port 8000
|
||||
- Check CORS settings in backend
|
||||
- Verify API_BASE_URL in .env.development
|
||||
|
||||
**Build fails:**
|
||||
```bash
|
||||
# Clear cache and reinstall
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Docker Issues
|
||||
|
||||
**Cannot connect to backend:**
|
||||
- Ensure both containers are on the same network
|
||||
- Check docker-compose logs
|
||||
- Verify service names match in nginx.conf
|
||||
|
||||
**Volume permission errors:**
|
||||
```bash
|
||||
# Change volume permissions
|
||||
docker-compose down
|
||||
docker volume rm ics-generator_calink-data
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Test thoroughly
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
- Check the troubleshooting section
|
||||
- Review API documentation at `/docs`
|
||||
- Open an issue on GitHub
|
||||
|
||||
---
|
||||
|
||||
**Powered by Calink** • FastAPI + React + Tailwind CSS
|
||||
23
backend/.dockerignore
Normal file
23
backend/.dockerignore
Normal file
@ -0,0 +1,23 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
.venv
|
||||
*.so
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
README.md
|
||||
.pytest_cache
|
||||
.coverage
|
||||
htmlcov/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
.DS_Store
|
||||
26
backend/Dockerfile
Normal file
26
backend/Dockerfile
Normal file
@ -0,0 +1,26 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 calink
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements and install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY main.py .
|
||||
|
||||
# Create data directory and set permissions
|
||||
RUN mkdir -p /data && chown -R calink:calink /app /data
|
||||
|
||||
# Switch to non-root user
|
||||
USER calink
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
536
backend/main.py
Normal file
536
backend/main.py
Normal file
@ -0,0 +1,536 @@
|
||||
"""
|
||||
Calink API - Generate reusable calendar invitation links (.ics)
|
||||
|
||||
Requirements:
|
||||
pip install fastapi uvicorn pydantic aiosqlite
|
||||
|
||||
Example usage:
|
||||
# Run the server
|
||||
python main.py
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Optional, List
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import sqlite3
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
# Configuration
|
||||
DATABASE_PATH = os.getenv("DATABASE_PATH", "./data/app.db")
|
||||
|
||||
|
||||
# Database initialization
|
||||
def init_db():
|
||||
"""Initialize SQLite database with required tables."""
|
||||
os.makedirs(os.path.dirname(DATABASE_PATH) if os.path.dirname(DATABASE_PATH) else "./data", exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Events history table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS events_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
location TEXT,
|
||||
start_dt TEXT NOT NULL,
|
||||
end_dt TEXT,
|
||||
timezone TEXT NOT NULL,
|
||||
reminders_json TEXT,
|
||||
last_downloaded_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Reminder templates table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS reminder_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
reminders_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan handler."""
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
# FastAPI app
|
||||
app = FastAPI(
|
||||
title="Calink API",
|
||||
description="Calink API – Generate reusable calendar invitation links (.ics)",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Models
|
||||
class ReminderUnit(str, Enum):
|
||||
"""Supported reminder time units."""
|
||||
minutes = "minutes"
|
||||
hours = "hours"
|
||||
days = "days"
|
||||
weeks = "weeks"
|
||||
|
||||
|
||||
class Reminder(BaseModel):
|
||||
"""Reminder configuration for an event."""
|
||||
amount: int = Field(..., gt=0, description="Amount of time before event")
|
||||
unit: ReminderUnit = Field(..., description="Time unit for reminder")
|
||||
|
||||
|
||||
class EventRequest(BaseModel):
|
||||
"""Request model for creating an event."""
|
||||
title: str = Field(..., min_length=1, description="Event title/summary")
|
||||
description: Optional[str] = Field(None, description="Event description")
|
||||
location: Optional[str] = Field(None, description="Event location")
|
||||
start_dt: str = Field(..., description="Event start datetime (ISO 8601 string)")
|
||||
end_dt: Optional[str] = Field(None, description="Event end datetime (ISO 8601 string)")
|
||||
timezone: str = Field("UTC", description="Timezone for naive datetimes")
|
||||
reminders: Optional[List[Reminder]] = Field(None, max_length=10, description="Event reminders (max 10)")
|
||||
|
||||
@field_validator('reminders')
|
||||
@classmethod
|
||||
def validate_reminders(cls, v):
|
||||
"""Validate reminders list."""
|
||||
if v is not None and len(v) > 10:
|
||||
raise ValueError("Maximum 10 reminders allowed")
|
||||
return v
|
||||
|
||||
|
||||
class ReminderTemplate(BaseModel):
|
||||
"""Reminder template model."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
reminders: List[Reminder] = Field(..., max_length=10)
|
||||
|
||||
|
||||
class HistoryEvent(BaseModel):
|
||||
"""History event response model."""
|
||||
id: int
|
||||
created_at: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
location: Optional[str]
|
||||
start_dt: str
|
||||
end_dt: Optional[str]
|
||||
timezone: str
|
||||
reminders: Optional[List[Reminder]]
|
||||
last_downloaded_at: Optional[str]
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
"""Template response model."""
|
||||
id: int
|
||||
name: str
|
||||
reminders: List[Reminder]
|
||||
created_at: str
|
||||
|
||||
|
||||
# Helper functions
|
||||
def get_db():
|
||||
"""Get database connection."""
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def parse_datetime(dt_str: str, tz: str = "UTC") -> datetime:
|
||||
"""Parse ISO datetime string and apply timezone if naive."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=ZoneInfo(tz))
|
||||
return dt
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid datetime format: {dt_str}. Expected ISO 8601 format.")
|
||||
|
||||
|
||||
def format_datetime_for_ics(dt: datetime) -> str:
|
||||
"""Format datetime for iCalendar format (UTC with Z suffix)."""
|
||||
utc_dt = dt.astimezone(ZoneInfo("UTC"))
|
||||
return utc_dt.strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def reminder_to_trigger(reminder: Reminder) -> str:
|
||||
"""Convert reminder to iCalendar TRIGGER format."""
|
||||
amount = reminder.amount
|
||||
unit = reminder.unit
|
||||
|
||||
if unit == ReminderUnit.minutes:
|
||||
return f"-PT{amount}M"
|
||||
elif unit == ReminderUnit.hours:
|
||||
return f"-PT{amount}H"
|
||||
elif unit == ReminderUnit.days:
|
||||
return f"-P{amount}D"
|
||||
elif unit == ReminderUnit.weeks:
|
||||
return f"-P{amount}W"
|
||||
|
||||
return "-PT15M"
|
||||
|
||||
|
||||
def generate_ics(event: EventRequest) -> str:
|
||||
"""Generate iCalendar .ics file content from event parameters."""
|
||||
# Parse datetimes
|
||||
start_dt = parse_datetime(event.start_dt, event.timezone)
|
||||
|
||||
# Calculate end_dt if not provided (default 60 minutes)
|
||||
if event.end_dt:
|
||||
end_dt = parse_datetime(event.end_dt, event.timezone)
|
||||
else:
|
||||
end_dt = start_dt + timedelta(minutes=60)
|
||||
|
||||
# Validate end_dt is after start_dt
|
||||
if end_dt <= start_dt:
|
||||
raise ValueError("end_dt must be after start_dt")
|
||||
|
||||
# Generate unique ID and timestamp
|
||||
uid = str(uuid.uuid4())
|
||||
dtstamp = datetime.now(ZoneInfo("UTC"))
|
||||
|
||||
# Start building ICS content
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Calink//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
"BEGIN:VEVENT",
|
||||
f"UID:{uid}",
|
||||
f"DTSTAMP:{format_datetime_for_ics(dtstamp)}",
|
||||
f"DTSTART:{format_datetime_for_ics(start_dt)}",
|
||||
f"DTEND:{format_datetime_for_ics(end_dt)}",
|
||||
f"SUMMARY:{event.title}",
|
||||
]
|
||||
|
||||
# Add optional DESCRIPTION
|
||||
if event.description:
|
||||
desc = event.description.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;").replace("\n", "\\n")
|
||||
lines.append(f"DESCRIPTION:{desc}")
|
||||
|
||||
# Add optional LOCATION
|
||||
if event.location:
|
||||
loc = event.location.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;")
|
||||
lines.append(f"LOCATION:{loc}")
|
||||
|
||||
# Add VALARM blocks for reminders
|
||||
if event.reminders:
|
||||
for reminder in event.reminders:
|
||||
trigger = reminder_to_trigger(reminder)
|
||||
lines.extend([
|
||||
"BEGIN:VALARM",
|
||||
"ACTION:DISPLAY",
|
||||
"DESCRIPTION:Reminder",
|
||||
f"TRIGGER:{trigger}",
|
||||
"END:VALARM"
|
||||
])
|
||||
|
||||
# Close VEVENT and VCALENDAR
|
||||
lines.extend([
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR"
|
||||
])
|
||||
|
||||
# Join with CRLF and ensure trailing CRLF
|
||||
ics_content = "\r\n".join(lines) + "\r\n"
|
||||
|
||||
return ics_content
|
||||
|
||||
|
||||
def save_to_history(event: EventRequest, is_download: bool = False):
|
||||
"""Save event to history."""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
reminders_json = json.dumps([r.dict() for r in event.reminders]) if event.reminders else None
|
||||
now = datetime.now(ZoneInfo("UTC")).isoformat()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO events_history
|
||||
(created_at, title, description, location, start_dt, end_dt, timezone, reminders_json, last_downloaded_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
now,
|
||||
event.title,
|
||||
event.description,
|
||||
event.location,
|
||||
event.start_dt,
|
||||
event.end_dt,
|
||||
event.timezone,
|
||||
reminders_json,
|
||||
now if is_download else None
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# API Endpoints
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with API information."""
|
||||
return {
|
||||
"app": "Calink",
|
||||
"description": "Generate reusable calendar invitation links (.ics)",
|
||||
"docs": "/docs",
|
||||
"health": "/health"
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/ics")
|
||||
async def create_ics(event: EventRequest):
|
||||
"""Generate and download an iCalendar .ics file."""
|
||||
try:
|
||||
ics_content = generate_ics(event)
|
||||
save_to_history(event, is_download=True)
|
||||
|
||||
return Response(
|
||||
content=ics_content,
|
||||
media_type="text/calendar; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="event.ics"'
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error generating ICS file: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/api/preview")
|
||||
async def preview_ics(event: EventRequest):
|
||||
"""Generate and return ICS content as plain text for preview."""
|
||||
try:
|
||||
ics_content = generate_ics(event)
|
||||
save_to_history(event, is_download=False)
|
||||
|
||||
return Response(
|
||||
content=ics_content,
|
||||
media_type="text/plain; charset=utf-8"
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error generating ICS file: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/api/history")
|
||||
async def get_history(limit: int = Query(20, ge=1, le=100)):
|
||||
"""Get events history."""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT * FROM events_history
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
events = []
|
||||
for row in rows:
|
||||
reminders = None
|
||||
if row['reminders_json']:
|
||||
reminders_data = json.loads(row['reminders_json'])
|
||||
reminders = [Reminder(**r) for r in reminders_data]
|
||||
|
||||
events.append(HistoryEvent(
|
||||
id=row['id'],
|
||||
created_at=row['created_at'],
|
||||
title=row['title'],
|
||||
description=row['description'],
|
||||
location=row['location'],
|
||||
start_dt=row['start_dt'],
|
||||
end_dt=row['end_dt'],
|
||||
timezone=row['timezone'],
|
||||
reminders=reminders,
|
||||
last_downloaded_at=row['last_downloaded_at']
|
||||
))
|
||||
|
||||
return events
|
||||
|
||||
|
||||
@app.get("/api/history/{event_id}")
|
||||
async def get_history_event(event_id: int):
|
||||
"""Get a specific event from history."""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM events_history WHERE id = ?", (event_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
reminders = None
|
||||
if row['reminders_json']:
|
||||
reminders_data = json.loads(row['reminders_json'])
|
||||
reminders = [Reminder(**r) for r in reminders_data]
|
||||
|
||||
return HistoryEvent(
|
||||
id=row['id'],
|
||||
created_at=row['created_at'],
|
||||
title=row['title'],
|
||||
description=row['description'],
|
||||
location=row['location'],
|
||||
start_dt=row['start_dt'],
|
||||
end_dt=row['end_dt'],
|
||||
timezone=row['timezone'],
|
||||
reminders=reminders,
|
||||
last_downloaded_at=row['last_downloaded_at']
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/history/{event_id}")
|
||||
async def delete_history_event(event_id: int):
|
||||
"""Delete an event from history."""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM events_history WHERE id = ?", (event_id,))
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {"message": "Event deleted successfully"}
|
||||
|
||||
|
||||
@app.post("/api/templates")
|
||||
async def create_template(template: ReminderTemplate):
|
||||
"""Create a new reminder template."""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
reminders_json = json.dumps([r.dict() for r in template.reminders])
|
||||
now = datetime.now(ZoneInfo("UTC")).isoformat()
|
||||
|
||||
try:
|
||||
cursor.execute("""
|
||||
INSERT INTO reminder_templates (name, reminders_json, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
""", (template.name, reminders_json, now))
|
||||
|
||||
template_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"id": template_id,
|
||||
"name": template.name,
|
||||
"reminders": template.reminders,
|
||||
"created_at": now
|
||||
}
|
||||
except sqlite3.IntegrityError:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=400, detail="Template with this name already exists")
|
||||
|
||||
|
||||
@app.get("/api/templates")
|
||||
async def get_templates():
|
||||
"""Get all reminder templates."""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM reminder_templates ORDER BY created_at DESC")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
templates = []
|
||||
for row in rows:
|
||||
reminders_data = json.loads(row['reminders_json'])
|
||||
reminders = [Reminder(**r) for r in reminders_data]
|
||||
|
||||
templates.append(TemplateResponse(
|
||||
id=row['id'],
|
||||
name=row['name'],
|
||||
reminders=reminders,
|
||||
created_at=row['created_at']
|
||||
))
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
@app.get("/api/templates/{template_id}")
|
||||
async def get_template(template_id: int):
|
||||
"""Get a specific template."""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM reminder_templates WHERE id = ?", (template_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
reminders_data = json.loads(row['reminders_json'])
|
||||
reminders = [Reminder(**r) for r in reminders_data]
|
||||
|
||||
return TemplateResponse(
|
||||
id=row['id'],
|
||||
name=row['name'],
|
||||
reminders=reminders,
|
||||
created_at=row['created_at']
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/templates/{template_id}")
|
||||
async def delete_template(template_id: int):
|
||||
"""Delete a template."""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM reminder_templates WHERE id = ?", (template_id,))
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {"message": "Template deleted successfully"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
5
backend/requirements.txt
Normal file
5
backend/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
pydantic>=2.0.0
|
||||
python-multipart>=0.0.6
|
||||
aiosqlite>=0.19.0
|
||||
6
calink-chart/Chart.yaml
Normal file
6
calink-chart/Chart.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: calink
|
||||
description: Calink calendar event generator
|
||||
type: application
|
||||
version: 1.0.0
|
||||
appVersion: "1.0.0"
|
||||
29
calink-chart/templates/NOTES.txt
Normal file
29
calink-chart/templates/NOTES.txt
Normal file
@ -0,0 +1,29 @@
|
||||
Thank you for installing {{ .Chart.Name }}!
|
||||
|
||||
Your release is named {{ .Release.Name }}.
|
||||
|
||||
To learn more about the release, try:
|
||||
|
||||
$ helm status {{ .Release.Name }}
|
||||
$ helm get all {{ .Release.Name }}
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
Calink is accessible at:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
|
||||
To access Calink, forward the frontend port:
|
||||
|
||||
kubectl port-forward svc/{{ include "calink.fullname" . }}-frontend 8080:80
|
||||
|
||||
Then visit http://localhost:8080
|
||||
{{- end }}
|
||||
|
||||
Backend API documentation is available at:
|
||||
/docs
|
||||
|
||||
Health check endpoint:
|
||||
/health
|
||||
84
calink-chart/templates/_helpers.tpl
Normal file
84
calink-chart/templates/_helpers.tpl
Normal file
@ -0,0 +1,84 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "calink.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "calink.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "calink.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "calink.labels" -}}
|
||||
helm.sh/chart: {{ include "calink.chart" . }}
|
||||
{{ include "calink.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- with .Values.commonLabels }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "calink.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "calink.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Backend labels
|
||||
*/}}
|
||||
{{- define "calink.backend.labels" -}}
|
||||
{{ include "calink.labels" . }}
|
||||
app.kubernetes.io/component: backend
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Backend selector labels
|
||||
*/}}
|
||||
{{- define "calink.backend.selectorLabels" -}}
|
||||
{{ include "calink.selectorLabels" . }}
|
||||
app.kubernetes.io/component: backend
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Frontend labels
|
||||
*/}}
|
||||
{{- define "calink.frontend.labels" -}}
|
||||
{{ include "calink.labels" . }}
|
||||
app.kubernetes.io/component: frontend
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Frontend selector labels
|
||||
*/}}
|
||||
{{- define "calink.frontend.selectorLabels" -}}
|
||||
{{ include "calink.selectorLabels" . }}
|
||||
app.kubernetes.io/component: frontend
|
||||
{{- end }}
|
||||
59
calink-chart/templates/deployment-backend.yaml
Normal file
59
calink-chart/templates/deployment-backend.yaml
Normal file
@ -0,0 +1,59 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "calink.fullname" . }}-backend
|
||||
labels:
|
||||
{{- include "calink.backend.labels" . | nindent 4 }}
|
||||
{{- with .Values.commonAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: {{ .Values.backend.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "calink.backend.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "calink.backend.selectorLabels" . | nindent 8 }}
|
||||
{{- with .Values.commonAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8000
|
||||
protocol: TCP
|
||||
env:
|
||||
{{- toYaml .Values.backend.env | nindent 12 }}
|
||||
{{- if .Values.backend.persistence.enabled }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: {{ .Values.backend.persistence.mountPath }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.backend.healthCheck.path }}
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.backend.healthCheck.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.backend.healthCheck.periodSeconds }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.backend.healthCheck.path }}
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.backend.healthCheck.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.backend.healthCheck.periodSeconds }}
|
||||
resources:
|
||||
{{- toYaml .Values.backend.resources | nindent 12 }}
|
||||
{{- if .Values.backend.persistence.enabled }}
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "calink.fullname" . }}-backend-data
|
||||
{{- end }}
|
||||
46
calink-chart/templates/deployment-frontend.yaml
Normal file
46
calink-chart/templates/deployment-frontend.yaml
Normal file
@ -0,0 +1,46 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "calink.fullname" . }}-frontend
|
||||
labels:
|
||||
{{- include "calink.frontend.labels" . | nindent 4 }}
|
||||
{{- with .Values.commonAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: {{ .Values.frontend.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "calink.frontend.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "calink.frontend.selectorLabels" . | nindent 8 }}
|
||||
{{- with .Values.commonAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.frontend.healthCheck.path }}
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.frontend.healthCheck.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.frontend.healthCheck.periodSeconds }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.frontend.healthCheck.path }}
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.frontend.healthCheck.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.frontend.healthCheck.periodSeconds }}
|
||||
resources:
|
||||
{{- toYaml .Values.frontend.resources | nindent 12 }}
|
||||
56
calink-chart/templates/ingress.yaml
Normal file
56
calink-chart/templates/ingress.yaml
Normal file
@ -0,0 +1,56 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "calink.fullname" . }}
|
||||
labels:
|
||||
{{- include "calink.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.commonAnnotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "calink.fullname" $ }}-backend
|
||||
port:
|
||||
number: {{ $.Values.backend.service.port }}
|
||||
- path: /health
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "calink.fullname" $ }}-backend
|
||||
port:
|
||||
number: {{ $.Values.backend.service.port }}
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "calink.fullname" $ }}-frontend
|
||||
port:
|
||||
number: {{ $.Values.frontend.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
21
calink-chart/templates/pvc.yaml
Normal file
21
calink-chart/templates/pvc.yaml
Normal file
@ -0,0 +1,21 @@
|
||||
{{- if .Values.backend.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "calink.fullname" . }}-backend-data
|
||||
labels:
|
||||
{{- include "calink.backend.labels" . | nindent 4 }}
|
||||
{{- with .Values.commonAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- if .Values.backend.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.backend.persistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.backend.persistence.size }}
|
||||
{{- end }}
|
||||
19
calink-chart/templates/service-backend.yaml
Normal file
19
calink-chart/templates/service-backend.yaml
Normal file
@ -0,0 +1,19 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "calink.fullname" . }}-backend
|
||||
labels:
|
||||
{{- include "calink.backend.labels" . | nindent 4 }}
|
||||
{{- with .Values.commonAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.backend.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.backend.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "calink.backend.selectorLabels" . | nindent 4 }}
|
||||
19
calink-chart/templates/service-frontend.yaml
Normal file
19
calink-chart/templates/service-frontend.yaml
Normal file
@ -0,0 +1,19 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "calink.fullname" . }}-frontend
|
||||
labels:
|
||||
{{- include "calink.frontend.labels" . | nindent 4 }}
|
||||
{{- with .Values.commonAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.frontend.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.frontend.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "calink.frontend.selectorLabels" . | nindent 4 }}
|
||||
81
calink-chart/values.yaml
Normal file
81
calink-chart/values.yaml
Normal file
@ -0,0 +1,81 @@
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
commonLabels: {}
|
||||
commonAnnotations: {}
|
||||
|
||||
backend:
|
||||
image:
|
||||
repository: calink-backend
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
replicas: 1
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
|
||||
env:
|
||||
- name: DATABASE_PATH
|
||||
value: "/data/app.db"
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
storageClass: ""
|
||||
size: 1Gi
|
||||
mountPath: /data
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8000
|
||||
|
||||
healthCheck:
|
||||
path: /health
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
|
||||
frontend:
|
||||
image:
|
||||
repository: calink-frontend
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
replicas: 1
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
||||
healthCheck:
|
||||
path: /
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: ""
|
||||
annotations: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
hosts:
|
||||
- host: calink.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
# - secretName: calink-tls
|
||||
# hosts:
|
||||
# - calink.example.com
|
||||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
@ -0,0 +1,40 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
calink-backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: calink-backend
|
||||
environment:
|
||||
- DATABASE_PATH=/data/app.db
|
||||
volumes:
|
||||
- calink-data:/data
|
||||
ports:
|
||||
- "8000:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
calink-frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: calink-frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- calink-backend
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
calink-data:
|
||||
driver: local
|
||||
13
frontend/.dockerignore
Normal file
13
frontend/.dockerignore
Normal file
@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
README.md
|
||||
.env.development
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
coverage
|
||||
.vscode
|
||||
.idea
|
||||
1
frontend/.env
Normal file
1
frontend/.env
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
1
frontend/.env.development
Normal file
1
frontend/.env.development
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000/api
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/calendar.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Calink – Calendar Link Generator</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
frontend/nginx.conf
Normal file
32
frontend/nginx.conf
Normal file
@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Frontend routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
location /api {
|
||||
proxy_pass http://calink-backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
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;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
proxy_pass http://calink-backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
2666
frontend/package-lock.json
generated
Normal file
2666
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "calink-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
346
frontend/src/App.tsx
Normal file
346
frontend/src/App.tsx
Normal file
@ -0,0 +1,346 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import ReminderEditor from './components/ReminderEditor';
|
||||
import HistoryPanel from './components/HistoryPanel';
|
||||
import TemplatesPanel from './components/TemplatesPanel';
|
||||
import { createIcs, previewIcs, Reminder, EventPayload, HistoryEvent } from './api';
|
||||
|
||||
function App() {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [startDt, setStartDt] = useState('');
|
||||
const [endDt, setEndDt] = useState('');
|
||||
const [timezone, setTimezone] = useState('Asia/Jerusalem');
|
||||
const [reminders, setReminders] = useState<Reminder[]>([]);
|
||||
const [previewText, setPreviewText] = useState('');
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'history' | 'templates'>('history');
|
||||
|
||||
const isFormValid = () => {
|
||||
if (!title.trim() || !startDt) return false;
|
||||
if (reminders.some(r => r.amount <= 0)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const buildPayload = (): EventPayload => {
|
||||
// Convert datetime-local to ISO 8601
|
||||
const startIso = startDt ? new Date(startDt).toISOString() : '';
|
||||
const endIso = endDt ? new Date(endDt).toISOString() : undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description: description || undefined,
|
||||
location: location || undefined,
|
||||
start_dt: startIso,
|
||||
end_dt: endIso,
|
||||
timezone,
|
||||
reminders: reminders.length > 0 ? reminders : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const handleDownload = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isFormValid()) return;
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
const blob = await createIcs(payload);
|
||||
|
||||
// Trigger download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'event.ics';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
setMessage({ type: 'success', text: 'ICS file downloaded successfully!' });
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Failed to generate ICS file'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!isFormValid()) return;
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
const text = await previewIcs(payload);
|
||||
setPreviewText(text);
|
||||
setMessage({ type: 'success', text: 'Preview generated!' });
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Failed to preview ICS file'
|
||||
});
|
||||
setPreviewText('');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadEvent = (event: HistoryEvent) => {
|
||||
setTitle(event.title);
|
||||
setDescription(event.description || '');
|
||||
setLocation(event.location || '');
|
||||
|
||||
// Convert ISO strings back to datetime-local format
|
||||
try {
|
||||
const startDate = new Date(event.start_dt);
|
||||
setStartDt(startDate.toISOString().slice(0, 16));
|
||||
|
||||
if (event.end_dt) {
|
||||
const endDate = new Date(event.end_dt);
|
||||
setEndDt(endDate.toISOString().slice(0, 16));
|
||||
} else {
|
||||
setEndDt('');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing dates:', e);
|
||||
}
|
||||
|
||||
setTimezone(event.timezone);
|
||||
setReminders(event.reminders || []);
|
||||
setMessage({ type: 'success', text: 'Event loaded from history!' });
|
||||
};
|
||||
|
||||
const handleApplyTemplate = (templateReminders: Reminder[]) => {
|
||||
setReminders(templateReminders);
|
||||
setMessage({ type: 'success', text: 'Template applied!' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50 py-8 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-5xl font-bold text-indigo-600 mb-2">
|
||||
CALINK
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Create & share calendar events instantly
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Form - Takes up 2 columns on large screens */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||
<form onSubmit={handleDownload} className="space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="Team Meeting"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="Discuss project updates..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div>
|
||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Location
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="location"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="Conference Room A"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date/Time Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Start DateTime */}
|
||||
<div>
|
||||
<label htmlFor="startDt" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Date & Time <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="startDt"
|
||||
value={startDt}
|
||||
onChange={(e) => setStartDt(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* End DateTime */}
|
||||
<div>
|
||||
<label htmlFor="endDt" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Date & Time
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="endDt"
|
||||
value={endDt}
|
||||
onChange={(e) => setEndDt(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Defaults to +60 minutes if not set
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timezone */}
|
||||
<div>
|
||||
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Timezone
|
||||
</label>
|
||||
<select
|
||||
id="timezone"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="Asia/Jerusalem">Asia/Jerusalem</option>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
<option value="America/New_York">America/New York</option>
|
||||
<option value="America/Chicago">America/Chicago</option>
|
||||
<option value="America/Los_Angeles">America/Los Angeles</option>
|
||||
<option value="Europe/Paris">Europe/Paris</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Reminders */}
|
||||
<ReminderEditor reminders={reminders} onChange={setReminders} />
|
||||
|
||||
{/* Message Display */}
|
||||
{message && (
|
||||
<div
|
||||
className={`p-4 rounded-md ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 text-green-800 border border-green-200'
|
||||
: 'bg-red-50 text-red-800 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isFormValid() || loading}
|
||||
className="flex-1 bg-indigo-600 text-white px-6 py-3 rounded-md hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed font-medium transition-colors"
|
||||
>
|
||||
{loading ? 'Processing...' : '📥 Download .ics'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
disabled={!isFormValid() || loading}
|
||||
className="flex-1 bg-white text-indigo-600 border-2 border-indigo-600 px-6 py-3 rounded-md hover:bg-indigo-50 disabled:border-gray-300 disabled:text-gray-300 disabled:cursor-not-allowed font-medium transition-colors"
|
||||
>
|
||||
👁️ Preview
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Preview Section */}
|
||||
{previewText && (
|
||||
<div className="mt-6 border-t pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Preview</h3>
|
||||
<pre className="bg-gray-50 p-4 rounded-md overflow-x-auto text-xs border border-gray-200 font-mono">
|
||||
{previewText}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side Panel - History & Templates */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 sticky top-4">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'history'
|
||||
? 'border-indigo-600 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
📋 History
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('templates')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'templates'
|
||||
? 'border-indigo-600 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
⚡ Templates
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
{activeTab === 'history' ? (
|
||||
<HistoryPanel onLoadEvent={handleLoadEvent} />
|
||||
) : (
|
||||
<TemplatesPanel
|
||||
currentReminders={reminders}
|
||||
onApplyTemplate={handleApplyTemplate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-8 text-sm text-gray-500">
|
||||
<p>Powered by Calink • FastAPI + React + Tailwind CSS</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
139
frontend/src/api.ts
Normal file
139
frontend/src/api.ts
Normal file
@ -0,0 +1,139 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
export type ReminderUnit = 'minutes' | 'hours' | 'days' | 'weeks';
|
||||
|
||||
export interface Reminder {
|
||||
amount: number;
|
||||
unit: ReminderUnit;
|
||||
}
|
||||
|
||||
export interface EventPayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
start_dt: string;
|
||||
end_dt?: string;
|
||||
timezone: string;
|
||||
reminders?: Reminder[];
|
||||
}
|
||||
|
||||
export interface HistoryEvent {
|
||||
id: number;
|
||||
created_at: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
start_dt: string;
|
||||
end_dt?: string;
|
||||
timezone: string;
|
||||
reminders?: Reminder[];
|
||||
last_downloaded_at?: string;
|
||||
}
|
||||
|
||||
export interface ReminderTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
reminders: Reminder[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function createIcs(payload: EventPayload): Promise<Blob> {
|
||||
const response = await fetch(`${API_BASE_URL}/ics`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Failed to generate ICS file' }));
|
||||
throw new Error(error.detail || 'Failed to generate ICS file');
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function previewIcs(payload: EventPayload): Promise<string> {
|
||||
const response = await fetch(`${API_BASE_URL}/preview`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Failed to preview ICS file' }));
|
||||
throw new Error(error.detail || 'Failed to preview ICS file');
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
export async function getHistory(limit: number = 20): Promise<HistoryEvent[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/history?limit=${limit}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch history');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getHistoryEvent(id: number): Promise<HistoryEvent> {
|
||||
const response = await fetch(`${API_BASE_URL}/history/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch event');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteHistoryEvent(id: number): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/history/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete event');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTemplates(): Promise<ReminderTemplate[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/templates`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch templates');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createTemplate(name: string, reminders: Reminder[]): Promise<ReminderTemplate> {
|
||||
const response = await fetch(`${API_BASE_URL}/templates`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name, reminders }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Failed to create template' }));
|
||||
throw new Error(error.detail || 'Failed to create template');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: number): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/templates/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete template');
|
||||
}
|
||||
}
|
||||
110
frontend/src/components/HistoryPanel.tsx
Normal file
110
frontend/src/components/HistoryPanel.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getHistory, deleteHistoryEvent, HistoryEvent } from '../api';
|
||||
|
||||
interface HistoryPanelProps {
|
||||
onLoadEvent: (event: HistoryEvent) => void;
|
||||
}
|
||||
|
||||
export default function HistoryPanel({ onLoadEvent }: HistoryPanelProps) {
|
||||
const [events, setEvents] = useState<HistoryEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadHistory = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getHistory(20);
|
||||
setEvents(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load history');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Delete this event from history?')) return;
|
||||
|
||||
try {
|
||||
await deleteHistoryEvent(id);
|
||||
setEvents(events.filter(e => e.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete event');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (isoString: string) => {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-500 py-4">Loading...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-red-600 text-sm p-3 bg-red-50 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
<p>No history yet</p>
|
||||
<p className="text-xs mt-1">Events you create will appear here</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="border border-gray-200 rounded-md p-3 hover:border-indigo-300 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900 text-sm">{event.title}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{formatDate(event.start_dt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.reminders && event.reminders.length > 0 && (
|
||||
<div className="text-xs text-gray-600 mb-2">
|
||||
🔔 {event.reminders.length} reminder{event.reminders.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onLoadEvent(event)}
|
||||
className="flex-1 px-3 py-1 text-xs font-medium text-indigo-600 bg-indigo-50 rounded hover:bg-indigo-100 transition-colors"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(event.id)}
|
||||
className="px-3 py-1 text-xs font-medium text-red-600 bg-red-50 rounded hover:bg-red-100 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
frontend/src/components/ReminderEditor.tsx
Normal file
119
frontend/src/components/ReminderEditor.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { Reminder, ReminderUnit } from '../api';
|
||||
|
||||
interface ReminderEditorProps {
|
||||
reminders: Reminder[];
|
||||
onChange: (reminders: Reminder[]) => void;
|
||||
}
|
||||
|
||||
const QUICK_ADD_REMINDERS: Reminder[] = [
|
||||
{ amount: 10, unit: 'minutes' },
|
||||
{ amount: 1, unit: 'hours' },
|
||||
{ amount: 1, unit: 'days' },
|
||||
{ amount: 1, unit: 'weeks' },
|
||||
];
|
||||
|
||||
export default function ReminderEditor({ reminders, onChange }: ReminderEditorProps) {
|
||||
const addReminder = (reminder?: Reminder) => {
|
||||
if (reminders.length >= 10) {
|
||||
return;
|
||||
}
|
||||
onChange([...reminders, reminder || { amount: 10, unit: 'minutes' }]);
|
||||
};
|
||||
|
||||
const removeReminder = (index: number) => {
|
||||
onChange(reminders.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateReminder = (index: number, field: keyof Reminder, value: number | ReminderUnit) => {
|
||||
const updated = [...reminders];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const isValidReminder = (reminder: Reminder) => {
|
||||
return reminder.amount > 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Reminders
|
||||
</label>
|
||||
<span className="text-xs text-gray-500">
|
||||
{reminders.length}/10
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Quick add buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{QUICK_ADD_REMINDERS.map((reminder, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => addReminder(reminder)}
|
||||
disabled={reminders.length >= 10}
|
||||
className="px-3 py-1 text-xs font-medium text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
+ {reminder.amount} {reminder.unit}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reminder list */}
|
||||
{reminders.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{reminders.map((reminder, index) => (
|
||||
<div key={index} className="flex gap-2 items-start">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={reminder.amount}
|
||||
onChange={(e) => updateReminder(index, 'amount', parseInt(e.target.value) || 0)}
|
||||
className={`flex-1 px-3 py-2 border rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${
|
||||
!isValidReminder(reminder) ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Amount"
|
||||
/>
|
||||
<select
|
||||
value={reminder.unit}
|
||||
onChange={(e) => updateReminder(index, 'unit', e.target.value as ReminderUnit)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
<option value="minutes">Minutes</option>
|
||||
<option value="hours">Hours</option>
|
||||
<option value="days">Days</option>
|
||||
<option value="weeks">Weeks</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeReminder(index)}
|
||||
className="px-3 py-2 text-red-600 border border-red-300 rounded-md hover:bg-red-50 transition-colors"
|
||||
title="Remove reminder"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addReminder()}
|
||||
disabled={reminders.length >= 10}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-indigo-600 border border-indigo-300 rounded-md hover:bg-indigo-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
+ Add Custom Reminder
|
||||
</button>
|
||||
|
||||
{/* Validation messages */}
|
||||
{reminders.some(r => !isValidReminder(r)) && (
|
||||
<p className="text-sm text-red-600">
|
||||
All reminder amounts must be greater than 0
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
frontend/src/components/TemplatesPanel.tsx
Normal file
170
frontend/src/components/TemplatesPanel.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getTemplates, createTemplate, deleteTemplate, ReminderTemplate, Reminder } from '../api';
|
||||
|
||||
interface TemplatesPanelProps {
|
||||
currentReminders: Reminder[];
|
||||
onApplyTemplate: (reminders: Reminder[]) => void;
|
||||
}
|
||||
|
||||
export default function TemplatesPanel({ currentReminders, onApplyTemplate }: TemplatesPanelProps) {
|
||||
const [templates, setTemplates] = useState<ReminderTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSaveForm, setShowSaveForm] = useState(false);
|
||||
const [newTemplateName, setNewTemplateName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getTemplates();
|
||||
setTemplates(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load templates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const handleSaveTemplate = async () => {
|
||||
if (!newTemplateName.trim()) {
|
||||
setError('Template name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentReminders.length === 0) {
|
||||
setError('Add at least one reminder to save as template');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newTemplate = await createTemplate(newTemplateName.trim(), currentReminders);
|
||||
setTemplates([newTemplate, ...templates]);
|
||||
setNewTemplateName('');
|
||||
setShowSaveForm(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save template');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Delete this template?')) return;
|
||||
|
||||
try {
|
||||
await deleteTemplate(id);
|
||||
setTemplates(templates.filter(t => t.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete template');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-500 py-4">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm p-2 bg-red-50 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save current reminders as template */}
|
||||
<div className="border-b border-gray-200 pb-3">
|
||||
{!showSaveForm ? (
|
||||
<button
|
||||
onClick={() => setShowSaveForm(true)}
|
||||
disabled={currentReminders.length === 0}
|
||||
className="w-full px-3 py-2 text-sm font-medium text-indigo-600 border border-indigo-300 rounded-md hover:bg-indigo-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
💾 Save Current Reminders
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTemplateName}
|
||||
onChange={(e) => setNewTemplateName(e.target.value)}
|
||||
placeholder="Template name"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={saving}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:bg-gray-300 transition-colors"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSaveForm(false);
|
||||
setNewTemplateName('');
|
||||
setError(null);
|
||||
}}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Templates list */}
|
||||
{templates.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
<p>No templates yet</p>
|
||||
<p className="text-xs mt-1">Save your reminders for quick reuse</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="border border-gray-200 rounded-md p-3 hover:border-indigo-300 transition-colors"
|
||||
>
|
||||
<h4 className="font-medium text-gray-900 text-sm mb-2">
|
||||
{template.name}
|
||||
</h4>
|
||||
|
||||
<div className="text-xs text-gray-600 mb-2 space-y-1">
|
||||
{template.reminders.map((r, idx) => (
|
||||
<div key={idx}>
|
||||
• {r.amount} {r.unit}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onApplyTemplate(template.reminders)}
|
||||
className="flex-1 px-3 py-1 text-xs font-medium text-indigo-600 bg-indigo-50 rounded hover:bg-indigo-100 transition-colors"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(template.id)}
|
||||
className="px-3 py-1 text-xs font-medium text-red-600 bg-red-50 rounded hover:bg-red-100 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
frontend/src/index.css
Normal file
17
frontend/src/index.css
Normal file
@ -0,0 +1,17 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
9
frontend/src/vite-env.d.ts
vendored
Normal file
9
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
10
frontend/vite.config.ts
Normal file
10
frontend/vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
})
|
||||
403
main.py
Normal file
403
main.py
Normal file
@ -0,0 +1,403 @@
|
||||
"""
|
||||
FastAPI ICS Generator
|
||||
Generate iCalendar .ics files from API parameters.
|
||||
|
||||
Requirements:
|
||||
pip install fastapi uvicorn icalendar python-dateutil
|
||||
|
||||
Example usage:
|
||||
# Run the server
|
||||
python main.py
|
||||
|
||||
# Simple POST request with minimal parameters
|
||||
curl -X POST "http://localhost:8000/ics" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Team Meeting",
|
||||
"start_dt": "2026-03-01T10:00:00Z"
|
||||
}' \
|
||||
--output event.ics
|
||||
|
||||
# Full POST request with all parameters including reminders
|
||||
curl -X POST "http://localhost:8000/ics" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Product Launch Meeting",
|
||||
"description": "Discuss Q2 product launch strategy",
|
||||
"location": "Conference Room A",
|
||||
"start_dt": "2026-03-15T14:00:00-05:00",
|
||||
"end_dt": "2026-03-15T16:00:00-05:00",
|
||||
"timezone": "America/New_York",
|
||||
"organizer_email": "manager@company.com",
|
||||
"attendees": ["alice@company.com", "bob@company.com"],
|
||||
"reminders": [
|
||||
{"amount": 10, "unit": "minutes"},
|
||||
{"amount": 1, "unit": "hours"},
|
||||
{"amount": 1, "unit": "weeks"}
|
||||
]
|
||||
}' \
|
||||
--output event.ics
|
||||
|
||||
# GET request with query parameters
|
||||
curl "http://localhost:8000/ics?title=Quick%20Meeting&start_dt=2026-03-01T15:00:00Z" \
|
||||
--output event.ics
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Optional, List
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
import uuid
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="ICS Generator API",
|
||||
description="Generate iCalendar .ics files from event parameters",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class ReminderUnit(str, Enum):
|
||||
"""Supported reminder time units."""
|
||||
minutes = "minutes"
|
||||
hours = "hours"
|
||||
days = "days"
|
||||
weeks = "weeks"
|
||||
|
||||
|
||||
class Reminder(BaseModel):
|
||||
"""Reminder configuration for an event."""
|
||||
amount: int = Field(..., gt=0, description="Amount of time before event")
|
||||
unit: ReminderUnit = Field(..., description="Time unit for reminder")
|
||||
|
||||
|
||||
class EventRequest(BaseModel):
|
||||
"""Request model for creating an event."""
|
||||
title: str = Field(..., min_length=1, description="Event title/summary")
|
||||
description: Optional[str] = Field(None, description="Event description")
|
||||
location: Optional[str] = Field(None, description="Event location")
|
||||
start_dt: str = Field(..., description="Event start datetime (ISO 8601 string)")
|
||||
end_dt: Optional[str] = Field(None, description="Event end datetime (ISO 8601 string)")
|
||||
timezone: str = Field("UTC", description="Timezone for naive datetimes")
|
||||
organizer_email: Optional[str] = Field(None, description="Organizer email address")
|
||||
attendees: Optional[List[str]] = Field(None, description="List of attendee email addresses")
|
||||
reminders: Optional[List[Reminder]] = Field(None, max_length=10, description="Event reminders (max 10)")
|
||||
|
||||
@field_validator('reminders')
|
||||
@classmethod
|
||||
def validate_reminders(cls, v):
|
||||
"""Validate reminders list."""
|
||||
if v is not None and len(v) > 10:
|
||||
raise ValueError("Maximum 10 reminders allowed")
|
||||
return v
|
||||
|
||||
|
||||
def parse_datetime(dt_str: str, tz: str = "UTC") -> datetime:
|
||||
"""Parse ISO datetime string and apply timezone if naive."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=ZoneInfo(tz))
|
||||
return dt
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid datetime format: {dt_str}. Expected ISO 8601 format.")
|
||||
|
||||
|
||||
def format_datetime_for_ics(dt: datetime) -> str:
|
||||
"""
|
||||
Format datetime for iCalendar format.
|
||||
|
||||
Args:
|
||||
dt: datetime object (can be naive or aware)
|
||||
|
||||
Returns:
|
||||
Formatted datetime string for ICS file
|
||||
"""
|
||||
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
|
||||
# Naive datetime - format without timezone
|
||||
return dt.strftime("%Y%m%dT%H%M%S")
|
||||
else:
|
||||
# Aware datetime - convert to UTC and format with Z suffix
|
||||
utc_dt = dt.astimezone(ZoneInfo("UTC"))
|
||||
return utc_dt.strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def format_datetime_with_tzid(dt: datetime, timezone: str) -> tuple[str, str]:
|
||||
"""
|
||||
Format datetime with TZID parameter for iCalendar.
|
||||
|
||||
Args:
|
||||
dt: datetime object
|
||||
timezone: timezone string
|
||||
|
||||
Returns:
|
||||
Tuple of (parameter, value) for DTSTART/DTEND
|
||||
"""
|
||||
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
|
||||
# Naive datetime - apply timezone
|
||||
dt = dt.replace(tzinfo=ZoneInfo(timezone))
|
||||
|
||||
if timezone == "UTC":
|
||||
# Use UTC format with Z
|
||||
return "", format_datetime_for_ics(dt)
|
||||
else:
|
||||
# Use TZID format
|
||||
local_dt = dt.astimezone(ZoneInfo(timezone))
|
||||
dt_str = local_dt.strftime("%Y%m%dT%H%M%S")
|
||||
return f";TZID={timezone}", dt_str
|
||||
|
||||
|
||||
def reminder_to_trigger(reminder: Reminder) -> str:
|
||||
"""
|
||||
Convert reminder to iCalendar TRIGGER format.
|
||||
|
||||
Args:
|
||||
reminder: Reminder object
|
||||
|
||||
Returns:
|
||||
ISO 8601 duration string (negative for before event)
|
||||
"""
|
||||
amount = reminder.amount
|
||||
unit = reminder.unit
|
||||
|
||||
if unit == ReminderUnit.minutes:
|
||||
return f"-PT{amount}M"
|
||||
elif unit == ReminderUnit.hours:
|
||||
return f"-PT{amount}H"
|
||||
elif unit == ReminderUnit.days:
|
||||
return f"-P{amount}D"
|
||||
elif unit == ReminderUnit.weeks:
|
||||
return f"-P{amount}W"
|
||||
|
||||
return "-PT15M" # Default fallback
|
||||
|
||||
|
||||
def generate_ics(event: EventRequest) -> str:
|
||||
"""
|
||||
Generate iCalendar .ics file content from event parameters.
|
||||
|
||||
Args:
|
||||
event: EventRequest object with event details
|
||||
|
||||
Returns:
|
||||
ICS file content as string with CRLF line endings
|
||||
"""
|
||||
# Parse datetimes
|
||||
start_dt = parse_datetime(event.start_dt, event.timezone)
|
||||
|
||||
# Calculate end_dt if not provided (default 60 minutes)
|
||||
if event.end_dt:
|
||||
end_dt = parse_datetime(event.end_dt, event.timezone)
|
||||
else:
|
||||
end_dt = start_dt + timedelta(minutes=60)
|
||||
|
||||
# Validate end_dt is after start_dt
|
||||
if end_dt <= start_dt:
|
||||
raise ValueError("end_dt must be after start_dt")
|
||||
|
||||
# Generate unique ID and timestamp
|
||||
uid = str(uuid.uuid4())
|
||||
dtstamp = datetime.now(ZoneInfo("UTC"))
|
||||
|
||||
# Start building ICS content
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//ICS Generator//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
"BEGIN:VEVENT",
|
||||
f"UID:{uid}",
|
||||
f"DTSTAMP:{format_datetime_for_ics(dtstamp)}",
|
||||
]
|
||||
|
||||
# Add DTSTART and DTEND
|
||||
lines.append(f"DTSTART:{format_datetime_for_ics(start_dt)}")
|
||||
lines.append(f"DTEND:{format_datetime_for_ics(end_dt)}")
|
||||
|
||||
# Add SUMMARY (title)
|
||||
lines.append(f"SUMMARY:{event.title}")
|
||||
|
||||
# Add optional DESCRIPTION
|
||||
if event.description:
|
||||
# Escape special characters and handle multiline
|
||||
desc = event.description.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;").replace("\n", "\\n")
|
||||
lines.append(f"DESCRIPTION:{desc}")
|
||||
|
||||
# Add optional LOCATION
|
||||
if event.location:
|
||||
loc = event.location.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;")
|
||||
lines.append(f"LOCATION:{loc}")
|
||||
|
||||
# Add optional ORGANIZER
|
||||
if event.organizer_email:
|
||||
lines.append(f"ORGANIZER:mailto:{event.organizer_email}")
|
||||
|
||||
# Add optional ATTENDEES
|
||||
if event.attendees:
|
||||
for attendee in event.attendees:
|
||||
lines.append(f"ATTENDEE:mailto:{attendee}")
|
||||
|
||||
# Add VALARM blocks for reminders
|
||||
if event.reminders:
|
||||
for reminder in event.reminders:
|
||||
trigger = reminder_to_trigger(reminder)
|
||||
lines.extend([
|
||||
"BEGIN:VALARM",
|
||||
"ACTION:DISPLAY",
|
||||
"DESCRIPTION:Reminder",
|
||||
f"TRIGGER:{trigger}",
|
||||
"END:VALARM"
|
||||
])
|
||||
|
||||
# Close VEVENT and VCALENDAR
|
||||
lines.extend([
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR"
|
||||
])
|
||||
|
||||
# Join with CRLF and ensure trailing CRLF
|
||||
ics_content = "\r\n".join(lines) + "\r\n"
|
||||
|
||||
return ics_content
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with API usage information."""
|
||||
return {
|
||||
"message": "ICS Generator API",
|
||||
"usage": {
|
||||
"endpoint": "/ics",
|
||||
"methods": ["GET", "POST"],
|
||||
"description": "Generate iCalendar .ics files from event parameters",
|
||||
"docs": "/docs",
|
||||
"example": {
|
||||
"POST": {
|
||||
"url": "/ics",
|
||||
"body": {
|
||||
"title": "Team Meeting",
|
||||
"start_dt": "2026-03-01T10:00:00Z",
|
||||
"description": "Monthly team sync",
|
||||
"reminders": [
|
||||
{"amount": 10, "unit": "minutes"},
|
||||
{"amount": 1, "unit": "hours"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"GET": {
|
||||
"url": "/ics?title=Meeting&start_dt=2026-03-01T10:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.post("/ics")
|
||||
async def create_ics_post(event: EventRequest):
|
||||
"""
|
||||
Generate and download an iCalendar .ics file from POST body parameters.
|
||||
|
||||
Returns:
|
||||
Response with .ics file content and appropriate headers for download
|
||||
"""
|
||||
try:
|
||||
ics_content = generate_ics(event)
|
||||
|
||||
return Response(
|
||||
content=ics_content,
|
||||
media_type="text/calendar; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="event.ics"'
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error generating ICS file: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/preview")
|
||||
async def preview_ics(event: EventRequest):
|
||||
"""Generate and return ICS content as plain text for preview."""
|
||||
try:
|
||||
ics_content = generate_ics(event)
|
||||
|
||||
return Response(
|
||||
content=ics_content,
|
||||
media_type="text/plain; charset=utf-8"
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error generating ICS file: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/ics")
|
||||
async def create_ics_get(
|
||||
title: str = Query(..., description="Event title"),
|
||||
start_dt: str = Query(..., description="Event start datetime (ISO 8601)"),
|
||||
description: Optional[str] = Query(None, description="Event description"),
|
||||
location: Optional[str] = Query(None, description="Event location"),
|
||||
end_dt: Optional[str] = Query(None, description="Event end datetime (ISO 8601)"),
|
||||
timezone: str = Query("UTC", description="Timezone for naive datetimes"),
|
||||
organizer_email: Optional[str] = Query(None, description="Organizer email"),
|
||||
):
|
||||
"""
|
||||
Generate and download an iCalendar .ics file from GET query parameters.
|
||||
|
||||
Note: This GET endpoint supports basic parameters only. For full functionality
|
||||
including attendees and reminders, use the POST endpoint.
|
||||
|
||||
Returns:
|
||||
Response with .ics file content and appropriate headers for download
|
||||
"""
|
||||
# Create EventRequest from query parameters
|
||||
event = EventRequest(
|
||||
title=title,
|
||||
start_dt=start_dt,
|
||||
description=description,
|
||||
location=location,
|
||||
end_dt=end_dt,
|
||||
timezone=timezone,
|
||||
organizer_email=organizer_email
|
||||
)
|
||||
|
||||
try:
|
||||
ics_content = generate_ics(event)
|
||||
|
||||
return Response(
|
||||
content=ics_content,
|
||||
media_type="text/calendar; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="event.ics"'
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error generating ICS file: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
python-dateutil>=2.8.2
|
||||
Loading…
x
Reference in New Issue
Block a user