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:
commit
f795cc48b5
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
27
.env.example
Normal 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
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
.vite
|
||||
.env
|
||||
*.tmp
|
||||
backend/data/
|
||||
.claude/
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal 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
68
Makefile
Normal 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
52
README.md
Normal 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
2
backend/.dockerignore
Normal file
@ -0,0 +1,2 @@
|
||||
*.md
|
||||
*.test
|
||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal 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
3
backend/go.mod
Normal file
@ -0,0 +1,3 @@
|
||||
module homepage-backend
|
||||
|
||||
go 1.23
|
||||
404
backend/main.go
Normal file
404
backend/main.go
Normal 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
717
clock-lille.html
Normal 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`
|
||||
+ `¤t_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
68
docker-compose.yml
Normal 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
13
index.html
Normal 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
17
nginx.conf
Normal 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
1546
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
6
public/favicon.svg
Normal 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
7
src/App.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
388
src/components/ClockCanvas.vue
Normal file
388
src/components/ClockCanvas.vue
Normal 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>
|
||||
112
src/components/ClockControls.vue
Normal file
112
src/components/ClockControls.vue
Normal 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>
|
||||
177
src/components/ClockLegend.vue
Normal file
177
src/components/ClockLegend.vue
Normal 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>
|
||||
197
src/components/EphemeralNotes.vue
Normal file
197
src/components/EphemeralNotes.vue
Normal 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>
|
||||
71
src/components/NavPanel.vue
Normal file
71
src/components/NavPanel.vue
Normal 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>
|
||||
128
src/components/PomodoroTimer.vue
Normal file
128
src/components/PomodoroTimer.vue
Normal 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>
|
||||
115
src/components/QuickSearch.vue
Normal file
115
src/components/QuickSearch.vue
Normal 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
198
src/components/RSSFeed.vue
Normal 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>
|
||||
518
src/components/TodoBoard.vue
Normal file
518
src/components/TodoBoard.vue
Normal 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>
|
||||
63
src/composables/useAuth.js
Normal file
63
src/composables/useAuth.js
Normal 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 }
|
||||
}
|
||||
28
src/composables/useExpandable.js
Normal file
28
src/composables/useExpandable.js
Normal 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 }
|
||||
}
|
||||
48
src/composables/useNotes.js
Normal file
48
src/composables/useNotes.js
Normal 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 }
|
||||
}
|
||||
88
src/composables/useParisTime.js
Normal file
88
src/composables/useParisTime.js
Normal 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
96
src/composables/useRSS.js
Normal 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 }
|
||||
}
|
||||
63
src/composables/useTodos.js
Normal file
63
src/composables/useTodos.js
Normal 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 }
|
||||
}
|
||||
178
src/composables/useWeather.js
Normal file
178
src/composables/useWeather.js
Normal 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` +
|
||||
`¤t_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
89
src/data/feeds.json
Normal 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
63
src/data/links.json
Normal 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
6
src/main.js
Normal 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
17
src/router/index.js
Normal 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
42
src/styles/global.css
Normal 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
16
src/utils/astro.js
Normal 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
62
src/utils/colors.js
Normal 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
146
src/views/HomeView.vue
Normal 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
10
vite.config.js
Normal 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,
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user