v0.29.10 — Admin UI refonte (modals + onglets), emails de release, 4-digit versions
Admin backoffice — UI redesign : - Pages versions.php + licenses.php : remplace les <details> inline qui débordaient horizontalement par UN bouton « ✎ Modifier » par row qui ouvre une modal <dialog> avec onglets. 5 tabs versions (Méta, Notes, BÊTA, Channels, Avancé) + 6 tabs licenses (Prolonger, Slots, Channel, BÊTA, Lock, Contacts, Machines). Délégation JS unique pour les onglets. - Bouton 📋 Copier la clé license dans chaque row (Clipboard API + feedback visuel ✓ vert 1.5s). Évite le détour par phpMyAdmin pour transmettre la clé aux clients. - Overlay « hashing en cours » plein écran sur tous les boutons de hash (3-5 min sur OVH pour 13 Go ZIP). Spinner CSS + message contextualisé par scope (bulk vs single). - Date de release passée de datetime-local à date (l'heure n'a pas de sens UX), avec défaut = aujourd'hui pour release_at et aujourd'hui-1an pour min_license_date (= license standard couvre les releases sur 1 an). Emails de notification release : - Migration 004 : colonne contact_emails TEXT NULL sur licenses (CSV) - Onglet « Contacts » sur la modal licenses pour saisir les emails par license (parsing tolérant : CSV, ligne par ligne, point-virgule) - Bouton « ✉ Notifier » par version : POST notify_release filtre les licenses éligibles (channel match + min_license_date + can_see_betas pour BÊTA) et envoie un email HTML à chaque contact (dédup global) - Template email table-based + bgcolor (compat Outlook/Word engine), navy foncé #0F172A, logo Asterion en CID embed (= affichage direct sans demande de permission Outlook), bouton download installer centré (align="center" + margin auto), release notes en <pre> - Mailer.php helper : parse emails, multipart/related avec attachments inline, fallback execCommand pour clipboard 4-digit version support (X.Y.Z.B) : - SemVer Parse/CompareTo/ToString gèrent 3 ou 4 digits ; Build absent = 0 implicite (1.5.4 == 1.5.4.0 < 1.5.4.13). HasExplicitBuild préserve le format d'origine au round-trip. - Regex InstallationRegistry étendue avec (?:\.\d+)? → reconnaît « PROSERVE v1.5.4.13 » côte-à-côte avec « PROSERVE v1.5.4 » sur disque - Server-side : versions.php, launcher.php, DownloadUrl.php, api/index.php, Releasenotes.php — toutes les regex de validation acceptent le 4ᵉ digit - Use case : dev/test iterations cohabitant avec leur release stable Bugs fixes : - migrate.php : strip ligne par ligne les commentaires SQL avant le check is-empty. Sans ça, le PREMIER chunk d'un fichier migration (= header + premier ALTER) commençait par `--` et était silencieusement skip → ALTER jamais appliqué. Affectait migrations 003, 004. - SignManifest::getOrComputeSha256 ignorait son cache interne quand force demandé par le caller, retournant l'ancien hash en 0 ms même après re-upload SFTP (avec mtime préservé). Propage maintenant le flag $force. Bouton « 🔁 Hash » per-row force maintenant un re-calcul systématique. - DownloadManager : 416 (Range Not Satisfiable) ajouté aux URL-refresh triggers, avec HEAD probe pour comparer taille serveur vs manifest → message d'erreur explicite si ZIP tronqué. Bps display lissé sur une fenêtre glissante de 12 samples (3 s) → plus de clignotement quand un segment finit / Polly retry. SHA mismatch popup enrichi avec les deux SHAs (attendu vs calculé) extraits via regex de l'exception. - DownloadUrl.php : signature de l'URL utilisait un template hardcodé /builds/proserve-{version}.zip, ignorant tout rename serveur. Lit maintenant download.url du manifest et signe le filename réel. Strings i18n (5 langues) : - ~15 nouveaux : SHA mismatch enrichi avec sources, 416 size mismatch, stale manifest, manifest refreshed auto-retry, force fresh menu Bumps : 0.29.7 → 0.29.10 (4-digit support + accumulated UI fixes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,14 @@ declare(strict_types=1);
|
||||
require __DIR__ . '/lib/Auth.php';
|
||||
require __DIR__ . '/lib/Layout.php';
|
||||
require __DIR__ . '/lib/Channels.php';
|
||||
require __DIR__ . '/lib/Mailer.php';
|
||||
require __DIR__ . '/../api/lib/Db.php';
|
||||
|
||||
use PSLauncher\Admin\Auth;
|
||||
use PSLauncher\Admin\Layout;
|
||||
use PSLauncher\Admin\Channels;
|
||||
use PSLauncher\Admin\Mailer;
|
||||
use PSLauncher\Db;
|
||||
|
||||
Auth::requireLogin();
|
||||
$config = require __DIR__ . '/../api/config.php';
|
||||
@@ -97,6 +101,162 @@ function find_entry_index_by_id(array $manifest, string $id): ?int
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le corps HTML de l'email de release announce. Message en anglais
|
||||
* (= langue par défaut du parc client international VR PROSERVE), inclut les
|
||||
* release notes brutes en Markdown (sans rendu — les MUA modernes Markdown
|
||||
* basique en clair reste très lisible et évite la dépendance Markdig PHP).
|
||||
* Le owner_name de la license est utilisé pour personnaliser le greeting.
|
||||
* Le logo (optionnel via <c>notifications.logo_url</c>) est inséré dans le
|
||||
* bandeau d'en-tête bleu — hotlinkable depuis n'importe quel MUA (pas de CID
|
||||
* embed pour rester compatible avec Gmail/Outlook qui bloquent les data URIs).
|
||||
*/
|
||||
function renderReleaseAnnounceEmail(string $version, string $ownerName, string $notesMarkdown, string $baseUrl, string $logoSrc = ''): string
|
||||
{
|
||||
$versionEsc = htmlspecialchars($version, ENT_QUOTES, 'UTF-8');
|
||||
$ownerEsc = htmlspecialchars($ownerName !== '' ? $ownerName : 'team', ENT_QUOTES, 'UTF-8');
|
||||
$notesEsc = htmlspecialchars(trim($notesMarkdown), ENT_QUOTES, 'UTF-8');
|
||||
// $logoSrc peut être une URL https://… (hotlink) OU un cid:xxx (inline embed).
|
||||
// Le caller décide selon ce qu'il préfère ; on échappe juste pour mettre dans src=.
|
||||
$logoEsc = htmlspecialchars($logoSrc, ENT_QUOTES, 'UTF-8');
|
||||
// Wrap les release notes en <pre> pour préserver le Markdown formatting
|
||||
// (indentation, listes, line breaks). Pas de rendu HTML — l'admin sait que
|
||||
// ses notes sont en Markdown et l'opérateur peut lire le format brut.
|
||||
$notesBlock = $notesEsc !== ''
|
||||
? "<h3 style=\"color: #1F2937; margin: 24px 0 8px;\">Release notes</h3>\n"
|
||||
. "<pre style=\"background: #F3F4F6; padding: 16px; border-radius: 6px; "
|
||||
. "font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; "
|
||||
. "white-space: pre-wrap; word-wrap: break-word; line-height: 1.5; "
|
||||
. "border-left: 3px solid #3B82F6;\">{$notesEsc}</pre>"
|
||||
: '<p style="color: #6B7280; font-style: italic;">No release notes for this version.</p>';
|
||||
|
||||
// Logo block (optionnel). Image hotlinkée + alt fallback : si le MUA bloque
|
||||
// les images (Outlook protégé par défaut), l'alt "ASTERION VR" s'affiche
|
||||
// en blanc sur le fond bleu et le destinataire voit quand même la marque.
|
||||
$logoBlock = $logoEsc !== ''
|
||||
? "<img src=\"{$logoEsc}\" alt=\"ASTERION VR\" width=\"180\" "
|
||||
. "style=\"display:inline-block; max-height:48px; max-width:240px; height:auto; "
|
||||
. "color:#FFFFFF; font-family:Segoe UI,Arial,sans-serif; font-size:18px; "
|
||||
. "font-weight:700; text-align:center; line-height:48px;\"/>"
|
||||
: '<span style="color:#FFFFFF; font-family:Segoe UI,Arial,sans-serif; font-size:22px; font-weight:700;">ASTERION VR</span>';
|
||||
|
||||
// Email HTML doit utiliser table-based layout + bgcolor attribute (pas juste
|
||||
// CSS background) parce qu'Outlook desktop utilise le moteur HTML de Word,
|
||||
// qui ignore linear-gradient(), :root, position:fixed, et beaucoup d'autres
|
||||
// règles CSS modernes. Le résultat précédent ressemblait à un email plain
|
||||
// text sans bandeau bleu. Avec ce layout :
|
||||
// • <table bgcolor="#1E40AF"> → fond bleu solide visible dans TOUS les MUA
|
||||
// • Styles inline sur chaque <td> → pas de strip Outlook
|
||||
// • width="640" attribute (vs CSS max-width) → respecté par Outlook
|
||||
// • Pas de gradient (impossible OOTB en HTML email standard) — solid blue
|
||||
// URL d'install du launcher (= installer Inno Setup self-extracting Windows).
|
||||
// Servi statiquement par Apache depuis server/installer/. Si l'opérateur
|
||||
// change ce path, mettre à jour ici. Pas de paramétrage config volontaire :
|
||||
// le path est canonique côté projet et tous les launchers en partagent un.
|
||||
$installerUrl = rtrim($baseUrl, '/') !== ''
|
||||
? rtrim($baseUrl, '/') . '/installer/PS_Launcher-Setup.exe'
|
||||
: 'https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe';
|
||||
$installerUrlEsc = htmlspecialchars($installerUrl, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
||||
<body style="margin:0; padding:24px 12px; background-color:#F3F4F6; font-family:'Segoe UI',Arial,sans-serif; color:#1F2937;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="640"
|
||||
style="max-width:640px; margin:0 auto; border-collapse:collapse;">
|
||||
<tr>
|
||||
<td bgcolor="#0F172A" align="center"
|
||||
style="background-color:#0F172A; padding:32px 24px; color:#FFFFFF; border-radius:8px 8px 0 0;">
|
||||
<div style="margin-bottom:16px;">{$logoBlock}</div>
|
||||
<h1 style="margin:0; font-size:24px; color:#FFFFFF; font-weight:600;">
|
||||
PROSERVE v{$versionEsc} is available!
|
||||
</h1>
|
||||
<p style="margin:8px 0 0; color:#94A3B8; font-size:14px;">
|
||||
A new version is ready to install via your PROSERVE Launcher.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td bgcolor="#FFFFFF"
|
||||
style="background-color:#FFFFFF; padding:28px 28px; border:1px solid #E5E7EB; border-top:none; border-radius:0 0 8px 8px;">
|
||||
<p style="margin:0 0 14px;">Hi {$ownerEsc},</p>
|
||||
<p style="margin:0 0 14px;">
|
||||
We're happy to let you know that
|
||||
<strong>PROSERVE version {$versionEsc}</strong>
|
||||
has just been published and is now available for download.
|
||||
</p>
|
||||
<p style="margin:0 0 14px;">
|
||||
If you already have the <strong>PROSERVE Launcher</strong> installed,
|
||||
simply open it on any of your machines, click
|
||||
<em>« Check for updates »</em>, and install the new version with one click.
|
||||
</p>
|
||||
|
||||
<!-- Section « Don't have the launcher yet ? » : bouton de téléchargement
|
||||
+ brève instruction. Bordure haut/bas pour le détacher visuellement
|
||||
du paragraphe d'install standard ci-dessus. -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
|
||||
style="margin:20px 0; border-top:1px solid #E5E7EB; border-bottom:1px solid #E5E7EB;">
|
||||
<tr>
|
||||
<td style="padding:18px 0;">
|
||||
<p style="margin:0 0 12px; font-weight:600; color:#1F2937;">
|
||||
First install or new workstation?
|
||||
</p>
|
||||
<p style="margin:0 0 16px; color:#4B5563;">
|
||||
If this is your first time, or you're setting up a new PC,
|
||||
download the PROSERVE Launcher installer below.
|
||||
<strong>You'll need to run it on EACH workstation</strong>
|
||||
where PROSERVE will be used.
|
||||
</p>
|
||||
<!-- Bouton stylé via table + bgcolor (vs CSS button) pour
|
||||
compat Outlook qui ne supporte ni border-radius CSS sur
|
||||
<a> ni padding CSS bien rendu. Centrage via align="center"
|
||||
sur la table wrapper, qui marche dans Outlook ET les MUA
|
||||
modernes (vs `margin:auto` sur table ignoré par Outlook). -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto;">
|
||||
<tr>
|
||||
<td bgcolor="#3B82F6" align="center"
|
||||
style="background-color:#3B82F6; border-radius:6px;">
|
||||
<a href="{$installerUrlEsc}"
|
||||
style="display:inline-block; padding:12px 28px;
|
||||
color:#FFFFFF; text-decoration:none;
|
||||
font-family:'Segoe UI',Arial,sans-serif;
|
||||
font-size:14px; font-weight:600;">
|
||||
⬇ Download PROSERVE Launcher (Windows)
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:12px 0 0; color:#9CA3AF; font-size:11px; text-align:center;">
|
||||
Direct link:
|
||||
<a href="{$installerUrlEsc}" style="color:#3B82F6; text-decoration:underline; word-break:break-all;">{$installerUrlEsc}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{$notesBlock}
|
||||
<p style="margin:24px 0 14px;">
|
||||
As always, if you run into any issue (download failure, install error,
|
||||
anything weird in-game), don't hesitate to reach out — we're here to help.
|
||||
</p>
|
||||
<p style="margin:0;">
|
||||
Happy training!<br/>
|
||||
<strong>The ASTERION VR team</strong>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding:16px 8px; color:#9CA3AF; font-size:11px; line-height:1.5;">
|
||||
This automated notification was sent because your contact email is registered with one of your PROSERVE licenses.<br/>
|
||||
To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body></html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un nom de fichier ZIP saisi par l'admin :
|
||||
* - strip tout chemin (path traversal défense, basename)
|
||||
@@ -133,8 +293,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$isBeta = isset($_POST['is_beta']);
|
||||
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
|
||||
|
||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
|
||||
throw new Exception('Numéro de version invalide (format X.Y.Z attendu).');
|
||||
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||||
// Le 4ᵉ digit est optionnel et permet de gérer plusieurs builds de
|
||||
// pré-release sans bumper le numéro de release final (ex. 1.5.4.13
|
||||
// pendant le dev, 1.5.4 au release).
|
||||
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $version)) {
|
||||
throw new Exception('Numéro de version invalide (format X.Y.Z ou X.Y.Z.B attendu).');
|
||||
}
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
|
||||
throw new Exception('Date min_license_date invalide.');
|
||||
@@ -357,6 +521,127 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
. "\n" . implode("\n", $resign['log']);
|
||||
if (!$resign['ok']) $messageType = 'error';
|
||||
}
|
||||
elseif ($action === 'notify_release') {
|
||||
// Envoie une notif email aux contacts des licenses ÉLIGIBLES pour
|
||||
// cette version. Critères d'éligibilité (= license verra la version
|
||||
// dans son manifest signé) :
|
||||
// • Pas révoquée
|
||||
// • download_entitlement_until >= minLicenseDate de la version
|
||||
// • channel de la license ∈ channels de la version (ou « default »)
|
||||
// • can_see_betas = 1 si la version est isBeta=true
|
||||
// Pour chaque license éligible : split contact_emails + send via Mailer.
|
||||
// Compteurs retournés dans le message admin.
|
||||
$entryId = (string)($_POST['id'] ?? '');
|
||||
$idx = find_entry_index_by_id($manifest, $entryId);
|
||||
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||||
$entry = $manifest['versions'][$idx];
|
||||
$version = $entry['version'] ?? '?';
|
||||
$isBeta = !empty($entry['isBeta']);
|
||||
$vChannels = isset($entry['channels']) && is_array($entry['channels']) && !empty($entry['channels'])
|
||||
? $entry['channels']
|
||||
: ['default'];
|
||||
$minLic = $entry['minLicenseDate'] ?? '1970-01-01';
|
||||
|
||||
// Charge release notes (Markdown brut → on l'envoie tel quel,
|
||||
// les MUA modernes l'affichent OK même sans rendu)
|
||||
$notesFile = "$notesDir/{$entryId}.md";
|
||||
$notesBody = is_file($notesFile) ? file_get_contents($notesFile) : '';
|
||||
|
||||
// Récupère les licenses éligibles
|
||||
$db = Db::get($config);
|
||||
$stmt = $db->prepare(
|
||||
'SELECT id, owner_name, channel, can_see_betas, contact_emails, download_entitlement_until
|
||||
FROM licenses
|
||||
WHERE revoked_at IS NULL
|
||||
AND download_entitlement_until >= ?
|
||||
AND contact_emails IS NOT NULL
|
||||
AND contact_emails != \'\''
|
||||
);
|
||||
$stmt->execute([$minLic]);
|
||||
$licenses = $stmt->fetchAll();
|
||||
|
||||
$notifConfig = $config['notifications'] ?? [];
|
||||
|
||||
// Prépare le logo en CID embed (vs hotlink) pour qu'Outlook l'affiche
|
||||
// SANS demander de permission "Télécharger les images". Le path
|
||||
// pointe vers admin/assets/asterion-logo.png par défaut, ou un
|
||||
// override via $notifConfig['logo_path']. Si le fichier n'existe
|
||||
// pas, $logoCid reste vide → le template skip le bloc image et
|
||||
// utilise le fallback texte "ASTERION VR" en blanc.
|
||||
//
|
||||
// PAS DE FALLBACK HOTLINK si le fichier est introuvable : Outlook
|
||||
// bloque tout hotlink par défaut, et quand l'URL retourne 404,
|
||||
// il n'offre même pas le bouton « Télécharger les images »
|
||||
// (il affiche juste un placeholder rouge cassé). Mieux vaut le
|
||||
// fallback texte que ce visuel anti-pro.
|
||||
$logoPath = $notifConfig['logo_path'] ?? (__DIR__ . '/assets/asterion-logo.png');
|
||||
$logoCid = '';
|
||||
$logoMissing = false;
|
||||
$inlineImages = [];
|
||||
if (is_file($logoPath) && is_readable($logoPath)) {
|
||||
$logoCid = 'asterion-logo@pslauncher';
|
||||
$inlineImages[] = [
|
||||
'cid' => $logoCid,
|
||||
'path' => $logoPath,
|
||||
'type' => 'image/png',
|
||||
];
|
||||
} else {
|
||||
$logoMissing = true;
|
||||
}
|
||||
|
||||
$totalSent = 0; $totalFailed = 0; $licsTouched = 0; $emailsSet = [];
|
||||
foreach ($licenses as $lic) {
|
||||
$licChannel = $lic['channel'] ?? 'default';
|
||||
// Match : la license voit la version si son channel est dans
|
||||
// la liste des channels de la version (ou si version='default'
|
||||
// et license sans channel = public)
|
||||
$licChannelEffective = $licChannel ?? 'default';
|
||||
if (!in_array($licChannelEffective, $vChannels, true)) continue;
|
||||
if ($isBeta && (int)$lic['can_see_betas'] !== 1) continue;
|
||||
|
||||
$emails = Mailer::parseEmails($lic['contact_emails']);
|
||||
if (empty($emails)) continue;
|
||||
$licsTouched++;
|
||||
|
||||
$subject = "PROSERVE v{$version} is now available";
|
||||
// Logo via CID inline si fichier disponible sur disque, sinon
|
||||
// chaîne vide → le template tombera sur le fallback texte
|
||||
// "ASTERION VR" en blanc sur le bandeau dark navy. On NE
|
||||
// tombe PAS sur logo_url (hotlink) parce qu'Outlook bloque
|
||||
// tout hotlink par défaut et affiche un placeholder cassé
|
||||
// sans même offrir le bouton "Télécharger les images" si
|
||||
// l'URL retourne 404. Cf logoMissing détecté plus haut.
|
||||
$logoSrc = $logoCid !== '' ? "cid:{$logoCid}" : '';
|
||||
$bodyHtml = renderReleaseAnnounceEmail(
|
||||
$version,
|
||||
$lic['owner_name'] ?? '',
|
||||
$notesBody,
|
||||
$config['base_url'] ?? '',
|
||||
$logoSrc
|
||||
);
|
||||
|
||||
foreach ($emails as $email) {
|
||||
// Dédup : ne pas envoyer 2× à la même adresse si elle est
|
||||
// contact sur plusieurs licenses du même client.
|
||||
if (isset($emailsSet[$email])) continue;
|
||||
$emailsSet[$email] = true;
|
||||
$ok = Mailer::send($notifConfig, $email, $subject, $bodyHtml, $inlineImages);
|
||||
if ($ok) $totalSent++; else $totalFailed++;
|
||||
}
|
||||
}
|
||||
$message = "Notification release v{$version} : {$totalSent} email(s) envoyé(s) " .
|
||||
"(à {$licsTouched} license(s) éligibles, " .
|
||||
($totalFailed > 0 ? "{$totalFailed} échec(s) — vérifier mail() / SMTP serveur)" : "0 échec)");
|
||||
// Si le logo PNG est introuvable côté serveur, le préviens — sinon
|
||||
// l'opérateur découvre le bug seulement quand un destinataire lui
|
||||
// dit que l'email n'a pas de logo.
|
||||
if ($logoMissing) {
|
||||
$message .= "\n\n⚠ Logo Asterion non trouvé à : {$logoPath}\n" .
|
||||
" Upload `asterion-logo.png` dans admin/assets/ via SFTP, sinon les emails partent avec le fallback texte 'ASTERION VR' au lieu du logo.";
|
||||
$messageType = 'error';
|
||||
}
|
||||
if ($totalFailed > 0) $messageType = 'error';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$message = $e->getMessage();
|
||||
$messageType = 'error';
|
||||
@@ -412,16 +697,34 @@ Layout::header('Versions', 'versions');
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div class="row">
|
||||
<div class="col field">
|
||||
<label>Version (X.Y.Z)</label>
|
||||
<input type="text" name="version" placeholder="1.4.8" required pattern="\d+\.\d+\.\d+">
|
||||
<label>Version (X.Y.Z, ou X.Y.Z.B pour une itération dev)</label>
|
||||
<input type="text" name="version" placeholder="1.4.8 ou 1.5.4.13" required pattern="\d+\.\d+\.\d+(\.\d+)?"
|
||||
title="Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne avec build number)">
|
||||
</div>
|
||||
<?php
|
||||
// Défauts conventionnels :
|
||||
// • released_at = aujourd'hui (l'opérateur est en train d'éditer
|
||||
// l'entry au moment du release, ça serait absurde d'avoir un
|
||||
// champ vide qu'il faut systématiquement remplir)
|
||||
// • min_license_date = aujourd'hui - 1 an (les licenses sont
|
||||
// valides 1 an, donc une license émise il y a moins d'un an
|
||||
// a accès à cette version par défaut). Modifiable pour les
|
||||
// cas particuliers (release LTS, security patch limité aux
|
||||
// dernières licenses, etc.).
|
||||
$defaultReleasedAt = date('Y-m-d');
|
||||
$defaultMinLicDate = date('Y-m-d', strtotime('-1 year'));
|
||||
?>
|
||||
<div class="col field">
|
||||
<label>Date de release</label>
|
||||
<input type="datetime-local" name="released_at">
|
||||
<input type="date" name="released_at"
|
||||
value="<?= $defaultReleasedAt ?>"
|
||||
title="Date publique de la release. Default = aujourd'hui. L'heure n'est pas conservée — toutes les releases sont datées à minuit UTC.">
|
||||
</div>
|
||||
<div class="col field">
|
||||
<label>min_license_date (license requise)</label>
|
||||
<input type="date" name="min_license_date" required>
|
||||
<label>min_license_date <span class="muted" style="font-weight: normal; font-size: 11px;">(license émise après cette date requise)</span></label>
|
||||
<input type="date" name="min_license_date" required
|
||||
value="<?= $defaultMinLicDate ?>"
|
||||
title="Une license n'a accès à cette version que si elle a été émise APRÈS cette date. Default = aujourd'hui - 1 an (= les licenses 'standard' couvrent les releases du dernier an).">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -479,7 +782,7 @@ Layout::header('Versions', 'versions');
|
||||
• latest : <code>v<?= htmlspecialchars($manifest['latest']) ?></code>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<form method="post" style="display:inline">
|
||||
<form method="post" style="display:inline" onsubmit="showBusyOverlay('bulk')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="sync_versions">
|
||||
<button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button>
|
||||
@@ -558,130 +861,261 @@ Layout::header('Versions', 'versions');
|
||||
</form>
|
||||
</td>
|
||||
<td style="text-align: right; white-space: nowrap;">
|
||||
<?php if ($zipExists && !$hashSkipped): ?>
|
||||
<form method="post" style="display:inline" title="Recalcule le SHA-256 de cette version uniquement (utilise le cache si le ZIP n'a pas changé)">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="sync_one">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
<summary class="btn btn-secondary">⚙ Hash</summary>
|
||||
<div style="margin-top: 8px; min-width: 280px;">
|
||||
<p class="muted" style="font-size: 12px; margin: 0 0 8px;">
|
||||
<?php
|
||||
// entryId est utilisé partout (boutons row + modal IDs).
|
||||
$entryId = (string)($v['id'] ?? '');
|
||||
$entryIdEsc = htmlspecialchars($entryId);
|
||||
$modalId = 'edit-' . $entryIdEsc;
|
||||
?>
|
||||
<div class="row-actions">
|
||||
<?php if ($zipExists && !$hashSkipped): ?>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')"
|
||||
title="Recalcule de force le SHA-256 de cette version, en ignorant le cache .hashcache.json. À utiliser après un re-upload SFTP — le cache server-side peut considérer le fichier 'inchangé' si SFTP a préservé le mtime, ce qui retourne l'ancien hash sans recalcul.">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="sync_one">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<input type="hidden" name="force" value="1">
|
||||
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn btn-primary"
|
||||
onclick="document.getElementById('<?= $modalId ?>').showModal()">
|
||||
✎ Modifier
|
||||
</button>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="return confirm('Envoyer un email d\'annonce de v<?= htmlspecialchars($v['version']) ?> à tous les contacts des licenses éligibles ?\n\nUn email par contact (dédup automatique). Inclut les release notes actuelles.')"
|
||||
title="Envoie une notification email à toutes les licenses qui voient cette version (channel + entitlement + can_see_betas pour les BÊTA). Inclut les release notes.">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="notify_release">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<button class="btn btn-secondary" type="submit">✉ Notifier</button>
|
||||
</form>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Retirer v<?= htmlspecialchars($v['version']) ?> du manifest ?\n(Le ZIP reste dans builds/, supprime-le en SFTP si voulu.)')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<button class="btn btn-danger" type="submit">✕ Retirer</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php // Dialog rendu plus bas dans un foreach séparé hors de la <table>
|
||||
// — un <dialog> dans un <tr style="display:none"> est exclu du
|
||||
// layout tree par certains browsers, ce qui empêche showModal()
|
||||
// de fonctionner (le bouton « ✎ Modifier » devient muet). ?>
|
||||
<?php ob_start(); /* début capture HTML dialog → flush après </table> */ ?>
|
||||
<dialog id="<?= $modalId ?>" class="edit-modal">
|
||||
<header>
|
||||
<h3>Éditer v<?= htmlspecialchars($v['version']) ?>
|
||||
<?php if (!empty($v['isBeta'])): ?>
|
||||
<span class="badge badge-warning" style="margin-left: 6px; font-size: 10px;">BÊTA</span>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
<button type="button" class="close-x" aria-label="Fermer"
|
||||
onclick="this.closest('dialog').close()">×</button>
|
||||
</header>
|
||||
|
||||
<!-- Tabs : data-tab pointe vers la section à afficher, géré par
|
||||
un seul handler global JS déclaré en bas de page (délégation
|
||||
d'événement sur document → pas besoin de rebind par modal). -->
|
||||
<nav class="modal-tabs" data-modal="<?= $modalId ?>">
|
||||
<button type="button" data-tab="meta" class="active">Méta</button>
|
||||
<button type="button" data-tab="notes">Notes</button>
|
||||
<button type="button" data-tab="beta">BÊTA</button>
|
||||
<button type="button" data-tab="channels">Channels</button>
|
||||
<button type="button" data-tab="advanced">Avancé</button>
|
||||
</nav>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- TAB : Méta (min_license, released_at, rename ZIP) -->
|
||||
<section data-tab="meta">
|
||||
<p class="section-intro">
|
||||
Date d'expiration mini d'une license pour télécharger cette version,
|
||||
date de release publique, et nom du fichier ZIP côté serveur (utile pour
|
||||
invalider le cache CDN après un re-build).
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="edit_meta">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<div class="field">
|
||||
<label>min_license_date <span class="muted" style="font-weight: normal;">(license émise après cette date requise)</span></label>
|
||||
<input type="date" name="min_license_date"
|
||||
value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Date de release</label>
|
||||
<?php
|
||||
// On extrait UNIQUEMENT la portion YYYY-MM-DD du releasedAt
|
||||
// stocké (qui peut être un ISO complet avec heure : « 2026-05-27T00:00:00Z »).
|
||||
// L'heure n'est pas affichée à l'opérateur — toutes les releases
|
||||
// sont datées à minuit UTC, le moment précis n'a pas de sens UX.
|
||||
$releasedDateOnly = substr($v['releasedAt'] ?? '', 0, 10);
|
||||
?>
|
||||
<input type="date" name="released_at"
|
||||
value="<?= htmlspecialchars($releasedDateOnly) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Renommer le ZIP
|
||||
<span class="muted" style="font-weight: normal;">
|
||||
(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)
|
||||
</span>
|
||||
</label>
|
||||
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
|
||||
<p class="warn">⚠ Renommer invalide le sha256 — re-upload le nouveau ZIP en SFTP puis « 🔁 Sync ».</p>
|
||||
</div>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer Méta</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- TAB : Notes -->
|
||||
<section data-tab="notes" hidden>
|
||||
<p class="section-intro">
|
||||
Markdown des release notes de cette version. Affichées dans le dialog
|
||||
d'install côté launcher et accessibles via le menu « Notes de release ».
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="edit_notes">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<div class="field">
|
||||
<textarea name="notes" rows="14"
|
||||
style="font-family: 'Cascadia Code', Consolas, monospace;"><?= htmlspecialchars($existingNotes[$entryId] ?? '') ?></textarea>
|
||||
</div>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer Notes</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- TAB : BÊTA -->
|
||||
<section data-tab="beta" hidden>
|
||||
<p class="section-intro">
|
||||
Une version BÊTA n'est visible que par les licenses qui ont le flag
|
||||
<code>can_see_betas</code> activé. Les autres clients voient la version
|
||||
comme inexistante.
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_beta">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<div class="field">
|
||||
<label>
|
||||
<input type="checkbox" name="is_beta" value="1" <?= !empty($v['isBeta']) ? 'checked' : '' ?>>
|
||||
Cette version est une BÊTA
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notes pour les testeurs <span class="muted" style="font-weight: normal;">(affichées en tooltip dans le launcher)</span></label>
|
||||
<input type="text" name="beta_notes"
|
||||
value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
|
||||
placeholder="Tester en priorité…">
|
||||
</div>
|
||||
<p class="warn">⚠ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.</p>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- TAB : Channels -->
|
||||
<section data-tab="channels" hidden>
|
||||
<p class="section-intro">
|
||||
Détermine quelles licenses voient cette version. Coche « default » pour
|
||||
la rendre publique, ou cible un ou plusieurs channels privés (gérés sur
|
||||
la page <a href="channels.php">Channels</a>).
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_channels">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<div class="field">
|
||||
<div class="checkbox-list">
|
||||
<?php foreach ($channelRegistry['channels'] as $c):
|
||||
$cn = $c['name'] ?? '';
|
||||
$checked = in_array($cn, $vChannels, true) ? 'checked' : '';
|
||||
?>
|
||||
<label>
|
||||
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $checked ?>>
|
||||
<code><?= htmlspecialchars($cn) ?></code>
|
||||
<span class="muted"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<p class="warn">⚠ Re-signe le manifest après modif.</p>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer Channels</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- TAB : Avancé (skip hash + force re-hash) -->
|
||||
<section data-tab="advanced" hidden>
|
||||
<p class="section-intro">
|
||||
Options de vérification d'intégrité. La sécurité repose sur la signature
|
||||
Ed25519 du manifest + HTTPS — désactiver SHA-256 reste relativement sûr,
|
||||
utile sur très gros builds pour gagner 30s-3min en fin de DL.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label>Vérification SHA-256 chez les clients</label>
|
||||
<p class="hint" style="margin: 0 0 8px;">
|
||||
<?php if ($hashSkipped): ?>
|
||||
La vérif SHA-256 est <strong>désactivée</strong> pour cette release.
|
||||
Le manifest sert <code>hashAlgorithm: "none"</code>. La sécurité repose
|
||||
uniquement sur la signature Ed25519 du manifest + HTTPS.
|
||||
Actuellement <strong style="color: var(--busy);">désactivée</strong>
|
||||
pour cette release (le manifest sert <code>hashAlgorithm: "none"</code>).
|
||||
<?php else: ?>
|
||||
Désactive la vérif SHA-256 chez les clients pour cette release
|
||||
(utile sur très gros builds où on accepte le compromis perf/sécurité).
|
||||
Actuellement <strong style="color: var(--installed);">activée</strong>
|
||||
(default sain).
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<form method="post" style="display:inline">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_skip_hash">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
|
||||
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
|
||||
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
|
||||
</button>
|
||||
</form>
|
||||
<?php if ($zipExists): ?>
|
||||
<form method="post" style="margin-top: 8px;" title="Recalcule en ignorant le cache même si le ZIP n'a pas changé">
|
||||
</div>
|
||||
<?php if ($zipExists): ?>
|
||||
<div class="field">
|
||||
<label>Forcer un re-hash du ZIP</label>
|
||||
<p class="hint" style="margin: 0 0 8px;">
|
||||
Recalcule le SHA-256 en ignorant le cache, même si le ZIP n'a pas changé.
|
||||
Utile pour valider une intégrité après transfert SFTP douteux.
|
||||
</p>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="sync_one">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<input type="hidden" name="force" value="1">
|
||||
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</details>
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
<summary class="btn btn-secondary">Méta</summary>
|
||||
<form method="post" style="margin-top: 8px; min-width: 360px;">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="edit_meta">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<div class="field">
|
||||
<label>min_license_date</label>
|
||||
<input type="date" name="min_license_date" value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Date de release (UTC)</label>
|
||||
<input type="datetime-local" name="released_at" value="<?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 16)) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Renommer le ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)</span></label>
|
||||
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
|
||||
<span class="muted" style="font-size: 11px;">⚠️ Renommer = invalide le sha256, il faudra re-uploader le nouveau ZIP en SFTP puis re-Sync.</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||
</form>
|
||||
</details>
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
<summary class="btn btn-secondary">Notes</summary>
|
||||
<form method="post" style="margin-top: 8px;">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="edit_notes">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<textarea name="notes" rows="10" style="font-family: 'Cascadia Code', Consolas, monospace; min-width: 400px;"><?= htmlspecialchars($existingNotes[$v['id'] ?? ''] ?? '') ?></textarea>
|
||||
<br><br>
|
||||
<button class="btn btn-primary" type="submit">Enregistrer les notes</button>
|
||||
</form>
|
||||
</details>
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
<summary class="btn <?= !empty($v['isBeta']) ? 'btn-warning' : 'btn-secondary' ?>"><?= !empty($v['isBeta']) ? '🟡 BÊTA' : 'Bêta' ?></summary>
|
||||
<form method="post" style="margin-top: 8px; min-width: 360px;">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_beta">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<div class="field">
|
||||
<label><input type="checkbox" name="is_beta" value="1" <?= !empty($v['isBeta']) ? 'checked' : '' ?>>
|
||||
Cette version est une BÊTA
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notes pour les testeurs</label>
|
||||
<input type="text" name="beta_notes" value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
|
||||
placeholder="Tester en priorité…">
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||
<p class="muted" style="font-size: 11px; margin: 8px 0 0;">⚠️ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.</p>
|
||||
</form>
|
||||
</details>
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
<summary class="btn btn-secondary">Channels</summary>
|
||||
<form method="post" style="margin-top: 8px; min-width: 280px;">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_channels">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<div class="field">
|
||||
<?php foreach ($channelRegistry['channels'] as $c):
|
||||
$cn = $c['name'] ?? '';
|
||||
$checked = in_array($cn, $vChannels, true) ? 'checked' : '';
|
||||
?>
|
||||
<label style="display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer;">
|
||||
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $checked ?>>
|
||||
<code><?= htmlspecialchars($cn) ?></code>
|
||||
<span class="muted" style="font-size: 11px;"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||
<p class="muted" style="font-size: 11px; margin: 8px 0 0;">⚠️ Re-signe le manifest après modif.</p>
|
||||
</form>
|
||||
</details>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Retirer v<?= htmlspecialchars($v['version']) ?> du manifest ?\n(Le ZIP reste dans builds/, supprime-le en SFTP si voulu.)')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<button class="btn btn-danger" type="submit">Retirer</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
<?php
|
||||
// Capture le markup du dialog et le pousse dans un buffer global
|
||||
// qui sera vidé APRÈS </table>. Pourquoi : <dialog> directement
|
||||
// ou indirectement dans <table>/<tbody>/<tr> est foster-parented
|
||||
// de manière imprévisible par le parser HTML, ce qui peut casser
|
||||
// showModal() (élément non connecté au layout tree).
|
||||
$GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean();
|
||||
?>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($manifest['versions'])): ?>
|
||||
<tr><td colspan="8" class="muted" style="text-align:center; padding: 32px;">Aucune version dans le manifest.</td></tr>
|
||||
@@ -690,6 +1124,11 @@ Layout::header('Versions', 'versions');
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php // Tous les <dialog> d'édition sont émis ici, hors de la <table> parent,
|
||||
// pour que showModal() fonctionne fiablement (le top layer est indépendant
|
||||
// du DOM context mais l'élément doit être dans le layout tree d'abord). ?>
|
||||
<?= $GLOBALS['__editDialogs'] ?? '' ?>
|
||||
|
||||
<div class="card">
|
||||
<h2>ZIPs présents dans builds/</h2>
|
||||
<?php if (empty($zips)): ?>
|
||||
@@ -718,4 +1157,62 @@ Layout::header('Versions', 'versions');
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Overlay busy : visible pendant les opérations longues côté serveur
|
||||
(hash SHA-256 d'un build de 13 Go = 3-5 min sur OVH mutualisé).
|
||||
Affiché en synchrone juste avant le submit du form ; disparaît
|
||||
naturellement au reload de page qui suit la réponse POST. -->
|
||||
<div id="busy-overlay" class="busy-overlay" hidden>
|
||||
<div class="busy-content">
|
||||
<div class="busy-spinner"></div>
|
||||
<h3 id="busy-title">Calcul du SHA-256 en cours…</h3>
|
||||
<p id="busy-detail">Sur un build de 13 Go, ça prend 3–5 minutes selon les performances disque du serveur.</p>
|
||||
<p class="muted">La page se rafraîchira automatiquement quand c'est fini.<br>
|
||||
Tu peux laisser l'onglet ouvert en arrière-plan.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Handler global pour les onglets des modals d'édition de version.
|
||||
Délégation d'événement sur document → marche pour tous les .modal-tabs,
|
||||
y compris ceux qui seraient ajoutés dynamiquement plus tard. -->
|
||||
<script>
|
||||
(function () {
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.modal-tabs button[data-tab]');
|
||||
if (!btn) return;
|
||||
var tabName = btn.dataset.tab;
|
||||
var modal = btn.closest('dialog');
|
||||
if (!modal) return;
|
||||
// Active button → toggle .active sur les boutons du même <nav>
|
||||
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
|
||||
b.classList.toggle('active', b === btn);
|
||||
});
|
||||
// Sections → toggle [hidden] selon data-tab
|
||||
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
|
||||
s.hidden = s.dataset.tab !== tabName;
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Affiche l'overlay busy au submit des forms de hash. Message adapté au scope :
|
||||
// • 'bulk' → "tous les ZIPs" (bouton global Hasher les versions + signer)
|
||||
// • 'single' → version précise (bouton 🔁 Hash per-row ou Force re-hash modal)
|
||||
// Note : on N'EMPÊCHE PAS le submit (return undefined) — l'overlay reste affiché
|
||||
// jusqu'au reload qui suit la réponse POST, donc disparaît naturellement.
|
||||
function showBusyOverlay(scope, version) {
|
||||
var overlay = document.getElementById('busy-overlay');
|
||||
if (!overlay) return;
|
||||
var title = document.getElementById('busy-title');
|
||||
var detail = document.getElementById('busy-detail');
|
||||
if (scope === 'bulk') {
|
||||
if (title) title.textContent = 'Hashing + signing tous les ZIPs…';
|
||||
if (detail) detail.textContent = 'Recalcul SHA-256 sur chaque ZIP (3–5 min par ZIP de 13 Go). Les ZIPs déjà hashés et inchangés sont skip via cache, sauf si « Force re-hash » coché.';
|
||||
} else {
|
||||
if (title) title.textContent = 'Hashing SHA-256 v' + (version || '?') + '…';
|
||||
if (detail) detail.textContent = 'Sur un build de 13 Go, ça prend 3–5 minutes selon les performances disque du serveur.';
|
||||
}
|
||||
overlay.hidden = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php Layout::footer();
|
||||
|
||||
Reference in New Issue
Block a user