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