From f795cc48b54c1fabb8602b5144e9e950a1579742 Mon Sep 17 00:00:00 2001 From: Nathan Leclercq Date: Thu, 9 Apr 2026 12:49:55 +0200 Subject: [PATCH] =?UTF-8?q?Homepage=20dashboard=20:=20horloge=20anim=C3=A9?= =?UTF-8?q?e,=20todo,=20notes,=20RSS,=20pomodoro,=20recherche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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é) --- .dockerignore | 12 + .env.example | 27 + .gitignore | 9 + .nvmrc | 1 + Dockerfile | 22 + Makefile | 68 ++ README.md | 52 + backend/.dockerignore | 2 + backend/Dockerfile | 15 + backend/go.mod | 3 + backend/main.go | 404 ++++++++ clock-lille.html | 717 +++++++++++++ docker-compose.yml | 68 ++ index.html | 13 + nginx.conf | 17 + package-lock.json | 1546 +++++++++++++++++++++++++++++ package.json | 19 + public/favicon.svg | 6 + src/App.vue | 7 + src/components/ClockCanvas.vue | 388 ++++++++ src/components/ClockControls.vue | 112 +++ src/components/ClockLegend.vue | 177 ++++ src/components/EphemeralNotes.vue | 197 ++++ src/components/NavPanel.vue | 71 ++ src/components/PomodoroTimer.vue | 128 +++ src/components/QuickSearch.vue | 115 +++ src/components/RSSFeed.vue | 198 ++++ src/components/TodoBoard.vue | 518 ++++++++++ src/composables/useAuth.js | 63 ++ src/composables/useExpandable.js | 28 + src/composables/useNotes.js | 48 + src/composables/useParisTime.js | 88 ++ src/composables/useRSS.js | 96 ++ src/composables/useTodos.js | 63 ++ src/composables/useWeather.js | 178 ++++ src/data/feeds.json | 89 ++ src/data/links.json | 63 ++ src/main.js | 6 + src/router/index.js | 17 + src/styles/global.css | 42 + src/utils/astro.js | 16 + src/utils/colors.js | 62 ++ src/views/HomeView.vue | 146 +++ vite.config.js | 10 + 44 files changed, 5927 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/go.mod create mode 100644 backend/main.go create mode 100644 clock-lille.html create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 src/App.vue create mode 100644 src/components/ClockCanvas.vue create mode 100644 src/components/ClockControls.vue create mode 100644 src/components/ClockLegend.vue create mode 100644 src/components/EphemeralNotes.vue create mode 100644 src/components/NavPanel.vue create mode 100644 src/components/PomodoroTimer.vue create mode 100644 src/components/QuickSearch.vue create mode 100644 src/components/RSSFeed.vue create mode 100644 src/components/TodoBoard.vue create mode 100644 src/composables/useAuth.js create mode 100644 src/composables/useExpandable.js create mode 100644 src/composables/useNotes.js create mode 100644 src/composables/useParisTime.js create mode 100644 src/composables/useRSS.js create mode 100644 src/composables/useTodos.js create mode 100644 src/composables/useWeather.js create mode 100644 src/data/feeds.json create mode 100644 src/data/links.json create mode 100644 src/main.js create mode 100644 src/router/index.js create mode 100644 src/styles/global.css create mode 100644 src/utils/astro.js create mode 100644 src/utils/colors.js create mode 100644 src/views/HomeView.vue create mode 100644 vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d14c11c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +.git +.gitignore +.vite +.env +.env.example +docker-compose.yml +backend +*.log +*.md +clock-lille.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e2fe9f2 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0affd6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +dist +.DS_Store +*.log +.vite +.env +*.tmp +backend/data/ +.claude/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b11cd2d --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1270508 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7da16b9 --- /dev/null +++ b/README.md @@ -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` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..712bb17 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,2 @@ +*.md +*.test diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..019c277 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..7dd9e0b --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,3 @@ +module homepage-backend + +go 1.23 diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..8fa1bdd --- /dev/null +++ b/backend/main.go @@ -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) +} diff --git a/clock-lille.html b/clock-lille.html new file mode 100644 index 0000000..d4901aa --- /dev/null +++ b/clock-lille.html @@ -0,0 +1,717 @@ + + + + + +The Clock — Lille + + + + + +
+ + +
+ anneau météo — 24h glissantes +
+ +
+ +
+ +
+ +
+
+
+
+
+ précip. mm/h — extérieur +
+
+
+ temp. + nuages — centre +
+
+
+ rafales km/h — intérieur +
+
+
+
+ +
+ +
+ −10° +
+ 40°C + température +
+
+ 0 → 100 % + + + + nuages (voile) +
+
+ + + + + + précipitations mm/h +
+
+ 30 +
+ 80 km/h + rafales +
+
+ 970 +
+
+ ··· +
+
+ 1040 hPa + pression (centre) +
+
+
+
+
+
+ sec +
+
+
+ min +
+
+
+ heure +
+
+
+ météo +
+
+
+ jour +
+
+ + soleil +
+
+
+ année +
+
+
+
+ +
+
+ + +
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ba7b299 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/index.html b/index.html new file mode 100644 index 0000000..daa1266 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Homepage + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..c5aee9b --- /dev/null +++ b/nginx.conf @@ -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"; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f935ecd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1546 @@ +{ + "name": "homepage", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "homepage", + "version": "0.1.0", + "dependencies": { + "vue": "^3.5.32", + "vue-router": "^5.0.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.5", + "vite": "^8.0.7" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", + "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", + "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", + "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.1", + "@emnapi/runtime": "1.9.1", + "@napi-rs/wasm-runtime": "^1.1.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.1.tgz", + "integrity": "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.1.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.1.tgz", + "integrity": "sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.1.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", + "integrity": "sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", + "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.123.0", + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-x64": "1.0.0-rc.13", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", + "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.13", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.4.tgz", + "integrity": "sha512-lCqDLCI2+fKVRl2OzXuzdSWmxXFLQRxQbmHugnRpTMyYiT+hNaycV0faqG5FBHDXoYrZ6MQcX87BvbY8mQ20Bg==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..94508ea --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..692742b --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..13ee9ea --- /dev/null +++ b/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/src/components/ClockCanvas.vue b/src/components/ClockCanvas.vue new file mode 100644 index 0000000..2e5fdca --- /dev/null +++ b/src/components/ClockCanvas.vue @@ -0,0 +1,388 @@ + + + + + diff --git a/src/components/ClockControls.vue b/src/components/ClockControls.vue new file mode 100644 index 0000000..a42049c --- /dev/null +++ b/src/components/ClockControls.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/components/ClockLegend.vue b/src/components/ClockLegend.vue new file mode 100644 index 0000000..95fe61f --- /dev/null +++ b/src/components/ClockLegend.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/src/components/EphemeralNotes.vue b/src/components/EphemeralNotes.vue new file mode 100644 index 0000000..a10bd91 --- /dev/null +++ b/src/components/EphemeralNotes.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/src/components/NavPanel.vue b/src/components/NavPanel.vue new file mode 100644 index 0000000..0eff065 --- /dev/null +++ b/src/components/NavPanel.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/src/components/PomodoroTimer.vue b/src/components/PomodoroTimer.vue new file mode 100644 index 0000000..6f5676f --- /dev/null +++ b/src/components/PomodoroTimer.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/src/components/QuickSearch.vue b/src/components/QuickSearch.vue new file mode 100644 index 0000000..9b00cb1 --- /dev/null +++ b/src/components/QuickSearch.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/src/components/RSSFeed.vue b/src/components/RSSFeed.vue new file mode 100644 index 0000000..f9f37d7 --- /dev/null +++ b/src/components/RSSFeed.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/src/components/TodoBoard.vue b/src/components/TodoBoard.vue new file mode 100644 index 0000000..7a3d03b --- /dev/null +++ b/src/components/TodoBoard.vue @@ -0,0 +1,518 @@ + + + + + diff --git a/src/composables/useAuth.js b/src/composables/useAuth.js new file mode 100644 index 0000000..645d6ee --- /dev/null +++ b/src/composables/useAuth.js @@ -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 } +} diff --git a/src/composables/useExpandable.js b/src/composables/useExpandable.js new file mode 100644 index 0000000..4a07d62 --- /dev/null +++ b/src/composables/useExpandable.js @@ -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 } +} diff --git a/src/composables/useNotes.js b/src/composables/useNotes.js new file mode 100644 index 0000000..de0394b --- /dev/null +++ b/src/composables/useNotes.js @@ -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 } +} diff --git a/src/composables/useParisTime.js b/src/composables/useParisTime.js new file mode 100644 index 0000000..b54f70c --- /dev/null +++ b/src/composables/useParisTime.js @@ -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, + } +} diff --git a/src/composables/useRSS.js b/src/composables/useRSS.js new file mode 100644 index 0000000..e92fcf4 --- /dev/null +++ b/src/composables/useRSS.js @@ -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 : + const atomLink = [...el.querySelectorAll('link')].find((l) => l.getAttribute('href')) + if (atomLink) return atomLink.getAttribute('href') + // RSS 2.0 / 1.0 : text + 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('', '', '']) { + 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 (RSS) or (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 } +} diff --git a/src/composables/useTodos.js b/src/composables/useTodos.js new file mode 100644 index 0000000..e2d2994 --- /dev/null +++ b/src/composables/useTodos.js @@ -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 } +} diff --git a/src/composables/useWeather.js b/src/composables/useWeather.js new file mode 100644 index 0000000..3bcacd4 --- /dev/null +++ b/src/composables/useWeather.js @@ -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, + } +} diff --git a/src/data/feeds.json b/src/data/feeds.json new file mode 100644 index 0000000..a714673 --- /dev/null +++ b/src/data/feeds.json @@ -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" + } + ] +} diff --git a/src/data/links.json b/src/data/links.json new file mode 100644 index 0000000..57e1c89 --- /dev/null +++ b/src/data/links.json @@ -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": [] + } + ] +} \ No newline at end of file diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..745b80d --- /dev/null +++ b/src/main.js @@ -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') diff --git a/src/router/index.js b/src/router/index.js new file mode 100644 index 0000000..c46c50f --- /dev/null +++ b/src/router/index.js @@ -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 diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..6077d6c --- /dev/null +++ b/src/styles/global.css @@ -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; +} diff --git a/src/utils/astro.js b/src/utils/astro.js new file mode 100644 index 0000000..60f762c --- /dev/null +++ b/src/utils/astro.js @@ -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) +} diff --git a/src/utils/colors.js b/src/utils/colors.js new file mode 100644 index 0000000..73a0a1e --- /dev/null +++ b/src/utils/colors.js @@ -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})` +} diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue new file mode 100644 index 0000000..4575d66 --- /dev/null +++ b/src/views/HomeView.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..08c9d6f --- /dev/null +++ b/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + host: true, + port: 5173, + }, +})