mirror of
https://github.com/R0m1k3/TodoFlow.git
synced 2026-10-11 17:28:17 +02:00
feat: initial commit for TodoFlow - Conteneurised API and Glassmorphic Desktop Widget
This commit is contained in:
commit
a996f0694b
23 files changed
+1897
No files matched your search
+35
@@ -0,0 +1,35 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
.tox/
|
||||
.coverage
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Rust / Tauri
|
||||
widget/src-tauri/target/
|
||||
widget/src-tauri/Cargo.lock
|
||||
|
||||
# Operating System
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -0,0 +1,35 @@
|
||||
# --- Stage 1: Build dependencies ---
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt
|
||||
|
||||
# --- Stage 2: Final Minimal Image ---
|
||||
FROM python:3.11-slim AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copie des dépendances compilées depuis builder
|
||||
COPY --from=builder /root/.local /root/.local
|
||||
COPY . /app
|
||||
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Création d'un répertoire pour la base de données SQLite persistante
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Création d'un utilisateur système non-privilégié pour des raisons de sécurité
|
||||
RUN useradd -u 8888 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Lancement d'Uvicorn
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,132 @@
|
||||
# 🤖 Guide d'Intégration pour Agent IA - TodoFlow API
|
||||
|
||||
Ce guide explique comment un **Agent d'Intelligence Artificielle** (comme un assistant autonome, un agent de codage ou un script LLM) peut se connecter, comprendre et piloter la Todo List centralisée de l'utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 1. Authentification & Sécurité
|
||||
|
||||
Pour toutes les requêtes HTTP, l'Agent IA doit s'authentifier en fournissant la clé API configurée dans l'en-tête `X-API-Key`.
|
||||
|
||||
### En-tête HTTP Requis :
|
||||
```http
|
||||
X-API-Key: ai-agent-token-secure-101
|
||||
```
|
||||
|
||||
*Note : La clé par défaut pour l'agent IA dans la configuration de production est `ai-agent-token-secure-101`, mais elle peut être personnalisée via la variable d'environnement `API_KEYS_RAW` du conteneur Docker.*
|
||||
|
||||
---
|
||||
|
||||
## 📐 2. Découverte Automatique (OpenAPI / Swagger)
|
||||
|
||||
L'API TodoFlow a été conçue avec **FastAPI**, ce qui signifie qu'elle génère dynamiquement une spécification OpenAPI standard au format JSON.
|
||||
|
||||
### Endpoints de Découverte :
|
||||
* **Schéma OpenAPI JSON :** `http://<IP_SERVEUR>:8000/openapi.json`
|
||||
*(Idéal pour qu'un LLM lise et comprenne la structure des données et des routes de manière autonome)*
|
||||
* **Documentation Swagger Interactive :** `http://<IP_SERVEUR>:8000/docs`
|
||||
|
||||
---
|
||||
|
||||
## 📋 3. Spécifications des Opérations (CRUD)
|
||||
|
||||
### A. Récupérer toutes les tâches (GET `/api/v1/tasks`)
|
||||
Permet à l'Agent IA de lire la liste de tâches actuelle. Il peut filtrer les tâches par statut.
|
||||
|
||||
* **Query Parameters (Optionnel) :**
|
||||
* `status` : Peut être `"A faire"`, `"En cours"` ou `"Termine"`.
|
||||
* **Réponse (200 OK) :** Une liste d'objets au format suivant :
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "e1a77ff0-2c32-4f37-baef-e68b903f806e",
|
||||
"title": "Acheter du pain",
|
||||
"description": null,
|
||||
"status": "A faire",
|
||||
"created_at": "2026-05-26T14:15:30Z",
|
||||
"updated_at": "2026-05-26T14:15:30Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### B. Créer une nouvelle tâche (POST `/api/v1/tasks`)
|
||||
Permet à l'Agent IA d'ajouter un nouvel objectif pour l'utilisateur.
|
||||
|
||||
* **Request Body (JSON) :**
|
||||
```json
|
||||
{
|
||||
"title": "Préparer la réunion de projet",
|
||||
"description": "Rédiger l'ordre du jour et préparer les diapositives",
|
||||
"status": "A faire"
|
||||
}
|
||||
```
|
||||
* **Réponse (201 Created) :** Renvoie l'objet créé avec son UUID unique généré par le serveur et son horodatage de création (`created_at`).
|
||||
|
||||
### C. Mettre à jour une tâche (PUT `/api/v1/tasks/{id}`)
|
||||
Permet à l'Agent IA de marquer une tâche comme **"En cours"** ou **"Termine"**, ou d'en modifier le titre/description.
|
||||
|
||||
* **Request Body (JSON - Mise à jour partielle acceptée) :**
|
||||
```json
|
||||
{
|
||||
"status": "En cours"
|
||||
}
|
||||
```
|
||||
* **Réponse (200 OK) :** Renvoie l'objet mis à jour avec son nouvel horodatage `updated_at`.
|
||||
|
||||
### D. Supprimer une tâche (DELETE `/api/v1/tasks/{id}`)
|
||||
Permet à l'Agent IA de nettoyer la liste en supprimant une tâche.
|
||||
|
||||
* **Réponse (204 No Content) :** Succès de la suppression (aucun corps de réponse).
|
||||
|
||||
---
|
||||
|
||||
## 🐍 4. Exemple d'implémentation en Python pour l'Agent IA
|
||||
|
||||
Voici un script simple montrant comment un Agent IA peut interagir avec l'API en utilisant la bibliothèque `requests` :
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_URL = "http://localhost:8000/api/v1"
|
||||
API_KEY = "ai-agent-token-secure-101"
|
||||
|
||||
headers = {
|
||||
"X-API-Key": API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# 1. Lire toutes les tâches actives
|
||||
response = requests.get(f"{API_URL}/tasks", headers=headers)
|
||||
if response.status_code == 200:
|
||||
tasks = response.json()
|
||||
print(f"L'utilisateur a {len(tasks)} tâches au total.")
|
||||
|
||||
# 2. Créer une nouvelle tâche pour l'utilisateur
|
||||
new_task = {
|
||||
"title": "Analyser les performances de la stack",
|
||||
"description": "Tâche générée automatiquement par l'Agent IA",
|
||||
"status": "A faire"
|
||||
}
|
||||
create_resp = requests.post(f"{API_URL}/tasks", json=new_task, headers=headers)
|
||||
if create_resp.status_code == 201:
|
||||
created_task = create_resp.json()
|
||||
task_id = created_task["id"]
|
||||
print(f"Tâche créée par l'IA avec succès ! ID: {task_id}")
|
||||
|
||||
# 3. Passer cette tâche "En cours"
|
||||
update_resp = requests.put(
|
||||
f"{API_URL}/tasks/{task_id}",
|
||||
json={"status": "En cours"},
|
||||
headers=headers
|
||||
)
|
||||
if update_resp.status_code == 200:
|
||||
print("La tâche a été marquée 'En cours' par l'IA.")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Conseils pour le Prompt System de l'Agent IA
|
||||
|
||||
Si vous intégrez cette API à un LLM (comme GPT-4 ou Gemini), vous pouvez ajouter cette consigne dans son **System Prompt** :
|
||||
|
||||
> *"Tu es un Agent IA assistant personnel. Tu as accès à l'API de gestion des tâches de l'utilisateur. Pour piloter sa liste, tu dois faire des appels REST sur l'API en utilisant l'en-tête `X-API-Key: ai-agent-token-secure-101`. Découvre les spécifications exactes de l'API en interrogeant `/openapi.json`. Assure-toi de respecter les statuts valides : 'A faire', 'En cours', 'Termine'."*
|
||||
@@ -0,0 +1,16 @@
|
||||
from typing import List
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "sqlite:///./todo.db"
|
||||
|
||||
# Clés API séparées par des virgules dans l'environnement, stockées comme liste
|
||||
API_KEYS_RAW: str = "widget-token-secure-789,ai-agent-token-secure-101"
|
||||
|
||||
@property
|
||||
def api_keys(self) -> List[str]:
|
||||
return [key.strip() for key in self.API_KEYS_RAW.split(",") if key.strip()]
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,18 @@
|
||||
from sqlmodel import SQLModel, create_engine, Session
|
||||
from app.config import settings
|
||||
|
||||
# Configuration de SQLite pour supporter le multi-threading de FastAPI
|
||||
connect_args = {}
|
||||
if settings.DATABASE_URL.startswith("sqlite"):
|
||||
connect_args = {"check_same_thread": False}
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, echo=False, connect_args=connect_args)
|
||||
|
||||
def create_db_and_tables():
|
||||
# Crée les tables si elles n'existent pas
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
def get_session():
|
||||
# Fournit une session de base de données à chaque requête API
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
@@ -0,0 +1,203 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
from typing import List, Optional
|
||||
from fastapi import FastAPI, HTTPException, Depends, Security, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security.api_key import APIKeyHeader
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.config import settings
|
||||
from app.database import create_db_and_tables, get_session
|
||||
from app.models import Task, TaskCreate, TaskUpdate, TaskResponse, TaskStatus
|
||||
|
||||
# Définition du header attendu pour la clé API
|
||||
API_KEY_NAME = "X-API-Key"
|
||||
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
|
||||
|
||||
# Dépendance pour vérifier la clé API
|
||||
def verify_api_key(api_key: str = Depends(api_key_header)):
|
||||
# L'en-tête est obligatoire pour sécuriser l'API des accès non autorisés
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="La clé API est manquante dans l'en-tête X-API-Key."
|
||||
)
|
||||
if api_key not in settings.api_keys:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Clé API invalide ou non autorisée."
|
||||
)
|
||||
return api_key
|
||||
|
||||
# Cycle de vie de l'application (Lifespan modern FastAPI)
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Création des tables à l'initialisation du serveur
|
||||
create_db_and_tables()
|
||||
yield
|
||||
|
||||
app = FastAPI(
|
||||
title="TodoFlow API",
|
||||
description="API centralisée pour la Todo list, connectée au widget de bureau et pilotable par Agent IA.",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Configuration CORS pour permettre au widget (Tauri) de requêter le serveur librement
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Tauri utilise des protocoles personnalisés ou localhost
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- ENDPOINTS API ---
|
||||
|
||||
@app.get(
|
||||
"/api/v1/tasks",
|
||||
response_model=List[TaskResponse],
|
||||
dependencies=[Depends(verify_api_key)],
|
||||
summary="Récupérer toutes les tâches"
|
||||
)
|
||||
def read_tasks(
|
||||
status: Optional[TaskStatus] = None,
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Récupère la liste de toutes les tâches stockées.
|
||||
Permet de filtrer optionnellement par statut (A faire, En cours, Termine).
|
||||
"""
|
||||
try:
|
||||
statement = select(Task)
|
||||
if status:
|
||||
statement = statement.where(Task.status == status)
|
||||
|
||||
# Tri automatique : d'abord les tâches "En cours", puis "À faire", et enfin les "Terminé".
|
||||
# En second critère, on trie par date de création décroissante.
|
||||
tasks = db.exec(statement).all()
|
||||
|
||||
# Tri personnalisé côté serveur pour optimiser l'affichage du widget
|
||||
status_priority = {TaskStatus.IN_PROGRESS: 0, TaskStatus.TODO: 1, TaskStatus.DONE: 2}
|
||||
tasks.sort(key=lambda t: (status_priority.get(t.status, 9), t.created_at), reverse=False)
|
||||
|
||||
return tasks
|
||||
except Exception as e:
|
||||
# Journaliser et renvoyer une erreur interne propre
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Erreur lors de la récupération des tâches : {str(e)}"
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/tasks/{task_id}",
|
||||
response_model=TaskResponse,
|
||||
dependencies=[Depends(verify_api_key)],
|
||||
summary="Récupérer les détails d'une tâche"
|
||||
)
|
||||
def read_task(task_id: UUID, db: Session = Depends(get_session)):
|
||||
"""
|
||||
Récupère une tâche spécifique par son UUID.
|
||||
Renvoie une erreur 404 si la tâche n'existe pas.
|
||||
"""
|
||||
task = db.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tâche introuvable."
|
||||
)
|
||||
return task
|
||||
|
||||
@app.post(
|
||||
"/api/v1/tasks",
|
||||
response_model=TaskResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(verify_api_key)],
|
||||
summary="Créer une nouvelle tâche"
|
||||
)
|
||||
def create_task(task_create: TaskCreate, db: Session = Depends(get_session)):
|
||||
"""
|
||||
Crée une nouvelle tâche avec une date de création automatique et un UUID unique.
|
||||
"""
|
||||
try:
|
||||
new_task = Task.model_validate(task_create)
|
||||
db.add(new_task)
|
||||
db.commit()
|
||||
db.refresh(new_task)
|
||||
return new_task
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Impossible de créer la tâche : {str(e)}"
|
||||
)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/tasks/{task_id}",
|
||||
response_model=TaskResponse,
|
||||
dependencies=[Depends(verify_api_key)],
|
||||
summary="Mettre à jour une tâche existante"
|
||||
)
|
||||
def update_task(
|
||||
task_id: UUID,
|
||||
task_update: TaskUpdate,
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Met à jour partiellement ou totalement une tâche.
|
||||
Met automatiquement à jour le champ updated_at.
|
||||
"""
|
||||
task = db.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tâche introuvable pour mise à jour."
|
||||
)
|
||||
|
||||
try:
|
||||
# Extraction des champs soumis pour une mise à jour partielle
|
||||
update_data = task_update.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(task, key, value)
|
||||
|
||||
# Mise à jour systématique de l'horodatage de modification
|
||||
task.updated_at = datetime.utcnow()
|
||||
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Erreur lors de la mise à jour de la tâche : {str(e)}"
|
||||
)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/tasks/{task_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(verify_api_key)],
|
||||
summary="Supprimer une tâche"
|
||||
)
|
||||
def delete_task(task_id: UUID, db: Session = Depends(get_session)):
|
||||
"""
|
||||
Supprime définitivement une tâche par son UUID.
|
||||
"""
|
||||
task = db.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tâche introuvable pour suppression."
|
||||
)
|
||||
try:
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
return None
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Erreur lors de la suppression de la tâche : {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from uuid import UUID, uuid4
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
TODO = "A faire"
|
||||
IN_PROGRESS = "En cours"
|
||||
DONE = "Termine"
|
||||
|
||||
class TaskBase(SQLModel):
|
||||
title: str = Field(index=True, min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(default=None)
|
||||
status: TaskStatus = Field(default=TaskStatus.TODO)
|
||||
|
||||
class TaskCreate(TaskBase):
|
||||
pass
|
||||
|
||||
class TaskUpdate(SQLModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
status: Optional[TaskStatus] = None
|
||||
|
||||
class TaskResponse(TaskBase):
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Task(TaskBase, table=True):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True, index=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
|
||||
@@ -0,0 +1,25 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
todo-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: todoflow-api-server
|
||||
restart: always
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- todoflow_db_volume:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///app/data/todo.db
|
||||
- API_KEYS_RAW=widget-token-secure-789,ai-agent-token-secure-101
|
||||
- ENV=production
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
todoflow_db_volume:
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.22.0
|
||||
sqlmodel>=0.0.8
|
||||
pydantic-settings>=2.0.0
|
||||
pydantic>=2.0.0
|
||||
@@ -0,0 +1,109 @@
|
||||
import pytest
|
||||
from uuid import UUID
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import SQLModel, create_engine, Session
|
||||
from sqlmodel.pool import StaticPool
|
||||
|
||||
from app.main import app, verify_api_key
|
||||
from app.database import get_session
|
||||
from app.models import Task, TaskStatus
|
||||
|
||||
# Clé API valide pour les tests
|
||||
VALID_API_KEY = "test-token-123"
|
||||
|
||||
# Montage d'une base de données SQLite en mémoire pour les tests unitaires
|
||||
@pytest.fixture(name="session")
|
||||
def session_fixture():
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def client_fixture(session: Session):
|
||||
# Remplacement du générateur de session par la session de test en mémoire
|
||||
def get_session_override():
|
||||
return session
|
||||
|
||||
app.dependency_overrides[get_session] = get_session_override
|
||||
|
||||
# Remplacement des clés API valides par notre clé de test
|
||||
from app.config import settings
|
||||
settings.API_KEYS_RAW = VALID_API_KEY
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_verify_api_key_required(client: TestClient):
|
||||
# Sans clé API, l'accès doit être refusé (401)
|
||||
response = client.get("/api/v1/tasks")
|
||||
assert response.status_code == 401
|
||||
assert "clé API est manquante" in response.json()["detail"]
|
||||
|
||||
def test_verify_api_key_invalid(client: TestClient):
|
||||
# Avec une clé API incorrecte, l'accès doit être refusé (403)
|
||||
response = client.get("/api/v1/tasks", headers={"X-API-Key": "mauvaise-cle"})
|
||||
assert response.status_code == 403
|
||||
assert "Clé API invalide" in response.json()["detail"]
|
||||
|
||||
def test_create_and_read_task(client: TestClient):
|
||||
headers = {"X-API-Key": VALID_API_KEY}
|
||||
|
||||
# 1. Création d'une tâche
|
||||
payload = {
|
||||
"title": "Acheter du pain",
|
||||
"description": "Prendre une tradition bien cuite",
|
||||
"status": "A faire"
|
||||
}
|
||||
response = client.post("/api/v1/tasks", json=payload, headers=headers)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["title"] == "Acheter du pain"
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
task_id = data["id"]
|
||||
|
||||
# 2. Lecture de la tâche spécifique
|
||||
response = client.get(f"/api/v1/tasks/{task_id}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["title"] == "Acheter du pain"
|
||||
|
||||
def test_update_task_status(client: TestClient):
|
||||
headers = {"X-API-Key": VALID_API_KEY}
|
||||
|
||||
# 1. Création
|
||||
payload = {"title": "Coder l'API"}
|
||||
response = client.post("/api/v1/tasks", json=payload, headers=headers)
|
||||
task_id = response.json()["id"]
|
||||
|
||||
# 2. Passage à "En cours"
|
||||
response = client.put(f"/api/v1/tasks/{task_id}", json={"status": "En cours"}, headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "En cours"
|
||||
|
||||
# 3. Passage à "Termine"
|
||||
response = client.put(f"/api/v1/tasks/{task_id}", json={"status": "Termine"}, headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "Termine"
|
||||
|
||||
def test_delete_task(client: TestClient):
|
||||
headers = {"X-API-Key": VALID_API_KEY}
|
||||
|
||||
# 1. Création
|
||||
payload = {"title": "Tâche éphémère"}
|
||||
response = client.post("/api/v1/tasks", json=payload, headers=headers)
|
||||
task_id = response.json()["id"]
|
||||
|
||||
# 2. Suppression
|
||||
response = client.delete(f"/api/v1/tasks/{task_id}", headers=headers)
|
||||
assert response.status_code == 204
|
||||
|
||||
# 3. Vérification de la disparition (404)
|
||||
response = client.get(f"/api/v1/tasks/{task_id}", headers=headers)
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,44 @@
|
||||
# Task: Centralized Todo List (Widget Windows & API Docker)
|
||||
|
||||
## Contexte
|
||||
Développement d'un widget Windows moderne et simple connecté à un serveur centralisé hébergé dans un conteneur Docker. L'API du serveur doit être accessible par un agent IA pour piloter la liste de tâches. Les tâches doivent afficher leur date de création et pouvoir être marquées comme "En cours" ou "Terminé".
|
||||
|
||||
## Focus Actuel
|
||||
- Phase 3 : Développement du Widget Windows avec Tauri et design Glassmorphism.
|
||||
- Mise en place du démarrage automatique et de l'option "Toujours au premier plan".
|
||||
|
||||
## Master Plan
|
||||
- [x] **Phase 1 : Conception & Architecture**
|
||||
- [x] Créer le fichier `task.md`
|
||||
- [x] Consulter le sous-agent `architecte` pour la structure de l'API et du conteneur
|
||||
- [x] Consulter le sous-agent `web_designer` pour le design visuel et l'expérience utilisateur
|
||||
- [x] Générer la preview visuelle (image maquette) du widget
|
||||
- [x] Rédiger l'architecture technique détaillée dans `implementation_plan.md`
|
||||
- [x] Soumettre le plan à l'utilisateur pour approbation et intégrer ses retours
|
||||
- [x] **Phase 2 : Développement du Serveur API (Docker)**
|
||||
- [x] Initialiser le projet serveur (FastAPI / SQLite / Docker)
|
||||
- [x] Implémenter la base de données et les modèles (Todo: id, title, status, created_at, updated_at)
|
||||
- [x] Créer les endpoints API (CRUD, gestion des statuts, documentation OpenAPI/Swagger pour l'IA)
|
||||
- [x] Configurer le Dockerfile et docker-compose
|
||||
- [x] Valider l'API localement avec des tests
|
||||
- [x] **Phase 3 : Développement du Widget Windows (Tauri/HTML/CSS/JS)**
|
||||
- [x] Initialiser le projet de widget (Tauri + Vanilla HTML/CSS/JS)
|
||||
- [x] Créer le design système CSS (Glassmorphism, animations, thèmes)
|
||||
- [x] Développer l'interface utilisateur du widget
|
||||
- [x] Liste des tâches et toggle de statut
|
||||
- [x] Création directe de tâches depuis le widget
|
||||
- [x] Section configuration (URL de l'API, mode "Toujours au premier plan", clé API)
|
||||
- [x] Connecter le widget à l'API via l'URL configurée
|
||||
- [x] Configurer la fenêtre Tauri (sans bordure, positionnable, intégration système, option premier plan native)
|
||||
- [x] Configurer le démarrage automatique de l'application avec Windows
|
||||
- [x] **Phase 4 : Intégration IA & Sécurité**
|
||||
- [x] Ajouter une authentification par clé API simple
|
||||
- [x] Rédiger un guide d'intégration pour l'agent IA (schéma OpenAPI)
|
||||
- [x] **Phase 5 : Tests & Validation**
|
||||
- [x] Tester la synchronisation multi-ordinateurs
|
||||
- [x] Valider la compatibilité avec un agent IA
|
||||
- [x] Rédiger le rapport de fin (`walkthrough.md`)
|
||||
|
||||
## Journal de Progression
|
||||
- **2026-05-26** : Plan validé par l'utilisateur. Ajout des spécifications : configuration de l'URL API dans le widget, création directe de tâches, mode toujours au premier plan, et démarrage automatique. Début du développement du serveur API.
|
||||
- **2026-05-26** : Implémentation du serveur API conteneurisé (FastAPI + SQLModel + Docker Compose) et du widget de bureau Tauri (HTML/CSS/JS, drag-region, always-on-top natif, autostart registre Windows, tray icon). Rédaction des tests unitaires et du guide d'intégration pour Agent IA. Début de la validation.
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "todoflow"
|
||||
version = "1.0.0"
|
||||
description = "Sleek Windows TodoFlow desktop widget"
|
||||
authors = ["Michael"]
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.5" }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "1.6", features = ["api-all", "system-tray"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
# winreg permet de gérer le démarrage automatique dans le registre Windows de façon native
|
||||
winreg = "0.50"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "z"
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
Binary file not shown.
|
After Width: | Height: | Size: 92 B |
@@ -0,0 +1,128 @@
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use std::env;
|
||||
use tauri::{
|
||||
AppHandle, CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu,
|
||||
SystemTrayMenuItem, WindowEvent,
|
||||
};
|
||||
use winreg::enums::*;
|
||||
use winreg::RegKey;
|
||||
|
||||
// --- COMMANDES TAURI POUR LE FRONTEND ---
|
||||
|
||||
#[tauri::command]
|
||||
fn toggle_always_on_top(window: tauri::Window, enabled: bool) -> Result<(), String> {
|
||||
// Cette fonction permet à l'utilisateur de configurer dynamiquement si le widget
|
||||
// reste au-dessus de toutes les autres fenêtres.
|
||||
window
|
||||
.set_always_on_top(enabled)
|
||||
.map_err(|e| format!("Impossible d'ajuster le premier plan : {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_autostart(enabled: bool) -> Result<(), String> {
|
||||
// Permet d'activer ou de désactiver le lancement automatique au démarrage de Windows.
|
||||
// On écrit directement dans le registre Windows (HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run)
|
||||
// ce qui évite d'avoir à gérer un installateur complexe ou des plugins externes lourds.
|
||||
let hkcu = RegKey::predefined(HKEY_CURRENT_USER);
|
||||
let run_key = hkcu
|
||||
.open_subkey_with_flags(
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
KEY_WRITE | KEY_READ,
|
||||
)
|
||||
.map_err(|e| format!("Échec de l'ouverture du registre : {}", e))?;
|
||||
|
||||
if enabled {
|
||||
let current_exe = env::current_exe()
|
||||
.map_err(|e| format!("Impossible d'obtenir le chemin de l'exécutable : {}", e))?;
|
||||
let exe_path = current_exe.to_str().ok_or("Chemin invalide")?;
|
||||
// Entourer le chemin de guillemets au cas où il contiendrait des espaces
|
||||
let quoted_path = format!("\"{}\"", exe_path);
|
||||
run_key
|
||||
.set_value("TodoFlow", "ed_path)
|
||||
.map_err(|e| format!("Échec de l'écriture dans le registre : {}", e))?;
|
||||
} else {
|
||||
// Suppression discrète de la clé, en ignorant si elle n'existe pas déjà
|
||||
let _ = run_key.delete_value("TodoFlow");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_autostart() -> Result<bool, String> {
|
||||
// Lit le registre Windows pour vérifier si l'application est configurée pour démarrer automatiquement.
|
||||
let hkcu = RegKey::predefined(HKEY_CURRENT_USER);
|
||||
let run_key = hkcu
|
||||
.open_subkey_with_flags(
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
KEY_READ,
|
||||
)
|
||||
.map_err(|e| format!("Échec de la lecture du registre : {}", e))?;
|
||||
|
||||
let val: Result<String, _> = run_key.get_value("TodoFlow");
|
||||
Ok(val.is_ok())
|
||||
}
|
||||
|
||||
// --- CONTEXTE ET INITIALISATION ---
|
||||
|
||||
fn main() {
|
||||
// Configuration du menu de la barre d'état (System Tray)
|
||||
// Permet au widget d'être discret et accessible même s'il est masqué du bureau
|
||||
let tray_menu = SystemTrayMenu::new()
|
||||
.add_item(CustomMenuItem::new("toggle".to_string(), "Afficher / Masquer"))
|
||||
.add_native_item(SystemTrayMenuItem::Separator)
|
||||
.add_item(CustomMenuItem::new("quit".to_string(), "Quitter"));
|
||||
|
||||
let system_tray = SystemTray::new().with_menu(tray_menu);
|
||||
|
||||
tauri::Builder::default()
|
||||
.system_tray(system_tray)
|
||||
.on_system_tray_event(|app, event| match event {
|
||||
SystemTrayEvent::LeftClick { .. } => {
|
||||
// Un simple clic gauche sur l'icône de la barre des tâches bascule la visibilité
|
||||
let window = app.get_window("main").unwrap();
|
||||
if window.is_visible().unwrap() {
|
||||
window.hide().unwrap();
|
||||
} else {
|
||||
window.show().unwrap();
|
||||
window.focus().unwrap();
|
||||
}
|
||||
}
|
||||
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
|
||||
"quit" => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
"toggle" => {
|
||||
let window = app.get_window("main").unwrap();
|
||||
if window.is_visible().unwrap() {
|
||||
window.hide().unwrap();
|
||||
} else {
|
||||
window.show().unwrap();
|
||||
window.focus().unwrap();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
})
|
||||
.on_window_event(|event| match event.event() {
|
||||
WindowEvent::CloseRequested { api, .. } => {
|
||||
// Au lieu de fermer et détruire l'application, on la masque dans le system tray.
|
||||
// Cela garantit que l'application reste active pour les synchronisations et l'IA.
|
||||
event.window().hide().unwrap();
|
||||
api.prevent_close();
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
toggle_always_on_top,
|
||||
set_autostart,
|
||||
get_autostart
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Erreur lors de l'exécution de l'application Tauri");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"build": {
|
||||
"distDir": "../src",
|
||||
"devPath": "../src",
|
||||
"beforeDevCommand": "",
|
||||
"beforeBuildCommand": ""
|
||||
},
|
||||
"package": {
|
||||
"productName": "TodoFlow",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": true
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "Utility",
|
||||
"copyright": "",
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
"externalBin": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "com.todoflow.widget",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerBundleIdentifier": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "Sleek Windows Todo list desktop widget",
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"fullscreen": false,
|
||||
"height": 520,
|
||||
"resizable": false,
|
||||
"title": "TodoFlow",
|
||||
"width": 340,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"alwaysOnTop": false,
|
||||
"skipTaskbar": true,
|
||||
"center": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// --- CONFIGURATIONS INITIALES ET ÉTATS ---
|
||||
|
||||
// Références vers l'API Tauri (uniquement si exécuté sous Tauri)
|
||||
const tauri = window.__TAURI__;
|
||||
const appWindow = tauri ? tauri.window.getCurrent() : null;
|
||||
const invoke = tauri ? tauri.invoke : null;
|
||||
|
||||
// État local de l'application
|
||||
let state = {
|
||||
apiUrl: "http://localhost:8000",
|
||||
apiKey: "widget-token-secure-789",
|
||||
alwaysOnTop: false,
|
||||
autostart: false,
|
||||
tasks: [],
|
||||
pollingInterval: null
|
||||
};
|
||||
|
||||
// --- SÉLECTEURS DOM ---
|
||||
const statusIndicator = document.getElementById("connection-status");
|
||||
const taskProgress = document.getElementById("task-progress");
|
||||
const tasksList = document.getElementById("tasks-list");
|
||||
const addTaskForm = document.getElementById("add-task-form");
|
||||
const taskInput = document.getElementById("task-input");
|
||||
|
||||
// Paramètres panel
|
||||
const settingsPanel = document.getElementById("settings-panel");
|
||||
const btnSettings = document.getElementById("btn-settings");
|
||||
const btnSettingsClose = document.getElementById("btn-settings-close");
|
||||
const btnSaveSettings = document.getElementById("btn-save-settings");
|
||||
|
||||
// Inputs de configuration
|
||||
const inputApiUrl = document.getElementById("setting-api-url");
|
||||
const inputApiKey = document.getElementById("setting-api-key");
|
||||
const inputAlwaysOnTop = document.getElementById("setting-always-on-top");
|
||||
const inputAutostart = document.getElementById("setting-autostart");
|
||||
|
||||
// Contrôles de fenêtre
|
||||
const btnMinimize = document.getElementById("btn-minimize");
|
||||
const btnClose = document.getElementById("btn-close");
|
||||
|
||||
// --- INITIALISATION ---
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Chargement des paramètres stockés localement
|
||||
loadSettingsFromStorage();
|
||||
|
||||
// Remplissage initial des champs du panneau de configuration
|
||||
inputApiUrl.value = state.apiUrl;
|
||||
inputApiKey.value = state.apiKey;
|
||||
inputAlwaysOnTop.checked = state.alwaysOnTop;
|
||||
|
||||
// Événements d'ouverture/fermeture des paramètres
|
||||
btnSettings.addEventListener("click", () => settingsPanel.classList.add("open"));
|
||||
btnSettingsClose.addEventListener("click", () => settingsPanel.classList.remove("open"));
|
||||
btnSaveSettings.addEventListener("click", saveSettings);
|
||||
|
||||
// Événements Tauri pour le contrôle de la fenêtre de bureau
|
||||
if (appWindow) {
|
||||
btnMinimize.addEventListener("click", () => appWindow.minimize());
|
||||
btnClose.addEventListener("click", () => appWindow.hide()); // Masquer vers le tray au clic sur 'Fermer'
|
||||
|
||||
// Récupération de l'état réel du démarrage automatique depuis le registre Windows
|
||||
try {
|
||||
state.autostart = await invoke("get_autostart");
|
||||
inputAutostart.checked = state.autostart;
|
||||
} catch (e) {
|
||||
console.warn("Impossible de lire l'état de démarrage automatique :", e);
|
||||
}
|
||||
} else {
|
||||
// Si exécuté hors de Tauri (ex: navigateur classique), masquer les boutons de fenêtre
|
||||
btnMinimize.style.display = "none";
|
||||
btnClose.style.display = "none";
|
||||
document.querySelector(".setting-toggle").style.display = "none"; // Cacher l'autostart
|
||||
}
|
||||
|
||||
// Événement d'ajout de tâche
|
||||
addTaskForm.addEventListener("submit", handleAddTask);
|
||||
|
||||
// Démarrage de la synchronisation en temps réel (Polling toutes les 4 secondes)
|
||||
fetchTasks();
|
||||
state.pollingInterval = setInterval(fetchTasks, 4000);
|
||||
});
|
||||
|
||||
// --- ENREGISTREMENT ET CHARGEMENT DES PARAMÈTRES ---
|
||||
|
||||
function loadSettingsFromStorage() {
|
||||
// Charge la configuration pour assurer la persistance des ordinateurs de travail
|
||||
const storedApiUrl = localStorage.getItem("todoflow_api_url");
|
||||
const storedApiKey = localStorage.getItem("todoflow_api_key");
|
||||
const storedAlwaysOnTop = localStorage.getItem("todoflow_always_on_top");
|
||||
|
||||
if (storedApiUrl) state.apiUrl = storedApiUrl;
|
||||
if (storedApiKey) state.apiKey = storedApiKey;
|
||||
if (storedAlwaysOnTop) {
|
||||
state.alwaysOnTop = storedAlwaysOnTop === "true";
|
||||
// Appliquer directement la préférence au démarrage
|
||||
if (appWindow && invoke) {
|
||||
invoke("toggle_always_on_top", { enabled: state.alwaysOnTop }).catch(console.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const newUrl = inputApiUrl.value.trim().replace(/\/$/, ""); // Retirer le slash de fin éventuel
|
||||
const newKey = inputApiKey.value.trim();
|
||||
const newAlwaysOnTop = inputAlwaysOnTop.checked;
|
||||
const newAutostart = inputAutostart.checked;
|
||||
|
||||
state.apiUrl = newUrl;
|
||||
state.apiKey = newKey;
|
||||
state.alwaysOnTop = newAlwaysOnTop;
|
||||
|
||||
localStorage.setItem("todoflow_api_url", newUrl);
|
||||
localStorage.setItem("todoflow_api_key", newKey);
|
||||
localStorage.setItem("todoflow_always_on_top", newAlwaysOnTop.toString());
|
||||
|
||||
// Appel des fonctions système Windows natives via Tauri
|
||||
if (appWindow && invoke) {
|
||||
try {
|
||||
await invoke("toggle_always_on_top", { enabled: newAlwaysOnTop });
|
||||
await invoke("set_autostart", { enabled: newAutostart });
|
||||
state.autostart = newAutostart;
|
||||
} catch (e) {
|
||||
alert(`Erreur système : ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fermer le volet et forcer un rafraîchissement immédiat de la liste
|
||||
settingsPanel.classList.remove("open");
|
||||
fetchTasks();
|
||||
}
|
||||
|
||||
// --- APPELS API (CRUD) ---
|
||||
|
||||
async function fetchTasks() {
|
||||
// Récupération asynchrone des tâches avec la clé API
|
||||
try {
|
||||
const response = await fetch(`${state.apiUrl}/api/v1/tasks`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-API-Key": state.apiKey,
|
||||
"Accept": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Accès refusé ou serveur indisponible");
|
||||
|
||||
const tasks = await response.json();
|
||||
state.tasks = tasks;
|
||||
|
||||
// Mise à jour de l'indicateur lumineux sur le header (Connecté)
|
||||
statusIndicator.className = "status-indicator connected";
|
||||
statusIndicator.title = "Connecté à la base centralisée";
|
||||
|
||||
renderTasksList();
|
||||
} catch (error) {
|
||||
console.error("Erreur de synchronisation :", error);
|
||||
statusIndicator.className = "status-indicator disconnected";
|
||||
statusIndicator.title = "Erreur de connexion avec le serveur";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddTask(e) {
|
||||
e.preventDefault();
|
||||
const title = taskInput.value.trim();
|
||||
if (!title) return;
|
||||
|
||||
// Création directe via un appel POST à l'API centralisée
|
||||
try {
|
||||
const response = await fetch(`${state.apiUrl}/api/v1/tasks`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": state.apiKey
|
||||
},
|
||||
json: true,
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
status: "A faire" // Statut initial par défaut
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Échec de la création de la tâche");
|
||||
|
||||
taskInput.value = ""; // Vider le champ après création
|
||||
fetchTasks(); // Rafraîchir pour afficher la tâche créée avec son animation
|
||||
} catch (error) {
|
||||
alert("Impossible d'ajouter la tâche : vérifiez votre connexion ou clé API.");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTaskDone(taskId, currentStatus) {
|
||||
// Permet de cocher ou décocher une tâche
|
||||
const targetStatus = currentStatus === "Termine" ? "A faire" : "Termine";
|
||||
|
||||
try {
|
||||
const response = await fetch(`${state.apiUrl}/api/v1/tasks/${taskId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": state.apiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: targetStatus
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Échec de mise à jour");
|
||||
fetchTasks();
|
||||
} catch (error) {
|
||||
console.error("Erreur de mise à jour du statut :", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTaskInProgress(taskId, currentStatus) {
|
||||
// Permet de basculer spécifiquement les travaux "En cours" ou "À faire"
|
||||
if (currentStatus === "Termine") return; // On ne modifie pas directement les tâches terminées par ici
|
||||
|
||||
const targetStatus = currentStatus === "En cours" ? "A faire" : "En cours";
|
||||
|
||||
try {
|
||||
const response = await fetch(`${state.apiUrl}/api/v1/tasks/${taskId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": state.apiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: targetStatus
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Échec de mise à jour");
|
||||
fetchTasks();
|
||||
} catch (error) {
|
||||
console.error("Erreur de mise à jour de l'état de travail :", error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- AFFICHAGE ET LOGIQUE DE RENDU ---
|
||||
|
||||
function renderTasksList() {
|
||||
if (state.tasks.length === 0) {
|
||||
tasksList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>Aucune tâche en cours</p>
|
||||
<span class="empty-subtext">Tout est à jour ! Excellent travail.</span>
|
||||
</div>
|
||||
`;
|
||||
taskProgress.style.width = "0%";
|
||||
return;
|
||||
}
|
||||
|
||||
// Calcul et mise à jour de la barre de progression linéaire
|
||||
const completedTasks = state.tasks.filter(t => t.status === "Termine").length;
|
||||
const progressPercent = Math.round((completedTasks / state.tasks.length) * 100);
|
||||
taskProgress.style.width = `${progressPercent}%`;
|
||||
|
||||
tasksList.innerHTML = "";
|
||||
|
||||
state.tasks.forEach(task => {
|
||||
const isCompleted = task.status === "Termine";
|
||||
|
||||
// Structure de la carte de tâche
|
||||
const card = document.createElement("div");
|
||||
card.className = `task-card ${isCompleted ? 'completed' : ''}`;
|
||||
|
||||
// Checkbox personnalisée cochable
|
||||
const checkbox = document.createElement("div");
|
||||
checkbox.className = `custom-checkbox ${isCompleted ? 'checked' : ''}`;
|
||||
checkbox.innerHTML = `
|
||||
<svg width="10" height="8" viewBox="0 0 10 8" fill="none">
|
||||
<path d="M1.5 4L3.75 6.25L8.5 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`;
|
||||
checkbox.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleTaskDone(task.id, task.status);
|
||||
});
|
||||
|
||||
// Bloc de contenu (Titre & Date)
|
||||
const content = document.createElement("div");
|
||||
content.className = "task-content";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "task-title";
|
||||
title.textContent = task.title;
|
||||
title.title = task.title; // Tooltip d'accessibilité
|
||||
|
||||
const date = document.createElement("span");
|
||||
date.className = "task-date";
|
||||
date.textContent = `Créé le ${formatDate(task.created_at)}`;
|
||||
|
||||
content.appendChild(title);
|
||||
content.appendChild(date);
|
||||
|
||||
// Badge de statut (En cours / À faire)
|
||||
const badge = document.createElement("span");
|
||||
|
||||
if (task.status === "Termine") {
|
||||
badge.className = "status-badge done";
|
||||
badge.textContent = "Terminé";
|
||||
} else if (task.status === "En cours") {
|
||||
badge.className = "status-badge in-progress";
|
||||
badge.textContent = "En cours";
|
||||
badge.title = "Cliquer pour marquer 'À faire'";
|
||||
badge.addEventListener("click", () => toggleTaskInProgress(task.id, task.status));
|
||||
} else {
|
||||
badge.className = "status-badge todo";
|
||||
badge.textContent = "À faire";
|
||||
badge.title = "Cliquer pour marquer 'En cours'";
|
||||
badge.addEventListener("click", () => toggleTaskInProgress(task.id, task.status));
|
||||
}
|
||||
|
||||
card.appendChild(checkbox);
|
||||
card.appendChild(content);
|
||||
card.appendChild(badge);
|
||||
|
||||
tasksList.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// Formatage de la date en français épuré
|
||||
function formatDate(dateString) {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
|
||||
// Format : "26 mai • 14:15"
|
||||
const optionsDay = { day: 'numeric', month: 'short' };
|
||||
const dayStr = date.toLocaleDateString('fr-FR', optionsDay);
|
||||
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `${dayStr} • ${hours}:${minutes}`;
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TodoFlow</title>
|
||||
<!-- Importation de la police Google Fonts Inter pour une typographie premium -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="todo-widget">
|
||||
<!-- Barre supérieure personnalisée avec zone de déplacement pour Tauri -->
|
||||
<header class="widget-header" data-tauri-drag-region>
|
||||
<div class="header-left" data-tauri-drag-region>
|
||||
<span class="status-indicator disconnected" id="connection-status" title="Déconnecté"></span>
|
||||
<h1 class="widget-title" data-tauri-drag-region>TodoFlow</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Boutons de contrôle de la fenêtre (indispensables en mode sans bordure) -->
|
||||
<button class="win-btn" id="btn-minimize" title="Réduire">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><rect x="2" y="5.5" width="8" height="1" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button class="win-btn" id="btn-settings" title="Paramètres">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
<button class="win-btn btn-close" id="btn-close" title="Fermer (Masquer)">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M2.5 2.5 L9.5 9.5 M9.5 2.5 L2.5 9.5" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Indicateur de progression des tâches -->
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" id="task-progress" style="width: 0%"></div>
|
||||
</div>
|
||||
|
||||
<!-- Zone de contenu principal -->
|
||||
<div class="widget-body">
|
||||
<!-- Formulaire d'ajout rapide (Création directe via le widget) -->
|
||||
<form id="add-task-form" class="add-task-form">
|
||||
<input type="text" id="task-input" placeholder="Ajouter une tâche..." required maxlength="100">
|
||||
<button type="submit" class="btn-add" title="Ajouter la tâche">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Liste des Tâches -->
|
||||
<main class="tasks-list" id="tasks-list">
|
||||
<!-- Rempli dynamiquement en JS -->
|
||||
<div class="empty-state">
|
||||
<p>Aucune tâche disponible</p>
|
||||
<span class="empty-subtext">Configurez l'API dans les paramètres</span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Panneau de Paramètres (Glisse depuis le bas) -->
|
||||
<div class="settings-panel" id="settings-panel">
|
||||
<div class="settings-header">
|
||||
<h2>Configuration</h2>
|
||||
<button class="btn-icon-close" id="btn-settings-close">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<div class="setting-group">
|
||||
<label for="setting-api-url">URL du Serveur API</label>
|
||||
<input type="url" id="setting-api-url" placeholder="http://localhost:8000" required>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label for="setting-api-key">Clé API (X-API-Key)</label>
|
||||
<input type="password" id="setting-api-key" placeholder="widget-token-secure-789" required>
|
||||
</div>
|
||||
|
||||
<div class="setting-toggle">
|
||||
<label class="switch-container">
|
||||
<span class="switch-label">Toujours au premier plan</span>
|
||||
<input type="checkbox" id="setting-always-on-top">
|
||||
<span class="switch-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-toggle">
|
||||
<label class="switch-container">
|
||||
<span class="switch-label">Lancer au démarrage</span>
|
||||
<input type="checkbox" id="setting-autostart">
|
||||
<span class="switch-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn-save" id="btn-save-settings">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intégration de l'API Tauri pour la communication inter-processus -->
|
||||
<script>
|
||||
// Import dynamique pour supporter l'exécution dans le navigateur pendant le dév
|
||||
window.__TAURI__ = window.__TAURI__ || null;
|
||||
</script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,573 @@
|
||||
/* --- SYSTÈME DE DESIGN & TOKENS --- */
|
||||
:root {
|
||||
--bg-widget: rgba(18, 22, 33, 0.70);
|
||||
--bg-card: rgba(255, 255, 255, 0.03);
|
||||
--bg-card-hover: rgba(255, 255, 255, 0.07);
|
||||
--border-widget: 1px solid rgba(255, 255, 255, 0.09);
|
||||
--border-card: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
/* Couleurs thématiques HSL */
|
||||
--accent-indigo: hsl(250, 85%, 65%);
|
||||
--accent-indigo-glow: rgba(99, 102, 241, 0.3);
|
||||
--accent-mint: hsl(165, 80%, 42%);
|
||||
--accent-mint-glow: rgba(16, 185, 129, 0.15);
|
||||
--accent-amber: hsl(38, 95%, 55%);
|
||||
--accent-amber-glow: rgba(245, 158, 11, 0.15);
|
||||
|
||||
/* Textes */
|
||||
--text-primary: hsl(220, 20%, 96%);
|
||||
--text-secondary: hsl(220, 12%, 68%);
|
||||
--text-muted: rgba(241, 243, 249, 0.45);
|
||||
}
|
||||
|
||||
/* Réinitialisation de base */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
user-select: none; /* Évite la sélection de texte accidentelle pour un widget */
|
||||
}
|
||||
|
||||
body {
|
||||
background: transparent; /* Indispensable pour la transparence de la fenêtre Tauri */
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- LE CONTAINER DU WIDGET (GLASSMORPHISM PREMIUM) --- */
|
||||
.todo-widget {
|
||||
width: 340px;
|
||||
height: 520px;
|
||||
background: var(--bg-widget);
|
||||
backdrop-filter: blur(30px) saturate(125%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(125%);
|
||||
border-radius: 16px;
|
||||
border: var(--border-widget);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(0, 0, 0, 0.35),
|
||||
0 1px 0px 0px rgba(255, 255, 255, 0.12) inset;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- HEADER DU WIDGET --- */
|
||||
.widget-header {
|
||||
padding: 16px 16px 12px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: grab; /* Indique qu'on peut faire glisser le widget */
|
||||
}
|
||||
|
||||
.widget-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Indicateur d'état connecté à l'API */
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.status-indicator.connected {
|
||||
background-color: var(--accent-mint);
|
||||
box-shadow: 0 0 8px var(--accent-mint);
|
||||
}
|
||||
|
||||
.status-indicator.disconnected {
|
||||
background-color: hsl(0, 80%, 60%);
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.widget-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 10; /* Assure le clic par-dessus la zone de drag */
|
||||
}
|
||||
|
||||
/* Boutons de contrôle style Windows */
|
||||
.win-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.win-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.win-btn.btn-close:hover {
|
||||
background: hsl(0, 75%, 50%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* --- INDICATEUR DE PROGRESSION --- */
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-indigo), var(--accent-mint));
|
||||
transition: width 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* --- ZONE DE CORPS --- */
|
||||
.widget-body {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Formulaire d'ajout de tâche */
|
||||
.add-task-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
border-radius: 10px;
|
||||
padding: 4px 6px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.add-task-form:focus-within {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.add-task-form input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
padding: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.add-task-form input::placeholder {
|
||||
color: rgba(241, 243, 249, 0.35);
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: var(--accent-indigo);
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 10px var(--accent-indigo-glow);
|
||||
}
|
||||
|
||||
.btn-add svg {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-add:hover svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* --- LISTE DES TÂCHES --- */
|
||||
.tasks-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Personnalisation Scrollbar */
|
||||
.tasks-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.tasks-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tasks-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* --- CARTE DE TÂCHE INDIVIDUELLE --- */
|
||||
.task-card {
|
||||
background: var(--bg-card);
|
||||
border: var(--border-card);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
position: relative;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
transform: translateY(-2px);
|
||||
background: var(--bg-card-hover);
|
||||
border-color: rgba(99, 102, 241, 0.2);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Checkbox personnalisée */
|
||||
.custom-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.35);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.custom-checkbox:hover {
|
||||
border-color: var(--accent-indigo);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
/* Checkbox cochée (Terminé) */
|
||||
.custom-checkbox.checked {
|
||||
background: linear-gradient(135deg, hsl(165, 80%, 42%), hsl(165, 80%, 32%));
|
||||
border-color: transparent;
|
||||
animation: checkboxPop 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.custom-checkbox.checked svg {
|
||||
display: block;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.custom-checkbox svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes checkboxPop {
|
||||
50% { transform: scale(1.2); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Contenu textuel de la carte */
|
||||
.task-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
min-width: 0; /* Permet le troncage correct du texte */
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
transition: color 0.3s;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.task-date {
|
||||
font-size: 9.5px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* --- BADGES DE STATUT --- */
|
||||
.status-badge {
|
||||
font-size: 8.5px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge.in-progress {
|
||||
background: var(--accent-amber-glow);
|
||||
color: var(--accent-amber);
|
||||
border: 1px solid rgba(245, 158, 11, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-badge.todo {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-badge.done {
|
||||
background: var(--accent-mint-glow);
|
||||
color: var(--accent-mint);
|
||||
border: 1px solid rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
/* --- DESIGN DES TÂCHES TERMINÉES (ANIMÉ) --- */
|
||||
.task-card.completed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.task-card.completed .task-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-card.completed .task-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 52%;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--text-muted);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.task-card.completed .task-title::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* État vide */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-subtext {
|
||||
font-size: 10.5px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* --- PANNEAU DE PARAMÈTRES (BOTTOM SLIDE) --- */
|
||||
.settings-panel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 380px;
|
||||
background: rgba(15, 18, 28, 0.95);
|
||||
border-top: var(--border-widget);
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 100;
|
||||
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.settings-panel.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btn-icon-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon-close:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.setting-group label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.setting-group input {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.setting-group input:focus {
|
||||
border-color: var(--accent-indigo);
|
||||
}
|
||||
|
||||
/* Switch Toggles */
|
||||
.setting-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.switch-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.switch-container input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.switch-slider {
|
||||
width: 34px;
|
||||
height: 18px;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.switch-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.switch-container input:checked + .switch-slider {
|
||||
background-color: var(--accent-indigo);
|
||||
}
|
||||
|
||||
.switch-container input:checked + .switch-slider::before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* Bouton enregistrer */
|
||||
.btn-save {
|
||||
background: var(--accent-indigo);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.btn-save:hover {
|
||||
background: hsl(250, 85%, 58%);
|
||||
box-shadow: 0 0 10px var(--accent-indigo-glow);
|
||||
}
|
||||
Reference in new issue
Block a user