mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: add calendar to app
This commit is contained in:
parent
b17715eaff
commit
08717c24bc
29 changed files with 1478 additions and 313 deletions
14
.env.example
14
.env.example
|
|
@ -44,3 +44,17 @@ APP_PORT=3000
|
||||||
SPOTIFY_CLIENT_ID=
|
SPOTIFY_CLIENT_ID=
|
||||||
SPOTIFY_CLIENT_SECRET=
|
SPOTIFY_CLIENT_SECRET=
|
||||||
YOUTUBE_API_KEY=
|
YOUTUBE_API_KEY=
|
||||||
|
|
||||||
|
# Disponibilités (calendrier). N8N_* sont optionnels : sans eux, la page
|
||||||
|
# fonctionne quand même en lecture (données déjà ingérées) mais le bouton
|
||||||
|
# "Actualiser" est désactivé côté serveur (400 n8n_not_configured).
|
||||||
|
N8N_WEBHOOK_URL=
|
||||||
|
N8N_WEBHOOK_USER=
|
||||||
|
N8N_WEBHOOK_PASS=
|
||||||
|
|
||||||
|
# Requis — protège les deux endpoints appelés directement par n8n
|
||||||
|
# (POST /api/calendar/ingest et /api/calendar/workflow-error), qui ne passent
|
||||||
|
# pas par la session de connexion. Générer avec `openssl rand -hex 32`, et
|
||||||
|
# configurer la même valeur dans le header X-Calendar-Webhook-Secret des
|
||||||
|
# noeuds HTTP du workflow n8n. Voir README "Disponibilités (calendrier)".
|
||||||
|
CALENDAR_WEBHOOK_SECRET=
|
||||||
|
|
|
||||||
54
README.md
54
README.md
|
|
@ -238,10 +238,11 @@ erDiagram
|
||||||
| Page | Accès | Description |
|
| Page | Accès | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/index.html` | Tous (lecture et écriture) | Répertoire des morceaux travaillés, liens/vignettes YouTube et Spotify, tutos embarqués par morceau et par instrument. Ajout avec autocomplete titre/artiste + liens auto-trouvés ([détails](#recherche-automatique-de-morceaux)) |
|
| `/index.html` | Tous (lecture et écriture) | Répertoire des morceaux travaillés, liens/vignettes YouTube et Spotify, tutos embarqués par morceau et par instrument. Ajout avec autocomplete titre/artiste + liens auto-trouvés ([détails](#recherche-automatique-de-morceaux)) |
|
||||||
| `/suggestions.html` | Tous | Proposer un morceau (avec lien YouTube embarqué + note libre), voter approuver/rejeter avec commentaire, attribué nominativement |
|
| `/suggestions.html` | Tous | Proposer un morceau (liens YouTube et Spotify + note libre), voter approuver/rejeter avec commentaire (attribué nominativement), ajouter une suggestion au répertoire |
|
||||||
| `/setlist.html` | Tous (lecture et écriture) | Setlist du prochain concert : choix des morceaux du répertoire, ordre, notes, section rappel |
|
| `/setlist.html` | Tous (lecture et écriture) | Setlist du prochain concert : choix des morceaux du répertoire, ordre, notes, section rappel |
|
||||||
| `/history.html`, `/history-detail.html` | Tous (lecture seule) | Historique des setlists des concerts passés |
|
| `/history.html`, `/history-detail.html` | Tous (lecture et écriture) | Historique des setlists des concerts passés — modifiable (date, morceaux, ordre, rappel) et supprimable en cas d'erreur |
|
||||||
| `/profile.html` | Chacun voit le sien | Profil issu d'Authentik (nom, avatar, groupes) + votre activité (morceaux ajoutés, suggestions, votes) |
|
| `/profile.html` | Chacun voit le sien | Profil issu d'Authentik (nom, avatar, groupes) + votre activité (morceaux ajoutés, suggestions, votes) |
|
||||||
|
| `/calendar.html` | Tous (lecture et écriture) | Disponibilités du groupe pour les 3 prochaines semaines (calendrier, filtres par personne, modale par jour) — [détails](#disponibilités-calendrier) |
|
||||||
|
|
||||||
Le mode par défaut est la consultation ; les pages Répertoire, Setlist et Suggestions sont interactives pour toute personne connectée (chaque action reste attribuée nominativement via Authentik).
|
Le mode par défaut est la consultation ; les pages Répertoire, Setlist et Suggestions sont interactives pour toute personne connectée (chaque action reste attribuée nominativement via Authentik).
|
||||||
|
|
||||||
|
|
@ -249,8 +250,8 @@ Un bouton clair/sombre dans la barre de navigation permet de forcer un thème (m
|
||||||
|
|
||||||
## Rôles
|
## Rôles
|
||||||
|
|
||||||
- **Membre** : tout le monde — consulte, ajoute/modifie/supprime des morceaux du répertoire et leurs tutos, crée/modifie des concerts et leur setlist, propose des suggestions, vote/commente.
|
- **Membre** : tout le monde — consulte, ajoute/modifie/supprime des morceaux du répertoire et leurs tutos, crée/modifie/supprime des concerts (à venir ou passés) et leur setlist, propose des suggestions, vote/commente, ajoute une suggestion au répertoire.
|
||||||
- **Admin** : en plus, modère les suggestions (promouvoir une suggestion approuvée en morceau du répertoire, la rejeter, la supprimer).
|
- **Admin** : en plus, rejette ou supprime une suggestion (modération).
|
||||||
|
|
||||||
Le rôle admin n'est volontairement pas plus étendu pour l'instant : son périmètre exact (au-delà de la modération des suggestions) reste ouvert et pourra évoluer. Il n'y a pas de gestion des utilisateurs dans l'application elle-même — Authentik reste la seule source de vérité pour qui a accès et qui est admin (claim `groups`, recalculé à chaque connexion).
|
Le rôle admin n'est volontairement pas plus étendu pour l'instant : son périmètre exact (au-delà de la modération des suggestions) reste ouvert et pourra évoluer. Il n'y a pas de gestion des utilisateurs dans l'application elle-même — Authentik reste la seule source de vérité pour qui a accès et qui est admin (claim `groups`, recalculé à chaque connexion).
|
||||||
|
|
||||||
|
|
@ -269,6 +270,48 @@ Si aucune des deux n'est configurée, l'autocomplete titre/artiste marche quand
|
||||||
|
|
||||||
Si la recherche ne trouve rien (morceau trop obscur, faute de frappe...), rien ne bloque : titre, artiste et liens restent modifiables à la main comme avant.
|
Si la recherche ne trouve rien (morceau trop obscur, faute de frappe...), rien ne bloque : titre, artiste et liens restent modifiables à la main comme avant.
|
||||||
|
|
||||||
|
## Disponibilités (calendrier)
|
||||||
|
|
||||||
|
La page `/calendar.html` montre, pour les 3 prochaines semaines, les créneaux de répétition (lun–ven 18h30–21h, sam–dim 15h–19h) où chaque membre est disponible — calculé par un workflow **n8n** externe qui interroge Google Calendar (FreeBusy API) de chaque personne. Cette fonctionnalité est une fusion complète de l'ancien projet séparé [octane-calendar](https://github.com/nfonteyne/octane-calendar) dans cette application (même thème, même authentification, un seul déploiement).
|
||||||
|
|
||||||
|
### Fonctionnement
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
UI["Page /calendar.html"]
|
||||||
|
API["API /api/calendar/*"]
|
||||||
|
N8N["Workflow n8n"]
|
||||||
|
GCal[("Google Calendar<br/>FreeBusy API")]
|
||||||
|
DB[("Postgres<br/>calendar_*")]
|
||||||
|
|
||||||
|
UI -- "Actualiser" --> API
|
||||||
|
API -- "déclenche (GET webhook)" --> N8N
|
||||||
|
N8N -- "interroge" --> GCal
|
||||||
|
N8N -- "{ slots: [...] }" --> API
|
||||||
|
API -- "ingère" --> DB
|
||||||
|
DB -- "lecture" --> API
|
||||||
|
API -- "sert les données" --> UI
|
||||||
|
```
|
||||||
|
|
||||||
|
- Bouton **"Actualiser les disponibilités"** : appelle `POST /api/calendar/refresh`, qui déclenche le webhook n8n et attend sa réponse en tâche de fond (jusqu'à 5 min), pendant que la page sonde `GET /api/calendar/workflow-status` toutes les 4 secondes.
|
||||||
|
- Deux endpoints sont appelés **directement par n8n** (pas par le navigateur, donc pas de session possible) : `POST /api/calendar/ingest` (résultats d'une ingestion) et `POST /api/calendar/workflow-error` (notifié par le workflow d'erreur n8n). Ils sont protégés par un secret partagé plutôt que par la connexion Authentik :
|
||||||
|
```
|
||||||
|
X-Calendar-Webhook-Secret: <valeur de CALENDAR_WEBHOOK_SECRET>
|
||||||
|
```
|
||||||
|
Si vous reprenez un workflow n8n existant (depuis l'ancien `octane-calendar`), mettez à jour ses noeuds HTTP pour pointer vers `https://octane.dandrove.com/api/calendar/...` et ajouter ce header.
|
||||||
|
|
||||||
|
### Variables d'environnement
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `N8N_WEBHOOK_URL` | URL complète du webhook n8n (production, pas l'URL de test) — **optionnel** : sans elle, la consultation des disponibilités déjà connues fonctionne, seul le bouton "Actualiser" renvoie une erreur explicite |
|
||||||
|
| `N8N_WEBHOOK_USER` / `N8N_WEBHOOK_PASS` | Identifiants HTTP basic-auth configurés sur le noeud webhook n8n, si vous en avez mis un |
|
||||||
|
| `CALENDAR_WEBHOOK_SECRET` | **Requis** — générer avec `openssl rand -hex 32`, à renseigner aussi côté n8n (header `X-Calendar-Webhook-Secret`) |
|
||||||
|
|
||||||
|
### Personnes suivies
|
||||||
|
|
||||||
|
La liste des personnes (et leur couleur) est amorcée en base par la migration `006_calendar_seed_people.sql` (Nathan, Raphaël, Yann, Jules, AK — mêmes noms que dans le workflow n8n d'origine, dont le mapping calendrier Google ↔ nom se fait dans le noeud "Build FreeBusy Request"). De nouvelles personnes apparaissant dans les données ingérées sont ajoutées automatiquement avec une couleur de la palette.
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
||||||
- Docker + Docker Compose
|
- Docker + Docker Compose
|
||||||
|
|
@ -300,6 +343,9 @@ Le [Démarrage rapide](#démarrage-rapide-serveur-avec-traefik) ci-dessus couvre
|
||||||
| `TRAEFIK_NETWORK_NAME` | Nom du réseau Docker externe partagé avec Traefik et Authentik (défaut `traefik-proxy`) |
|
| `TRAEFIK_NETWORK_NAME` | Nom du réseau Docker externe partagé avec Traefik et Authentik (défaut `traefik-proxy`) |
|
||||||
| `APP_DOMAIN` | Nom de domaine public utilisé par Traefik pour router vers l'app (ex: `octane.dandrove.com`) |
|
| `APP_DOMAIN` | Nom de domaine public utilisé par Traefik pour router vers l'app (ex: `octane.dandrove.com`) |
|
||||||
| `APP_PORT` | Port hôte utilisé uniquement par `docker-compose.dev.yml` (test local sans Traefik) |
|
| `APP_PORT` | Port hôte utilisé uniquement par `docker-compose.dev.yml` (test local sans Traefik) |
|
||||||
|
| `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` / `YOUTUBE_API_KEY` | Optionnels — voir [Recherche automatique de morceaux](#recherche-automatique-de-morceaux) |
|
||||||
|
| `N8N_WEBHOOK_URL` / `N8N_WEBHOOK_USER` / `N8N_WEBHOOK_PASS` | Optionnels — voir [Disponibilités (calendrier)](#disponibilités-calendrier) |
|
||||||
|
| `CALENDAR_WEBHOOK_SECRET` | Requis — voir [Disponibilités (calendrier)](#disponibilités-calendrier) |
|
||||||
|
|
||||||
Les migrations SQL (`src/db/migrations/*.sql`) sont exécutées automatiquement au démarrage du conteneur `app`, de façon idempotente (une table `schema_migrations` garde la trace des fichiers déjà appliqués).
|
Les migrations SQL (`src/db/migrations/*.sql`) sont exécutées automatiquement au démarrage du conteneur `app`, de façon idempotente (une table `schema_migrations` garde la trace des fichiers déjà appliqués).
|
||||||
|
|
||||||
|
|
|
||||||
71
public/calendar.html
Normal file
71
public/calendar.html
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Disponibilités — Octane</title>
|
||||||
|
<script src="/js/theme-init.js"></script>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<nav id="main-nav"></nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h1 style="margin:0">Disponibilités</h1>
|
||||||
|
<p class="page-subtitle" id="last-checked" style="margin:0.25rem 0 0">Chargement…</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:0.5rem;">
|
||||||
|
<button type="button" class="secondary nav-toggle calendar-filters-toggle" id="btn-filters-toggle">Filtres</button>
|
||||||
|
<button type="button" id="btn-refresh">Actualiser les disponibilités</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="error"></div>
|
||||||
|
|
||||||
|
<div class="calendar-layout">
|
||||||
|
<div class="calendar-filters-backdrop" id="filters-backdrop"></div>
|
||||||
|
<aside class="calendar-filters panel" id="filters-panel">
|
||||||
|
<div class="section-header" style="margin:0 0 0.5rem">
|
||||||
|
<h3 style="margin:0">Filtres</h3>
|
||||||
|
<button type="button" class="secondary icon-btn calendar-filters-close" id="btn-filters-close">×</button>
|
||||||
|
</div>
|
||||||
|
<p class="note" style="margin:0 0 0.5rem">Personnes incluses</p>
|
||||||
|
<div id="people-list" class="calendar-people-list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="calendar-main">
|
||||||
|
<div class="calendar-header-row">
|
||||||
|
<span>Lun</span><span>Mar</span><span>Mer</span><span>Jeu</span><span>Ven</span><span>Sam</span><span>Dim</span>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-grid" id="calendar-grid"></div>
|
||||||
|
<div class="calendar-legend" id="legend"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast" class="toast hidden"></div>
|
||||||
|
|
||||||
|
<div id="modal-overlay" class="modal-overlay hidden">
|
||||||
|
<div class="modal card">
|
||||||
|
<button type="button" class="icon-btn secondary modal-close" id="modal-close">×</button>
|
||||||
|
<div class="modal-date card-title" id="modal-date"></div>
|
||||||
|
<div class="modal-time note" id="modal-time"></div>
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title available">Disponibles</div>
|
||||||
|
<div class="modal-people" id="modal-available"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title busy">Indisponibles</div>
|
||||||
|
<div class="modal-people" id="modal-busy"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/render.js"></script>
|
||||||
|
<script src="/js/nav.js"></script>
|
||||||
|
<script src="/js/calendar.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -769,6 +769,182 @@ a.back-link {
|
||||||
|
|
||||||
a.back-link:hover { color: var(--accent); }
|
a.back-link:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---------- Calendar (availability) ---------- */
|
||||||
|
|
||||||
|
.calendar-filters-toggle,
|
||||||
|
.calendar-filters-close { display: none; }
|
||||||
|
|
||||||
|
.calendar-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-filters { position: sticky; top: 76px; }
|
||||||
|
|
||||||
|
.calendar-people-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-toggle.excluded { opacity: 0.4; }
|
||||||
|
|
||||||
|
.person-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-filters-backdrop { display: none; }
|
||||||
|
|
||||||
|
.calendar-header-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-cell {
|
||||||
|
min-height: 92px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-cell.today { border-color: var(--accent); border-width: 2px; }
|
||||||
|
.cal-cell.empty { background: transparent; border-style: dashed; color: var(--muted); }
|
||||||
|
.cal-cell.has-slot { cursor: pointer; }
|
||||||
|
.cal-cell.has-slot:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.cell-date { font-weight: 600; color: var(--muted); }
|
||||||
|
.cell-date .day-num { color: var(--text); font-size: 0.95rem; margin-right: 0.25rem; }
|
||||||
|
|
||||||
|
.no-slot-label { color: var(--muted); font-style: italic; margin-top: auto; }
|
||||||
|
|
||||||
|
.cell-time { color: var(--muted); font-size: 0.72rem; }
|
||||||
|
|
||||||
|
.cell-dots { display: flex; flex-wrap: wrap; gap: 0.2rem; margin-top: auto; }
|
||||||
|
|
||||||
|
.cell-dot { width: 8px; height: 8px; border-radius: 999px; }
|
||||||
|
.cell-dot.busy { opacity: 0.3; }
|
||||||
|
|
||||||
|
.cell-count { font-size: 0.72rem; color: var(--muted); align-self: flex-end; }
|
||||||
|
|
||||||
|
.cal-cell.all-available { background: var(--success-bg); border-color: var(--success); }
|
||||||
|
.cal-cell.heat-1 { background: color-mix(in srgb, var(--success-bg) 80%, var(--danger-bg)); }
|
||||||
|
.cal-cell.heat-2 { background: color-mix(in srgb, var(--success-bg) 55%, var(--danger-bg)); }
|
||||||
|
.cal-cell.heat-3 { background: color-mix(in srgb, var(--danger-bg) 55%, var(--success-bg)); }
|
||||||
|
.cal-cell.heat-4 { background: color-mix(in srgb, var(--danger-bg) 80%, var(--success-bg)); }
|
||||||
|
.cal-cell.heat-5 { background: var(--danger-bg); border-color: var(--danger); }
|
||||||
|
|
||||||
|
.calendar-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 0.4rem; }
|
||||||
|
|
||||||
|
.legend-dot { width: 10px; height: 10px; border-radius: 999px; display: inline-block; }
|
||||||
|
.legend-dot-all { background: var(--success-bg); border: 1px solid var(--success); }
|
||||||
|
|
||||||
|
/* ---------- Modal & toast (calendar) ---------- */
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.hidden { display: none; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: relative;
|
||||||
|
max-width: 360px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-time { margin-top: 0.1rem; }
|
||||||
|
|
||||||
|
.modal-section { margin-top: 1rem; }
|
||||||
|
|
||||||
|
.modal-section-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section-title.available { color: var(--success); }
|
||||||
|
.modal-section-title.busy { color: var(--danger); }
|
||||||
|
|
||||||
|
.modal-people { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
|
||||||
|
.modal-person {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.25rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
z-index: 200;
|
||||||
|
max-width: min(90vw, 420px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.hidden { display: none; }
|
||||||
|
.toast.error { border-color: var(--danger); color: var(--danger); }
|
||||||
|
|
||||||
/* ---------- Responsive / mobile ---------- */
|
/* ---------- Responsive / mobile ---------- */
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|
@ -825,4 +1001,38 @@ a.back-link:hover { color: var(--accent); }
|
||||||
.setlist-row .row-actions { flex-basis: 100%; order: 5; justify-content: flex-end; }
|
.setlist-row .row-actions { flex-basis: 100%; order: 5; justify-content: flex-end; }
|
||||||
|
|
||||||
.profile-header { flex-direction: column; text-align: center; }
|
.profile-header { flex-direction: column; text-align: center; }
|
||||||
|
|
||||||
|
.calendar-filters-close { display: inline-flex; }
|
||||||
|
|
||||||
|
.calendar-layout { grid-template-columns: 1fr; }
|
||||||
|
|
||||||
|
.calendar-filters {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 260px;
|
||||||
|
max-width: 80vw;
|
||||||
|
z-index: 110;
|
||||||
|
border-radius: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-filters.open { transform: translateX(0); }
|
||||||
|
|
||||||
|
.calendar-filters-backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 105;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-filters-backdrop.open { display: block; }
|
||||||
|
|
||||||
|
.calendar-header-row span:nth-child(n) { font-size: 0.7rem; }
|
||||||
|
|
||||||
|
.cal-cell { min-height: 64px; font-size: 0.7rem; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="/js/render.js"></script>
|
<script src="/js/render.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
|
<script src="/js/setlistEditor.js"></script>
|
||||||
<script src="/js/history-detail.js"></script>
|
<script src="/js/history-detail.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
337
public/js/calendar.js
Normal file
337
public/js/calendar.js
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
let state = {
|
||||||
|
people: [],
|
||||||
|
slots: [],
|
||||||
|
personIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadPeople() {
|
||||||
|
state.people = await api.get('/api/calendar/people');
|
||||||
|
state.personIds = state.people.map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSlots() {
|
||||||
|
state.slots = await api.get('/api/calendar/slots?weeks=3');
|
||||||
|
renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLastChecked() {
|
||||||
|
const { last_checked } = await api.get('/api/calendar/last-checked');
|
||||||
|
const el = document.getElementById('last-checked');
|
||||||
|
el.textContent = last_checked
|
||||||
|
? 'Dernière mise à jour : ' + formatDatetime(last_checked)
|
||||||
|
: 'Aucune donnée pour le moment — cliquez sur Actualiser';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(d) {
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortMonth(d) {
|
||||||
|
return d.toLocaleDateString('fr-FR', { month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso) {
|
||||||
|
return new Date(iso).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDatetime(iso) {
|
||||||
|
return new Date(iso).toLocaleString('fr-FR', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
const grid = document.getElementById('calendar-grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const selectedSet = new Set(state.personIds);
|
||||||
|
|
||||||
|
const slotMap = new Map();
|
||||||
|
for (const slot of state.slots) slotMap.set(slot.slot_date, slot);
|
||||||
|
|
||||||
|
// Grid starts on the Monday of the current week so columns align Mon->Sun.
|
||||||
|
const start = new Date(today);
|
||||||
|
const dow = start.getDay();
|
||||||
|
start.setDate(start.getDate() + (dow === 0 ? -6 : 1 - dow));
|
||||||
|
|
||||||
|
const daysBeforeToday = Math.round((today - start) / 86400000);
|
||||||
|
const totalDays = Math.ceil((daysBeforeToday + 21) / 7) * 7;
|
||||||
|
|
||||||
|
for (let i = 0; i < totalDays; i++) {
|
||||||
|
const date = new Date(start);
|
||||||
|
date.setDate(start.getDate() + i);
|
||||||
|
|
||||||
|
const isPastDay = date < today;
|
||||||
|
const isBeyond21 = i >= daysBeforeToday + 21;
|
||||||
|
const isToday = date.getTime() === today.getTime();
|
||||||
|
const slot = slotMap.get(isoDate(date));
|
||||||
|
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'cal-cell' + (isToday ? ' today' : '');
|
||||||
|
|
||||||
|
if (isBeyond21) {
|
||||||
|
cell.classList.add('empty');
|
||||||
|
grid.appendChild(cell);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateLabel = document.createElement('div');
|
||||||
|
dateLabel.className = 'cell-date';
|
||||||
|
dateLabel.innerHTML = `<span class="day-num">${date.getDate()}</span>${shortMonth(date)}`;
|
||||||
|
cell.appendChild(dateLabel);
|
||||||
|
|
||||||
|
if (isPastDay) {
|
||||||
|
cell.classList.add('empty');
|
||||||
|
const lbl = document.createElement('div');
|
||||||
|
lbl.className = 'no-slot-label';
|
||||||
|
lbl.textContent = 'passé';
|
||||||
|
cell.appendChild(lbl);
|
||||||
|
} else if (slot) {
|
||||||
|
const visible = slot.people.filter((p) => selectedSet.has(p.id));
|
||||||
|
const avail = visible.filter((p) => p.is_available);
|
||||||
|
|
||||||
|
cell.classList.add('has-slot');
|
||||||
|
if (visible.length > 0) {
|
||||||
|
if (avail.length === visible.length) {
|
||||||
|
cell.classList.add('all-available');
|
||||||
|
} else {
|
||||||
|
const unavailRatio = (visible.length - avail.length) / visible.length;
|
||||||
|
if (unavailRatio <= 0.2) cell.classList.add('heat-1');
|
||||||
|
else if (unavailRatio <= 0.4) cell.classList.add('heat-2');
|
||||||
|
else if (unavailRatio <= 0.6) cell.classList.add('heat-3');
|
||||||
|
else if (unavailRatio <= 0.8) cell.classList.add('heat-4');
|
||||||
|
else cell.classList.add('heat-5');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeEl = document.createElement('div');
|
||||||
|
timeEl.className = 'cell-time';
|
||||||
|
timeEl.textContent = formatTime(slot.lower) + '–' + formatTime(slot.upper);
|
||||||
|
cell.appendChild(timeEl);
|
||||||
|
|
||||||
|
const dotsEl = document.createElement('div');
|
||||||
|
dotsEl.className = 'cell-dots';
|
||||||
|
for (const person of visible) {
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.className = 'cell-dot' + (person.is_available ? '' : ' busy');
|
||||||
|
dot.style.backgroundColor = person.color;
|
||||||
|
dot.title = person.name + (person.is_available ? ' ✓' : ' ✗');
|
||||||
|
dotsEl.appendChild(dot);
|
||||||
|
}
|
||||||
|
cell.appendChild(dotsEl);
|
||||||
|
|
||||||
|
cell.addEventListener('click', () => openModal(date, slot, visible));
|
||||||
|
|
||||||
|
const countEl = document.createElement('div');
|
||||||
|
countEl.className = 'cell-count';
|
||||||
|
countEl.textContent = avail.length + '/' + visible.length;
|
||||||
|
cell.appendChild(countEl);
|
||||||
|
} else {
|
||||||
|
cell.classList.add('empty');
|
||||||
|
const lbl = document.createElement('div');
|
||||||
|
lbl.className = 'no-slot-label';
|
||||||
|
lbl.textContent = 'aucune donnée';
|
||||||
|
cell.appendChild(lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.appendChild(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFilters() {
|
||||||
|
const listEl = document.getElementById('people-list');
|
||||||
|
for (const person of state.people) {
|
||||||
|
const row = document.createElement('label');
|
||||||
|
row.className = 'person-toggle';
|
||||||
|
row.dataset.id = person.id;
|
||||||
|
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'person-dot';
|
||||||
|
dot.style.backgroundColor = person.color;
|
||||||
|
|
||||||
|
row.appendChild(dot);
|
||||||
|
row.appendChild(document.createTextNode(person.name));
|
||||||
|
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
const id = person.id;
|
||||||
|
if (state.personIds.includes(id)) {
|
||||||
|
if (state.personIds.length === 1) return;
|
||||||
|
state.personIds = state.personIds.filter((x) => x !== id);
|
||||||
|
row.classList.add('excluded');
|
||||||
|
} else {
|
||||||
|
state.personIds.push(id);
|
||||||
|
row.classList.remove('excluded');
|
||||||
|
}
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
|
||||||
|
listEl.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildLegend();
|
||||||
|
|
||||||
|
document.getElementById('btn-refresh').addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('btn-refresh');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Actualisation…';
|
||||||
|
showToast('Déclenchement du workflow n8n…');
|
||||||
|
try {
|
||||||
|
await api.post('/api/calendar/refresh', {});
|
||||||
|
showToast('Workflow en cours — en attente des résultats…');
|
||||||
|
pollWorkflowStatus(btn);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.message === 'n8n_not_configured'
|
||||||
|
? "L'actualisation automatique n'est pas configurée (n8n)."
|
||||||
|
: err.message;
|
||||||
|
showToast(message, true);
|
||||||
|
resetRefreshButton(btn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRefreshButton(btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Actualiser les disponibilités';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollWorkflowStatus(btn, maxMs = 180000, intervalMs = 4000) {
|
||||||
|
const started = Date.now();
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
if (Date.now() - started > maxMs) {
|
||||||
|
clearInterval(timer);
|
||||||
|
showToast('Le workflow a expiré — aucun résultat après 3 minutes', true);
|
||||||
|
resetRefreshButton(btn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await api.get('/api/calendar/workflow-status');
|
||||||
|
if (data.status === 'success') {
|
||||||
|
clearInterval(timer);
|
||||||
|
showToast('Calendrier mis à jour !');
|
||||||
|
resetRefreshButton(btn);
|
||||||
|
await loadSlots();
|
||||||
|
await loadLastChecked();
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
clearInterval(timer);
|
||||||
|
const detail = data.node ? ` (nœud : ${data.node})` : '';
|
||||||
|
showToast('Erreur du workflow : ' + (data.message || 'inconnue') + detail, true);
|
||||||
|
resetRefreshButton(btn);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
/* transient, keep polling */
|
||||||
|
}
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(date, slot, visible) {
|
||||||
|
const avail = visible.filter((p) => p.is_available);
|
||||||
|
const busy = visible.filter((p) => !p.is_available);
|
||||||
|
|
||||||
|
document.getElementById('modal-date').textContent = date.toLocaleDateString('fr-FR', {
|
||||||
|
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Paris',
|
||||||
|
});
|
||||||
|
document.getElementById('modal-time').textContent = formatTime(slot.lower) + ' – ' + formatTime(slot.upper);
|
||||||
|
|
||||||
|
const renderPeople = (list, containerId) => {
|
||||||
|
const el = document.getElementById(containerId);
|
||||||
|
el.innerHTML = '';
|
||||||
|
if (!list.length) {
|
||||||
|
const em = document.createElement('div');
|
||||||
|
em.className = 'modal-empty empty';
|
||||||
|
em.textContent = 'Aucune';
|
||||||
|
el.appendChild(em);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const person of list) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'modal-person';
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'person-dot';
|
||||||
|
dot.style.backgroundColor = person.color;
|
||||||
|
row.appendChild(dot);
|
||||||
|
row.appendChild(document.createTextNode(person.name));
|
||||||
|
el.appendChild(row);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderPeople(avail, 'modal-available');
|
||||||
|
renderPeople(busy, 'modal-busy');
|
||||||
|
|
||||||
|
document.getElementById('modal-overlay').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modal-overlay').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFilters() {
|
||||||
|
document.getElementById('filters-panel').classList.add('open');
|
||||||
|
document.getElementById('filters-backdrop').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFilters() {
|
||||||
|
document.getElementById('filters-panel').classList.remove('open');
|
||||||
|
document.getElementById('filters-backdrop').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegend() {
|
||||||
|
const el = document.getElementById('legend');
|
||||||
|
for (const person of state.people) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'legend-item';
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'legend-dot';
|
||||||
|
dot.style.backgroundColor = person.color;
|
||||||
|
item.appendChild(dot);
|
||||||
|
item.appendChild(document.createTextNode(person.name));
|
||||||
|
el.appendChild(item);
|
||||||
|
}
|
||||||
|
const allItem = document.createElement('div');
|
||||||
|
allItem.className = 'legend-item';
|
||||||
|
allItem.innerHTML = '<span class="legend-dot legend-dot-all"></span> Tout le monde libre';
|
||||||
|
el.appendChild(allItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, isError = false, duration = 5000) {
|
||||||
|
const el = document.getElementById('toast');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.remove('hidden', 'error');
|
||||||
|
if (isError) el.classList.add('error');
|
||||||
|
clearTimeout(el._timer);
|
||||||
|
el._timer = setTimeout(() => el.classList.add('hidden'), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function init() {
|
||||||
|
await initNav('calendar');
|
||||||
|
|
||||||
|
document.getElementById('modal-close').addEventListener('click', closeModal);
|
||||||
|
document.getElementById('modal-overlay').addEventListener('click', (e) => {
|
||||||
|
if (e.target === e.currentTarget) closeModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
closeFilters();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-filters-toggle').addEventListener('click', openFilters);
|
||||||
|
document.getElementById('btn-filters-close').addEventListener('click', closeFilters);
|
||||||
|
document.getElementById('filters-backdrop').addEventListener('click', closeFilters);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadPeople();
|
||||||
|
buildFilters();
|
||||||
|
await loadSlots();
|
||||||
|
await loadLastChecked();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -1,12 +1,3 @@
|
||||||
function formatDate(dateStr) {
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
return d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(message) {
|
|
||||||
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
(async function init() {
|
(async function init() {
|
||||||
await initNav('history');
|
await initNav('history');
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
@ -16,31 +7,23 @@ function showError(message) {
|
||||||
container.innerHTML = '<p class="empty">Concert introuvable.</p>';
|
container.innerHTML = '<p class="empty">Concert introuvable.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const setlist = await api.get(`/api/setlists/${id}`);
|
|
||||||
const main = setlist.songs.filter((s) => !s.is_encore);
|
|
||||||
const encore = setlist.songs.filter((s) => s.is_encore);
|
|
||||||
const rowHtml = (s) => `<li><span class="row-title">${escapeHtml(s.title)}</span> <span class="row-artist">— ${escapeHtml(s.artist)}</span>${s.note ? `<span class="note">${escapeHtml(s.note)}</span>` : ''}</li>`;
|
|
||||||
|
|
||||||
container.innerHTML = `
|
const allSongs = await api.get('/api/songs');
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">${escapeHtml(setlist.name || 'Concert')}</div>
|
const editor = createSetlistEditor({
|
||||||
<div class="card-subtitle">${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}</div>
|
allSongs,
|
||||||
</div>
|
getSetlist: async () => {
|
||||||
<div class="setlist-section">
|
try {
|
||||||
<h3>Setlist</h3>
|
return await api.get(`/api/setlists/${id}`);
|
||||||
${main.length
|
} catch (err) {
|
||||||
? `<ol class="setlist">${main.map(rowHtml).join('')}</ol>`
|
return null;
|
||||||
: '<p class="empty">Aucun morceau enregistré.</p>'}
|
}
|
||||||
</div>
|
},
|
||||||
<div class="setlist-section">
|
emptyMessage: 'Concert introuvable.',
|
||||||
<h3>Rappel</h3>
|
allowDelete: true,
|
||||||
${encore.length
|
onDeleted: () => {
|
||||||
? `<ol class="setlist">${encore.map(rowHtml).join('')}</ol>`
|
window.location.href = '/history.html';
|
||||||
: '<p class="empty">Aucun rappel enregistré.</p>'}
|
},
|
||||||
</div>
|
});
|
||||||
`;
|
await editor.load();
|
||||||
} catch (err) {
|
|
||||||
showError(err.message);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ async function initNav(activePage) {
|
||||||
<a href="/index.html" class="${activePage === 'repertoire' ? 'active' : ''}">Répertoire</a>
|
<a href="/index.html" class="${activePage === 'repertoire' ? 'active' : ''}">Répertoire</a>
|
||||||
<a href="/suggestions.html" class="${activePage === 'suggestions' ? 'active' : ''}">Suggestions</a>
|
<a href="/suggestions.html" class="${activePage === 'suggestions' ? 'active' : ''}">Suggestions</a>
|
||||||
<a href="/setlist.html" class="${activePage === 'setlist' ? 'active' : ''}">Prochain concert</a>
|
<a href="/setlist.html" class="${activePage === 'setlist' ? 'active' : ''}">Prochain concert</a>
|
||||||
|
<a href="/calendar.html" class="${activePage === 'calendar' ? 'active' : ''}">Disponibilités</a>
|
||||||
<a href="/history.html" class="${activePage === 'history' ? 'active' : ''}">Historique</a>
|
<a href="/history.html" class="${activePage === 'history' ? 'active' : ''}">Historique</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-user">
|
<div class="nav-user">
|
||||||
|
|
|
||||||
|
|
@ -1,253 +1,12 @@
|
||||||
let me = null;
|
|
||||||
let allSongs = [];
|
|
||||||
let setlist = null;
|
|
||||||
let mainRows = [];
|
|
||||||
let encoreRows = [];
|
|
||||||
let addSongId = null;
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
return d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function readOnlyView() {
|
|
||||||
const main = setlist.songs.filter((s) => !s.is_encore);
|
|
||||||
const encore = setlist.songs.filter((s) => s.is_encore);
|
|
||||||
return `
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">${escapeHtml(setlist.name || 'Concert')}</div>
|
|
||||||
<div class="card-subtitle">${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="setlist-section">
|
|
||||||
<h3>Setlist</h3>
|
|
||||||
${main.length
|
|
||||||
? `<ol class="setlist">${main.map((s) => `<li><span class="row-title">${escapeHtml(s.title)}</span> <span class="row-artist">— ${escapeHtml(s.artist)}</span>${s.note ? `<span class="note">${escapeHtml(s.note)}</span>` : ''}</li>`).join('')}</ol>`
|
|
||||||
: '<p class="empty">Aucun morceau pour le moment.</p>'}
|
|
||||||
</div>
|
|
||||||
<div class="setlist-section">
|
|
||||||
<h3>Rappel</h3>
|
|
||||||
${encore.length
|
|
||||||
? `<ol class="setlist">${encore.map((s) => `<li><span class="row-title">${escapeHtml(s.title)}</span> <span class="row-artist">— ${escapeHtml(s.artist)}</span>${s.note ? `<span class="note">${escapeHtml(s.note)}</span>` : ''}</li>`).join('')}</ol>`
|
|
||||||
: '<p class="empty">Aucun morceau de rappel prévu.</p>'}
|
|
||||||
</div>
|
|
||||||
<button id="edit-btn" class="secondary" style="margin-top:1rem">Modifier la setlist</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReadOnly() {
|
|
||||||
const container = document.getElementById('content');
|
|
||||||
if (!setlist) {
|
|
||||||
container.innerHTML = `<p class="empty">Aucun concert à venir.</p>${metaFormTemplate()}`;
|
|
||||||
attachMetaFormHandler();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = readOnlyView();
|
|
||||||
const editBtn = document.getElementById('edit-btn');
|
|
||||||
if (editBtn) editBtn.addEventListener('click', enterEditMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
function metaFormTemplate() {
|
|
||||||
return `
|
|
||||||
<div class="panel">
|
|
||||||
<h3>${setlist ? 'Détails du concert' : 'Créer le prochain concert'}</h3>
|
|
||||||
<form id="meta-form" class="inline-form">
|
|
||||||
<label>Nom <input name="name" value="${escapeHtml(setlist?.name || '')}"></label>
|
|
||||||
<label>Lieu <input name="venue" value="${escapeHtml(setlist?.venue || '')}"></label>
|
|
||||||
<label>Date <input type="date" name="concertDate" value="${setlist ? setlist.concert_date.slice(0, 10) : ''}" required></label>
|
|
||||||
<button type="submit">${setlist ? 'Enregistrer' : 'Créer le concert'}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usedSongIds() {
|
|
||||||
return new Set([...mainRows, ...encoreRows].map((r) => r.songId));
|
|
||||||
}
|
|
||||||
|
|
||||||
function availableSongOptions() {
|
|
||||||
const used = usedSongIds();
|
|
||||||
const available = allSongs.filter((s) => !used.has(s.id));
|
|
||||||
if (!available.length) return '<option value="">(tous les morceaux sont déjà dans la setlist)</option>';
|
|
||||||
return available.map((s) => `<option value="${s.id}">${escapeHtml(s.title)} — ${escapeHtml(s.artist)}</option>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowTemplate(row, index, section, total) {
|
|
||||||
return `
|
|
||||||
<div class="setlist-row" data-section="${section}" data-index="${index}">
|
|
||||||
<div class="row-index">${index + 1}</div>
|
|
||||||
<div class="row-main">
|
|
||||||
<div class="row-title">${escapeHtml(row.title)}</div>
|
|
||||||
<div class="row-artist">${escapeHtml(row.artist)}</div>
|
|
||||||
</div>
|
|
||||||
<input class="row-note-input" data-field="note" placeholder="Note (optionnel)" value="${escapeHtml(row.note || '')}">
|
|
||||||
<div class="row-actions">
|
|
||||||
<button type="button" class="secondary icon-btn move-up" ${index === 0 ? 'disabled' : ''} title="Monter">↑</button>
|
|
||||||
<button type="button" class="secondary icon-btn move-down" ${index === total - 1 ? 'disabled' : ''} title="Descendre">↓</button>
|
|
||||||
<button type="button" class="secondary icon-btn move-section" title="${section === 'main' ? 'Déplacer en rappel' : 'Déplacer au programme'}">${section === 'main' ? '⇥ Rappel' : '⇤ Programme'}</button>
|
|
||||||
<button type="button" class="danger icon-btn remove-row" title="Retirer">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function editView() {
|
|
||||||
return `
|
|
||||||
${metaFormTemplate()}
|
|
||||||
${setlist ? `
|
|
||||||
<div class="panel">
|
|
||||||
<h3>Ajouter un morceau du répertoire</h3>
|
|
||||||
<form id="add-song-form" class="inline-form">
|
|
||||||
<label>Morceau
|
|
||||||
<select id="add-song-select">${availableSongOptions()}</select>
|
|
||||||
</label>
|
|
||||||
<button type="button" id="add-to-main-btn" class="secondary">Ajouter au programme</button>
|
|
||||||
<button type="button" id="add-to-encore-btn" class="secondary">Ajouter au rappel</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setlist-section">
|
|
||||||
<h3>Programme principal</h3>
|
|
||||||
<div class="setlist-rows" id="main-rows">
|
|
||||||
${mainRows.length ? mainRows.map((r, i) => rowTemplate(r, i, 'main', mainRows.length)).join('') : '<p class="empty">Aucun morceau. Ajoutez-en depuis le répertoire ci-dessus.</p>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setlist-section">
|
|
||||||
<h3>Rappel</h3>
|
|
||||||
<div class="setlist-rows" id="encore-rows">
|
|
||||||
${encoreRows.length ? encoreRows.map((r, i) => rowTemplate(r, i, 'encore', encoreRows.length)).join('') : '<p class="empty">Aucun morceau de rappel prévu.</p>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inline-form" style="margin-top:1.25rem">
|
|
||||||
<button type="button" id="save-songs-btn">Enregistrer la setlist</button>
|
|
||||||
<button type="button" id="cancel-edit-btn" class="secondary">Retour</button>
|
|
||||||
</div>` : ''}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function enterEditMode() {
|
|
||||||
mainRows = setlist.songs
|
|
||||||
.filter((s) => !s.is_encore)
|
|
||||||
.map((s) => ({ setlistSongId: s.id, songId: s.song_id, title: s.title, artist: s.artist, note: s.note }));
|
|
||||||
encoreRows = setlist.songs
|
|
||||||
.filter((s) => s.is_encore)
|
|
||||||
.map((s) => ({ setlistSongId: s.id, songId: s.song_id, title: s.title, artist: s.artist, note: s.note }));
|
|
||||||
renderEdit();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEdit() {
|
|
||||||
const container = document.getElementById('content');
|
|
||||||
container.innerHTML = editView();
|
|
||||||
attachMetaFormHandler();
|
|
||||||
attachEditHandlers();
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachMetaFormHandler() {
|
|
||||||
const form = document.getElementById('meta-form');
|
|
||||||
if (!form) return;
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const name = form.name.value.trim();
|
|
||||||
const venue = form.venue.value.trim();
|
|
||||||
const concertDate = form.concertDate.value;
|
|
||||||
try {
|
|
||||||
if (setlist) {
|
|
||||||
setlist = await api.patch(`/api/setlists/${setlist.id}`, { name, venue, concertDate });
|
|
||||||
setlist.songs = (await api.get(`/api/setlists/${setlist.id}`)).songs;
|
|
||||||
} else {
|
|
||||||
setlist = await api.post('/api/setlists', { name, venue, concertDate });
|
|
||||||
setlist.songs = [];
|
|
||||||
}
|
|
||||||
enterEditMode();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowsForSection(section) {
|
|
||||||
return section === 'main' ? mainRows : encoreRows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachEditHandlers() {
|
|
||||||
const addSelect = document.getElementById('add-song-select');
|
|
||||||
const addToMain = document.getElementById('add-to-main-btn');
|
|
||||||
const addToEncore = document.getElementById('add-to-encore-btn');
|
|
||||||
if (addSelect) {
|
|
||||||
if (!addSelect.value) addSongId = null;
|
|
||||||
addSelect.addEventListener('change', () => { addSongId = addSelect.value ? parseInt(addSelect.value, 10) : null; });
|
|
||||||
}
|
|
||||||
if (addToMain) addToMain.addEventListener('click', () => addSongToSection('main', addSelect));
|
|
||||||
if (addToEncore) addToEncore.addEventListener('click', () => addSongToSection('encore', addSelect));
|
|
||||||
|
|
||||||
document.querySelectorAll('.setlist-row').forEach((rowEl) => {
|
|
||||||
const section = rowEl.dataset.section;
|
|
||||||
const index = parseInt(rowEl.dataset.index, 10);
|
|
||||||
const rows = rowsForSection(section);
|
|
||||||
|
|
||||||
rowEl.querySelector('.row-note-input').addEventListener('change', (e) => {
|
|
||||||
rows[index].note = e.target.value;
|
|
||||||
});
|
|
||||||
rowEl.querySelector('.move-up')?.addEventListener('click', () => {
|
|
||||||
if (index === 0) return;
|
|
||||||
[rows[index - 1], rows[index]] = [rows[index], rows[index - 1]];
|
|
||||||
renderEdit();
|
|
||||||
});
|
|
||||||
rowEl.querySelector('.move-down')?.addEventListener('click', () => {
|
|
||||||
if (index === rows.length - 1) return;
|
|
||||||
[rows[index + 1], rows[index]] = [rows[index], rows[index + 1]];
|
|
||||||
renderEdit();
|
|
||||||
});
|
|
||||||
rowEl.querySelector('.move-section').addEventListener('click', () => {
|
|
||||||
const [row] = rows.splice(index, 1);
|
|
||||||
if (section === 'main') encoreRows.push(row);
|
|
||||||
else mainRows.push(row);
|
|
||||||
renderEdit();
|
|
||||||
});
|
|
||||||
rowEl.querySelector('.remove-row').addEventListener('click', () => {
|
|
||||||
rows.splice(index, 1);
|
|
||||||
renderEdit();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveBtn = document.getElementById('save-songs-btn');
|
|
||||||
if (saveBtn) saveBtn.addEventListener('click', onSaveSongs);
|
|
||||||
const cancelBtn = document.getElementById('cancel-edit-btn');
|
|
||||||
if (cancelBtn) cancelBtn.addEventListener('click', renderReadOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSongToSection(section, selectEl) {
|
|
||||||
const songId = selectEl.value ? parseInt(selectEl.value, 10) : null;
|
|
||||||
if (!songId) return;
|
|
||||||
const song = allSongs.find((s) => s.id === songId);
|
|
||||||
if (!song) return;
|
|
||||||
const row = { setlistSongId: null, songId: song.id, title: song.title, artist: song.artist, note: '' };
|
|
||||||
if (section === 'main') mainRows.push(row);
|
|
||||||
else encoreRows.push(row);
|
|
||||||
renderEdit();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSaveSongs() {
|
|
||||||
try {
|
|
||||||
const payload = [
|
|
||||||
...mainRows.map((r, i) => ({ songId: r.songId, position: i + 1, note: r.note, isEncore: false })),
|
|
||||||
...encoreRows.map((r, i) => ({ songId: r.songId, position: i + 1, note: r.note, isEncore: true })),
|
|
||||||
];
|
|
||||||
setlist = await api.put(`/api/setlists/${setlist.id}/songs`, { songs: payload });
|
|
||||||
renderReadOnly();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(message) {
|
|
||||||
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
(async function init() {
|
(async function init() {
|
||||||
me = await initNav('setlist');
|
await initNav('setlist');
|
||||||
allSongs = await api.get('/api/songs');
|
const allSongs = await api.get('/api/songs');
|
||||||
setlist = await api.get('/api/setlists/next');
|
|
||||||
renderReadOnly();
|
const editor = createSetlistEditor({
|
||||||
|
allSongs,
|
||||||
|
getSetlist: () => api.get('/api/setlists/next'),
|
||||||
|
createSetlist: (data) => api.post('/api/setlists', data),
|
||||||
|
emptyMessage: 'Aucun concert à venir pour le moment.',
|
||||||
|
});
|
||||||
|
await editor.load();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
278
public/js/setlistEditor.js
Normal file
278
public/js/setlistEditor.js
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
// Shared setlist editor used by both "prochain concert" (setlist.js) and
|
||||||
|
// "historique" (history-detail.js): choose songs from the repertoire, order
|
||||||
|
// them, add per-song notes, mark encore songs, save. Parameterized so the
|
||||||
|
// caller decides how to load/create/delete the underlying setlist.
|
||||||
|
function createSetlistEditor({
|
||||||
|
containerId = 'content',
|
||||||
|
allSongs,
|
||||||
|
getSetlist,
|
||||||
|
createSetlist = null,
|
||||||
|
emptyMessage = 'Aucun concert.',
|
||||||
|
allowDelete = false,
|
||||||
|
onDeleted = null,
|
||||||
|
}) {
|
||||||
|
let setlist = null;
|
||||||
|
let mainRows = [];
|
||||||
|
let encoreRows = [];
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function container() {
|
||||||
|
return document.getElementById(containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOnlyView() {
|
||||||
|
const main = setlist.songs.filter((s) => !s.is_encore);
|
||||||
|
const encore = setlist.songs.filter((s) => s.is_encore);
|
||||||
|
const rowHtml = (s) => `<li><span class="row-title">${escapeHtml(s.title)}</span> <span class="row-artist">— ${escapeHtml(s.artist)}</span>${s.note ? `<span class="note">${escapeHtml(s.note)}</span>` : ''}</li>`;
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">${escapeHtml(setlist.name || 'Concert')}</div>
|
||||||
|
<div class="card-subtitle">${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setlist-section">
|
||||||
|
<h3>Setlist</h3>
|
||||||
|
${main.length ? `<ol class="setlist">${main.map(rowHtml).join('')}</ol>` : '<p class="empty">Aucun morceau pour le moment.</p>'}
|
||||||
|
</div>
|
||||||
|
<div class="setlist-section">
|
||||||
|
<h3>Rappel</h3>
|
||||||
|
${encore.length ? `<ol class="setlist">${encore.map(rowHtml).join('')}</ol>` : '<p class="empty">Aucun morceau de rappel prévu.</p>'}
|
||||||
|
</div>
|
||||||
|
<div class="inline-form" style="margin-top:1rem">
|
||||||
|
<button id="edit-btn" class="secondary">Modifier</button>
|
||||||
|
${allowDelete ? '<button id="delete-btn" class="danger">Supprimer ce concert</button>' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReadOnly() {
|
||||||
|
if (!setlist) {
|
||||||
|
container().innerHTML = createSetlist
|
||||||
|
? `<p class="empty">${escapeHtml(emptyMessage)}</p>${metaFormTemplate()}`
|
||||||
|
: `<p class="empty">${escapeHtml(emptyMessage)}</p>`;
|
||||||
|
if (createSetlist) attachMetaFormHandler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container().innerHTML = readOnlyView();
|
||||||
|
document.getElementById('edit-btn').addEventListener('click', enterEditMode);
|
||||||
|
const deleteBtn = document.getElementById('delete-btn');
|
||||||
|
if (deleteBtn) deleteBtn.addEventListener('click', onDelete);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete() {
|
||||||
|
try {
|
||||||
|
await api.del(`/api/setlists/${setlist.id}`);
|
||||||
|
if (onDeleted) onDeleted();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaFormTemplate() {
|
||||||
|
return `
|
||||||
|
<div class="panel">
|
||||||
|
<h3>${setlist ? 'Détails du concert' : 'Créer le prochain concert'}</h3>
|
||||||
|
<form id="meta-form" class="inline-form">
|
||||||
|
<label>Nom <input name="name" value="${escapeHtml(setlist?.name || '')}"></label>
|
||||||
|
<label>Lieu <input name="venue" value="${escapeHtml(setlist?.venue || '')}"></label>
|
||||||
|
<label>Date <input type="date" name="concertDate" value="${setlist ? setlist.concert_date.slice(0, 10) : ''}" required></label>
|
||||||
|
<button type="submit">${setlist ? 'Enregistrer' : 'Créer le concert'}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usedSongIds() {
|
||||||
|
return new Set([...mainRows, ...encoreRows].map((r) => r.songId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableSongOptions() {
|
||||||
|
const used = usedSongIds();
|
||||||
|
const available = allSongs.filter((s) => !used.has(s.id));
|
||||||
|
if (!available.length) return '<option value="">(tous les morceaux sont déjà dans la setlist)</option>';
|
||||||
|
return available.map((s) => `<option value="${s.id}">${escapeHtml(s.title)} — ${escapeHtml(s.artist)}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowTemplate(row, index, section, total) {
|
||||||
|
return `
|
||||||
|
<div class="setlist-row" data-section="${section}" data-index="${index}">
|
||||||
|
<div class="row-index">${index + 1}</div>
|
||||||
|
<div class="row-main">
|
||||||
|
<div class="row-title">${escapeHtml(row.title)}</div>
|
||||||
|
<div class="row-artist">${escapeHtml(row.artist)}</div>
|
||||||
|
</div>
|
||||||
|
<input class="row-note-input" data-field="note" placeholder="Note (optionnel)" value="${escapeHtml(row.note || '')}">
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" class="secondary icon-btn move-up" ${index === 0 ? 'disabled' : ''} title="Monter">↑</button>
|
||||||
|
<button type="button" class="secondary icon-btn move-down" ${index === total - 1 ? 'disabled' : ''} title="Descendre">↓</button>
|
||||||
|
<button type="button" class="secondary icon-btn move-section" title="${section === 'main' ? 'Déplacer en rappel' : 'Déplacer au programme'}">${section === 'main' ? '⇥ Rappel' : '⇤ Programme'}</button>
|
||||||
|
<button type="button" class="danger icon-btn remove-row" title="Retirer">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editView() {
|
||||||
|
return `
|
||||||
|
${metaFormTemplate()}
|
||||||
|
${setlist ? `
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Ajouter un morceau du répertoire</h3>
|
||||||
|
<form id="add-song-form" class="inline-form">
|
||||||
|
<label>Morceau
|
||||||
|
<select id="add-song-select">${availableSongOptions()}</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" id="add-to-main-btn" class="secondary">Ajouter au programme</button>
|
||||||
|
<button type="button" id="add-to-encore-btn" class="secondary">Ajouter au rappel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setlist-section">
|
||||||
|
<h3>Programme principal</h3>
|
||||||
|
<div class="setlist-rows" id="main-rows">
|
||||||
|
${mainRows.length ? mainRows.map((r, i) => rowTemplate(r, i, 'main', mainRows.length)).join('') : '<p class="empty">Aucun morceau. Ajoutez-en depuis le répertoire ci-dessus.</p>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setlist-section">
|
||||||
|
<h3>Rappel</h3>
|
||||||
|
<div class="setlist-rows" id="encore-rows">
|
||||||
|
${encoreRows.length ? encoreRows.map((r, i) => rowTemplate(r, i, 'encore', encoreRows.length)).join('') : '<p class="empty">Aucun morceau de rappel prévu.</p>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline-form" style="margin-top:1.25rem">
|
||||||
|
<button type="button" id="save-songs-btn">Enregistrer la setlist</button>
|
||||||
|
<button type="button" id="cancel-edit-btn" class="secondary">Retour</button>
|
||||||
|
</div>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterEditMode() {
|
||||||
|
mainRows = setlist.songs
|
||||||
|
.filter((s) => !s.is_encore)
|
||||||
|
.map((s) => ({ setlistSongId: s.id, songId: s.song_id, title: s.title, artist: s.artist, note: s.note }));
|
||||||
|
encoreRows = setlist.songs
|
||||||
|
.filter((s) => s.is_encore)
|
||||||
|
.map((s) => ({ setlistSongId: s.id, songId: s.song_id, title: s.title, artist: s.artist, note: s.note }));
|
||||||
|
renderEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEdit() {
|
||||||
|
container().innerHTML = editView();
|
||||||
|
attachMetaFormHandler();
|
||||||
|
attachEditHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachMetaFormHandler() {
|
||||||
|
const form = document.getElementById('meta-form');
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = form.name.value.trim();
|
||||||
|
const venue = form.venue.value.trim();
|
||||||
|
const concertDate = form.concertDate.value;
|
||||||
|
try {
|
||||||
|
if (setlist) {
|
||||||
|
setlist = await api.patch(`/api/setlists/${setlist.id}`, { name, venue, concertDate });
|
||||||
|
setlist.songs = (await api.get(`/api/setlists/${setlist.id}`)).songs;
|
||||||
|
} else {
|
||||||
|
setlist = await createSetlist({ name, venue, concertDate });
|
||||||
|
setlist.songs = setlist.songs || [];
|
||||||
|
}
|
||||||
|
enterEditMode();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsForSection(section) {
|
||||||
|
return section === 'main' ? mainRows : encoreRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachEditHandlers() {
|
||||||
|
const addSelect = document.getElementById('add-song-select');
|
||||||
|
const addToMain = document.getElementById('add-to-main-btn');
|
||||||
|
const addToEncore = document.getElementById('add-to-encore-btn');
|
||||||
|
if (addToMain) addToMain.addEventListener('click', () => addSongToSection('main', addSelect));
|
||||||
|
if (addToEncore) addToEncore.addEventListener('click', () => addSongToSection('encore', addSelect));
|
||||||
|
|
||||||
|
document.querySelectorAll('.setlist-row').forEach((rowEl) => {
|
||||||
|
const section = rowEl.dataset.section;
|
||||||
|
const index = parseInt(rowEl.dataset.index, 10);
|
||||||
|
const rows = rowsForSection(section);
|
||||||
|
|
||||||
|
rowEl.querySelector('.row-note-input').addEventListener('change', (e) => {
|
||||||
|
rows[index].note = e.target.value;
|
||||||
|
});
|
||||||
|
rowEl.querySelector('.move-up')?.addEventListener('click', () => {
|
||||||
|
if (index === 0) return;
|
||||||
|
[rows[index - 1], rows[index]] = [rows[index], rows[index - 1]];
|
||||||
|
renderEdit();
|
||||||
|
});
|
||||||
|
rowEl.querySelector('.move-down')?.addEventListener('click', () => {
|
||||||
|
if (index === rows.length - 1) return;
|
||||||
|
[rows[index + 1], rows[index]] = [rows[index], rows[index + 1]];
|
||||||
|
renderEdit();
|
||||||
|
});
|
||||||
|
rowEl.querySelector('.move-section').addEventListener('click', () => {
|
||||||
|
const [row] = rows.splice(index, 1);
|
||||||
|
if (section === 'main') encoreRows.push(row);
|
||||||
|
else mainRows.push(row);
|
||||||
|
renderEdit();
|
||||||
|
});
|
||||||
|
rowEl.querySelector('.remove-row').addEventListener('click', () => {
|
||||||
|
rows.splice(index, 1);
|
||||||
|
renderEdit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('save-songs-btn');
|
||||||
|
if (saveBtn) saveBtn.addEventListener('click', onSaveSongs);
|
||||||
|
const cancelBtn = document.getElementById('cancel-edit-btn');
|
||||||
|
if (cancelBtn) cancelBtn.addEventListener('click', renderReadOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSongToSection(section, selectEl) {
|
||||||
|
const songId = selectEl.value ? parseInt(selectEl.value, 10) : null;
|
||||||
|
if (!songId) return;
|
||||||
|
const song = allSongs.find((s) => s.id === songId);
|
||||||
|
if (!song) return;
|
||||||
|
const row = { setlistSongId: null, songId: song.id, title: song.title, artist: song.artist, note: '' };
|
||||||
|
if (section === 'main') mainRows.push(row);
|
||||||
|
else encoreRows.push(row);
|
||||||
|
renderEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaveSongs() {
|
||||||
|
try {
|
||||||
|
const payload = [
|
||||||
|
...mainRows.map((r, i) => ({ songId: r.songId, position: i + 1, note: r.note, isEncore: false })),
|
||||||
|
...encoreRows.map((r, i) => ({ songId: r.songId, position: i + 1, note: r.note, isEncore: true })),
|
||||||
|
];
|
||||||
|
setlist = await api.put(`/api/setlists/${setlist.id}/songs`, { songs: payload });
|
||||||
|
renderReadOnly();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
setlist = await getSetlist();
|
||||||
|
renderReadOnly();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,7 @@ function detailTemplate(s) {
|
||||||
const myVote = s.votes.find((v) => v.user_id === me.id);
|
const myVote = s.votes.find((v) => v.user_id === me.id);
|
||||||
return `
|
return `
|
||||||
${embed ? `<div class="youtube-embed"><iframe src="${embed}" allowfullscreen></iframe></div>` : `<p><a href="${escapeHtml(s.youtube_url)}" target="_blank" rel="noopener">${escapeHtml(s.youtube_url)}</a></p>`}
|
${embed ? `<div class="youtube-embed"><iframe src="${embed}" allowfullscreen></iframe></div>` : `<p><a href="${escapeHtml(s.youtube_url)}" target="_blank" rel="noopener">${escapeHtml(s.youtube_url)}</a></p>`}
|
||||||
|
${s.spotify_url ? `<div class="song-links"><a class="pill-link spotify" href="${escapeHtml(s.spotify_url)}" target="_blank" rel="noopener">♫ Spotify</a></div>` : ''}
|
||||||
${s.description ? `<div class="suggestion-note">${escapeHtml(s.description)}</div>` : ''}
|
${s.description ? `<div class="suggestion-note">${escapeHtml(s.description)}</div>` : ''}
|
||||||
|
|
||||||
<form class="inline-form vote-form" data-id="${s.id}">
|
<form class="inline-form vote-form" data-id="${s.id}">
|
||||||
|
|
@ -73,14 +74,14 @@ function detailTemplate(s) {
|
||||||
${s.votes.length ? s.votes.map(voteItemTemplate).join('') : '<p class="empty">Aucun vote pour le moment.</p>'}
|
${s.votes.length ? s.votes.map(voteItemTemplate).join('') : '<p class="empty">Aucun vote pour le moment.</p>'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${me.isAdmin ? `
|
|
||||||
<form class="inline-form admin-actions" data-id="${s.id}">
|
<form class="inline-form admin-actions" data-id="${s.id}">
|
||||||
<button type="button" class="promote-btn" data-id="${s.id}" ${s.promoted_song_id ? 'disabled' : ''}>
|
<button type="button" class="promote-btn" data-id="${s.id}" ${s.promoted_song_id ? 'disabled' : ''}>
|
||||||
${s.promoted_song_id ? 'Déjà au répertoire' : 'Promouvoir au répertoire'}
|
${s.promoted_song_id ? 'Déjà au répertoire' : 'Ajouter au répertoire'}
|
||||||
</button>
|
</button>
|
||||||
|
${me.isAdmin ? `
|
||||||
<button type="button" class="secondary reject-btn" data-id="${s.id}">Marquer rejeté</button>
|
<button type="button" class="secondary reject-btn" data-id="${s.id}">Marquer rejeté</button>
|
||||||
<button type="button" class="danger delete-btn" data-id="${s.id}">Supprimer</button>
|
<button type="button" class="danger delete-btn" data-id="${s.id}">Supprimer</button>` : ''}
|
||||||
</form>` : ''}
|
</form>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,9 +189,10 @@ async function onAddSuggestion(e) {
|
||||||
const title = form.title.value.trim();
|
const title = form.title.value.trim();
|
||||||
const artist = form.artist.value.trim();
|
const artist = form.artist.value.trim();
|
||||||
const youtubeUrl = form.youtubeUrl.value.trim();
|
const youtubeUrl = form.youtubeUrl.value.trim();
|
||||||
|
const spotifyUrl = form.spotifyUrl.value.trim();
|
||||||
const description = form.description.value.trim();
|
const description = form.description.value.trim();
|
||||||
try {
|
try {
|
||||||
await api.post('/api/suggestions', { title, artist, youtubeUrl, description });
|
await api.post('/api/suggestions', { title, artist, youtubeUrl, spotifyUrl, description });
|
||||||
form.reset();
|
form.reset();
|
||||||
document.getElementById('suggestion-link-status').textContent = '';
|
document.getElementById('suggestion-link-status').textContent = '';
|
||||||
if (suggestionAutocomplete) suggestionAutocomplete.close();
|
if (suggestionAutocomplete) suggestionAutocomplete.close();
|
||||||
|
|
@ -217,16 +219,20 @@ async function onSuggestionCandidateSelected(candidate) {
|
||||||
document.getElementById('suggestion-artist-input').value = candidate.artist;
|
document.getElementById('suggestion-artist-input').value = candidate.artist;
|
||||||
|
|
||||||
const statusEl = document.getElementById('suggestion-link-status');
|
const statusEl = document.getElementById('suggestion-link-status');
|
||||||
statusEl.textContent = 'Recherche du lien YouTube…';
|
statusEl.textContent = 'Recherche des liens YouTube / Spotify…';
|
||||||
try {
|
try {
|
||||||
const links = await api.get(
|
const links = await api.get(
|
||||||
`/api/music-search/links?title=${encodeURIComponent(candidate.title)}&artist=${encodeURIComponent(candidate.artist)}`
|
`/api/music-search/links?title=${encodeURIComponent(candidate.title)}&artist=${encodeURIComponent(candidate.artist)}`
|
||||||
);
|
);
|
||||||
const youtubeInput = document.getElementById('suggestion-youtube-input');
|
const youtubeInput = document.getElementById('suggestion-youtube-input');
|
||||||
|
const spotifyInput = document.getElementById('suggestion-spotify-input');
|
||||||
if (links.youtubeUrl && !youtubeInput.value) youtubeInput.value = links.youtubeUrl;
|
if (links.youtubeUrl && !youtubeInput.value) youtubeInput.value = links.youtubeUrl;
|
||||||
statusEl.textContent = links.youtubeUrl
|
if (links.spotifyUrl && !spotifyInput.value) spotifyInput.value = links.spotifyUrl;
|
||||||
? 'Lien YouTube trouvé automatiquement.'
|
|
||||||
: 'Aucun lien YouTube trouvé automatiquement — à saisir manuellement.';
|
if (links.youtubeUrl && links.spotifyUrl) statusEl.textContent = 'Liens YouTube et Spotify trouvés automatiquement.';
|
||||||
|
else if (links.youtubeUrl) statusEl.textContent = 'Lien YouTube trouvé automatiquement. Aucun lien Spotify trouvé — à saisir manuellement si besoin.';
|
||||||
|
else if (links.spotifyUrl) statusEl.textContent = 'Lien Spotify trouvé automatiquement. Aucun lien YouTube trouvé — à saisir manuellement si besoin.';
|
||||||
|
else statusEl.textContent = 'Aucun lien trouvé automatiquement — vous pouvez les saisir manuellement.';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="/js/render.js"></script>
|
<script src="/js/render.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
|
<script src="/js/setlistEditor.js"></script>
|
||||||
<script src="/js/setlist.js"></script>
|
<script src="/js/setlist.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
</div>
|
</div>
|
||||||
<label>Artiste <input name="artist" id="suggestion-artist-input"></label>
|
<label>Artiste <input name="artist" id="suggestion-artist-input"></label>
|
||||||
<label>Lien YouTube <input name="youtubeUrl" id="suggestion-youtube-input" type="url" required placeholder="https://youtube.com/watch?v=..."></label>
|
<label>Lien YouTube <input name="youtubeUrl" id="suggestion-youtube-input" type="url" required placeholder="https://youtube.com/watch?v=..."></label>
|
||||||
|
<label>Lien Spotify <input name="spotifyUrl" id="suggestion-spotify-input" type="url" placeholder="https://open.spotify.com/track/..."></label>
|
||||||
<label>Note / détails <textarea name="description" placeholder="Pourquoi ce morceau, difficulté, arrangement envisagé..."></textarea></label>
|
<label>Note / détails <textarea name="description" placeholder="Pourquoi ce morceau, difficulté, arrangement envisagé..."></textarea></label>
|
||||||
<p class="note" id="suggestion-link-status"></p>
|
<p class="note" id="suggestion-link-status"></p>
|
||||||
<button type="submit">Proposer</button>
|
<button type="submit">Proposer</button>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const sessionMiddleware = require('./auth/session');
|
||||||
const authRoutes = require('./auth/routes');
|
const authRoutes = require('./auth/routes');
|
||||||
const { attachUser, requireAuth } = require('./auth/middleware');
|
const { attachUser, requireAuth } = require('./auth/middleware');
|
||||||
const apiRouter = require('./routes');
|
const apiRouter = require('./routes');
|
||||||
|
const calendarWebhooksRouter = require('./routes/calendarWebhooks');
|
||||||
|
|
||||||
function createApp() {
|
function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
@ -19,6 +20,12 @@ function createApp() {
|
||||||
app.use('/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
app.use(attachUser);
|
app.use(attachUser);
|
||||||
|
|
||||||
|
// Called by n8n directly (server-to-server, no session) — secret-protected
|
||||||
|
// per-route inside the router itself, not gated by requireAuth. Must be
|
||||||
|
// mounted before the /api requireAuth line so unmatched paths (e.g.
|
||||||
|
// GET /api/calendar/people) fall through to the normal session-gated router.
|
||||||
|
app.use('/api/calendar', calendarWebhooksRouter);
|
||||||
|
|
||||||
app.use('/api', requireAuth, apiRouter);
|
app.use('/api', requireAuth, apiRouter);
|
||||||
app.use(requireAuth, express.static(path.join(__dirname, '../public')));
|
app.use(requireAuth, express.static(path.join(__dirname, '../public')));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ async function attachUser(req, res, next) {
|
||||||
|
|
||||||
function requireAuth(req, res, next) {
|
function requireAuth(req, res, next) {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
if (req.path.startsWith('/api/')) {
|
// req.path is relative to the mount prefix here (Express strips '/api'
|
||||||
|
// for every layer registered via app.use('/api', ...)), so it never
|
||||||
|
// actually starts with '/api/' — use req.originalUrl, which always holds
|
||||||
|
// the full request path regardless of mount nesting.
|
||||||
|
if (req.originalUrl.startsWith('/api/')) {
|
||||||
return res.status(401).json({ error: 'unauthenticated' });
|
return res.status(401).json({ error: 'unauthenticated' });
|
||||||
}
|
}
|
||||||
return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`);
|
return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`);
|
||||||
|
|
|
||||||
|
|
@ -49,4 +49,13 @@ module.exports = {
|
||||||
spotifyClientId: process.env.SPOTIFY_CLIENT_ID || null,
|
spotifyClientId: process.env.SPOTIFY_CLIENT_ID || null,
|
||||||
spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET || null,
|
spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET || null,
|
||||||
youtubeApiKey: process.env.YOUTUBE_API_KEY || null,
|
youtubeApiKey: process.env.YOUTUBE_API_KEY || null,
|
||||||
|
// Calendar (availability) feature. N8N_* are optional: without them,
|
||||||
|
// viewing already-ingested availability still works, only the "refresh"
|
||||||
|
// button (which triggers the n8n workflow) is disabled.
|
||||||
|
n8nWebhookUrl: process.env.N8N_WEBHOOK_URL || null,
|
||||||
|
n8nWebhookUser: process.env.N8N_WEBHOOK_USER || null,
|
||||||
|
n8nWebhookPass: process.env.N8N_WEBHOOK_PASS || null,
|
||||||
|
// Required: protects the two endpoints n8n calls directly (server-to-server,
|
||||||
|
// no browser session involved) — see src/lib/calendarWebhookAuth.js.
|
||||||
|
calendarWebhookSecret: required('CALENDAR_WEBHOOK_SECRET'),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
26
src/db/migrations/005_calendar.sql
Normal file
26
src/db/migrations/005_calendar.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
CREATE TABLE calendar_people (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
color TEXT NOT NULL DEFAULT '#4285f4'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE calendar_slots (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
lower TIMESTAMPTZ NOT NULL,
|
||||||
|
upper TIMESTAMPTZ NOT NULL,
|
||||||
|
slot_date DATE NOT NULL,
|
||||||
|
day_of_week SMALLINT NOT NULL,
|
||||||
|
UNIQUE (lower, upper)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE calendar_availability (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slot_id INTEGER NOT NULL REFERENCES calendar_slots(id) ON DELETE CASCADE,
|
||||||
|
person_id INTEGER NOT NULL REFERENCES calendar_people(id) ON DELETE CASCADE,
|
||||||
|
is_available BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (slot_id, person_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_calendar_slots_slot_date ON calendar_slots (slot_date);
|
||||||
|
CREATE INDEX idx_calendar_availability_slot_id ON calendar_availability (slot_id);
|
||||||
7
src/db/migrations/006_calendar_seed_people.sql
Normal file
7
src/db/migrations/006_calendar_seed_people.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
INSERT INTO calendar_people (name, color) VALUES
|
||||||
|
('Nathan', '#4285f4'),
|
||||||
|
('Raphaël', '#fbbc05'),
|
||||||
|
('Yann', '#34a853'),
|
||||||
|
('Jules', '#a142f4'),
|
||||||
|
('AK', '#24c1e0')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
1
src/db/migrations/007_suggestion_spotify.sql
Normal file
1
src/db/migrations/007_suggestion_spotify.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE suggestions ADD COLUMN IF NOT EXISTS spotify_url TEXT;
|
||||||
30
src/lib/calendarDates.js
Normal file
30
src/lib/calendarDates.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// Truncates to minute precision so repeated ingests of "the same" slot land
|
||||||
|
// on the same instant even though n8n's timestamps carry sub-minute noise
|
||||||
|
// that differs run to run (the DB's UNIQUE(lower, upper) constraint depends
|
||||||
|
// on this). Returns a Date (not a string) — pg accepts Date directly for a
|
||||||
|
// timestamptz column.
|
||||||
|
function normalizeISO(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
d.setSeconds(0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar date (YYYY-MM-DD) of the given instant in Europe/Paris, so e.g. a
|
||||||
|
// 18:30 CEST slot stays on the correct local day regardless of what UTC day
|
||||||
|
// it falls on.
|
||||||
|
function slotDateParis(iso) {
|
||||||
|
return new Date(iso).toLocaleDateString('en-CA', { timeZone: 'Europe/Paris' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_TO_NUMBER = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
|
||||||
|
|
||||||
|
// Monday=1..Sunday=7, from the Europe/Paris-local weekday name. Falls back
|
||||||
|
// to the instant's own (UTC) getDay() if the locale parsing ever fails.
|
||||||
|
function dayOfWeekParis(iso) {
|
||||||
|
const date = new Date(iso);
|
||||||
|
const short = date.toLocaleDateString('en-US', { timeZone: 'Europe/Paris', weekday: 'short' }).slice(0, 3);
|
||||||
|
const dow = WEEKDAY_TO_NUMBER[short];
|
||||||
|
return dow === undefined ? date.getDay() || 7 : dow;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizeISO, slotDateParis, dayOfWeekParis };
|
||||||
23
src/lib/calendarWebhookAuth.js
Normal file
23
src/lib/calendarWebhookAuth.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
// Protects the two endpoints n8n calls directly (server-to-server, no
|
||||||
|
// browser session available) — a shared secret header instead of the
|
||||||
|
// session-based auth the rest of /api uses. Constant-time compare since
|
||||||
|
// this is a bearer-secret check.
|
||||||
|
function calendarWebhookAuth(req, res, next) {
|
||||||
|
const provided = req.header('X-Calendar-Webhook-Secret') || '';
|
||||||
|
const expected = config.calendarWebhookSecret;
|
||||||
|
|
||||||
|
const providedBuf = Buffer.from(provided);
|
||||||
|
const expectedBuf = Buffer.from(expected);
|
||||||
|
const valid =
|
||||||
|
providedBuf.length === expectedBuf.length && crypto.timingSafeEqual(providedBuf, expectedBuf);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
return res.status(401).json({ error: 'invalid_webhook_secret' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = calendarWebhookAuth;
|
||||||
25
src/lib/calendarWorkflowState.js
Normal file
25
src/lib/calendarWorkflowState.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// In-memory only — resets on process restart, which is fine: it just tracks
|
||||||
|
// whether the last "refresh availability" run is idle/running/done/failed,
|
||||||
|
// not data that needs to survive a restart. Shared between the session-gated
|
||||||
|
// routes (src/routes/calendar.js) and the n8n-facing webhook routes
|
||||||
|
// (src/routes/calendarWebhooks.js) so both can read/update the same state
|
||||||
|
// without a require cycle between those two route files.
|
||||||
|
let state = { status: 'idle', triggeredAt: null, message: null, node: null };
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRunning() {
|
||||||
|
state = { status: 'running', triggeredAt: new Date().toISOString(), message: null, node: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSuccess() {
|
||||||
|
state = { ...state, status: 'success', message: null, node: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setError(message, node) {
|
||||||
|
state = { ...state, status: 'error', message: message || 'Unknown error', node: node || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getState, setRunning, setSuccess, setError };
|
||||||
125
src/repositories/calendarRepo.js
Normal file
125
src/repositories/calendarRepo.js
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
const pool = require('../db/pool');
|
||||||
|
const { normalizeISO, slotDateParis, dayOfWeekParis } = require('../lib/calendarDates');
|
||||||
|
|
||||||
|
const COLOR_PALETTE = [
|
||||||
|
'#4285f4', '#ea4335', '#fbbc05', '#34a853',
|
||||||
|
'#a142f4', '#24c1e0', '#ff6d00', '#795548',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function getPeople() {
|
||||||
|
const { rows } = await pool.query('SELECT id, name, color FROM calendar_people ORDER BY id');
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always called from within the ingestSlots transaction below (needs the
|
||||||
|
// same client so the color-index count and the insert see a consistent view).
|
||||||
|
async function upsertPerson(client, name) {
|
||||||
|
const existing = await client.query('SELECT id FROM calendar_people WHERE name = $1', [name]);
|
||||||
|
if (existing.rows[0]) return existing.rows[0].id;
|
||||||
|
|
||||||
|
const { rows: countRows } = await client.query('SELECT COUNT(*)::int AS count FROM calendar_people');
|
||||||
|
const color = COLOR_PALETTE[countRows[0].count % COLOR_PALETTE.length];
|
||||||
|
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`INSERT INTO calendar_people (name, color) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET name = excluded.name
|
||||||
|
RETURNING id`,
|
||||||
|
[name, color]
|
||||||
|
);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ingestSlots(slots) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
for (const slot of slots) {
|
||||||
|
const lower = normalizeISO(slot.lower);
|
||||||
|
const upper = normalizeISO(slot.upper);
|
||||||
|
const slotDate = slotDateParis(slot.lower);
|
||||||
|
const dayOfWeek = dayOfWeekParis(slot.lower);
|
||||||
|
|
||||||
|
const { rows: slotRows } = await client.query(
|
||||||
|
`INSERT INTO calendar_slots (lower, upper, slot_date, day_of_week)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (lower, upper) DO UPDATE SET
|
||||||
|
slot_date = excluded.slot_date,
|
||||||
|
day_of_week = excluded.day_of_week
|
||||||
|
RETURNING id`,
|
||||||
|
[lower, upper, slotDate, dayOfWeek]
|
||||||
|
);
|
||||||
|
const slotId = slotRows[0].id;
|
||||||
|
|
||||||
|
// people may arrive as a JSON string from n8n's Set-node serialization.
|
||||||
|
const people = typeof slot.people === 'string' ? JSON.parse(slot.people) : slot.people || [];
|
||||||
|
|
||||||
|
for (const person of people) {
|
||||||
|
if (!person || !person.name) continue;
|
||||||
|
const personId = await upsertPerson(client, person.name);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO calendar_availability (slot_id, person_id, is_available, checked_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (slot_id, person_id) DO UPDATE SET
|
||||||
|
is_available = excluded.is_available,
|
||||||
|
checked_at = excluded.checked_at`,
|
||||||
|
[slotId, personId, !!person.available]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSlots({ minPeople = 1, personIds = null, weeks = 3 } = {}) {
|
||||||
|
const cappedWeeks = Math.min(weeks || 3, 3);
|
||||||
|
const now = new Date();
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setDate(end.getDate() + cappedWeeks * 7);
|
||||||
|
|
||||||
|
const nowStr = now.toISOString().substring(0, 10);
|
||||||
|
const endStr = end.toISOString().substring(0, 10);
|
||||||
|
const hasPersonFilter = Array.isArray(personIds) && personIds.length > 0;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
ts.id, ts.lower, ts.upper, ts.slot_date, ts.day_of_week,
|
||||||
|
filtered.available_count, filtered.total_in_filter,
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object('id', p.id, 'name', p.name, 'color', p.color,
|
||||||
|
'is_available', sa2.is_available)
|
||||||
|
ORDER BY p.id
|
||||||
|
)
|
||||||
|
FROM calendar_availability sa2
|
||||||
|
JOIN calendar_people p ON p.id = sa2.person_id
|
||||||
|
WHERE sa2.slot_id = ts.id
|
||||||
|
) AS people
|
||||||
|
FROM calendar_slots ts
|
||||||
|
JOIN (
|
||||||
|
SELECT slot_id,
|
||||||
|
COUNT(*) FILTER (WHERE is_available) AS available_count,
|
||||||
|
COUNT(*) AS total_in_filter
|
||||||
|
FROM calendar_availability
|
||||||
|
WHERE ($3::int[] IS NULL OR person_id = ANY($3::int[]))
|
||||||
|
GROUP BY slot_id
|
||||||
|
) filtered ON filtered.slot_id = ts.id
|
||||||
|
WHERE ts.slot_date >= $1 AND ts.slot_date <= $2
|
||||||
|
AND filtered.available_count >= $4
|
||||||
|
ORDER BY ts.lower`,
|
||||||
|
[nowStr, endStr, hasPersonFilter ? personIds : null, minPeople]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({ ...row, people: row.people || [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLastChecked() {
|
||||||
|
const { rows } = await pool.query('SELECT MAX(checked_at) AS ts FROM calendar_availability');
|
||||||
|
return rows[0].ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getPeople, ingestSlots, getSlots, getLastChecked };
|
||||||
|
|
@ -2,7 +2,7 @@ const pool = require('../db/pool');
|
||||||
|
|
||||||
async function findAll() {
|
async function findAll() {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.description, sg.status, sg.promoted_song_id,
|
SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.spotify_url, sg.description, sg.status, sg.promoted_song_id,
|
||||||
sg.created_at, sg.suggested_by, u.name AS suggested_by_name,
|
sg.created_at, sg.suggested_by, u.name AS suggested_by_name,
|
||||||
COUNT(*) FILTER (WHERE v.vote = 'approve')::int AS approve_count,
|
COUNT(*) FILTER (WHERE v.vote = 'approve')::int AS approve_count,
|
||||||
COUNT(*) FILTER (WHERE v.vote = 'reject')::int AS reject_count
|
COUNT(*) FILTER (WHERE v.vote = 'reject')::int AS reject_count
|
||||||
|
|
@ -17,7 +17,7 @@ async function findAll() {
|
||||||
|
|
||||||
async function findById(id) {
|
async function findById(id) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.description, sg.status, sg.promoted_song_id,
|
`SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.spotify_url, sg.description, sg.status, sg.promoted_song_id,
|
||||||
sg.created_at, sg.suggested_by, u.name AS suggested_by_name
|
sg.created_at, sg.suggested_by, u.name AS suggested_by_name
|
||||||
FROM suggestions sg
|
FROM suggestions sg
|
||||||
JOIN users u ON u.id = sg.suggested_by
|
JOIN users u ON u.id = sg.suggested_by
|
||||||
|
|
@ -39,12 +39,12 @@ async function findVotes(suggestionId) {
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create({ title, artist, youtubeUrl, description, suggestedBy }) {
|
async function create({ title, artist, youtubeUrl, spotifyUrl, description, suggestedBy }) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO suggestions (title, artist, youtube_url, description, suggested_by)
|
`INSERT INTO suggestions (title, artist, youtube_url, spotify_url, description, suggested_by)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id, title, artist, youtube_url, description, status, promoted_song_id, created_at, suggested_by`,
|
RETURNING id, title, artist, youtube_url, spotify_url, description, status, promoted_song_id, created_at, suggested_by`,
|
||||||
[title, artist || null, youtubeUrl, description || null, suggestedBy]
|
[title, artist || null, youtubeUrl, spotifyUrl || null, description || null, suggestedBy]
|
||||||
);
|
);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +52,7 @@ async function create({ title, artist, youtubeUrl, description, suggestedBy }) {
|
||||||
async function updateStatus(id, status) {
|
async function updateStatus(id, status) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`UPDATE suggestions SET status = $2, updated_at = now() WHERE id = $1
|
`UPDATE suggestions SET status = $2, updated_at = now() WHERE id = $1
|
||||||
RETURNING id, title, artist, youtube_url, description, status, promoted_song_id, created_at, suggested_by`,
|
RETURNING id, title, artist, youtube_url, spotify_url, description, status, promoted_song_id, created_at, suggested_by`,
|
||||||
[id, status]
|
[id, status]
|
||||||
);
|
);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
|
|
@ -94,10 +94,17 @@ async function promoteToSong(suggestionId, addedBy) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { rows: songRows } = await client.query(
|
const { rows: songRows } = await client.query(
|
||||||
`INSERT INTO songs (title, artist, notes, youtube_url, added_by)
|
`INSERT INTO songs (title, artist, notes, youtube_url, spotify_url, added_by)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id, title, artist, notes, youtube_url, spotify_url, added_by, created_at, updated_at`,
|
RETURNING id, title, artist, notes, youtube_url, spotify_url, added_by, created_at, updated_at`,
|
||||||
[suggestion.title, suggestion.artist || suggestion.title, suggestion.description, suggestion.youtube_url, addedBy]
|
[
|
||||||
|
suggestion.title,
|
||||||
|
suggestion.artist || suggestion.title,
|
||||||
|
suggestion.description,
|
||||||
|
suggestion.youtube_url,
|
||||||
|
suggestion.spotify_url,
|
||||||
|
addedBy,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
const song = songRows[0];
|
const song = songRows[0];
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
|
||||||
96
src/routes/calendar.js
Normal file
96
src/routes/calendar.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
const express = require('express');
|
||||||
|
const calendarRepo = require('../repositories/calendarRepo');
|
||||||
|
const workflowState = require('../lib/calendarWorkflowState');
|
||||||
|
const config = require('../config');
|
||||||
|
const asyncHandler = require('../lib/asyncHandler');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/people',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
res.json(await calendarRepo.getPeople());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/slots',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const minPeople = req.query.min_people !== undefined ? parseInt(req.query.min_people, 10) : 1;
|
||||||
|
const weeks = req.query.weeks !== undefined ? parseInt(req.query.weeks, 10) : 3;
|
||||||
|
const personIds = req.query.person_ids
|
||||||
|
? req.query.person_ids.split(',').map(Number).filter((n) => !Number.isNaN(n))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const slots = await calendarRepo.getSlots({ minPeople, personIds, weeks });
|
||||||
|
res.json(slots);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/last-checked',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
res.json({ last_checked: await calendarRepo.getLastChecked() });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/workflow-status', (req, res) => {
|
||||||
|
res.json(workflowState.getState());
|
||||||
|
});
|
||||||
|
|
||||||
|
function webhookHeaders() {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (config.n8nWebhookUser && config.n8nWebhookPass) {
|
||||||
|
const token = Buffer.from(`${config.n8nWebhookUser}:${config.n8nWebhookPass}`).toString('base64');
|
||||||
|
headers.Authorization = `Basic ${token}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/refresh', (req, res) => {
|
||||||
|
if (!config.n8nWebhookUrl) {
|
||||||
|
return res.status(400).json({ error: 'n8n_not_configured' });
|
||||||
|
}
|
||||||
|
if (workflowState.getState().status === 'running') {
|
||||||
|
return res.status(409).json({ ok: false, error: 'A refresh is already in progress' });
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowState.setRunning();
|
||||||
|
res.json({ ok: true });
|
||||||
|
|
||||||
|
// Fire-and-forget: the browser polls GET /workflow-status for the result.
|
||||||
|
(async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const watchdog = setTimeout(() => controller.abort(), 5 * 60 * 1000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(config.n8nWebhookUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: webhookHeaders(),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(watchdog);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
workflowState.setError(`n8n returned ${response.status}: ${text}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!Array.isArray(data.slots)) {
|
||||||
|
workflowState.setError('Unexpected response format from n8n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await calendarRepo.ingestSlots(data.slots);
|
||||||
|
workflowState.setSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(watchdog);
|
||||||
|
const message = err.name === 'AbortError' ? 'n8n did not respond within 5 minutes' : err.message;
|
||||||
|
console.error('[calendar] n8n refresh failed:', message);
|
||||||
|
workflowState.setError(message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
42
src/routes/calendarWebhooks.js
Normal file
42
src/routes/calendarWebhooks.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
const express = require('express');
|
||||||
|
const calendarRepo = require('../repositories/calendarRepo');
|
||||||
|
const workflowState = require('../lib/calendarWorkflowState');
|
||||||
|
const calendarWebhookAuth = require('../lib/calendarWebhookAuth');
|
||||||
|
const asyncHandler = require('../lib/asyncHandler');
|
||||||
|
|
||||||
|
// Mounted directly in app.js, before the session-gated /api router — these
|
||||||
|
// two routes are called by n8n itself (server-to-server), so they cannot
|
||||||
|
// rely on a browser session cookie. The secret check is applied per-route
|
||||||
|
// here rather than as middleware on the whole /api/calendar mount, so that
|
||||||
|
// unmatched paths (e.g. GET /api/calendar/people) fall through untouched to
|
||||||
|
// the normal session-gated router mounted afterwards.
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/ingest',
|
||||||
|
calendarWebhookAuth,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const slots = Array.isArray(req.body) ? req.body : req.body?.slots;
|
||||||
|
if (!Array.isArray(slots)) {
|
||||||
|
return res.status(400).json({ error: 'expected_array_of_slots' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await calendarRepo.ingestSlots(slots);
|
||||||
|
workflowState.setSuccess();
|
||||||
|
res.json({ ok: true, count: slots.length });
|
||||||
|
} catch (err) {
|
||||||
|
workflowState.setError(err.message);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/workflow-error', calendarWebhookAuth, (req, res) => {
|
||||||
|
const message = req.body?.message || req.body?.error || 'Workflow failed';
|
||||||
|
const node = req.body?.node || null;
|
||||||
|
console.error('[calendar] n8n workflow error:', message, node ? `(node: ${node})` : '');
|
||||||
|
workflowState.setError(message, node);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -5,6 +5,7 @@ const songsRoutes = require('./songs');
|
||||||
const suggestionsRoutes = require('./suggestions');
|
const suggestionsRoutes = require('./suggestions');
|
||||||
const setlistsRoutes = require('./setlists');
|
const setlistsRoutes = require('./setlists');
|
||||||
const musicSearchRoutes = require('./musicSearch');
|
const musicSearchRoutes = require('./musicSearch');
|
||||||
|
const calendarRoutes = require('./calendar');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -14,5 +15,6 @@ router.use('/songs', songsRoutes);
|
||||||
router.use('/suggestions', suggestionsRoutes);
|
router.use('/suggestions', suggestionsRoutes);
|
||||||
router.use('/setlists', setlistsRoutes);
|
router.use('/setlists', setlistsRoutes);
|
||||||
router.use('/music-search', musicSearchRoutes);
|
router.use('/music-search', musicSearchRoutes);
|
||||||
|
router.use('/calendar', calendarRoutes);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const express = require('express');
|
||||||
const suggestionsRepo = require('../repositories/suggestionsRepo');
|
const suggestionsRepo = require('../repositories/suggestionsRepo');
|
||||||
const { requireAdmin } = require('../auth/middleware');
|
const { requireAdmin } = require('../auth/middleware');
|
||||||
const { isValidYoutubeUrl } = require('../lib/youtube');
|
const { isValidYoutubeUrl } = require('../lib/youtube');
|
||||||
|
const { isValidSpotifyUrl } = require('../lib/spotify');
|
||||||
const asyncHandler = require('../lib/asyncHandler');
|
const asyncHandler = require('../lib/asyncHandler');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -26,17 +27,21 @@ router.get(
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { title, artist, youtubeUrl, description } = req.body || {};
|
const { title, artist, youtubeUrl, spotifyUrl, description } = req.body || {};
|
||||||
if (!title || !title.trim() || !youtubeUrl || !youtubeUrl.trim()) {
|
if (!title || !title.trim() || !youtubeUrl || !youtubeUrl.trim()) {
|
||||||
return res.status(400).json({ error: 'title_and_youtube_url_required' });
|
return res.status(400).json({ error: 'title_and_youtube_url_required' });
|
||||||
}
|
}
|
||||||
if (!isValidYoutubeUrl(youtubeUrl.trim())) {
|
if (!isValidYoutubeUrl(youtubeUrl.trim())) {
|
||||||
return res.status(400).json({ error: 'invalid_youtube_url' });
|
return res.status(400).json({ error: 'invalid_youtube_url' });
|
||||||
}
|
}
|
||||||
|
if (spotifyUrl && !isValidSpotifyUrl(spotifyUrl.trim())) {
|
||||||
|
return res.status(400).json({ error: 'invalid_spotify_url' });
|
||||||
|
}
|
||||||
const suggestion = await suggestionsRepo.create({
|
const suggestion = await suggestionsRepo.create({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
artist: artist ? artist.trim() : null,
|
artist: artist ? artist.trim() : null,
|
||||||
youtubeUrl: youtubeUrl.trim(),
|
youtubeUrl: youtubeUrl.trim(),
|
||||||
|
spotifyUrl: spotifyUrl ? spotifyUrl.trim() : null,
|
||||||
description: description ? description.trim() : null,
|
description: description ? description.trim() : null,
|
||||||
suggestedBy: req.user.id,
|
suggestedBy: req.user.id,
|
||||||
});
|
});
|
||||||
|
|
@ -91,7 +96,6 @@ router.delete(
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/:id/promote',
|
'/:id/promote',
|
||||||
requireAdmin,
|
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const song = await suggestionsRepo.promoteToSong(req.params.id, req.user.id);
|
const song = await suggestionsRepo.promoteToSong(req.params.id, req.user.id);
|
||||||
if (!song) return res.status(404).json({ error: 'not_found' });
|
if (!song) return res.status(404).json({ error: 'not_found' });
|
||||||
|
|
|
||||||
49
test/calendarDates.test.js
Normal file
49
test/calendarDates.test.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { normalizeISO, slotDateParis, dayOfWeekParis } = require('../src/lib/calendarDates');
|
||||||
|
|
||||||
|
test('normalizeISO: collapses sub-minute noise to the same instant', () => {
|
||||||
|
const a = normalizeISO('2026-06-10T18:30:12.345Z');
|
||||||
|
const b = normalizeISO('2026-06-10T18:30:47.001Z');
|
||||||
|
assert.equal(a.getTime(), b.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeISO: zeroes seconds and milliseconds only, keeps minute/hour/day', () => {
|
||||||
|
const d = normalizeISO('2026-06-10T18:30:47.001Z');
|
||||||
|
assert.equal(d.getUTCSeconds(), 0);
|
||||||
|
assert.equal(d.getUTCMilliseconds(), 0);
|
||||||
|
assert.equal(d.getUTCMinutes(), 30);
|
||||||
|
assert.equal(d.getUTCHours(), 18);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slotDateParis: late-evening UTC instant rolls onto the next Paris-local day (winter, UTC+1)', () => {
|
||||||
|
assert.equal(slotDateParis('2026-01-15T23:30:00Z'), '2026-01-16');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slotDateParis: late-evening UTC instant rolls onto the next Paris-local day (summer, UTC+2)', () => {
|
||||||
|
assert.equal(slotDateParis('2026-07-15T22:30:00Z'), '2026-07-16');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slotDateParis: spring-forward DST boundary (2026-03-29, CET->CEST at 01:00 UTC)', () => {
|
||||||
|
// Just before the jump: still CET (UTC+1) -> 01:30 local.
|
||||||
|
assert.equal(slotDateParis('2026-03-29T00:30:00Z'), '2026-03-29');
|
||||||
|
// Just after the jump: already CEST (UTC+2) -> 03:30 local, same calendar day.
|
||||||
|
assert.equal(slotDateParis('2026-03-29T01:30:00Z'), '2026-03-29');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slotDateParis: fall-back DST boundary (2026-10-25, CEST->CET at 01:00 UTC)', () => {
|
||||||
|
// The 01:00-02:00 UTC hour occurs twice in local time (02:xx both times);
|
||||||
|
// both instants should still resolve to the same calendar day.
|
||||||
|
assert.equal(slotDateParis('2026-10-25T00:30:00Z'), '2026-10-25');
|
||||||
|
assert.equal(slotDateParis('2026-10-25T01:30:00Z'), '2026-10-25');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dayOfWeekParis: maps Monday..Sunday to 1..7', () => {
|
||||||
|
assert.equal(dayOfWeekParis('2026-01-16T00:30:00+01:00'), 5); // Friday
|
||||||
|
assert.equal(dayOfWeekParis('2026-01-11T12:00:00Z'), 7); // Sunday, not 0
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dayOfWeekParis: DST boundary days still resolve to Sunday (7)', () => {
|
||||||
|
assert.equal(dayOfWeekParis('2026-03-29T00:30:00Z'), 7);
|
||||||
|
assert.equal(dayOfWeekParis('2026-10-25T01:30:00Z'), 7);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue