Homepage dashboard : horloge animée, todo, notes, RSS, pomodoro, recherche

- Frontend Vue 3 (Composition API) + Vite + Vue Router
- Backend Go (stdlib) : API REST todo/notes + proxy RSS + auth token
- Docker Compose : SPA nginx + backend + Miniflux + Postgres
- Widgets : horloge canvas météo, todo 3 colonnes, notes persistées,
  agrégateur RSS multi-feeds, pomodoro, recherche DuckDuckGo (Ctrl+K)
- Auth : dashboard public, todo/notes protégés par token
- Widgets expandables (mode agrandi centré)
This commit is contained in:
Nathan Leclercq 2026-04-09 12:49:55 +02:00
commit f795cc48b5
44 changed files with 5927 additions and 0 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
node_modules
dist
.git
.gitignore
.vite
.env
.env.example
docker-compose.yml
backend
*.log
*.md
clock-lille.html

27
.env.example Normal file
View File

@ -0,0 +1,27 @@
# Copie ce fichier en `.env` puis remplis les valeurs.
# Le `.env` est gitignored — il ne sera jamais commit.
# Port hôte pour la homepage (SPA Vue, sert sur :80 dans le container)
HOMEPAGE_PORT=8080
# Port hôte pour Miniflux (sert sur :8080 dans le container)
MINIFLUX_PORT=8081
# Port hôte pour le backend Go (todos)
BACKEND_PORT=8082
# URL publique du backend, vue depuis le NAVIGATEUR de l'utilisateur.
# Utilisée à BUILD TIME par Vite (inlinée dans le bundle JS).
# En local : http://localhost:8082 — en prod : https://api.tondomaine.tld
VITE_BACKEND_URL=http://localhost:8082
# Token pour protéger les endpoints notes/todo (seul toi y accèdes)
# Générer avec : openssl rand -base64 24
API_TOKEN=change-me-to-a-random-token
# Postgres dédié à Miniflux
MINIFLUX_DB_PASSWORD=change-me-to-a-strong-password
# Admin Miniflux créé au premier démarrage
MINIFLUX_ADMIN_USER=admin
MINIFLUX_ADMIN_PASSWORD=change-me-too

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules
dist
.DS_Store
*.log
.vite
.env
*.tmp
backend/data/
.claude/

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
24

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# --- build stage -----------------------------------------------------------
FROM node:24-alpine AS build
WORKDIR /app
# Build-time arg : URL publique du backend (inlinée dans le bundle Vite)
ARG VITE_BACKEND_URL=http://localhost:8082
ENV VITE_BACKEND_URL=$VITE_BACKEND_URL
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- runtime stage ---------------------------------------------------------
FROM nginx:alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

68
Makefile Normal file
View File

@ -0,0 +1,68 @@
.PHONY: help install dev build preview clean docker-build docker-run docker-dev up down restart logs ps backend-up backend-down backend-logs dev-stack
NODE_VERSION := 24
IMAGE := homepage:latest
PORT := 8080
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install: ## Install npm deps
npm install
dev: ## Lance TOUT en mode dev : backend + miniflux + db (détachés) puis vite hot-reload
@test -f .env || (echo "→ create .env from .env.example first" && exit 1)
docker compose up -d --build backend miniflux miniflux-db
@echo "→ services up : backend :$${BACKEND_PORT:-8082}, miniflux :$${MINIFLUX_PORT:-8081}"
npm run dev
dev-front: ## Uniquement Vite hot-reload (sans toucher aux containers)
npm run dev
build: ## Build production bundle into dist/
npm run build
preview: build ## Serve the production build locally
npm run preview
docker-build: ## Build the production Docker image
docker build -t $(IMAGE) .
docker-run: docker-build ## Build + run the prod image on http://localhost:$(PORT)
docker run --rm -p $(PORT):80 $(IMAGE)
docker-dev: ## Run the Vite dev server inside a throwaway Node container
docker run --rm -it -p 5173:5173 -v $(PWD):/app -w /app \
node:$(NODE_VERSION)-alpine sh -c "npm install && npm run dev -- --host"
## --- contrôle individuel des services ---
backend-up: ## Lance uniquement le backend Go
docker compose up -d --build backend
backend-down: ## Stoppe le backend Go
docker compose stop backend
backend-logs: ## Tail des logs du backend
docker compose logs -f backend
## --- docker compose stack complète (homepage + backend + miniflux + db) ---
up: ## Lance toute la stack en arrière-plan
@test -f .env || (echo "→ create .env from .env.example first" && exit 1)
docker compose up -d --build
down: ## Stoppe la stack
docker compose down
restart: ## Redémarre la stack
docker compose restart
logs: ## Suit les logs de la stack
docker compose logs -f
ps: ## État des containers
docker compose ps
clean: ## Remove node_modules and dist
rm -rf node_modules dist

52
README.md Normal file
View File

@ -0,0 +1,52 @@
# Homepage
Dashboard personnel avec horloge animée, widgets todo/notes, agrégateur RSS, pomodoro et recherche rapide.
## Stack
- **Frontend** : Vue 3 (Composition API) + Vite + Vue Router
- **Backend** : Go (stdlib uniquement) — API REST pour todo, notes, proxy RSS
- **Déploiement** : Docker Compose (SPA nginx + backend Go + Miniflux + Postgres)
## Démarrage rapide
```bash
# 1. Configurer l'environnement
cp .env.example .env
# Éditer .env : changer les mots de passe et API_TOKEN
# 2. Lancer tout (backend + frontend dev)
make dev
# 3. Ouvrir http://localhost:5173
```
## Commandes
| Commande | Description |
|---|---|
| `make install` | Installer les dépendances npm |
| `make dev` | Lancer le backend (Docker) + frontend (Vite) |
| `make build` | Build de production dans `dist/` |
| `make up` | Lancer toute la stack Docker |
| `make down` | Arrêter la stack |
## Widgets
- **Horloge** : Canvas animé avec données météo (Open-Meteo), position solaire, 3 villes
- **Todo** : Vue 3 colonnes (hier/ajd/demain), persisté côté serveur, protégé par token
- **Notes** : Notes éphémères, persistées côté serveur, protégées par token
- **RSS** : Agrégateur multi-feeds (configurable via `src/data/feeds.json`)
- **Pomodoro** : Timer 25/5 min
- **Recherche** : Ctrl+K, DuckDuckGo avec support des bangs
- **Liens** : Panel de navigation (configurable via `src/data/links.json`)
## Authentification
Le dashboard est public. Les widgets todo et notes sont protégés par un token (`API_TOKEN` dans `.env`). Cliquer sur le cadenas en bas de page pour se connecter.
## Configuration
- **Liens** : `src/data/links.json`
- **Flux RSS** : `src/data/feeds.json`
- **Ports / tokens** : `.env`

2
backend/.dockerignore Normal file
View File

@ -0,0 +1,2 @@
*.md
*.test

15
backend/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
# --- build stage -----------------------------------------------------------
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /backend .
# --- runtime stage ---------------------------------------------------------
FROM alpine:latest
RUN mkdir -p /data && adduser -D -u 1000 app && chown app:app /data
USER app
COPY --from=build /backend /backend
EXPOSE 8080
VOLUME ["/data"]
CMD ["/backend"]

3
backend/go.mod Normal file
View File

@ -0,0 +1,3 @@
module homepage-backend
go 1.23

404
backend/main.go Normal file
View File

@ -0,0 +1,404 @@
// homepage-backend — tiny REST API for the homepage todo board.
//
// Storage: a single JSON file at $DATA_PATH (default /data/todos.json).
// No external deps — stdlib only. Mounted as a docker volume for persistence.
//
// Routes:
//
// GET /api/health
// GET /api/tasks?from=YYYY-MM-DD&to=YYYY-MM-DD
// POST /api/tasks { title, details?, due_date? }
// PATCH /api/tasks/{id} partial update
// DELETE /api/tasks/{id}
//
// On every list request, undone tasks whose due_date is in the past
// are auto-bumped to today (rollover).
package main
import (
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Details string `json:"details"`
DueDate string `json:"due_date"` // YYYY-MM-DD
Done bool `json:"done"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type Note struct {
ID int64 `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"created_at"`
}
type Store struct {
mu sync.Mutex
Tasks []Task `json:"tasks"`
Next int64 `json:"next_id"`
Notes []Note `json:"notes"`
NoteNext int64 `json:"note_next_id"`
path string
}
func (s *Store) load() error {
s.mu.Lock()
defer s.mu.Unlock()
b, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
s.Next = 1
return nil
}
return err
}
if err := json.Unmarshal(b, s); err != nil {
return err
}
if s.Next == 0 {
s.Next = 1
}
if s.NoteNext == 0 {
s.NoteNext = 1
}
return nil
}
// save must be called with the mutex held.
func (s *Store) save() error {
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// rollover must be called with the mutex held.
func (s *Store) rollover() {
today := time.Now().Format("2006-01-02")
now := time.Now().UTC().Format(time.RFC3339)
changed := false
for i := range s.Tasks {
if !s.Tasks[i].Done && s.Tasks[i].DueDate < today {
s.Tasks[i].DueDate = today
s.Tasks[i].UpdatedAt = now
changed = true
}
}
if changed {
_ = s.save()
}
}
var store *Store
var apiToken string
func main() {
path := getenv("DATA_PATH", "/data/todos.json")
apiToken = getenv("API_TOKEN", "")
if apiToken == "" {
log.Println("WARNING: API_TOKEN not set — notes/tasks endpoints are unprotected")
}
store = &Store{path: path}
if err := store.load(); err != nil {
log.Fatalf("failed to load store: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
mux.HandleFunc("/api/auth", handleAuth)
mux.HandleFunc("/api/tasks", requireAuth(handleTasks))
mux.HandleFunc("/api/tasks/", requireAuth(handleTask))
mux.HandleFunc("/api/notes", requireAuth(handleNotes))
mux.HandleFunc("/api/notes/", requireAuth(handleNote))
mux.HandleFunc("/api/rss", handleRSS)
addr := ":" + getenv("PORT", "8080")
log.Println("homepage-backend listening on", addr, "store:", path)
if err := http.ListenAndServe(addr, cors(mux)); err != nil {
log.Fatal(err)
}
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func checkAuth(r *http.Request) bool {
if apiToken == "" {
return true
}
h := r.Header.Get("Authorization")
return h == "Bearer "+apiToken
}
func requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func handleAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var in struct {
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if apiToken != "" && in.Token != apiToken {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func cors(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
h.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func handleTasks(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
from := r.URL.Query().Get("from")
to := r.URL.Query().Get("to")
store.mu.Lock()
store.rollover()
out := make([]Task, 0, len(store.Tasks))
for _, t := range store.Tasks {
if (from == "" || t.DueDate >= from) && (to == "" || t.DueDate <= to) {
out = append(out, t)
}
}
store.mu.Unlock()
writeJSON(w, out)
case http.MethodPost:
var in Task
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if strings.TrimSpace(in.Title) == "" {
http.Error(w, "title required", http.StatusBadRequest)
return
}
if in.DueDate == "" {
in.DueDate = time.Now().Format("2006-01-02")
}
now := time.Now().UTC().Format(time.RFC3339)
store.mu.Lock()
in.ID = store.Next
store.Next++
in.CreatedAt = now
in.UpdatedAt = now
in.Done = false
store.Tasks = append(store.Tasks, in)
_ = store.save()
store.mu.Unlock()
writeJSON(w, in)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// handleRSS proxies a remote RSS/Atom feed (so the SPA can read it without
// hitting CORS). Basic guards: only http/https, response capped at 5MB,
// 10s timeout.
func handleRSS(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("url")
if target == "" || !(strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://")) {
http.Error(w, "missing or invalid url", http.StatusBadRequest)
return
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("User-Agent", "homepage-rss-proxy/1.0")
resp, err := client.Do(req)
if err != nil {
http.Error(w, "upstream fetch failed: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
http.Error(w, "upstream "+resp.Status, http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
_, _ = io.Copy(w, io.LimitReader(resp.Body, 5*1024*1024))
}
func handleTask(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodPatch:
var in struct {
Title *string `json:"title"`
Details *string `json:"details"`
DueDate *string `json:"due_date"`
Done *bool `json:"done"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
store.mu.Lock()
defer store.mu.Unlock()
for i := range store.Tasks {
if store.Tasks[i].ID != id {
continue
}
if in.Title != nil {
store.Tasks[i].Title = *in.Title
}
if in.Details != nil {
store.Tasks[i].Details = *in.Details
}
if in.DueDate != nil {
store.Tasks[i].DueDate = *in.DueDate
}
if in.Done != nil {
store.Tasks[i].Done = *in.Done
}
store.Tasks[i].UpdatedAt = time.Now().UTC().Format(time.RFC3339)
_ = store.save()
writeJSON(w, store.Tasks[i])
return
}
http.NotFound(w, r)
case http.MethodDelete:
store.mu.Lock()
defer store.mu.Unlock()
for i := range store.Tasks {
if store.Tasks[i].ID == id {
store.Tasks = append(store.Tasks[:i], store.Tasks[i+1:]...)
_ = store.save()
w.WriteHeader(http.StatusNoContent)
return
}
}
http.NotFound(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// --- Notes ---
func handleNotes(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
store.mu.Lock()
out := make([]Note, len(store.Notes))
copy(out, store.Notes)
store.mu.Unlock()
writeJSON(w, out)
case http.MethodPost:
var in Note
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if strings.TrimSpace(in.Text) == "" {
http.Error(w, "text required", http.StatusBadRequest)
return
}
store.mu.Lock()
in.ID = store.NoteNext
store.NoteNext++
in.CreatedAt = time.Now().UTC().Format(time.RFC3339)
store.Notes = append([]Note{in}, store.Notes...)
if len(store.Notes) > 50 {
store.Notes = store.Notes[:50]
}
_ = store.save()
store.mu.Unlock()
writeJSON(w, in)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func handleNote(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/notes/")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
store.mu.Lock()
defer store.mu.Unlock()
for i := range store.Notes {
if store.Notes[i].ID == id {
store.Notes = append(store.Notes[:i], store.Notes[i+1:]...)
_ = store.save()
w.WriteHeader(http.StatusNoContent)
return
}
}
http.NotFound(w, r)
}

717
clock-lille.html Normal file
View File

@ -0,0 +1,717 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Clock — Lille</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
font-family: 'Courier New', monospace;
margin: 0;
}
canvas {
display: block;
position: fixed;
top: 0; left: 0;
width: 100vw !important;
height: 100vh !important;
z-index: 0;
}
#controls {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.75rem;
background: transparent;
border: 1px solid rgba(128,128,128,0.3);
border-radius: 4px;
padding: 0.5rem 0.75rem;
opacity: 0.4;
transition: opacity 0.2s;
white-space: nowrap;
z-index: 10;
}
#controls:hover { opacity: 1; }
#controls input[type="datetime-local"] {
font-family: inherit;
font-size: 0.75rem;
background: transparent;
border: none;
outline: none;
cursor: pointer;
color-scheme: light dark;
color: inherit;
}
#controls button {
font-family: inherit;
font-size: 0.7rem;
background: transparent;
border: 1px solid rgba(128,128,128,0.4);
border-radius: 2px;
padding: 0.2rem 0.5rem;
cursor: pointer;
color: inherit;
opacity: 0.7;
}
#controls button:hover { opacity: 1; }
#dot {
width: 6px; height: 6px;
border-radius: 50%;
background: #4a9;
flex-shrink: 0;
transition: background 0.3s;
}
#dot.paused { background: #a84; }
#legend {
position: fixed;
top: 1.5rem;
right: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
opacity: 0.15;
transition: opacity 0.25s;
color: inherit;
z-index: 10;
}
#legend:hover { opacity: 0.88; }
.leg-row {
display: flex;
align-items: center;
gap: 0.6rem;
justify-content: flex-end;
}
.leg-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 12px;
font-style: italic;
opacity: 0.8;
white-space: nowrap;
flex-shrink: 0;
letter-spacing: 0.02em;
}
.leg-val {
font-family: 'Courier New', monospace;
font-size: 10px;
opacity: 0.5;
flex-shrink: 0;
}
.leg-bar {
width: 64px;
height: 4px;
border-radius: 2px;
flex-shrink: 0;
}
.leg-circles {
display: flex;
align-items: center;
gap: 4px;
}
.leg-circle {
border-radius: 50%;
border: 1.5px solid currentColor;
opacity: 0.4;
flex-shrink: 0;
}
.leg-sep {
border: none;
border-top: 0.5px solid rgba(128,128,128,0.2);
margin: 0.15rem 0;
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="legend">
<!-- Anneau météo : schéma des couches -->
<div class="leg-row" style="flex-direction:column;align-items:flex-end;gap:5px;">
<span class="leg-label" style="font-size:10px;">anneau météo — 24h glissantes</span>
<div style="display:flex;align-items:center;gap:6px;">
<!-- schéma concentrique miniature -->
<div style="position:relative;width:44px;height:44px;flex-shrink:0;">
<!-- rafales intérieur -->
<div style="position:absolute;inset:0;border-radius:50%;border:3px solid rgba(255,150,0,0.6);"></div>
<!-- température + nuages -->
<div style="position:absolute;inset:4px;border-radius:50%;border:5px solid rgba(0,200,200,0.45);"></div>
<!-- précipitations extérieur -->
<div style="position:absolute;inset:-5px;border-radius:50%;border:3px solid rgba(80,150,230,0.5);"></div>
</div>
<div style="display:flex;flex-direction:column;gap:4px;align-items:flex-start;">
<div style="display:flex;align-items:center;gap:5px;">
<div style="width:18px;height:3px;background:rgba(80,150,230,0.7);border-radius:1px;"></div>
<span class="leg-val" style="opacity:0.6;">précip. mm/h — extérieur</span>
</div>
<div style="display:flex;align-items:center;gap:5px;">
<div style="width:18px;height:5px;background:linear-gradient(to right,rgba(0,200,200,0.6),rgba(200,255,0,0.6));border-radius:1px;"></div>
<span class="leg-val" style="opacity:0.6;">temp. + nuages — centre</span>
</div>
<div style="display:flex;align-items:center;gap:5px;">
<div style="width:18px;height:3px;background:rgba(255,150,0,0.7);border-radius:1px;"></div>
<span class="leg-val" style="opacity:0.6;">rafales km/h — intérieur</span>
</div>
</div>
</div>
</div>
<hr class="leg-sep">
<div class="leg-row">
<span class="leg-val">10°</span>
<div class="leg-bar" style="background:linear-gradient(to right,rgba(120,0,180,0.8),rgba(40,80,255,0.8),rgba(0,200,200,0.8),rgba(200,255,0,0.8),rgba(240,140,0,0.8),rgba(255,20,0,0.8));"></div>
<span class="leg-val">40°C</span>
<span class="leg-label">température</span>
</div>
<div class="leg-row">
<span class="leg-val">0 → 100 %</span>
<svg width="17" height="11" viewBox="0 0 17 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" opacity="0.5">
<path d="M4 9.5Q1 9.5 1 7Q1 4.5 3.5 4.5Q4 1 8 1Q12 1 12 4.5Q15.5 4.5 15.5 7.5Q15.5 9.5 12 9.5Z"/>
</svg>
<span class="leg-label">nuages (voile)</span>
</div>
<div class="leg-row">
<svg width="13" height="11" viewBox="0 0 13 11" fill="none" stroke="rgba(80,150,230,0.8)" stroke-width="1.5" stroke-linecap="round">
<line x1="2.5" y1="1" x2="1.5" y2="10"/>
<line x1="6.5" y1="1" x2="5.5" y2="10"/>
<line x1="10.5" y1="1" x2="9.5" y2="10"/>
</svg>
<span class="leg-label">précipitations mm/h</span>
</div>
<div class="leg-row">
<span class="leg-val">30</span>
<div class="leg-bar" style="background:linear-gradient(to right,rgba(180,200,40,0.8),rgba(255,170,0,0.8),rgba(255,70,0,0.8),rgba(220,20,20,0.8));"></div>
<span class="leg-val">80 km/h</span>
<span class="leg-label">rafales</span>
</div>
<div class="leg-row">
<span class="leg-val">970</span>
<div class="leg-circles">
<div class="leg-circle" style="width:5px;height:5px;"></div>
<span class="leg-val" style="opacity:0.3;letter-spacing:-1px;">···</span>
<div class="leg-circle" style="width:13px;height:13px;"></div>
</div>
<span class="leg-val">1040 hPa</span>
<span class="leg-label">pression (centre)</span>
</div>
<hr class="leg-sep">
<div class="leg-row" style="flex-direction:column;align-items:flex-end;gap:6px;">
<div style="display:flex;gap:10px;align-items:flex-end;">
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
<div style="width:10px;height:10px;border-radius:50%;border:1px solid currentColor;opacity:0.35;"></div>
<span class="leg-val">sec</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
<div style="width:16px;height:16px;border-radius:50%;border:1.5px solid currentColor;opacity:0.4;"></div>
<span class="leg-val">min</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
<div style="width:22px;height:22px;border-radius:50%;border:1.5px solid currentColor;opacity:0.4;"></div>
<span class="leg-val">heure</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
<div style="width:28px;height:28px;border-radius:50%;border:3px solid rgba(80,150,230,0.5);"></div>
<span class="leg-val">météo</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
<div style="width:36px;height:36px;border-radius:50%;border:2px solid currentColor;opacity:0.45;"></div>
<span class="leg-val">jour</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
<svg width="13" height="13" viewBox="0 0 13 13" fill="currentColor" opacity="0.45"><circle cx="6.5" cy="6.5" r="6"/></svg>
<span class="leg-val">soleil</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
<div style="width:44px;height:44px;border-radius:50%;border:1px dashed currentColor;opacity:0.3;"></div>
<span class="leg-val">année</span>
</div>
</div>
</div>
</div>
<div id="controls">
<div id="dot"></div>
<input type="datetime-local" id="dt-input" step="1">
<button id="btn-now">now</button>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const dtInput = document.getElementById('dt-input');
const btnNow = document.getElementById('btn-now');
const dot = document.getElementById('dot');
const TZ = 'Europe/Paris';
function parisComponents(d) {
const parts = new Intl.DateTimeFormat('fr-FR', {
timeZone: TZ,
year:'numeric', month:'2-digit', day:'2-digit',
hour:'2-digit', minute:'2-digit', second:'2-digit', hour12: false
}).formatToParts(d);
const get = t => parseInt(parts.find(x=>x.type===t).value, 10);
return { year:get('year'), month:get('month'), day:get('day'), hour:get('hour'), minute:get('minute'), second:get('second') };
}
function parisDateStr(d) {
const p=parisComponents(d), pad=n=>String(n).padStart(2,'0');
return `${p.year}-${pad(p.month)}-${pad(p.day)}`;
}
function toInputStr(d) {
const p=parisComponents(d), pad=n=>String(n).padStart(2,'0');
return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)}`;
}
function getParisOffsetMs(d) {
const fmt = tz => new Intl.DateTimeFormat('en-GB',{timeZone:tz,hour:'2-digit',minute:'2-digit',hour12:false}).format(d);
const [uh,um]=fmt('UTC').split(':').map(Number);
const [ph,pm]=fmt(TZ).split(':').map(Number);
return ((ph*60+pm)-(uh*60+um))*60000;
}
function parisStrToDate(val) {
const naive = new Date(val+'Z');
return new Date(naive.getTime() - getParisOffsetMs(naive));
}
// --- Temps simulé ---
let simDate = null;
let isLive = true;
function getNow() { return isLive ? new Date() : simDate; }
dtInput.addEventListener('change', () => {
if (!dtInput.value) return;
simDate = parisStrToDate(dtInput.value);
isLive = false;
dot.classList.add('paused');
lastFetchKey = null;
});
btnNow.addEventListener('click', () => {
isLive=true; simDate=null;
dot.classList.remove('paused');
lastFetchKey=null;
});
dtInput.value = toInputStr(new Date());
// --- État météo (valeurs cibles) ---
let weatherTarget = {
temp:null, cloudcover:0, precip:0, pressure:1013,
precipForecast:[], tempForecast:[], cloudForecast:[],
gustForecast:[], sunriseHour:6, sunsetHour:20
};
// Valeurs interpolées (ce qu'on dessine)
let weatherSmooth = {
temp:null, cloudcover:0, precip:0, pressure:1013,
precipForecast:Array(24).fill(0), tempForecast:Array(24).fill(null),
cloudForecast:Array(24).fill(0), gustForecast:Array(24).fill(0),
sunriseHour:6, sunsetHour:20
};
let lastFetchKey = null;
let lastFetchTs = 0;
// Lerp scalaire
const lerp = (a,b,t) => a + (b-a)*t;
const LSPEED = 0.04; // vitesse d'interpolation par frame (~60fps → ~1.5s)
function smoothWeather() {
const s = weatherSmooth, tg = weatherTarget;
if (tg.temp !== null) {
s.temp = s.temp === null ? tg.temp : lerp(s.temp, tg.temp, LSPEED);
}
s.cloudcover = lerp(s.cloudcover, tg.cloudcover, LSPEED);
s.precip = lerp(s.precip, tg.precip, LSPEED);
s.pressure = lerp(s.pressure, tg.pressure, LSPEED);
s.sunriseHour = lerp(s.sunriseHour, tg.sunriseHour, LSPEED);
s.sunsetHour = lerp(s.sunsetHour, tg.sunsetHour, LSPEED);
for (let h=0; h<24; h++) {
s.precipForecast[h] = lerp(s.precipForecast[h]||0, tg.precipForecast[h]||0, LSPEED);
s.cloudForecast[h] = lerp(s.cloudForecast[h]||0, tg.cloudForecast[h]||0, LSPEED);
s.gustForecast[h] = lerp(s.gustForecast[h]||0, tg.gustForecast[h]||0, LSPEED);
if (tg.tempForecast[h] !== null && tg.tempForecast[h] !== undefined) {
s.tempForecast[h] = s.tempForecast[h] === null
? tg.tempForecast[h]
: lerp(s.tempForecast[h], tg.tempForecast[h], LSPEED);
}
}
}
async function fetchWeather(d) {
const p = parisComponents(d);
const pad = n => String(n).padStart(2,'0');
const dateStr = `${p.year}-${pad(p.month)}-${pad(p.day)}`;
const key = `${dateStr}T${pad(p.hour)}`;
const now10 = Date.now();
if (key === lastFetchKey && now10 - lastFetchTs < 10*60*1000) return;
lastFetchKey = key;
lastFetchTs = now10;
const diffDays = (d.getTime() - Date.now()) / 86400000;
const tomorrow = new Date(d.getTime() + 86400000);
const tp = parisComponents(tomorrow);
const tDateStr = `${tp.year}-${pad(tp.month)}-${pad(tp.day)}`;
try {
let url, hourly, timeArr, daily;
if (diffDays > 1) {
url = `https://api.open-meteo.com/v1/forecast?latitude=50.633&longitude=3.067`
+ `&hourly=temperature_2m,cloudcover,precipitation,surface_pressure,windgusts_10m`
+ `&daily=sunrise,sunset`
+ `&start_date=${dateStr}&end_date=${tDateStr}&timezone=${encodeURIComponent(TZ)}`;
const r=await fetch(url); const dd=await r.json();
hourly=dd.hourly; timeArr=dd.hourly.time; daily=dd.daily;
weatherTarget.temp = hourly.temperature_2m?.[p.hour] ?? null;
weatherTarget.cloudcover = hourly.cloudcover?.[p.hour] ?? 0;
weatherTarget.precip = hourly.precipitation?.[p.hour] ?? 0;
weatherTarget.pressure = hourly.surface_pressure?.[p.hour] ?? 1013;
} else if (diffDays < -1) {
url = `https://archive-api.open-meteo.com/v1/archive?latitude=50.633&longitude=3.067`
+ `&hourly=temperature_2m,cloudcover,precipitation,surface_pressure,windgusts_10m`
+ `&daily=sunrise,sunset`
+ `&start_date=${dateStr}&end_date=${tDateStr}&timezone=${encodeURIComponent(TZ)}`;
const r=await fetch(url); const dd=await r.json();
hourly=dd.hourly; timeArr=dd.hourly.time; daily=dd.daily;
weatherTarget.temp = hourly.temperature_2m?.[p.hour] ?? null;
weatherTarget.cloudcover = hourly.cloudcover?.[p.hour] ?? 0;
weatherTarget.precip = hourly.precipitation?.[p.hour] ?? 0;
weatherTarget.pressure = hourly.surface_pressure?.[p.hour] ?? 1013;
} else {
url = `https://api.open-meteo.com/v1/forecast?latitude=50.633&longitude=3.067`
+ `&current_weather=true`
+ `&hourly=temperature_2m,cloudcover,precipitation,surface_pressure,windgusts_10m`
+ `&daily=sunrise,sunset`
+ `&forecast_days=3&timezone=${encodeURIComponent(TZ)}`;
const r=await fetch(url); const dd=await r.json();
hourly=dd.hourly; timeArr=dd.hourly.time; daily=dd.daily;
weatherTarget.temp = dd.current_weather?.temperature ?? hourly.temperature_2m?.[p.hour] ?? null;
weatherTarget.cloudcover = hourly.cloudcover?.[p.hour] ?? 0;
weatherTarget.precip = hourly.precipitation?.[p.hour] ?? 0;
weatherTarget.pressure = hourly.surface_pressure?.[p.hour] ?? 1013;
}
// Lever/coucher depuis l'API (format "HH:MM")
if (daily?.sunrise?.[0]) {
const sr = daily.sunrise[0].split('T')[1] || daily.sunrise[0];
const [sh,sm] = sr.split(':').map(Number);
weatherTarget.sunriseHour = sh + sm/60;
}
if (daily?.sunset?.[0]) {
const ss = daily.sunset[0].split('T')[1] || daily.sunset[0];
const [sh,sm] = ss.split(':').map(Number);
weatherTarget.sunsetHour = sh + sm/60;
}
// Forecasts 24h
const currentTimeStr = `${dateStr}T${pad(p.hour)}:00`;
const startIdx = timeArr ? timeArr.indexOf(currentTimeStr) : -1;
if (startIdx >= 0) {
weatherTarget.precipForecast = Array.from({length:24},(_,i)=>hourly.precipitation?.[startIdx+i]??0);
weatherTarget.tempForecast = Array.from({length:24},(_,i)=>hourly.temperature_2m?.[startIdx+i]??null);
weatherTarget.cloudForecast = Array.from({length:24},(_,i)=>hourly.cloudcover?.[startIdx+i]??0);
weatherTarget.gustForecast = Array.from({length:24},(_,i)=>hourly.windgusts_10m?.[startIdx+i]??0);
}
} catch(e) { console.warn('weather fetch failed', e); }
}
// --- Astro & couleurs ---
const CITIES = [
{ label:'LIL', lon:3.067, lat:50.63 },
{ label:'TYO', lon:139.69, lat:35.68 },
{ label:'NYC', lon:-74.006, lat:40.71 },
];
function solarDeclination(doy) {
return -23.45*Math.cos((2*Math.PI/365)*(doy+10))*Math.PI/180;
}
function dayOfYear(d) {
return Math.floor((d-new Date(d.getFullYear(),0,0))/86400000);
}
function timeColor(sh,alpha) {
const h=((sh%24)+24)%24;
let r,g,b;
if (h<6) {const t=h/6; r=20+t*210; g=30+t*100; b=80-t*30; }
else if (h<12) {const t=(h-6)/6; r=230-t*30; g=130+t*90; b=50+t*100; }
else if (h<18) {const t=(h-12)/6; r=200+t*50; g=220-t*90; b=150-t*100;}
else {const t=(h-18)/6; r=250-t*230; g=130-t*100; b=50+t*30; }
return `rgba(${Math.round(r)},${Math.round(g)},${Math.round(b)},${alpha})`;
}
function tempColor(temp,alpha) {
if(temp===null)return `rgba(128,128,128,${alpha})`;
const t=Math.max(0,Math.min(1,(temp+10)/50));
let r,g,b;
if(t<0.2) {const u=t/0.2; r=Math.round(120-u*80); g=Math.round(u*20); b=Math.round(180+u*75); }
else if(t<0.4) {const u=(t-0.2)/0.2; r=Math.round(40-u*40); g=Math.round(20+u*180); b=Math.round(255-u*60); }
else if(t<0.6) {const u=(t-0.4)/0.2; r=Math.round(u*200); g=Math.round(200+u*55); b=Math.round(195-u*195);}
else if(t<0.8) {const u=(t-0.6)/0.2; r=Math.round(200+u*40); g=Math.round(255-u*130); b=0; }
else {const u=(t-0.8)/0.2; r=Math.round(240+u*15); g=Math.round(125-u*105); b=0; }
return `rgba(${Math.round(r)},${Math.round(g)},${Math.round(b)},${alpha})`;
}
// --- Luminosité ambiante interpolée ---
let ambientSmooth = 0;
// --- Rotation interpolée (pour transition douce au changement de date) ---
let ringRotSmooth = null; // initialisé au premier draw
// --- Draw ---
function draw() {
const now = getNow();
if (isLive) dtInput.value = toInputStr(now);
fetchWeather(now);
smoothWeather();
const W = canvas.width = window.innerWidth;
const H = canvas.height = window.innerHeight;
const size = Math.min(W, H - 160);
const cx = W/2, cy = H/2;
// Heure solaire Lille
const utcFrac = now.getUTCHours()+now.getUTCMinutes()/60+now.getUTCSeconds()/3600+now.getUTCMilliseconds()/3600000;
const lilSolarHour = ((utcFrac+CITIES[0].lon/15)%24+24)%24;
const ringRotTarget = ((lilSolarHour-12)/24)*Math.PI*2;
// Interpolation rotation anneau
if (ringRotSmooth === null) ringRotSmooth = ringRotTarget;
// Interpoler sur le chemin le plus court (évite rotation 360°)
let diff = ringRotTarget - ringRotSmooth;
while (diff > Math.PI) diff -= Math.PI*2;
while (diff < -Math.PI) diff += Math.PI*2;
ringRotSmooth += diff * 0.06;
const doy = dayOfYear(now);
const decl = solarDeclination(doy);
// Élévation solaire
const hourAngle = ((lilSolarHour-12)/24)*Math.PI*2;
const lat = CITIES[0].lat*Math.PI/180;
const sinElev = Math.sin(lat)*Math.sin(decl)+Math.cos(lat)*Math.cos(decl)*Math.cos(hourAngle);
const sunElev = Math.max(0, sinElev);
// Luminosité : uniquement élévation solaire, pas les nuages
const ambientTarget = Math.pow(sunElev, 0.5);
// Interpolation luminosité (très lente = sunrise/sunset progressif)
ambientSmooth = lerp(ambientSmooth, ambientTarget, 0.008);
const bgLum = ambientSmooth;
const bgR = Math.round(8 + bgLum*237);
const bgG = Math.round(8 + bgLum*234);
const bgB = Math.round(18 + bgLum*217);
const fgR = bgLum>0.5 ? 25 : 215;
const fgG = bgLum>0.5 ? 25 : 210;
const fgB = bgLum>0.5 ? 20 : 200;
const fgMid = `rgba(${fgR},${fgG},${fgB},0.42)`;
const fgLow = `rgba(${fgR},${fgG},${fgB},0.13)`;
ctx.clearRect(0,0,W,H);
ctx.fillStyle=`rgb(${bgR},${bgG},${bgB})`;
ctx.fillRect(0,0,W,H);
document.body.style.background=`rgb(${bgR},${bgG},${bgB})`;
document.body.style.color=bgLum>0.5?'rgba(25,25,20,0.75)':'rgba(215,210,200,0.8)';
const p = parisComponents(now);
const ps = now.getSeconds()+now.getMilliseconds()/1000;
const pm = p.minute+ps/60;
const parisHourFrac = p.hour+pm/60;
const R_sec=size*0.11, R_min=size*0.18, R_hour=size*0.23;
const R_weather=size*0.29;
const R_day=size*0.36, R_sun=size*0.415, R_year=size*0.46;
const MIDNIGHT=-Math.PI/2;
ctx.save();
ctx.translate(cx,cy);
// Pression centrale
const pressNorm = Math.max(0,Math.min(1,(weatherSmooth.pressure-970)/70));
const R_pressure = R_sec*(0.3+pressNorm*0.55);
ctx.beginPath();ctx.arc(0,0,R_pressure,0,Math.PI*2);
ctx.strokeStyle=`rgba(${fgR},${fgG},${fgB},0.18)`;
ctx.lineWidth=size*0.008;ctx.stroke();
// Orbite annuelle
ctx.beginPath();ctx.arc(0,0,R_year,0,Math.PI*2);
ctx.strokeStyle=fgLow;ctx.lineWidth=size*0.0015;ctx.stroke();
const yearFrac=(doy-3)/365;
const earthAngle=yearFrac*Math.PI*2-Math.PI/2;
ctx.beginPath();
ctx.arc(Math.cos(earthAngle)*R_year,Math.sin(earthAngle)*R_year,size*0.011,0,Math.PI*2);
ctx.fillStyle=tempColor(weatherSmooth.temp,0.9);ctx.fill();
const dim=[31,28,31,30,31,30,31,31,30,31,30,31];
let cumDay=0;
for(let m=0;m<12;m++){
const a=((cumDay-3)/365)*Math.PI*2-Math.PI/2, len=size*0.013;
ctx.beginPath();
ctx.moveTo(Math.cos(a)*(R_year-len),Math.sin(a)*(R_year-len));
ctx.lineTo(Math.cos(a)*(R_year+len),Math.sin(a)*(R_year+len));
ctx.strokeStyle=fgLow;ctx.lineWidth=size*0.0015;ctx.stroke();
cumDay+=dim[m];
}
// Soleil fixe en haut
ctx.beginPath();ctx.arc(0,-R_sun,size*0.011,0,Math.PI*2);
ctx.fillStyle=bgLum>0.5?'rgba(30,25,10,0.9)':'rgba(255,235,150,0.95)';
ctx.fill();
// Arc nuit FIXE
// lever/coucher sont en heure Paris locale
// L'anneau est orienté sur le temps SOLAIRE de Lille (midi solaire = haut)
// Conversion : heure solaire Lille = heure Paris + (lon_Lille/15 - offset_Paris)
// Offset Paris en heures depuis UTC
const parisOffsetH = getParisOffsetMs(now) / 3600000;
const lilleOffsetSolar = CITIES[0].lon / 15; // offset solaire Lille depuis UTC
// heure solaire Lille = heure Paris - parisOffsetH + lilleOffsetSolar
const parisToSolar = lilleOffsetSolar - parisOffsetH;
const srSolar = weatherSmooth.sunriseHour + parisToSolar;
const ssSolar = weatherSmooth.sunsetHour + parisToSolar;
// Sur l'anneau : midi solaire = haut (-PI/2), 1h = 2PI/24
const angleFromSolar = h => -Math.PI/2 + ((h - 12) / 24) * Math.PI*2;
const nEnd = angleFromSolar(srSolar); // lever = fin de nuit
const nStart = angleFromSolar(ssSolar); // coucher = début de nuit
ctx.beginPath();ctx.arc(0,0,R_day,nStart,nEnd);
ctx.strokeStyle=bgLum>0.5?'rgba(20,20,40,0.07)':'rgba(20,30,70,0.55)';
ctx.lineWidth=size*0.048;ctx.stroke();
// --- Anneau jour (rotation) : ticks + villes uniquement ---
ctx.save();
ctx.rotate(ringRotSmooth);
ctx.beginPath();ctx.arc(0,0,R_day,0,Math.PI*2);
ctx.strokeStyle=fgMid;ctx.lineWidth=size*0.002;ctx.stroke();
for(let h=0;h<24;h++){
const a=(h/24)*Math.PI*2-Math.PI/2, isMain=h%6===0, len=isMain?size*0.020:size*0.009;
ctx.beginPath();
ctx.moveTo(Math.cos(a)*(R_day-len),Math.sin(a)*(R_day-len));
ctx.lineTo(Math.cos(a)*(R_day+len),Math.sin(a)*(R_day+len));
ctx.strokeStyle=isMain?fgMid:fgLow;ctx.lineWidth=size*0.0015;ctx.stroke();
}
for(const city of CITIES){
const lonDiff=city.lon-CITIES[0].lon;
const a=(lonDiff/360)*Math.PI*2-Math.PI/2;
const citySolarHour=lilSolarHour+lonDiff/15;
const px=Math.cos(a)*R_day, py=Math.sin(a)*R_day;
ctx.beginPath();ctx.arc(px,py,size*0.010,0,Math.PI*2);
ctx.fillStyle=timeColor(citySolarHour,0.92);ctx.fill();
ctx.beginPath();ctx.arc(px,py,size*0.010,0,Math.PI*2);
ctx.strokeStyle=fgMid;ctx.lineWidth=size*0.0015;ctx.stroke();
const lx=Math.cos(a)*(R_day+size*0.050), ly=Math.sin(a)*(R_day+size*0.050);
ctx.save();
ctx.translate(lx,ly);ctx.rotate(-ringRotSmooth);
ctx.font=`${size*0.022}px 'Courier New',monospace`;
ctx.fillStyle=fgMid;ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(city.label,0,0);
ctx.restore();
}
ctx.restore(); // fin rotation
// --- Anneau météo fixe ---
if(weatherSmooth.tempForecast.length > 0 || weatherSmooth.precipForecast.length > 0){
const maxPrecip = Math.max(...weatherSmooth.precipForecast, 0.1);
for(let h=0; h<24; h++){
const aStart=MIDNIGHT+(h/24)*Math.PI*2;
const aEnd =MIDNIGHT+((h+1)/24)*Math.PI*2;
// Température
const temp=weatherSmooth.tempForecast[h]??null;
if(temp!==null){
ctx.beginPath();ctx.arc(0,0,R_weather,aStart,aEnd);
ctx.strokeStyle=tempColor(temp,0.75);
ctx.lineWidth=size*0.018;ctx.lineCap='butt';ctx.stroke();
}
// Nuages
const cloud=weatherSmooth.cloudForecast[h]??0;
if(cloud>0){
ctx.beginPath();ctx.arc(0,0,R_weather,aStart,aEnd);
ctx.strokeStyle=bgLum>0.5
?`rgba(90,95,110,${cloud/100*0.25})`
:`rgba(160,165,180,${cloud/100*0.45})`;
ctx.lineWidth=size*0.018;ctx.lineCap='butt';ctx.stroke();
}
// Précipitations (vers l'extérieur)
const mm=weatherSmooth.precipForecast[h]??0;
if(mm>0){
const intensity=Math.min(1,mm/Math.max(maxPrecip,2));
const rainWidth=size*(0.003+intensity*0.010);
const rainR=R_weather+size*0.014+rainWidth/2;
ctx.beginPath();ctx.arc(0,0,rainR,aStart,aEnd);
ctx.strokeStyle=`rgba(80,150,230,${0.25+intensity*0.55})`;
ctx.lineWidth=rainWidth;ctx.lineCap='butt';ctx.stroke();
}
// Rafales (vers l'intérieur, couleur + épaisseur)
const gust=weatherSmooth.gustForecast[h]??0;
if(gust>30){
const gInt=Math.min(1,gust/80);
const gustWidth=size*(0.003+gInt*0.010);
const gustR=R_weather-size*0.014-gustWidth/2;
let gr,gg,gb;
if(gInt<0.375) {const t=gInt/0.375; gr=Math.round(80+t*175); gg=Math.round(200-t*30); gb=Math.round(40-t*40);}
else if(gInt<0.625) {const t=(gInt-0.375)/0.25; gr=255; gg=Math.round(170-t*100); gb=0;}
else if(gInt<0.875) {const t=(gInt-0.625)/0.25; gr=255; gg=Math.round(70-t*70); gb=0;}
else {gr=220;gg=20;gb=20;}
ctx.beginPath();ctx.arc(0,0,gustR,aStart,aEnd);
ctx.strokeStyle=`rgba(${gr},${gg},${gb},${0.3+gInt*0.6})`;
ctx.lineWidth=gustWidth;ctx.lineCap='butt';ctx.stroke();
}
}
ctx.beginPath();ctx.arc(0,0,R_weather,0,Math.PI*2);
ctx.strokeStyle=fgLow;ctx.lineWidth=size*0.001;ctx.stroke();
}
// Anneau heure 24h
ctx.beginPath();ctx.arc(0,0,R_hour,0,Math.PI*2);
ctx.strokeStyle=fgLow;ctx.lineWidth=size*0.001;ctx.stroke();
for(let h=0;h<24;h++){
const a=(h/24)*Math.PI*2+MIDNIGHT, isMain=h%6===0, len=isMain?size*0.016:size*0.008;
ctx.beginPath();
ctx.moveTo(Math.cos(a)*(R_hour-len),Math.sin(a)*(R_hour-len));
ctx.lineTo(Math.cos(a)*(R_hour+len),Math.sin(a)*(R_hour+len));
ctx.strokeStyle=isMain?fgMid:fgLow;ctx.lineWidth=size*0.0012;ctx.stroke();
}
const hourEnd=MIDNIGHT+(parisHourFrac/24)*Math.PI*2;
ctx.beginPath();ctx.arc(0,0,R_hour,MIDNIGHT,hourEnd);
ctx.strokeStyle=timeColor(lilSolarHour,0.75);
ctx.lineWidth=size*0.007;ctx.lineCap='round';ctx.stroke();
// Arc minutes
ctx.beginPath();ctx.arc(0,0,R_min,0,Math.PI*2);
ctx.strokeStyle=fgLow;ctx.lineWidth=size*0.001;ctx.stroke();
const minEnd=MIDNIGHT+(pm/60)*Math.PI*2;
ctx.beginPath();ctx.arc(0,0,R_min,MIDNIGHT,minEnd);
ctx.strokeStyle=timeColor(lilSolarHour,0.85);
ctx.lineWidth=size*0.008;ctx.lineCap='round';ctx.stroke();
// Arc secondes
ctx.beginPath();ctx.arc(0,0,R_sec,0,Math.PI*2);
ctx.strokeStyle=fgLow;ctx.lineWidth=size*0.001;ctx.stroke();
const secEnd=MIDNIGHT+(ps/60)*Math.PI*2;
ctx.beginPath();ctx.arc(0,0,R_sec,MIDNIGHT,secEnd);
ctx.strokeStyle=fgMid;ctx.lineWidth=size*0.005;ctx.lineCap='round';ctx.stroke();
ctx.restore();
requestAnimationFrame(draw);
}
draw();
window.addEventListener('resize', draw);
</script>
</body>
</html>

68
docker-compose.yml Normal file
View File

@ -0,0 +1,68 @@
# Stack homepage : SPA Vue + Miniflux (RSS) + Postgres
#
# Lance avec : make up
# Stop avec : make down
# Logs avec : make logs
#
# Configure les variables dans .env (voir .env.example).
services:
homepage:
build:
context: .
args:
VITE_BACKEND_URL: ${VITE_BACKEND_URL:-http://localhost:8082}
image: homepage:latest
container_name: homepage
restart: unless-stopped
ports:
- "${HOMEPAGE_PORT:-8080}:80"
backend:
build: ./backend
image: homepage-backend:latest
container_name: homepage-backend
restart: unless-stopped
ports:
- "${BACKEND_PORT:-8082}:8080"
environment:
API_TOKEN: ${API_TOKEN:-}
volumes:
- backend-data:/data
miniflux:
image: miniflux/miniflux:latest
container_name: miniflux
restart: unless-stopped
depends_on:
miniflux-db:
condition: service_healthy
environment:
DATABASE_URL: postgres://miniflux:${MINIFLUX_DB_PASSWORD}@miniflux-db/miniflux?sslmode=disable
RUN_MIGRATIONS: "1"
CREATE_ADMIN: "1"
ADMIN_USERNAME: ${MINIFLUX_ADMIN_USER}
ADMIN_PASSWORD: ${MINIFLUX_ADMIN_PASSWORD}
ports:
- "${MINIFLUX_PORT:-8081}:8080"
miniflux-db:
image: postgres:16-alpine
container_name: miniflux-db
restart: unless-stopped
environment:
POSTGRES_USER: miniflux
POSTGRES_PASSWORD: ${MINIFLUX_DB_PASSWORD}
POSTGRES_DB: miniflux
volumes:
- miniflux-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
volumes:
miniflux-db-data:
backend-data:

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Homepage</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

17
nginx.conf Normal file
View File

@ -0,0 +1,17 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback for Vue Router (history mode)
location / {
try_files $uri $uri/ /index.html;
}
# Long-term cache for built assets (hashed filenames)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

1546
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "homepage",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host"
},
"dependencies": {
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.7"
}
}

6
public/favicon.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="none" stroke="#888" stroke-width="1.5"/>
<circle cx="16" cy="16" r="2" fill="#888"/>
<line x1="16" y1="16" x2="16" y2="6" stroke="#888" stroke-width="1.5" stroke-linecap="round"/>
<line x1="16" y1="16" x2="22" y2="20" stroke="#888" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 389 B

7
src/App.vue Normal file
View File

@ -0,0 +1,7 @@
<script setup>
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>

View File

@ -0,0 +1,388 @@
<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { useClockState, getParisOffsetMs, parisComponents } from '../composables/useParisTime.js'
import { useWeather } from '../composables/useWeather.js'
import { CITIES, dayOfYear, solarDeclination } from '../utils/astro.js'
import { timeColor, tempColor } from '../utils/colors.js'
const canvasEl = ref(null)
const { getNow } = useClockState()
const { weatherSmooth, fetchWeather, smoothWeather } = useWeather()
let rafId = null
let ambientSmooth = 0
let ringRotSmooth = null
function draw() {
const canvas = canvasEl.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const now = getNow()
fetchWeather(now)
smoothWeather()
const W = (canvas.width = window.innerWidth)
const H = (canvas.height = window.innerHeight)
const size = Math.min(W, H - 160)
const cx = W / 2
const cy = H / 2
// Heure solaire Lille
const utcFrac =
now.getUTCHours() +
now.getUTCMinutes() / 60 +
now.getUTCSeconds() / 3600 +
now.getUTCMilliseconds() / 3600000
const lilSolarHour = (((utcFrac + CITIES[0].lon / 15) % 24) + 24) % 24
const ringRotTarget = ((lilSolarHour - 12) / 24) * Math.PI * 2
if (ringRotSmooth === null) ringRotSmooth = ringRotTarget
let diff = ringRotTarget - ringRotSmooth
while (diff > Math.PI) diff -= Math.PI * 2
while (diff < -Math.PI) diff += Math.PI * 2
ringRotSmooth += diff * 0.06
const doy = dayOfYear(now)
const decl = solarDeclination(doy)
const hourAngle = ((lilSolarHour - 12) / 24) * Math.PI * 2
const lat = (CITIES[0].lat * Math.PI) / 180
const sinElev =
Math.sin(lat) * Math.sin(decl) + Math.cos(lat) * Math.cos(decl) * Math.cos(hourAngle)
const sunElev = Math.max(0, sinElev)
const ambientTarget = Math.pow(sunElev, 0.5)
ambientSmooth = ambientSmooth + (ambientTarget - ambientSmooth) * 0.008
const bgLum = ambientSmooth
// Non-linear curve : stay near black/white longer, race through grey
const tBg =
bgLum < 0.5
? Math.pow(2 * bgLum, 3) / 2
: 1 - Math.pow(2 * (1 - bgLum), 3) / 2
const bgR = Math.round(8 + tBg * 244)
const bgG = Math.round(6 + tBg * 240)
const bgB = Math.round(4 + tBg * 232)
const fgR = Math.round(215 - tBg * 190)
const fgG = Math.round(210 - tBg * 185)
const fgB = Math.round(200 - tBg * 180)
const fgMid = `rgba(${fgR},${fgG},${fgB},0.42)`
const fgLow = `rgba(${fgR},${fgG},${fgB},0.13)`
ctx.clearRect(0, 0, W, H)
ctx.fillStyle = `rgb(${bgR},${bgG},${bgB})`
ctx.fillRect(0, 0, W, H)
document.body.style.background = `rgb(${bgR},${bgG},${bgB})`
document.documentElement.style.setProperty('--bg-r', bgR)
document.documentElement.style.setProperty('--bg-g', bgG)
document.documentElement.style.setProperty('--bg-b', bgB)
document.body.style.color = `rgba(${fgR},${fgG},${fgB},0.8)`
const p = parisComponents(now)
const ps = now.getSeconds() + now.getMilliseconds() / 1000
const pm = p.minute + ps / 60
const parisHourFrac = p.hour + pm / 60
const R_sec = size * 0.11
const R_min = size * 0.18
const R_hour = size * 0.23
const R_weather = size * 0.29
const R_day = size * 0.36
const R_sun = size * 0.415
const R_year = size * 0.46
const MIDNIGHT = -Math.PI / 2
ctx.save()
ctx.translate(cx, cy)
// Pression centrale
const pressNorm = Math.max(0, Math.min(1, (weatherSmooth.pressure - 970) / 70))
const R_pressure = R_sec * (0.3 + pressNorm * 0.55)
ctx.beginPath()
ctx.arc(0, 0, R_pressure, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(${fgR},${fgG},${fgB},0.18)`
ctx.lineWidth = size * 0.008
ctx.stroke()
// Orbite annuelle
ctx.beginPath()
ctx.arc(0, 0, R_year, 0, Math.PI * 2)
ctx.strokeStyle = fgLow
ctx.lineWidth = size * 0.0015
ctx.stroke()
const yearFrac = (doy - 3) / 365
const earthAngle = yearFrac * Math.PI * 2 - Math.PI / 2
ctx.beginPath()
ctx.arc(
Math.cos(earthAngle) * R_year,
Math.sin(earthAngle) * R_year,
size * 0.011,
0,
Math.PI * 2
)
ctx.fillStyle = tempColor(weatherSmooth.temp, 0.9)
ctx.fill()
const dim = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
let cumDay = 0
for (let m = 0; m < 12; m++) {
const a = ((cumDay - 3) / 365) * Math.PI * 2 - Math.PI / 2
const len = size * 0.013
ctx.beginPath()
ctx.moveTo(Math.cos(a) * (R_year - len), Math.sin(a) * (R_year - len))
ctx.lineTo(Math.cos(a) * (R_year + len), Math.sin(a) * (R_year + len))
ctx.strokeStyle = fgLow
ctx.lineWidth = size * 0.0015
ctx.stroke()
cumDay += dim[m]
}
// Soleil fixe en haut
ctx.beginPath()
ctx.arc(0, -R_sun, size * 0.011, 0, Math.PI * 2)
ctx.fillStyle = bgLum > 0.5 ? 'rgba(30,25,10,0.9)' : 'rgba(255,235,150,0.95)'
ctx.fill()
// Arc nuit fixe
const parisOffsetH = getParisOffsetMs(now) / 3600000
const lilleOffsetSolar = CITIES[0].lon / 15
const parisToSolar = lilleOffsetSolar - parisOffsetH
const srSolar = weatherSmooth.sunriseHour + parisToSolar
const ssSolar = weatherSmooth.sunsetHour + parisToSolar
const angleFromSolar = (h) => -Math.PI / 2 + ((h - 12) / 24) * Math.PI * 2
const nEnd = angleFromSolar(srSolar)
const nStart = angleFromSolar(ssSolar)
ctx.beginPath()
ctx.arc(0, 0, R_day, nStart, nEnd)
ctx.strokeStyle = bgLum > 0.5 ? 'rgba(20,20,40,0.07)' : 'rgba(20,30,70,0.55)'
ctx.lineWidth = size * 0.048
ctx.stroke()
// Anneau jour (rotation) : ticks + villes
ctx.save()
ctx.rotate(ringRotSmooth)
ctx.beginPath()
ctx.arc(0, 0, R_day, 0, Math.PI * 2)
ctx.strokeStyle = fgMid
ctx.lineWidth = size * 0.002
ctx.stroke()
for (let h = 0; h < 24; h++) {
const a = (h / 24) * Math.PI * 2 - Math.PI / 2
const isMain = h % 6 === 0
const len = isMain ? size * 0.02 : size * 0.009
ctx.beginPath()
ctx.moveTo(Math.cos(a) * (R_day - len), Math.sin(a) * (R_day - len))
ctx.lineTo(Math.cos(a) * (R_day + len), Math.sin(a) * (R_day + len))
ctx.strokeStyle = isMain ? fgMid : fgLow
ctx.lineWidth = size * 0.0015
ctx.stroke()
}
for (const city of CITIES) {
const lonDiff = city.lon - CITIES[0].lon
const a = (lonDiff / 360) * Math.PI * 2 - Math.PI / 2
const citySolarHour = lilSolarHour + lonDiff / 15
const px = Math.cos(a) * R_day
const py = Math.sin(a) * R_day
ctx.beginPath()
ctx.arc(px, py, size * 0.01, 0, Math.PI * 2)
ctx.fillStyle = timeColor(citySolarHour, 0.92)
ctx.fill()
ctx.beginPath()
ctx.arc(px, py, size * 0.01, 0, Math.PI * 2)
ctx.strokeStyle = fgMid
ctx.lineWidth = size * 0.0015
ctx.stroke()
const lx = Math.cos(a) * (R_day + size * 0.05)
const ly = Math.sin(a) * (R_day + size * 0.05)
ctx.save()
ctx.translate(lx, ly)
ctx.rotate(-ringRotSmooth)
ctx.font = `${size * 0.022}px 'Courier New',monospace`
ctx.fillStyle = fgMid
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(city.label, 0, 0)
ctx.restore()
}
ctx.restore()
// Anneau météo fixe
if (weatherSmooth.tempForecast.length > 0 || weatherSmooth.precipForecast.length > 0) {
const maxPrecip = Math.max(...weatherSmooth.precipForecast, 0.1)
for (let h = 0; h < 24; h++) {
const aStart = MIDNIGHT + (h / 24) * Math.PI * 2
const aEnd = MIDNIGHT + ((h + 1) / 24) * Math.PI * 2
const temp = weatherSmooth.tempForecast[h] ?? null
if (temp !== null) {
ctx.beginPath()
ctx.arc(0, 0, R_weather, aStart, aEnd)
ctx.strokeStyle = tempColor(temp, 0.75)
ctx.lineWidth = size * 0.018
ctx.lineCap = 'butt'
ctx.stroke()
}
const cloud = weatherSmooth.cloudForecast[h] ?? 0
if (cloud > 0) {
ctx.beginPath()
ctx.arc(0, 0, R_weather, aStart, aEnd)
ctx.strokeStyle =
bgLum > 0.5
? `rgba(90,95,110,${(cloud / 100) * 0.25})`
: `rgba(160,165,180,${(cloud / 100) * 0.45})`
ctx.lineWidth = size * 0.018
ctx.lineCap = 'butt'
ctx.stroke()
}
const mm = weatherSmooth.precipForecast[h] ?? 0
if (mm > 0) {
const intensity = Math.min(1, mm / Math.max(maxPrecip, 2))
const rainWidth = size * (0.003 + intensity * 0.01)
const rainR = R_weather + size * 0.014 + rainWidth / 2
ctx.beginPath()
ctx.arc(0, 0, rainR, aStart, aEnd)
ctx.strokeStyle = `rgba(80,150,230,${0.25 + intensity * 0.55})`
ctx.lineWidth = rainWidth
ctx.lineCap = 'butt'
ctx.stroke()
}
const gust = weatherSmooth.gustForecast[h] ?? 0
if (gust > 30) {
const gInt = Math.min(1, gust / 80)
const gustWidth = size * (0.003 + gInt * 0.01)
const gustR = R_weather - size * 0.014 - gustWidth / 2
let gr, gg, gb
if (gInt < 0.375) {
const t = gInt / 0.375
gr = Math.round(80 + t * 175)
gg = Math.round(200 - t * 30)
gb = Math.round(40 - t * 40)
} else if (gInt < 0.625) {
const t = (gInt - 0.375) / 0.25
gr = 255
gg = Math.round(170 - t * 100)
gb = 0
} else if (gInt < 0.875) {
const t = (gInt - 0.625) / 0.25
gr = 255
gg = Math.round(70 - t * 70)
gb = 0
} else {
gr = 220
gg = 20
gb = 20
}
ctx.beginPath()
ctx.arc(0, 0, gustR, aStart, aEnd)
ctx.strokeStyle = `rgba(${gr},${gg},${gb},${0.3 + gInt * 0.6})`
ctx.lineWidth = gustWidth
ctx.lineCap = 'butt'
ctx.stroke()
}
}
ctx.beginPath()
ctx.arc(0, 0, R_weather, 0, Math.PI * 2)
ctx.strokeStyle = fgLow
ctx.lineWidth = size * 0.001
ctx.stroke()
}
// Anneau heure 24h
ctx.beginPath()
ctx.arc(0, 0, R_hour, 0, Math.PI * 2)
ctx.strokeStyle = fgLow
ctx.lineWidth = size * 0.001
ctx.stroke()
for (let h = 0; h < 24; h++) {
const a = (h / 24) * Math.PI * 2 + MIDNIGHT
const isMain = h % 6 === 0
const len = isMain ? size * 0.016 : size * 0.008
ctx.beginPath()
ctx.moveTo(Math.cos(a) * (R_hour - len), Math.sin(a) * (R_hour - len))
ctx.lineTo(Math.cos(a) * (R_hour + len), Math.sin(a) * (R_hour + len))
ctx.strokeStyle = isMain ? fgMid : fgLow
ctx.lineWidth = size * 0.0012
ctx.stroke()
}
const hourEnd = MIDNIGHT + (parisHourFrac / 24) * Math.PI * 2
ctx.beginPath()
ctx.arc(0, 0, R_hour, MIDNIGHT, hourEnd)
ctx.strokeStyle = timeColor(lilSolarHour, 0.75)
ctx.lineWidth = size * 0.007
ctx.lineCap = 'round'
ctx.stroke()
// Arc minutes
ctx.beginPath()
ctx.arc(0, 0, R_min, 0, Math.PI * 2)
ctx.strokeStyle = fgLow
ctx.lineWidth = size * 0.001
ctx.stroke()
const minEnd = MIDNIGHT + (pm / 60) * Math.PI * 2
ctx.beginPath()
ctx.arc(0, 0, R_min, MIDNIGHT, minEnd)
ctx.strokeStyle = timeColor(lilSolarHour, 0.85)
ctx.lineWidth = size * 0.008
ctx.lineCap = 'round'
ctx.stroke()
// Arc secondes
ctx.beginPath()
ctx.arc(0, 0, R_sec, 0, Math.PI * 2)
ctx.strokeStyle = fgLow
ctx.lineWidth = size * 0.001
ctx.stroke()
const secEnd = MIDNIGHT + (ps / 60) * Math.PI * 2
ctx.beginPath()
ctx.arc(0, 0, R_sec, MIDNIGHT, secEnd)
ctx.strokeStyle = fgMid
ctx.lineWidth = size * 0.005
ctx.lineCap = 'round'
ctx.stroke()
ctx.restore()
rafId = requestAnimationFrame(draw)
}
function handleResize() {
// draw() reads window size at every frame; nothing else needed
}
onMounted(() => {
window.addEventListener('resize', handleResize)
draw()
})
onBeforeUnmount(() => {
if (rafId !== null) cancelAnimationFrame(rafId)
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<canvas ref="canvasEl" class="clock-canvas"></canvas>
</template>
<style scoped>
.clock-canvas {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
z-index: 0;
}
</style>

View File

@ -0,0 +1,112 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import {
useClockState,
toInputStr,
parisStrToDate,
} from '../composables/useParisTime.js'
import { useWeather } from '../composables/useWeather.js'
const { isLive, getNow, setSimulated, goLive } = useClockState()
const { resetFetchCache } = useWeather()
const inputValue = ref(toInputStr(new Date()))
let tickId = null
function tick() {
if (isLive.value) {
inputValue.value = toInputStr(getNow())
}
tickId = requestAnimationFrame(tick)
}
function onChange(e) {
if (!e.target.value) return
setSimulated(parisStrToDate(e.target.value))
resetFetchCache()
}
function onNow() {
goLive()
resetFetchCache()
}
onMounted(() => {
tick()
})
onBeforeUnmount(() => {
if (tickId !== null) cancelAnimationFrame(tickId)
})
</script>
<template>
<div class="controls">
<div class="dot" :class="{ paused: !isLive }"></div>
<input
type="datetime-local"
step="1"
:value="inputValue"
@change="onChange"
/>
<button @click="onNow">now</button>
</div>
</template>
<style scoped>
.controls {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.75rem;
background: transparent;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 4px;
padding: 0.5rem 0.75rem;
opacity: 0.6;
transition: opacity 0.2s;
white-space: nowrap;
z-index: 10;
}
.controls:hover {
opacity: 1;
}
.controls input[type='datetime-local'] {
font-family: inherit;
font-size: 0.75rem;
background: transparent;
border: none;
outline: none;
cursor: pointer;
color-scheme: light dark;
color: inherit;
}
.controls button {
font-family: inherit;
font-size: 0.7rem;
background: transparent;
border: 1px solid rgba(128, 128, 128, 0.4);
border-radius: 2px;
padding: 0.2rem 0.5rem;
cursor: pointer;
color: inherit;
opacity: 0.7;
}
.controls button:hover {
opacity: 1;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #4a9;
flex-shrink: 0;
transition: background 0.3s;
}
.dot.paused {
background: #a84;
}
</style>

View File

@ -0,0 +1,177 @@
<script setup>
// Static legend (markup-only). Could later be wired to live values
// from useWeather() if needed.
</script>
<template>
<div class="legend">
<!-- Anneau météo : schéma des couches -->
<div class="leg-row" style="flex-direction: column; align-items: flex-end; gap: 8px;">
<span class="leg-label">anneau météo 24h glissantes</span>
<div style="display: flex; align-items: center; gap: 16px;">
<div style="position: relative; width: 52px; height: 52px; flex-shrink: 0;">
<div style="position: absolute; inset: 0; border-radius: 50%; border: 3px solid rgba(255, 150, 0, 0.6);"></div>
<div style="position: absolute; inset: 5px; border-radius: 50%; border: 6px solid rgba(0, 200, 200, 0.45);"></div>
<div style="position: absolute; inset: -6px; border-radius: 50%; border: 3px solid rgba(80, 150, 230, 0.5);"></div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px; align-items: flex-start;">
<div style="display: flex; align-items: center; gap: 7px;">
<div style="width: 20px; height: 3px; background: rgba(80, 150, 230, 0.7); border-radius: 1px;"></div>
<span class="leg-val" style="opacity: 0.7;">précip. mm/h extérieur</span>
</div>
<div style="display: flex; align-items: center; gap: 7px;">
<div style="width: 20px; height: 5px; background: linear-gradient(to right, rgba(0, 200, 200, 0.6), rgba(200, 255, 0, 0.6)); border-radius: 1px;"></div>
<span class="leg-val" style="opacity: 0.7;">temp. + nuages centre</span>
</div>
<div style="display: flex; align-items: center; gap: 7px;">
<div style="width: 20px; height: 3px; background: rgba(255, 150, 0, 0.7); border-radius: 1px;"></div>
<span class="leg-val" style="opacity: 0.7;">rafales km/h intérieur</span>
</div>
</div>
</div>
</div>
<hr class="leg-sep" />
<div class="leg-row">
<span class="leg-val">10°</span>
<div
class="leg-bar"
style="background: linear-gradient(to right, rgba(120, 0, 180, 0.8), rgba(40, 80, 255, 0.8), rgba(0, 200, 200, 0.8), rgba(200, 255, 0, 0.8), rgba(240, 140, 0, 0.8), rgba(255, 20, 0, 0.8));"
></div>
<span class="leg-val">40°C</span>
<span class="leg-label">température</span>
</div>
<div class="leg-row">
<span class="leg-val">0 100 %</span>
<svg width="17" height="11" viewBox="0 0 17 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" opacity="0.5">
<path d="M4 9.5Q1 9.5 1 7Q1 4.5 3.5 4.5Q4 1 8 1Q12 1 12 4.5Q15.5 4.5 15.5 7.5Q15.5 9.5 12 9.5Z" />
</svg>
<span class="leg-label">nuages (voile)</span>
</div>
<div class="leg-row">
<svg width="13" height="11" viewBox="0 0 13 11" fill="none" stroke="rgba(80,150,230,0.8)" stroke-width="1.5" stroke-linecap="round">
<line x1="2.5" y1="1" x2="1.5" y2="10" />
<line x1="6.5" y1="1" x2="5.5" y2="10" />
<line x1="10.5" y1="1" x2="9.5" y2="10" />
</svg>
<span class="leg-label">précipitations mm/h</span>
</div>
<div class="leg-row">
<span class="leg-val">30</span>
<div
class="leg-bar"
style="background: linear-gradient(to right, rgba(180, 200, 40, 0.8), rgba(255, 170, 0, 0.8), rgba(255, 70, 0, 0.8), rgba(220, 20, 20, 0.8));"
></div>
<span class="leg-val">80 km/h</span>
<span class="leg-label">rafales</span>
</div>
<div class="leg-row">
<span class="leg-val">970</span>
<div class="leg-circles">
<div class="leg-circle" style="width: 5px; height: 5px;"></div>
<span class="leg-val" style="opacity: 0.3; letter-spacing: -1px;">···</span>
<div class="leg-circle" style="width: 13px; height: 13px;"></div>
</div>
<span class="leg-val">1040 hPa</span>
<span class="leg-label">pression (centre)</span>
</div>
<hr class="leg-sep" />
<div class="leg-row" style="flex-direction: column; align-items: flex-end; gap: 6px;">
<div style="display: flex; gap: 10px; align-items: flex-end;">
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px;">
<div style="width: 10px; height: 10px; border-radius: 50%; border: 1px solid currentColor; opacity: 0.35;"></div>
<span class="leg-val">sec</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px;">
<div style="width: 16px; height: 16px; border-radius: 50%; border: 1.5px solid currentColor; opacity: 0.4;"></div>
<span class="leg-val">min</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px;">
<div style="width: 22px; height: 22px; border-radius: 50%; border: 1.5px solid currentColor; opacity: 0.4;"></div>
<span class="leg-val">heure</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px;">
<div style="width: 28px; height: 28px; border-radius: 50%; border: 3px solid rgba(80, 150, 230, 0.5);"></div>
<span class="leg-val">météo</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px;">
<div style="width: 36px; height: 36px; border-radius: 50%; border: 2px solid currentColor; opacity: 0.45;"></div>
<span class="leg-val">jour</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px;">
<svg width="13" height="13" viewBox="0 0 13 13" fill="currentColor" opacity="0.45">
<circle cx="6.5" cy="6.5" r="6" />
</svg>
<span class="leg-val">soleil</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px;">
<div style="width: 44px; height: 44px; border-radius: 50%; border: 1px dashed currentColor; opacity: 0.3;"></div>
<span class="leg-val">année</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.legend {
position: fixed;
top: 1.5rem;
right: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
opacity: 0.75;
transition: opacity 0.25s;
color: inherit;
z-index: 10;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
}
.legend:hover {
opacity: 1;
}
.leg-row {
display: flex;
align-items: center;
gap: 0.75rem;
justify-content: flex-end;
}
.leg-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 15px;
font-style: italic;
opacity: 0.85;
white-space: nowrap;
flex-shrink: 0;
letter-spacing: 0.03em;
}
.leg-val {
font-family: 'Courier New', monospace;
font-size: 13px;
opacity: 0.65;
flex-shrink: 0;
}
.leg-bar {
width: 78px;
height: 5px;
border-radius: 2px;
flex-shrink: 0;
}
.leg-circles {
display: flex;
align-items: center;
gap: 4px;
}
.leg-circle {
border-radius: 50%;
border: 1.5px solid currentColor;
opacity: 0.4;
flex-shrink: 0;
}
.leg-sep {
border: none;
border-top: 0.5px solid rgba(128, 128, 128, 0.2);
margin: 0.15rem 0;
}
</style>

View File

@ -0,0 +1,197 @@
<script setup>
// EphemeralNotes short notes persisted via backend API.
// Cap at 50 notes server-side.
import { ref, onMounted } from 'vue'
import { useNotes } from '../composables/useNotes.js'
import { useExpandable } from '../composables/useExpandable.js'
const MAX_NOTES = 50
const { notes, error, fetchNotes, createNote, deleteNote } = useNotes()
const draft = ref('')
const { expanded, toggle, collapse } = useExpandable()
onMounted(fetchNotes)
async function addNote() {
const text = draft.value.trim()
if (!text) return
await createNote(text)
draft.value = ''
}
async function removeNote(id) {
await deleteNote(id)
}
</script>
<template>
<div>
<div v-if="expanded" class="widget-backdrop" @click="collapse"></div>
<div class="notes" :class="{ expanded }">
<div class="notes-header">
<span class="notes-label">notes</span>
<div class="notes-header-right">
<span class="notes-count">{{ notes.length }}/{{ MAX_NOTES }}</span>
<button type="button" class="expand-btn" @click="toggle" :title="expanded ? 'réduire' : 'agrandir'">
{{ expanded ? '⤡' : '⤢' }}
</button>
</div>
</div>
<form class="notes-add" @submit.prevent="addNote">
<input
v-model="draft"
type="text"
placeholder="+ note éphémère"
spellcheck="false"
/>
</form>
<ul class="notes-list">
<li v-for="n in notes" :key="n.id" class="note-item">
<span class="note-text">{{ n.text }}</span>
<button class="note-del" @click="removeNote(n.id)" title="supprimer">×</button>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.notes {
position: fixed;
bottom: 23rem;
right: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
opacity: 0.75;
transition: opacity 0.25s;
color: inherit;
z-index: 10;
font-family: 'Courier New', monospace;
width: 320px;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
}
.notes:hover,
.notes:focus-within {
opacity: 1;
}
.notes.expanded {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
bottom: auto;
right: auto;
width: min(600px, 85vw);
z-index: 100;
opacity: 1;
background: rgba(var(--bg-r, 15), var(--bg-g, 15), var(--bg-b, 20), 0.92);
border-radius: 8px;
padding: 1rem 1.2rem;
}
.notes.expanded .notes-list {
max-height: 70vh;
}
.widget-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(2px);
z-index: 99;
}
.notes-header {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.notes-header-right {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.expand-btn {
background: transparent;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 2px;
padding: 0 0.35rem;
color: inherit;
font-size: 13px;
line-height: 1.3;
cursor: pointer;
opacity: 0.6;
}
.expand-btn:hover {
opacity: 1;
border-color: rgba(128, 128, 128, 0.6);
}
.notes-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 15px;
font-style: italic;
opacity: 0.85;
letter-spacing: 0.03em;
}
.notes-count {
font-size: 12px;
opacity: 0.5;
}
.notes-add input {
width: 100%;
font-family: inherit;
font-size: 14px;
color: inherit;
background: transparent;
border: 1px solid rgba(128, 128, 128, 0.35);
border-radius: 3px;
padding: 0.4rem 0.55rem;
outline: none;
transition: border-color 0.2s;
}
.notes-add input:focus {
border-color: rgba(128, 128, 128, 0.7);
}
.notes-add input::placeholder {
color: inherit;
opacity: 0.4;
font-style: italic;
}
.notes-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
max-height: 280px;
overflow-y: auto;
}
.note-item {
display: flex;
align-items: flex-start;
gap: 0.4rem;
font-size: 14px;
opacity: 0.75;
padding: 0.2rem 0.1rem 0.2rem 0.4rem;
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
}
.note-text {
flex: 1;
word-break: break-word;
line-height: 1.35;
}
.note-del {
background: transparent;
border: none;
color: inherit;
font-family: inherit;
font-size: 14px;
cursor: pointer;
opacity: 0.4;
padding: 0 0.3rem;
}
.note-del:hover {
opacity: 1;
}
</style>

View File

@ -0,0 +1,71 @@
<script setup>
// Top-left navigation panel symmetric to ClockLegend.
// Links live in src/data/links.json (edit there to add/remove).
import linksData from '../data/links.json'
const groups = linksData.groups.filter((g) => g.items.length > 0)
</script>
<template>
<nav class="nav-panel">
<div v-for="group in groups" :key="group.title" class="nav-group">
<span class="nav-title">{{ group.title }}</span>
<a
v-for="item in group.items"
:key="item.url"
:href="item.url"
class="nav-item"
target="_blank"
rel="noopener noreferrer"
>
{{ item.label }}
</a>
</div>
</nav>
</template>
<style scoped>
.nav-panel {
position: fixed;
top: 1.5rem;
left: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
opacity: 0.75;
transition: opacity 0.25s;
color: inherit;
z-index: 10;
font-family: 'Courier New', monospace;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
}
.nav-panel:hover {
opacity: 1;
}
.nav-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.nav-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 15px;
font-style: italic;
opacity: 0.85;
letter-spacing: 0.03em;
margin-bottom: 0.35rem;
}
.nav-item {
font-size: 14px;
color: inherit;
text-decoration: none;
opacity: 0.7;
padding-left: 0.85rem;
transition: opacity 0.15s;
letter-spacing: 0.02em;
line-height: 1.4;
}
.nav-item:hover {
opacity: 1;
}
</style>

View File

@ -0,0 +1,128 @@
<script setup>
import { ref, computed, onBeforeUnmount } from 'vue'
const WORK = 25 * 60
const BREAK = 5 * 60
const phase = ref('work') // 'work' | 'break'
const remaining = ref(WORK)
const running = ref(false)
let intervalId = null
const mmss = computed(() => {
const m = Math.floor(remaining.value / 60)
const s = remaining.value % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
})
function tick() {
if (remaining.value > 0) {
remaining.value -= 1
return
}
// Phase finished flip and continue running
phase.value = phase.value === 'work' ? 'break' : 'work'
remaining.value = phase.value === 'work' ? WORK : BREAK
}
function toggle() {
if (running.value) {
clearInterval(intervalId)
intervalId = null
running.value = false
} else {
intervalId = setInterval(tick, 1000)
running.value = true
}
}
function reset() {
clearInterval(intervalId)
intervalId = null
running.value = false
phase.value = 'work'
remaining.value = WORK
}
onBeforeUnmount(() => {
if (intervalId !== null) clearInterval(intervalId)
})
</script>
<template>
<div class="pomodoro" :class="{ active: running, break: phase === 'break' }">
<span class="pomo-label">pomodoro · {{ phase }}</span>
<div class="pomo-row">
<button class="pomo-time" @click="toggle">
{{ mmss }}
</button>
<button class="pomo-btn" @click="reset" title="reset"></button>
</div>
</div>
</template>
<style scoped>
.pomodoro {
position: fixed;
bottom: 22.5rem;
left: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
opacity: 0.75;
transition: opacity 0.25s;
color: inherit;
z-index: 10;
font-family: 'Courier New', monospace;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
}
.pomodoro:hover,
.pomodoro.active {
opacity: 1;
}
.pomo-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 13px;
font-style: italic;
opacity: 0.85;
letter-spacing: 0.03em;
}
.pomo-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pomo-time {
font-family: inherit;
font-size: 22px;
background: transparent;
border: 1px solid rgba(128, 128, 128, 0.35);
border-radius: 3px;
padding: 0.2rem 0.6rem;
color: inherit;
cursor: pointer;
letter-spacing: 0.05em;
transition: border-color 0.2s;
}
.pomo-time:hover {
border-color: rgba(128, 128, 128, 0.7);
}
.pomodoro.break .pomo-time {
border-color: rgba(80, 180, 140, 0.6);
}
.pomo-btn {
font-family: inherit;
font-size: 14px;
background: transparent;
border: 1px solid rgba(128, 128, 128, 0.35);
border-radius: 3px;
padding: 0.15rem 0.45rem;
color: inherit;
cursor: pointer;
opacity: 0.7;
}
.pomo-btn:hover {
opacity: 1;
}
</style>

View File

@ -0,0 +1,115 @@
<script setup>
import { ref, nextTick, onMounted, onBeforeUnmount } from 'vue'
// Ctrl+K / Cmd+K opens, ESC closes, Enter submits to DuckDuckGo
// (DDG supports bangs natively: !g foo, !gh repo, !w paris)
const open = ref(false)
const query = ref('')
const inputEl = ref(null)
async function show() {
open.value = true
query.value = ''
await nextTick()
inputEl.value?.focus()
}
function hide() {
open.value = false
query.value = ''
}
function submit() {
const q = query.value.trim()
if (!q) return
window.open(`https://duckduckgo.com/?q=${encodeURIComponent(q)}`, '_blank', 'noopener')
hide()
}
function onKey(e) {
// toggle on Ctrl+K or Cmd+K
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
open.value ? hide() : show()
return
}
if (open.value && e.key === 'Escape') {
e.preventDefault()
hide()
}
}
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<div v-if="open" class="search-overlay" @click.self="hide">
<div class="search-box">
<span class="search-hint"></span>
<input
ref="inputEl"
v-model="query"
class="search-input"
type="text"
placeholder="search · ddg bangs supported (!g, !gh, !w …)"
spellcheck="false"
@keydown.enter="submit"
/>
<span class="search-kbd">esc</span>
</div>
</div>
</template>
<style scoped>
.search-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(2px);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 22vh;
z-index: 100;
font-family: 'Courier New', monospace;
}
.search-box {
display: flex;
align-items: center;
gap: 0.7rem;
background: rgba(20, 20, 25, 0.85);
border: 1px solid rgba(180, 180, 180, 0.35);
border-radius: 4px;
padding: 0.7rem 1rem;
width: min(560px, 80vw);
color: rgba(230, 225, 215, 0.95);
}
.search-hint {
font-size: 18px;
opacity: 0.5;
}
.search-input {
flex: 1;
font-family: inherit;
font-size: 16px;
background: transparent;
border: none;
outline: none;
color: inherit;
letter-spacing: 0.01em;
}
.search-input::placeholder {
color: inherit;
opacity: 0.35;
font-style: italic;
}
.search-kbd {
font-size: 11px;
opacity: 0.4;
border: 1px solid rgba(180, 180, 180, 0.3);
border-radius: 2px;
padding: 0.1rem 0.35rem;
}
</style>

198
src/components/RSSFeed.vue Normal file
View File

@ -0,0 +1,198 @@
<script setup>
// RSSFeed bottom-left widget below the Pomodoro.
// Reads the feed list from src/data/feeds.json (edit there to add/remove).
// Items from all feeds are merged and sorted by date desc.
import { computed, onMounted, onBeforeUnmount } from 'vue'
import { useRSS } from '../composables/useRSS.js'
import { useExpandable } from '../composables/useExpandable.js'
import feedsData from '../data/feeds.json'
const FEEDS = feedsData.feeds
const MAX_COMPACT = 10
const MAX_EXPANDED = 40
const REFRESH_MS = 15 * 60 * 1000 // 15 min
const { items, error, loadMultiple } = useRSS()
const { expanded, toggle, collapse } = useExpandable()
const visible = computed(() => items.value.slice(0, expanded.value ? MAX_EXPANDED : MAX_COMPACT))
let timer = null
onMounted(() => {
loadMultiple(FEEDS)
timer = setInterval(() => loadMultiple(FEEDS), REFRESH_MS)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
function relDate(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const diffMs = Date.now() - d.getTime()
const mins = Math.round(diffMs / 60000)
if (mins < 60) return `${mins}m`
const hours = Math.round(mins / 60)
if (hours < 24) return `${hours}h`
const days = Math.round(hours / 24)
if (days < 30) return `${days}d`
const months = Math.round(days / 30)
return `${months}mo`
}
</script>
<template>
<div>
<div v-if="expanded" class="widget-backdrop" @click="collapse"></div>
<div class="rss" :class="{ expanded }">
<div class="rss-header">
<span class="rss-label">rss · {{ FEEDS.length }} feed{{ FEEDS.length > 1 ? 's' : '' }}</span>
<div class="rss-header-right">
<span v-if="error" class="rss-error" :title="error">err</span>
<button type="button" class="expand-btn" @click="toggle" :title="expanded ? 'réduire' : 'agrandir'">
{{ expanded ? '⤡' : '⤢' }}
</button>
</div>
</div>
<ul class="rss-list">
<li v-for="(it, i) in visible" :key="i" class="rss-item">
<span v-if="it.source" class="rss-source">{{ it.source }}</span>
<a :href="it.link" target="_blank" rel="noopener noreferrer" class="rss-link">
{{ it.title }}
</a>
<span v-if="relDate(it.date)" class="rss-date">{{ relDate(it.date) }}</span>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.rss {
position: fixed;
bottom: 2rem;
left: 1.5rem;
width: 340px;
display: flex;
flex-direction: column;
gap: 0.4rem;
opacity: 0.75;
transition: opacity 0.25s;
color: inherit;
z-index: 10;
font-family: 'Courier New', monospace;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
}
.rss:hover {
opacity: 1;
}
.rss.expanded {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
bottom: auto;
width: min(700px, 85vw);
z-index: 100;
opacity: 1;
background: rgba(var(--bg-r, 15), var(--bg-g, 15), var(--bg-b, 20), 0.92);
border-radius: 8px;
padding: 1rem 1.2rem;
}
.rss.expanded .rss-list {
max-height: 70vh;
}
.widget-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(2px);
z-index: 99;
}
.rss-header {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.rss-header-right {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.expand-btn {
background: transparent;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 2px;
padding: 0 0.35rem;
color: inherit;
font-size: 13px;
line-height: 1.3;
cursor: pointer;
opacity: 0.6;
}
.expand-btn:hover {
opacity: 1;
border-color: rgba(128, 128, 128, 0.6);
}
.rss-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 13px;
font-style: italic;
opacity: 0.85;
letter-spacing: 0.03em;
}
.rss-error {
font-size: 10px;
color: #c66;
opacity: 0.8;
cursor: help;
}
.rss-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 17rem;
overflow-y: auto;
}
.rss-item {
display: flex;
align-items: baseline;
gap: 0.45rem;
font-size: 13px;
line-height: 1.4;
padding: 0.15rem 0;
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
}
.rss-source {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.5;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 2px;
padding: 0 0.25rem;
flex-shrink: 0;
line-height: 1.4;
}
.rss-link {
flex: 1;
color: inherit;
text-decoration: none;
letter-spacing: 0.01em;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
.rss-date {
font-size: 11px;
opacity: 0.6;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,518 @@
<script setup>
// TodoBoard fixed widget at bottom-right, above EphemeralNotes.
// Three day-columns side by side; lateral nav with / / today.
// Click a task to open a detail popup (overlay) for editing.
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useTodos } from '../composables/useTodos.js'
import { useExpandable } from '../composables/useExpandable.js'
const { tasks, error, fetchRange, createTask, updateTask, deleteTask } = useTodos()
const { expanded, toggle: toggleExpand, collapse: collapseExpand } = useExpandable()
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function shiftDate(dateStr, days) {
const d = new Date(dateStr + 'T12:00:00') // noon to avoid DST/timezone edge cases
d.setDate(d.getDate() + days)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function labelForDate(dateStr) {
const today = todayStr()
if (dateStr === today) return "aujourd'hui"
if (dateStr === shiftDate(today, -1)) return 'hier'
if (dateStr === shiftDate(today, 1)) return 'demain'
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })
}
const center = ref(todayStr())
const isToday = computed(() => center.value === todayStr())
const days = computed(() => [shiftDate(center.value, -1), center.value, shiftDate(center.value, 1)])
const tasksByDay = computed(() => {
const map = {}
for (const d of days.value) map[d] = []
for (const t of tasks.value) {
if (map[t.due_date]) map[t.due_date].push(t)
}
return map
})
async function refresh() {
await fetchRange(days.value[0], days.value[2])
}
watch(days, refresh)
onMounted(refresh)
// Periodic refresh so rollover is reflected without manual reload
let refreshTimer = null
onMounted(() => {
refreshTimer = setInterval(refresh, 5 * 60 * 1000)
})
onBeforeUnmount(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
function shift(n) {
center.value = shiftDate(center.value, n)
}
function recenterToday() {
center.value = todayStr()
}
const newTitleByDay = ref({})
async function addTask(day) {
const title = (newTitleByDay.value[day] || '').trim()
if (!title) return
await createTask({ title, due_date: day })
newTitleByDay.value[day] = ''
}
// Detail editing popup
const selected = ref(null)
function selectTask(t) {
selected.value = { ...t }
}
function closeDetail() {
selected.value = null
}
async function saveSelected() {
if (!selected.value) return
await updateTask(selected.value.id, {
title: selected.value.title,
details: selected.value.details,
due_date: selected.value.due_date,
done: selected.value.done,
})
closeDetail()
}
async function deleteSelected() {
if (!selected.value) return
await deleteTask(selected.value.id)
closeDetail()
}
async function toggleDone(t) {
await updateTask(t.id, { done: !t.done })
}
function onKey(e) {
if (selected.value && e.key === 'Escape') {
e.preventDefault()
closeDetail()
}
}
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<div>
<div v-if="expanded" class="widget-backdrop" @click="collapseExpand"></div>
<div class="todo-widget" :class="{ expanded }">
<div class="todo-header">
<span class="todo-label">todo</span>
<div class="todo-header-right">
<button type="button" class="nav-btn" @click="shift(-1)" title="jour précédent"></button>
<button v-if="!isToday" type="button" class="nav-btn" @click="recenterToday" title="aujourd'hui"></button>
<button type="button" class="nav-btn" @click="shift(1)" title="jour suivant"></button>
<button type="button" class="expand-btn" @click="toggleExpand" :title="expanded ? 'réduire' : 'agrandir'">
{{ expanded ? '⤡' : '⤢' }}
</button>
</div>
</div>
<div v-if="error" class="todo-error">backend: {{ error }}</div>
<div class="todo-columns">
<div v-for="day in days" :key="day" class="todo-col">
<div class="col-header">
<span class="col-day">{{ labelForDate(day) }}</span>
<span class="col-date">{{ day.slice(5) }}</span>
</div>
<div class="col-tasks">
<div
v-for="t in tasksByDay[day]"
:key="t.id"
class="task"
:class="{ done: t.done }"
@click="selectTask(t)"
>
<input
type="checkbox"
:checked="t.done"
@click.stop
@change="toggleDone(t)"
/>
<span class="task-title">{{ t.title }}</span>
<span v-if="t.details" class="task-marker" title="détails">·</span>
</div>
</div>
<form class="add-task" @submit.prevent="addTask(day)">
<input
v-model="newTitleByDay[day]"
type="text"
placeholder="+"
spellcheck="false"
/>
</form>
</div>
</div>
<!-- Detail popup -->
<Teleport to="body">
<div v-if="selected" class="detail-overlay" @click.self="closeDetail">
<div class="detail-panel">
<header class="detail-header">
<span>détail</span>
<button class="close-btn" @click="closeDetail">×</button>
</header>
<label class="detail-field">
<span>titre</span>
<input v-model="selected.title" type="text" />
</label>
<label class="detail-field">
<span>date</span>
<input v-model="selected.due_date" type="date" />
</label>
<label class="detail-field">
<span>détails</span>
<textarea v-model="selected.details" rows="6" spellcheck="false"></textarea>
</label>
<label class="detail-checkbox">
<input v-model="selected.done" type="checkbox" />
<span>terminé</span>
</label>
<div class="detail-actions">
<button class="action delete" @click="deleteSelected">supprimer</button>
<button class="action save" @click="saveSelected">enregistrer</button>
</div>
</div>
</div>
</Teleport>
</div>
</div>
</template>
<style scoped>
.todo-widget {
position: fixed;
bottom: 2rem;
right: 1.5rem;
width: 480px;
display: flex;
flex-direction: column;
gap: 0.5rem;
opacity: 0.75;
transition: opacity 0.25s;
color: inherit;
z-index: 12;
font-family: 'Courier New', monospace;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
}
.todo-widget:hover,
.todo-widget:focus-within {
opacity: 1;
}
.todo-widget.expanded {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
bottom: auto;
right: auto;
width: min(750px, 90vw);
z-index: 100;
opacity: 1;
background: rgba(var(--bg-r, 15), var(--bg-g, 15), var(--bg-b, 20), 0.92);
border-radius: 8px;
padding: 1rem 1.2rem;
}
.todo-widget.expanded .col-tasks {
max-height: 60vh;
}
.widget-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(2px);
z-index: 99;
}
.todo-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.todo-header-right {
display: flex;
align-items: center;
gap: 0.3rem;
}
.expand-btn {
background: transparent;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 2px;
padding: 0 0.35rem;
color: inherit;
font-size: 13px;
line-height: 1.3;
cursor: pointer;
opacity: 0.6;
}
.expand-btn:hover {
opacity: 1;
border-color: rgba(128, 128, 128, 0.6);
}
.todo-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 15px;
font-style: italic;
opacity: 0.9;
letter-spacing: 0.03em;
}
.nav-btn {
background: transparent;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 2px;
padding: 0.15rem 0.55rem;
color: inherit;
font-family: inherit;
font-size: 14px;
line-height: 1.2;
cursor: pointer;
opacity: 0.75;
}
.nav-btn:hover {
opacity: 1;
border-color: rgba(128, 128, 128, 0.6);
}
.todo-reset {
display: flex;
justify-content: center;
}
.todo-error {
font-size: 11px;
opacity: 0.7;
color: #c66;
}
.todo-columns {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.5rem;
}
.todo-col {
display: flex;
flex-direction: column;
gap: 0.3rem;
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 3px;
padding: 0.4rem 0.45rem;
min-height: 130px;
}
.col-header {
display: flex;
align-items: baseline;
justify-content: space-between;
padding-bottom: 0.25rem;
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
}
.col-day {
font-family: 'Georgia', serif;
font-style: italic;
font-size: 13px;
opacity: 0.85;
}
.col-date {
font-size: 11px;
opacity: 0.45;
}
.col-tasks {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-height: 40px;
max-height: 240px;
overflow-y: auto;
border-top: 1px solid rgba(128, 128, 128, 0.15);
padding-top: 0.4rem;
}
.task {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.35rem;
border-radius: 2px;
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
.task:hover {
background: rgba(128, 128, 128, 0.1);
}
.task.done .task-title {
text-decoration: line-through;
opacity: 0.4;
}
.task input[type='checkbox'] {
appearance: none;
-webkit-appearance: none;
width: 14px;
height: 14px;
border: 1px solid rgba(180, 180, 180, 0.55);
border-radius: 3px;
background: transparent;
cursor: pointer;
flex-shrink: 0;
position: relative;
transition: all 0.15s;
}
.task input[type='checkbox']:hover {
border-color: rgba(180, 180, 180, 0.85);
}
.task input[type='checkbox']:checked {
background: rgba(140, 200, 160, 0.5);
border-color: rgba(140, 200, 160, 0.85);
}
.task input[type='checkbox']:checked::after {
content: '';
position: absolute;
left: 3px;
top: 0px;
width: 4px;
height: 8px;
border-right: 1.5px solid rgba(15, 15, 20, 0.95);
border-bottom: 1.5px solid rgba(15, 15, 20, 0.95);
transform: rotate(45deg);
}
.task-title {
flex: 1;
word-break: break-word;
line-height: 1.3;
}
.task-marker {
opacity: 0.4;
font-size: 13px;
flex-shrink: 0;
}
.add-task input {
width: 100%;
background: transparent;
border: 1px dashed rgba(128, 128, 128, 0.25);
border-radius: 2px;
padding: 0.3rem 0.45rem;
font-family: inherit;
font-size: 13px;
color: inherit;
outline: none;
}
.add-task input:focus {
border-style: solid;
border-color: rgba(128, 128, 128, 0.55);
}
.add-task input::placeholder {
opacity: 0.4;
}
/* Detail popup (teleported to body) */
.detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
z-index: 110;
font-family: 'Courier New', monospace;
color: rgba(230, 225, 215, 0.95);
}
.detail-panel {
background: rgba(15, 15, 20, 0.97);
border: 1px solid rgba(180, 180, 180, 0.35);
border-radius: 5px;
padding: 1rem 1.1rem;
width: min(380px, 90vw);
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
font-family: 'Georgia', serif;
font-style: italic;
font-size: 14px;
opacity: 0.85;
}
.close-btn {
background: transparent;
border: 1px solid rgba(180, 180, 180, 0.35);
border-radius: 2px;
padding: 0.1rem 0.45rem;
color: inherit;
font-family: inherit;
font-size: 13px;
cursor: pointer;
opacity: 0.75;
}
.close-btn:hover {
opacity: 1;
}
.detail-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 11px;
opacity: 0.85;
}
.detail-field input,
.detail-field textarea {
font-family: inherit;
font-size: 13px;
background: transparent;
border: 1px solid rgba(180, 180, 180, 0.35);
border-radius: 2px;
padding: 0.4rem 0.55rem;
color: inherit;
outline: none;
resize: vertical;
}
.detail-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 12px;
cursor: pointer;
}
.detail-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.3rem;
}
.action {
flex: 1;
background: transparent;
border: 1px solid rgba(180, 180, 180, 0.35);
border-radius: 3px;
padding: 0.4rem 0.7rem;
color: inherit;
font-family: inherit;
font-size: 12px;
cursor: pointer;
opacity: 0.8;
}
.action:hover {
opacity: 1;
}
.action.delete {
color: #e88;
border-color: rgba(232, 136, 136, 0.4);
}
.action.save {
border-color: rgba(140, 200, 160, 0.5);
}
</style>

View File

@ -0,0 +1,63 @@
// useAuth — simple token-based auth for private widgets (notes, todo).
// Token stored in localStorage. All authenticated API calls must use getHeaders().
import { ref } from 'vue'
const BACKEND = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8082'
const STORAGE_KEY = 'homepage:token'
const authenticated = ref(false)
const token = ref('')
// Restore from localStorage and verify token is still valid
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
token.value = saved
// Verify asynchronously — authenticated stays false until confirmed
fetch(`${BACKEND}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: saved }),
})
.then((r) => {
if (r.ok) {
authenticated.value = true
} else {
token.value = ''
localStorage.removeItem(STORAGE_KEY)
}
})
.catch(() => {
// Backend unreachable — keep token, try later
authenticated.value = true
})
}
async function login(pwd) {
const r = await fetch(`${BACKEND}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: pwd }),
})
if (!r.ok) {
throw new Error('invalid')
}
token.value = pwd
authenticated.value = true
localStorage.setItem(STORAGE_KEY, pwd)
}
function logout() {
token.value = ''
authenticated.value = false
localStorage.removeItem(STORAGE_KEY)
}
function getHeaders() {
if (!token.value) return {}
return { Authorization: `Bearer ${token.value}` }
}
export function useAuth() {
return { authenticated, token, login, logout, getHeaders }
}

View File

@ -0,0 +1,28 @@
// useExpandable — toggle between compact and expanded widget state.
// Escape key collapses. Returns { expanded, toggle, collapse }.
import { ref, onMounted, onBeforeUnmount } from 'vue'
export function useExpandable() {
const expanded = ref(false)
function toggle() {
expanded.value = !expanded.value
}
function collapse() {
expanded.value = false
}
function onKey(e) {
if (expanded.value && e.key === 'Escape') {
e.preventDefault()
collapse()
}
}
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
return { expanded, toggle, collapse }
}

View File

@ -0,0 +1,48 @@
// useNotes — client for the homepage-backend notes API.
// Persistent server-side storage (shared across devices).
import { ref } from 'vue'
import { useAuth } from './useAuth.js'
const BACKEND = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8082'
const { getHeaders } = useAuth()
const notes = ref([])
const loading = ref(false)
const error = ref(null)
async function fetchNotes() {
loading.value = true
try {
const r = await fetch(`${BACKEND}/api/notes`, { headers: getHeaders() })
if (!r.ok) throw new Error(`HTTP ${r.status}`)
notes.value = await r.json()
error.value = null
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function createNote(text) {
const r = await fetch(`${BACKEND}/api/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getHeaders() },
body: JSON.stringify({ text }),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const created = await r.json()
notes.value.unshift(created)
return created
}
async function deleteNote(id) {
const r = await fetch(`${BACKEND}/api/notes/${id}`, { method: 'DELETE', headers: getHeaders() })
if (!r.ok && r.status !== 204) throw new Error(`HTTP ${r.status}`)
notes.value = notes.value.filter((n) => n.id !== id)
}
export function useNotes() {
return { notes, loading, error, fetchNotes, createNote, deleteNote }
}

View File

@ -0,0 +1,88 @@
// Paris timezone helpers + simulated/live clock state.
//
// State is module-level (singleton) so all components share the same
// `simDate` / `isLive` without prop drilling.
import { ref, computed } from 'vue'
export const TZ = 'Europe/Paris'
export function parisComponents(d) {
const parts = new Intl.DateTimeFormat('fr-FR', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(d)
const get = (t) => parseInt(parts.find((x) => x.type === t).value, 10)
return {
year: get('year'),
month: get('month'),
day: get('day'),
hour: get('hour'),
minute: get('minute'),
second: get('second'),
}
}
export function parisDateStr(d) {
const p = parisComponents(d)
const pad = (n) => String(n).padStart(2, '0')
return `${p.year}-${pad(p.month)}-${pad(p.day)}`
}
export function toInputStr(d) {
const p = parisComponents(d)
const pad = (n) => String(n).padStart(2, '0')
return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)}`
}
export function getParisOffsetMs(d) {
const fmt = (tz) =>
new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(d)
const [uh, um] = fmt('UTC').split(':').map(Number)
const [ph, pm] = fmt(TZ).split(':').map(Number)
return ((ph * 60 + pm) - (uh * 60 + um)) * 60000
}
export function parisStrToDate(val) {
const naive = new Date(val + 'Z')
return new Date(naive.getTime() - getParisOffsetMs(naive))
}
// --- Shared singleton state ---
const simDate = ref(null)
const isLive = ref(true)
function getNow() {
return isLive.value ? new Date() : simDate.value
}
function setSimulated(date) {
simDate.value = date
isLive.value = false
}
function goLive() {
simDate.value = null
isLive.value = true
}
export function useClockState() {
return {
simDate: computed(() => simDate.value),
isLive: computed(() => isLive.value),
getNow,
setSimulated,
goLive,
}
}

96
src/composables/useRSS.js Normal file
View File

@ -0,0 +1,96 @@
// useRSS — fetch RSS/Atom feeds via the backend proxy and parse them
// in the browser using DOMParser. Handles RSS 2.0, RSS 1.0 (RDF), Atom.
import { ref } from 'vue'
const BACKEND = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8082'
function pickText(el, ...selectors) {
for (const sel of selectors) {
const node = el.querySelector(sel)
if (node?.textContent) return node.textContent.trim()
}
return ''
}
function pickLink(el) {
// Atom : <link href="..."/>
const atomLink = [...el.querySelectorAll('link')].find((l) => l.getAttribute('href'))
if (atomLink) return atomLink.getAttribute('href')
// RSS 2.0 / 1.0 : <link>text</link>
const rssLink = el.querySelector('link')
return rssLink?.textContent?.trim() || ''
}
export function parseFeed(xmlText, label) {
// Some servers return HTML error pages instead of XML
let trimmed = xmlText.trimStart()
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
throw new Error(`${label || 'feed'}: got HTML instead of XML`)
}
// Some feeds are malformed with duplicate closing tags — truncate at first valid end
for (const end of ['</rss>', '</feed>', '</rdf:RDF>']) {
const idx = trimmed.indexOf(end)
if (idx !== -1) {
trimmed = trimmed.slice(0, idx + end.length)
break
}
}
const doc = new DOMParser().parseFromString(trimmed, 'application/xml')
const err = doc.querySelector('parsererror')
if (err) {
throw new Error(`${label || 'feed'}: XML parse error — ${err.textContent.slice(0, 80)}`)
}
// Items can be <item> (RSS) or <entry> (Atom)
const nodes = [...doc.querySelectorAll('item, entry')]
return nodes.map((n) => ({
title: pickText(n, 'title'),
link: pickLink(n),
date: pickText(n, 'pubDate', 'published', 'updated', 'date'),
}))
}
async function fetchOne(feed) {
const r = await fetch(`${BACKEND}/api/rss?url=${encodeURIComponent(feed.url)}`)
if (!r.ok) throw new Error(`${feed.label || feed.url}: HTTP ${r.status}`)
const text = await r.text()
return parseFeed(text, feed.label || feed.url).map((it) => ({ ...it, source: feed.label || '' }))
}
export function useRSS() {
const items = ref([])
const loading = ref(false)
const error = ref(null)
async function load(url) {
return loadMultiple([{ url, label: '' }])
}
async function loadMultiple(feeds) {
loading.value = true
error.value = null
try {
const results = await Promise.allSettled(feeds.map(fetchOne))
const merged = []
const errors = []
for (let i = 0; i < results.length; i++) {
if (results[i].status === 'fulfilled') {
merged.push(...results[i].value)
} else {
errors.push(results[i].reason.message)
}
}
merged.sort((a, b) => {
const da = new Date(a.date).getTime() || 0
const db = new Date(b.date).getTime() || 0
return db - da
})
items.value = merged
if (errors.length) error.value = errors.join(' · ')
} finally {
loading.value = false
}
}
return { items, loading, error, load, loadMultiple }
}

View File

@ -0,0 +1,63 @@
// useTodos — small client for the homepage-backend tasks API.
//
// Module-level reactive state shared across components.
import { ref } from 'vue'
import { useAuth } from './useAuth.js'
const BACKEND = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8082'
const { getHeaders } = useAuth()
const tasks = ref([])
const loading = ref(false)
const error = ref(null)
async function fetchRange(from, to) {
loading.value = true
try {
const r = await fetch(`${BACKEND}/api/tasks?from=${from}&to=${to}`, { headers: getHeaders() })
if (!r.ok) throw new Error(`HTTP ${r.status}`)
tasks.value = await r.json()
error.value = null
} catch (e) {
error.value = e.message
tasks.value = []
} finally {
loading.value = false
}
}
async function createTask(payload) {
const r = await fetch(`${BACKEND}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getHeaders() },
body: JSON.stringify(payload),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const created = await r.json()
tasks.value.push(created)
return created
}
async function updateTask(id, patch) {
const r = await fetch(`${BACKEND}/api/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...getHeaders() },
body: JSON.stringify(patch),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const updated = await r.json()
const idx = tasks.value.findIndex((t) => t.id === id)
if (idx >= 0) tasks.value[idx] = updated
return updated
}
async function deleteTask(id) {
const r = await fetch(`${BACKEND}/api/tasks/${id}`, { method: 'DELETE', headers: getHeaders() })
if (!r.ok && r.status !== 204) throw new Error(`HTTP ${r.status}`)
tasks.value = tasks.value.filter((t) => t.id !== id)
}
export function useTodos() {
return { tasks, loading, error, fetchRange, createTask, updateTask, deleteTask }
}

View File

@ -0,0 +1,178 @@
// Weather state for Lille (Open-Meteo).
// Module-level singleton: targets are fetched, smoothed values are
// interpolated each frame and read by the canvas.
import { reactive } from 'vue'
import { TZ, parisComponents } from './useParisTime.js'
const LSPEED = 0.04 // interpolation speed per frame (~60fps → ~1.5s)
const lerp = (a, b, t) => a + (b - a) * t
const weatherTarget = {
temp: null,
cloudcover: 0,
precip: 0,
pressure: 1013,
precipForecast: [],
tempForecast: [],
cloudForecast: [],
gustForecast: [],
sunriseHour: 6,
sunsetHour: 20,
}
// Smoothed (drawn) values — reactive so the legend can read them.
const weatherSmooth = reactive({
temp: null,
cloudcover: 0,
precip: 0,
pressure: 1013,
precipForecast: Array(24).fill(0),
tempForecast: Array(24).fill(null),
cloudForecast: Array(24).fill(0),
gustForecast: Array(24).fill(0),
sunriseHour: 6,
sunsetHour: 20,
})
let lastFetchKey = null
let lastFetchTs = 0
export function resetFetchCache() {
lastFetchKey = null
}
export function smoothWeather() {
const s = weatherSmooth
const tg = weatherTarget
if (tg.temp !== null) {
s.temp = s.temp === null ? tg.temp : lerp(s.temp, tg.temp, LSPEED)
}
s.cloudcover = lerp(s.cloudcover, tg.cloudcover, LSPEED)
s.precip = lerp(s.precip, tg.precip, LSPEED)
s.pressure = lerp(s.pressure, tg.pressure, LSPEED)
s.sunriseHour = lerp(s.sunriseHour, tg.sunriseHour, LSPEED)
s.sunsetHour = lerp(s.sunsetHour, tg.sunsetHour, LSPEED)
for (let h = 0; h < 24; h++) {
s.precipForecast[h] = lerp(s.precipForecast[h] || 0, tg.precipForecast[h] || 0, LSPEED)
s.cloudForecast[h] = lerp(s.cloudForecast[h] || 0, tg.cloudForecast[h] || 0, LSPEED)
s.gustForecast[h] = lerp(s.gustForecast[h] || 0, tg.gustForecast[h] || 0, LSPEED)
if (tg.tempForecast[h] !== null && tg.tempForecast[h] !== undefined) {
s.tempForecast[h] =
s.tempForecast[h] === null
? tg.tempForecast[h]
: lerp(s.tempForecast[h], tg.tempForecast[h], LSPEED)
}
}
}
export async function fetchWeather(d) {
const p = parisComponents(d)
const pad = (n) => String(n).padStart(2, '0')
const dateStr = `${p.year}-${pad(p.month)}-${pad(p.day)}`
const key = `${dateStr}T${pad(p.hour)}`
const now10 = Date.now()
if (key === lastFetchKey && now10 - lastFetchTs < 10 * 60 * 1000) return
lastFetchKey = key
lastFetchTs = now10
const diffDays = (d.getTime() - Date.now()) / 86400000
const tomorrow = new Date(d.getTime() + 86400000)
const tp = parisComponents(tomorrow)
const tDateStr = `${tp.year}-${pad(tp.month)}-${pad(tp.day)}`
try {
let url, hourly, timeArr, daily
if (diffDays > 1) {
url =
`https://api.open-meteo.com/v1/forecast?latitude=50.633&longitude=3.067` +
`&hourly=temperature_2m,cloudcover,precipitation,surface_pressure,windgusts_10m` +
`&daily=sunrise,sunset` +
`&start_date=${dateStr}&end_date=${tDateStr}&timezone=${encodeURIComponent(TZ)}`
const r = await fetch(url)
const dd = await r.json()
hourly = dd.hourly
timeArr = dd.hourly.time
daily = dd.daily
weatherTarget.temp = hourly.temperature_2m?.[p.hour] ?? null
weatherTarget.cloudcover = hourly.cloudcover?.[p.hour] ?? 0
weatherTarget.precip = hourly.precipitation?.[p.hour] ?? 0
weatherTarget.pressure = hourly.surface_pressure?.[p.hour] ?? 1013
} else if (diffDays < -1) {
url =
`https://archive-api.open-meteo.com/v1/archive?latitude=50.633&longitude=3.067` +
`&hourly=temperature_2m,cloudcover,precipitation,surface_pressure,windgusts_10m` +
`&daily=sunrise,sunset` +
`&start_date=${dateStr}&end_date=${tDateStr}&timezone=${encodeURIComponent(TZ)}`
const r = await fetch(url)
const dd = await r.json()
hourly = dd.hourly
timeArr = dd.hourly.time
daily = dd.daily
weatherTarget.temp = hourly.temperature_2m?.[p.hour] ?? null
weatherTarget.cloudcover = hourly.cloudcover?.[p.hour] ?? 0
weatherTarget.precip = hourly.precipitation?.[p.hour] ?? 0
weatherTarget.pressure = hourly.surface_pressure?.[p.hour] ?? 1013
} else {
url =
`https://api.open-meteo.com/v1/forecast?latitude=50.633&longitude=3.067` +
`&current_weather=true` +
`&hourly=temperature_2m,cloudcover,precipitation,surface_pressure,windgusts_10m` +
`&daily=sunrise,sunset` +
`&forecast_days=3&timezone=${encodeURIComponent(TZ)}`
const r = await fetch(url)
const dd = await r.json()
hourly = dd.hourly
timeArr = dd.hourly.time
daily = dd.daily
weatherTarget.temp =
dd.current_weather?.temperature ?? hourly.temperature_2m?.[p.hour] ?? null
weatherTarget.cloudcover = hourly.cloudcover?.[p.hour] ?? 0
weatherTarget.precip = hourly.precipitation?.[p.hour] ?? 0
weatherTarget.pressure = hourly.surface_pressure?.[p.hour] ?? 1013
}
if (daily?.sunrise?.[0]) {
const sr = daily.sunrise[0].split('T')[1] || daily.sunrise[0]
const [sh, sm] = sr.split(':').map(Number)
weatherTarget.sunriseHour = sh + sm / 60
}
if (daily?.sunset?.[0]) {
const ss = daily.sunset[0].split('T')[1] || daily.sunset[0]
const [sh, sm] = ss.split(':').map(Number)
weatherTarget.sunsetHour = sh + sm / 60
}
const currentTimeStr = `${dateStr}T${pad(p.hour)}:00`
const startIdx = timeArr ? timeArr.indexOf(currentTimeStr) : -1
if (startIdx >= 0) {
weatherTarget.precipForecast = Array.from(
{ length: 24 },
(_, i) => hourly.precipitation?.[startIdx + i] ?? 0
)
weatherTarget.tempForecast = Array.from(
{ length: 24 },
(_, i) => hourly.temperature_2m?.[startIdx + i] ?? null
)
weatherTarget.cloudForecast = Array.from(
{ length: 24 },
(_, i) => hourly.cloudcover?.[startIdx + i] ?? 0
)
weatherTarget.gustForecast = Array.from(
{ length: 24 },
(_, i) => hourly.windgusts_10m?.[startIdx + i] ?? 0
)
}
} catch (e) {
console.warn('weather fetch failed', e)
}
}
export function useWeather() {
return {
weatherSmooth,
fetchWeather,
smoothWeather,
resetFetchCache,
}
}

89
src/data/feeds.json Normal file
View File

@ -0,0 +1,89 @@
{
"feeds": [
{
"label": "zwindler",
"name": "Zwindler's Reflection",
"url": "https://blog.zwindler.fr/index.xml"
},
{
"label": "jvns",
"name": "Julia Evans",
"url": "https://jvns.ca/atom.xml"
},
{
"label": "vicki",
"name": "Vicki Boykis",
"url": "https://vickiboykis.com/index.xml"
},
{
"label": "eli",
"name": "Eli Bendersky",
"url": "https://eli.thegreenplace.net/feeds/all.atom.xml"
},
{
"label": "noted",
"name": "Noted - Self-Hosting",
"url": "https://noted.lol/rss/"
},
{
"label": "mcorbin",
"name": "mcorbin",
"url": "https://www.mcorbin.fr/feed.xml"
},
{
"label": "seb666",
"name": "Seboss666",
"url": "https://blog.seboss666.info/feed"
},
{
"label": "iximiuz",
"name": "Ivan Velichko",
"url": "https://iximiuz.com/feed.rss"
},
{
"label": "chollinger",
"name": "Christian Hollinger",
"url": "https://chollinger.com/blog/index.xml"
},
{
"label": "rmoff",
"name": "Robin Moffatt",
"url": "https://rmoff.net/index.xml"
},
{
"label": "ssp",
"name": "Simon Spati",
"url": "https://www.ssp.sh/index.xml"
},
{
"label": "geerling",
"name": "Jeff Geerling",
"url": "https://www.jeffgeerling.com/blog.xml"
},
{
"label": "dataguy",
"name": "Confessions of a Data Guy",
"url": "https://www.confessionsofadataguy.com/feed/"
},
{
"label": "fasterth",
"name": "fasterthanlime",
"url": "https://fasterthanli.me/index.xml"
},
{
"label": "bitfield",
"name": "Bitfield Consulting (Go)",
"url": "https://bitfieldconsulting.com/posts?format=rss"
},
{
"label": "kobzol",
"name": "Kobzol (Rust)",
"url": "https://kobzol.github.io/feed.xml"
},
{
"label": "momjian",
"name": "Bruce Momjian (PostgreSQL)",
"url": "https://momjian.us/main/rss/pgblog.xml"
}
]
}

63
src/data/links.json Normal file
View File

@ -0,0 +1,63 @@
{
"groups": [
{
"title": "site",
"items": [
{
"label": "accueil",
"url": "https://nathan.leclercq.spacesheep.ovh/"
},
{
"label": "projets",
"url": "https://nathan.leclercq.spacesheep.ovh/projets/"
},
{
"label": "blog",
"url": "https://nathan.leclercq.spacesheep.ovh/posts/"
},
{
"label": "about",
"url": "https://nathan.leclercq.spacesheep.ovh/about/"
},
{
"label": "cv",
"url": "https://nathan.leclercq.spacesheep.ovh/cv_fr/"
}
]
},
{
"title": "services",
"items": [
{
"label": "gitea",
"url": "https://git.spacesheep.ovh/"
},
{
"label": "vaultwarden",
"url": "https://vaultwarden.spacesheep.ovh/"
}
]
},
{
"title": "social",
"items": [
{
"label": "github",
"url": "https://github.com/nathanlq"
},
{
"label": "linkedin",
"url": "https://www.linkedin.com/in/nathan-leclercq-51292014b/"
},
{
"label": "email",
"url": "mailto:nathan.leclercq9@protonmail.com"
}
]
},
{
"title": "shortcuts",
"items": []
}
]
}

6
src/main.js Normal file
View File

@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './styles/global.css'
createApp(App).use(router).mount('#app')

17
src/router/index.js Normal file
View File

@ -0,0 +1,17 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
// Ajouter ici de nouvelles pages :
// { path: '/about', name: 'about', component: () => import('../views/AboutView.vue') },
],
})
export default router

42
src/styles/global.css Normal file
View File

@ -0,0 +1,42 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body,
#app {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Courier New', monospace;
}
/* --- minimal scrollbars to match the dashboard aesthetic --- */
* {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.28);
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(128, 128, 128, 0.55);
}
*::-webkit-scrollbar-corner {
background: transparent;
}

16
src/utils/astro.js Normal file
View File

@ -0,0 +1,16 @@
// Astro helpers + city list
// Extracted as-is from clock-lille.html
export const CITIES = [
{ label: 'LIL', lon: 3.067, lat: 50.63 },
{ label: 'TYO', lon: 139.69, lat: 35.68 },
{ label: 'NYC', lon: -74.006, lat: 40.71 },
]
export function solarDeclination(doy) {
return (-23.45 * Math.cos((2 * Math.PI / 365) * (doy + 10)) * Math.PI) / 180
}
export function dayOfYear(d) {
return Math.floor((d - new Date(d.getFullYear(), 0, 0)) / 86400000)
}

62
src/utils/colors.js Normal file
View File

@ -0,0 +1,62 @@
// Color helpers (time of day, temperature)
// Extracted as-is from clock-lille.html
export function timeColor(sh, alpha) {
const h = ((sh % 24) + 24) % 24
let r, g, b
if (h < 6) {
const t = h / 6
r = 20 + t * 210
g = 30 + t * 100
b = 80 - t * 30
} else if (h < 12) {
const t = (h - 6) / 6
r = 230 - t * 30
g = 130 + t * 90
b = 50 + t * 100
} else if (h < 18) {
const t = (h - 12) / 6
r = 200 + t * 50
g = 220 - t * 90
b = 150 - t * 100
} else {
const t = (h - 18) / 6
r = 250 - t * 230
g = 130 - t * 100
b = 50 + t * 30
}
return `rgba(${Math.round(r)},${Math.round(g)},${Math.round(b)},${alpha})`
}
export function tempColor(temp, alpha) {
if (temp === null) return `rgba(128,128,128,${alpha})`
const t = Math.max(0, Math.min(1, (temp + 10) / 50))
let r, g, b
if (t < 0.2) {
const u = t / 0.2
r = Math.round(120 - u * 80)
g = Math.round(u * 20)
b = Math.round(180 + u * 75)
} else if (t < 0.4) {
const u = (t - 0.2) / 0.2
r = Math.round(40 - u * 40)
g = Math.round(20 + u * 180)
b = Math.round(255 - u * 60)
} else if (t < 0.6) {
const u = (t - 0.4) / 0.2
r = Math.round(u * 200)
g = Math.round(200 + u * 55)
b = Math.round(195 - u * 195)
} else if (t < 0.8) {
const u = (t - 0.6) / 0.2
r = Math.round(200 + u * 40)
g = Math.round(255 - u * 130)
b = 0
} else {
const u = (t - 0.8) / 0.2
r = Math.round(240 + u * 15)
g = Math.round(125 - u * 105)
b = 0
}
return `rgba(${Math.round(r)},${Math.round(g)},${Math.round(b)},${alpha})`
}

146
src/views/HomeView.vue Normal file
View File

@ -0,0 +1,146 @@
<script setup>
import { ref } from 'vue'
import { useAuth } from '../composables/useAuth.js'
import ClockCanvas from '../components/ClockCanvas.vue'
import ClockControls from '../components/ClockControls.vue'
import ClockLegend from '../components/ClockLegend.vue'
import NavPanel from '../components/NavPanel.vue'
import PomodoroTimer from '../components/PomodoroTimer.vue'
import RSSFeed from '../components/RSSFeed.vue'
import EphemeralNotes from '../components/EphemeralNotes.vue'
import QuickSearch from '../components/QuickSearch.vue'
import TodoBoard from '../components/TodoBoard.vue'
const { authenticated, login, logout } = useAuth()
const showLogin = ref(false)
const pwd = ref('')
const loginError = ref(false)
async function submit() {
loginError.value = false
try {
await login(pwd.value)
pwd.value = ''
showLogin.value = false
} catch {
loginError.value = true
}
}
</script>
<template>
<ClockCanvas />
<NavPanel />
<ClockLegend />
<PomodoroTimer />
<RSSFeed />
<TodoBoard v-if="authenticated" />
<EphemeralNotes v-if="authenticated" />
<ClockControls />
<QuickSearch />
<!-- Login toggle -->
<button
v-if="!authenticated"
class="auth-btn"
title="connexion"
@click="showLogin = !showLogin"
>🔒</button>
<button
v-else
class="auth-btn"
title="déconnexion"
@click="logout"
>🔓</button>
<!-- Login prompt -->
<div v-if="showLogin" class="login-overlay" @click.self="showLogin = false">
<form class="login-box" @submit.prevent="submit">
<span class="login-label">token</span>
<input
v-model="pwd"
type="password"
class="login-input"
placeholder="coller le token"
autofocus
spellcheck="false"
/>
<button type="submit" class="login-submit"></button>
<span v-if="loginError" class="login-err">invalide</span>
</form>
</div>
</template>
<style scoped>
.auth-btn {
position: fixed;
bottom: 0.7rem;
left: 50%;
transform: translateX(-50%);
background: transparent;
border: none;
font-size: 14px;
cursor: pointer;
opacity: 0.25;
transition: opacity 0.2s;
z-index: 10;
}
.auth-btn:hover {
opacity: 0.7;
}
.login-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
font-family: 'Courier New', monospace;
}
.login-box {
display: flex;
align-items: center;
gap: 0.6rem;
background: rgba(20, 20, 25, 0.9);
border: 1px solid rgba(180, 180, 180, 0.35);
border-radius: 4px;
padding: 0.7rem 1rem;
color: rgba(230, 225, 215, 0.95);
}
.login-label {
font-size: 13px;
opacity: 0.6;
font-style: italic;
}
.login-input {
font-family: inherit;
font-size: 14px;
background: transparent;
border: 1px solid rgba(180, 180, 180, 0.35);
border-radius: 2px;
padding: 0.35rem 0.6rem;
color: inherit;
outline: none;
width: 240px;
}
.login-input:focus {
border-color: rgba(180, 180, 180, 0.7);
}
.login-submit {
background: transparent;
border: 1px solid rgba(180, 180, 180, 0.35);
border-radius: 2px;
padding: 0.3rem 0.6rem;
color: inherit;
font-family: inherit;
font-size: 14px;
cursor: pointer;
}
.login-err {
font-size: 12px;
color: #e88;
}
</style>

10
vite.config.js Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: true,
port: 5173,
},
})