feat & fix : intégration IA éditeur + onglet admin IA + corrections CSP (v1.6.24-25)

- #96 : boutons IA sidebar éditeur (analyse critique / réécriture) via Anthropic API
- #97 : onglet admin /admin/ia — provider anthropic/claude_code, modèle, procédure CLI
- #95 : extraction scripts inline vers fichiers JS (comments.js, post_confirm.js, admin.js)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 12:18:38 +02:00
parent fabe5a9f53
commit 298f18dabe
16 changed files with 527 additions and 32 deletions
+8
View File
@@ -55,3 +55,11 @@ DATA_PATH=/srv/data/folio
# Logs Apache (onglet Recherches dans /admin) # Logs Apache (onglet Recherches dans /admin)
# Nom du fichier de log d'accès du vhost dans /var/log/apache2/ # Nom du fichier de log d'accès du vhost dans /var/log/apache2/
APACHE_ACCESS_LOG=lan.acegrp.varlog-access.log APACHE_ACCESS_LOG=lan.acegrp.varlog-access.log
# IA — analyse critique et réécriture d'articles dans l'éditeur
# Provider : anthropic (API) ou claude_code (CLI local)
# AI_PROVIDER=anthropic
# Clé API Anthropic (obtenir sur https://console.anthropic.com/)
ANTHROPIC_API_KEY=
# Modèle à utiliser (défaut : claude-haiku-4-5-20251001) — ignoré si provider=claude_code
# AI_MODEL=claude-haiku-4-5-20251001
+20
View File
@@ -5,6 +5,26 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
--- ---
## [1.6.25] - 2026-05-16
### Ajouté
- Admin : onglet « IA » — statut provider/clé, sélecteur `anthropic`/`claude_code`, champ modèle, procédure d'installation CLI, sauvegarde dans `site_settings.json` (#97)
- `AiService` : support du provider Claude Code CLI via `proc_open` + lecture provider/modèle depuis `SiteSettings` (#97)
### Corrigé
- Sécurité CSP : extraction du `<script>` inline de `comments_section.php` vers `comments.js` (#95)
- Sécurité CSP : remplacement du `onclick` inline dans `wizard/step6.php` par `data-confirm-discard` + listener dans `admin.js` (#95)
- Sécurité CSP : remplacement du `oninput` inline dans `post_confirm.php` par un `addEventListener` dans `post_confirm.js` (#95)
---
## [1.6.24] - 2026-05-16
### Ajouté
- Éditeur : deux boutons IA dans la sidebar (mode édition) — « Analyse critique » et « Réécrire l'article » — appel à l'API Anthropic via `ANTHROPIC_API_KEY` dans `.env` (#96)
---
## [1.6.23] - 2026-05-16 ## [1.6.23] - 2026-05-16
### Ajouté ### Ajouté
+10
View File
@@ -9,6 +9,16 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
}); });
// Boutons data-confirm-discard (évite onclick inline bloqué par CSP)
document.querySelectorAll('[data-confirm-discard]').forEach(function (btn) {
btn.addEventListener('click', function () {
var msg = btn.getAttribute('data-confirm-msg') || 'Confirmer ?';
if (window.confirm(msg)) {
window.location = btn.getAttribute('data-discard-url');
}
});
});
// Sélection globale articles // Sélection globale articles
var checkAll = document.getElementById('check-all'); var checkAll = document.getElementById('check-all');
if (checkAll) { if (checkAll) {
+89
View File
@@ -0,0 +1,89 @@
// ai-editor.js — boutons IA dans la sidebar éditeur
document.addEventListener('DOMContentLoaded', function () {
var btnCritique = document.getElementById('btn-ai-critique');
var btnRewrite = document.getElementById('btn-ai-rewrite');
if (!btnCritique || !btnRewrite) return;
var panel = document.getElementById('ai-result-panel');
var labelEl = document.getElementById('ai-result-label');
var resultEl = document.getElementById('ai-result-content');
var btnApply = document.getElementById('btn-ai-apply');
var btnClose = document.getElementById('btn-ai-close');
var ta = document.getElementById('content');
var titleEl = document.getElementById('title');
var lastRewrite = '';
function setLoading(btn, loading) {
btn.disabled = loading;
if (loading) {
btn._origText = btn.textContent;
btn.textContent = 'En cours…';
} else {
btn.textContent = btn._origText || btn.textContent;
}
}
async function callAi(action) {
var btn = (action === 'critique') ? btnCritique : btnRewrite;
setLoading(btn, true);
panel.style.display = 'none';
btnApply.style.display = 'none';
lastRewrite = '';
try {
var res = await fetch('/?action=ai_query', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
action: action,
title: titleEl ? titleEl.value : '',
content: ta ? ta.value : '',
}),
});
var data = await res.json();
if (!data.ok) {
labelEl.textContent = 'Erreur';
resultEl.textContent = data.error || 'Erreur inconnue.';
} else {
labelEl.textContent = (action === 'critique') ? 'Analyse critique' : 'Réécriture';
resultEl.textContent = data.text;
if (action === 'rewrite') {
lastRewrite = data.text;
btnApply.style.display = '';
}
}
panel.style.display = '';
} catch (e) {
labelEl.textContent = 'Erreur';
resultEl.textContent = 'Erreur de connexion.';
panel.style.display = '';
} finally {
setLoading(btn, false);
}
}
btnCritique.addEventListener('click', function () { callAi('critique'); });
btnRewrite.addEventListener('click', function () { callAi('rewrite'); });
btnApply.addEventListener('click', function () {
if (!lastRewrite) return;
if (!confirm("Remplacer le contenu de l’éditeur par la réécriture IA ?")) return;
if (ta) {
ta.value = lastRewrite;
ta.dispatchEvent(new Event('input'));
}
panel.style.display = 'none';
btnApply.style.display = 'none';
lastRewrite = '';
});
btnClose.addEventListener('click', function () {
panel.style.display = 'none';
btnApply.style.display = 'none';
lastRewrite = '';
});
});
+24
View File
@@ -0,0 +1,24 @@
document.addEventListener('DOMContentLoaded', function () {
var maxAge = 365 * 24 * 3600;
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
function setCookie(name, value) {
document.cookie = name + '=' + encodeURIComponent(value) + ';max-age=' + maxAge + ';path=/;SameSite=Lax';
}
var nameEl = document.getElementById('comment-name');
var emailEl = document.getElementById('comment-email');
if (!nameEl || !emailEl) { return; }
var savedName = getCookie('cmt_name');
var savedEmail = getCookie('cmt_email');
if (savedName) { nameEl.value = savedName; }
if (savedEmail) { emailEl.value = savedEmail; }
var form = document.getElementById('comment-form');
if (form) {
form.addEventListener('submit', function () {
if (nameEl.value.trim()) { setCookie('cmt_name', nameEl.value.trim()); }
if (emailEl.value.trim()) { setCookie('cmt_email', emailEl.value.trim()); }
});
}
});
+6
View File
@@ -40,6 +40,12 @@ document.addEventListener('DOMContentLoaded', function () {
var slugInput = document.getElementById('confirm-slug'); var slugInput = document.getElementById('confirm-slug');
var slugDisplay = document.getElementById('slug-display'); var slugDisplay = document.getElementById('slug-display');
if (slugInput && slugDisplay) {
slugInput.addEventListener('input', function () {
slugDisplay.textContent = slugInput.value;
});
}
var btnSuggest = document.getElementById('slug-btn-suggest'); var btnSuggest = document.getElementById('slug-btn-suggest');
if (btnSuggest && slugInput && slugDisplay) { if (btnSuggest && slugInput && slugDisplay) {
btnSuggest.addEventListener('click', function () { btnSuggest.addEventListener('click', function () {
+47 -1
View File
@@ -45,7 +45,7 @@ $action = $_GET['action'] ?? 'list';
$uuid = $_GET['uuid'] ?? ''; $uuid = $_GET['uuid'] ?? '';
$slug = $_GET['slug'] ?? ''; $slug = $_GET['slug'] ?? '';
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags', 'book_save', 'book_delete', 'admin_save_as_groups', 'admin_save_folio_config', 'run_engine_update', 'run_content_migrations', 'admin_delete_feed', 'rate']; $_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags', 'book_save', 'book_delete', 'admin_save_as_groups', 'admin_save_folio_config', 'run_engine_update', 'run_content_migrations', 'admin_delete_feed', 'rate', 'admin_save_ai_config'];
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null; $metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
unset($_noindexActions); unset($_noindexActions);
@@ -1688,6 +1688,24 @@ switch ($action) {
echo json_encode(fetchUrlMeta(trim($_GET['url'] ?? ''))); echo json_encode(fetchUrlMeta(trim($_GET['url'] ?? '')));
exit; exit;
case 'ai_query':
requireAuth();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'Méthode invalide']);
exit;
}
$_aiAction = trim($_POST['action'] ?? '');
$_aiTitle = trim($_POST['title'] ?? '');
$_aiContent = str_replace("\r\n", "\n", trim($_POST['content'] ?? ''));
if (!in_array($_aiAction, ['critique', 'rewrite'], true) || $_aiContent === '') {
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']);
exit;
}
require_once BASE_PATH . '/src/Service/AiService.php';
echo json_encode((new AiService())->query($_aiAction, $_aiTitle, $_aiContent));
exit;
case 'import_image_step2': case 'import_image_step2':
requireAuth(); requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
@@ -2765,9 +2783,37 @@ switch ($action) {
} }
} }
if ($tab === 'ia') {
if (!isAdmin()) { http_response_code(403); exit; }
require_once BASE_PATH . '/src/SiteSettings.php';
require_once BASE_PATH . '/src/Service/AiService.php';
$adminData['ai_provider'] = aiProvider();
$adminData['ai_model'] = aiModel();
$adminData['anthropic_key_set'] = (($_ENV['ANTHROPIC_API_KEY'] ?? getenv('ANTHROPIC_API_KEY') ?: '') !== '');
$adminData['claude_cli_found'] = is_executable('/usr/local/bin/claude');
$adminData['ai_notice'] = $_GET['notice'] ?? '';
}
include BASE_PATH . '/templates/admin.php'; include BASE_PATH . '/templates/admin.php';
break; break;
case 'admin_save_ai_config':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403); exit;
}
require_once BASE_PATH . '/src/SiteSettings.php';
$allowedProviders = ['anthropic', 'claude_code'];
$aiProvider = in_array($_POST['ai_provider'] ?? '', $allowedProviders, true)
? $_POST['ai_provider']
: 'anthropic';
$ok = saveSiteSettings([
'ai_provider' => $aiProvider,
'ai_model' => trim($_POST['ai_model'] ?? ''),
]);
header('Location: /admin/ia?notice=' . ($ok ? 'saved' : 'error'));
exit;
case 'admin_smtp_save': case 'admin_smtp_save':
requireAuth(); requireAuth();
if (!isAdmin()) { if (!isAdmin()) {
+1 -1
View File
@@ -1 +1 @@
1.6.23 1.6.25
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
class AiService
{
private const SYSTEM_CRITIQUE = <<<'PROMPT'
Tu es un relecteur expert de blogs. Analyse l'article ci-dessous et identifie ses faiblesses : arguments insuffisamment étayés, imprécisions, manques de clarté, structure à améliorer, points à développer. Sois constructif et précis. Réponds en markdown avec des sections claires.
PROMPT;
private const SYSTEM_REWRITE = <<<'PROMPT'
Tu es un rédacteur expert. Réécris l'article ci-dessous en améliorant le style, la clarté et la structure, sans modifier le sens ni les faits. Conserve le format markdown, les liens et les références aux images. Réponds uniquement avec l'article réécrit, sans commentaire ni explication.
PROMPT;
private string $apiKey;
private string $model;
private string $provider;
public function __construct()
{
require_once BASE_PATH . '/src/SiteSettings.php';
$this->provider = aiProvider();
$this->model = aiModel();
$this->apiKey = $_ENV['ANTHROPIC_API_KEY'] ?? getenv('ANTHROPIC_API_KEY') ?: '';
}
public function isConfigured(): bool
{
if ($this->provider === 'claude_code') {
return is_executable('/usr/local/bin/claude');
}
return $this->apiKey !== '';
}
/** @return array{ok: bool, text?: string, error?: string} */
public function query(string $action, string $title, string $content): array
{
$content = mb_substr(trim($content), 0, 8000);
if ($content === '') {
return ['ok' => false, 'error' => "Contenu de l'article vide"];
}
$systemPrompt = match ($action) {
'critique' => self::SYSTEM_CRITIQUE,
'rewrite' => self::SYSTEM_REWRITE,
default => null,
};
if ($systemPrompt === null) {
return ['ok' => false, 'error' => 'Action inconnue'];
}
$userMsg = $title !== '' ? "# {$title}\n\n{$content}" : $content;
if ($this->provider === 'claude_code') {
return $this->queryClaudeCode($systemPrompt, $userMsg);
}
return $this->queryAnthropic($action, $systemPrompt, $userMsg);
}
/** @return array{ok: bool, text?: string, error?: string} */
private function queryAnthropic(string $action, string $systemPrompt, string $userMsg): array
{
if ($this->apiKey === '') {
return ['ok' => false, 'error' => 'Clé Anthropic non configurée (ANTHROPIC_API_KEY manquante dans .env)'];
}
$maxTokens = ($action === 'rewrite') ? 4096 : 1200;
$payload = json_encode([
'model' => $this->model,
'max_tokens' => $maxTokens,
'system' => $systemPrompt,
'messages' => [['role' => 'user', 'content' => $userMsg]],
]);
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . $this->apiKey,
'anthropic-version: 2023-06-01',
'Content-Type: application/json',
],
]);
$resp = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err !== '') {
return ['ok' => false, 'error' => 'Erreur réseau : ' . $err];
}
$data = json_decode((string) $resp, true);
if ($http !== 200) {
$msg = $data['error']['message'] ?? ('Anthropic HTTP ' . $http);
return ['ok' => false, 'error' => $msg];
}
$text = $data['content'][0]['text'] ?? '';
return ['ok' => true, 'text' => $text];
}
/** @return array{ok: bool, text?: string, error?: string} */
private function queryClaudeCode(string $systemPrompt, string $userMsg): array
{
$bin = '/usr/local/bin/claude';
if (!is_executable($bin)) {
return ['ok' => false, 'error' => 'Claude Code CLI introuvable (/usr/local/bin/claude)'];
}
$prompt = $systemPrompt . "\n\n" . $userMsg;
$cmd = $bin . ' --print ' . escapeshellarg($prompt) . ' 2>&1';
$env = ['HOME' => '/var/lib/claude-www', 'PATH' => '/usr/local/bin:/usr/bin:/bin'];
$desc = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open($cmd, $desc, $pipes, '/tmp', $env);
if (!is_resource($proc)) {
return ['ok' => false, 'error' => 'proc_open échoué'];
}
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
if ($code !== 0) {
return ['ok' => false, 'error' => 'Claude Code exit ' . $code . ' : ' . trim((string)$out)];
}
return ['ok' => true, 'text' => trim((string)$out)];
}
}
+15 -1
View File
@@ -93,10 +93,24 @@ function asGroups(): array
return is_array($raw) ? $raw : []; return is_array($raw) ? $raw : [];
} }
function aiProvider(): string
{
$v = siteSettings()['ai_provider'] ?? '';
if ($v !== '') return $v;
return $_ENV['AI_PROVIDER'] ?? getenv('AI_PROVIDER') ?: 'anthropic';
}
function aiModel(): string
{
$v = siteSettings()['ai_model'] ?? '';
if ($v !== '') return $v;
return $_ENV['AI_MODEL'] ?? getenv('AI_MODEL') ?: 'claude-haiku-4-5-20251001';
}
function saveSiteSettings(array $data): bool function saveSiteSettings(array $data): bool
{ {
$current = siteSettings(); $current = siteSettings();
$stringKeys = ['site_title', 'site_claim', 'site_lang', 'site_license_label', 'site_license_url', 'apache_access_log', 'folio_repo_url', 'folio_update_branch']; $stringKeys = ['site_title', 'site_claim', 'site_lang', 'site_license_label', 'site_license_url', 'apache_access_log', 'folio_repo_url', 'folio_update_branch', 'ai_provider', 'ai_model'];
foreach ($stringKeys as $key) { foreach ($stringKeys as $key) {
if (array_key_exists($key, $data)) { if (array_key_exists($key, $data)) {
$val = trim((string)$data[$key]); $val = trim((string)$data[$key]);
+125
View File
@@ -73,6 +73,10 @@ function adminStatusBadge(array $a, int $now): string
<a class="nav-link <?= $tab === 'flux' ? 'active' : '' ?>" <a class="nav-link <?= $tab === 'flux' ? 'active' : '' ?>"
href="/admin/flux">Flux</a> href="/admin/flux">Flux</a>
</li> </li>
<li class="nav-item">
<a class="nav-link <?= $tab === 'ia' ? 'active' : '' ?>"
href="/admin/ia">IA</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link <?= $tab === 'stats' ? 'active' : '' ?>" <a class="nav-link <?= $tab === 'stats' ? 'active' : '' ?>"
href="/admin/stats">Statistiques</a> href="/admin/stats">Statistiques</a>
@@ -1486,6 +1490,127 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
<?php endif; ?> <?php endif; ?>
<?php if ($tab === 'ia' && isAdmin()): ?>
<?php
$_aiNotice = $adminData['ai_notice'] ?? '';
$_aiProvider = $adminData['ai_provider'] ?? 'anthropic';
$_aiModel = $adminData['ai_model'] ?? '';
$_anthropicOk = $adminData['anthropic_key_set'] ?? false;
$_cliOk = $adminData['claude_cli_found'] ?? false;
?>
<?php if ($_aiNotice === 'saved'): ?>
<div class="alert alert-success py-2 small">Configuration IA enregistrée.</div>
<?php elseif ($_aiNotice === 'error'): ?>
<div class="alert alert-danger py-2 small">Erreur lors de l'enregistrement.</div>
<?php endif; ?>
<h5 class="mb-3">Intelligence artificielle</h5>
<!-- Section 1 — Statut -->
<div class="card mb-4">
<div class="card-header fw-semibold small">Statut</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
<tr>
<th class="ps-3" scope="row">Clé Anthropic (<code>ANTHROPIC_API_KEY</code>)</th>
<td><?= $_anthropicOk ? '<span class="text-success">✓ Configurée</span>' : '<span class="text-danger">✗ Absente</span>' ?></td>
</tr>
<tr>
<th class="ps-3" scope="row">Claude Code CLI (<code>/usr/local/bin/claude</code>)</th>
<td><?= $_cliOk ? '<span class="text-success">✓ Trouvé</span>' : '<span class="text-danger">✗ Introuvable</span>' ?></td>
</tr>
<tr>
<th class="ps-3" scope="row">Provider actif</th>
<td><code><?= htmlspecialchars($_aiProvider) ?></code></td>
</tr>
<tr>
<th class="ps-3" scope="row">Modèle actif</th>
<td><code><?= htmlspecialchars($_aiModel ?: 'claude-haiku-4-5-20251001 (défaut)') ?></code></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Section 2 — Configuration -->
<div class="card mb-4">
<div class="card-header fw-semibold small">Configuration</div>
<div class="card-body">
<form method="POST" action="/?action=admin_save_ai_config">
<div class="mb-3">
<label class="form-label fw-semibold small">Provider</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="ai_provider" id="ai_provider_anthropic"
value="anthropic" <?= $_aiProvider === 'anthropic' ? 'checked' : '' ?>>
<label class="form-check-label" for="ai_provider_anthropic">Anthropic (API)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="ai_provider" id="ai_provider_claude_code"
value="claude_code" <?= $_aiProvider === 'claude_code' ? 'checked' : '' ?>>
<label class="form-check-label" for="ai_provider_claude_code">Claude Code CLI</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="ai_model" class="form-label fw-semibold small">Modèle Anthropic</label>
<input type="text" class="form-control form-control-sm font-monospace" id="ai_model" name="ai_model"
value="<?= htmlspecialchars($_aiModel) ?>"
placeholder="claude-haiku-4-5-20251001">
<div class="form-text">Laisser vide pour utiliser le défaut (<code>claude-haiku-4-5-20251001</code>). Ignoré si le provider est Claude Code CLI.</div>
</div>
<button type="submit" class="btn btn-primary btn-sm">Enregistrer</button>
</form>
</div>
</div>
<!-- Section 3 — Clé Anthropic -->
<div class="card mb-4">
<div class="card-header fw-semibold small">Clé API Anthropic</div>
<div class="card-body">
<div class="alert alert-warning py-2 small mb-0">
<strong>La clé API Anthropic ne peut pas être saisie ici.</strong><br>
Elle doit être définie dans le fichier <code>.env</code> du serveur :
<pre class="mt-2 mb-0 small"><code>ANTHROPIC_API_KEY=sk-ant-...</code></pre>
<div class="mt-2">Statut actuel : <?= $_anthropicOk ? '<span class="text-success">✓ Configurée</span>' : '<span class="text-danger">✗ Absente</span>' ?></div>
</div>
</div>
</div>
<!-- Section 4 — Procédure Claude Code CLI -->
<div class="card mb-4">
<div class="card-header fw-semibold small">Procédure d'installation de Claude Code CLI</div>
<div class="card-body">
<?php if ($_cliOk): ?>
<div class="alert alert-success py-2 small mb-3">✓ <code>/usr/local/bin/claude</code> détecté.</div>
<?php else: ?>
<div class="alert alert-secondary py-2 small mb-3">✗ <code>/usr/local/bin/claude</code> introuvable — suivez les étapes ci-dessous.</div>
<?php endif; ?>
<p class="small text-muted">À exécuter en SSH sur le serveur (en root ou via sudo) :</p>
<pre class="bg-dark text-light p-3 rounded small"><code># 1. Installer Claude Code CLI (en root)
sudo npm install -g @anthropic-ai/claude-code
# Vérifier l'installation
/usr/local/bin/claude --version
# 2. Créer le répertoire HOME de www-data pour Claude
sudo mkdir -p /var/lib/claude-www
sudo chown www-data:www-data /var/lib/claude-www
# 3. Authentifier Claude en tant que www-data
sudo -u www-data HOME=/var/lib/claude-www /usr/local/bin/claude auth login
# → Suivre les instructions (OAuth navigateur ou clé API)
# 4. Vérifier que ça fonctionne
sudo -u www-data HOME=/var/lib/claude-www /usr/local/bin/claude --print "Réponds juste OK"</code></pre>
</div>
</div>
<?php endif; ?>
<?php if ($tab === 'stats' && isAdmin()): ?> <?php if ($tab === 'stats' && isAdmin()): ?>
<?php include __DIR__ . '/admin_stats.php'; ?> <?php include __DIR__ . '/admin_stats.php'; ?>
+1 -26
View File
@@ -142,29 +142,4 @@ setcookie('_csrf_c', $_csrfToken, [
</div> </div>
</div> </div>
<script> <script src="/assets/js/comments.js" defer></script>
(function () {
var maxAge = 365 * 24 * 3600;
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
function setCookie(name, value) {
document.cookie = name + '=' + encodeURIComponent(value) + ';max-age=' + maxAge + ';path=/;SameSite=Lax';
}
var nameEl = document.getElementById('comment-name');
var emailEl = document.getElementById('comment-email');
if (!nameEl || !emailEl) { return; }
var savedName = getCookie('cmt_name');
var savedEmail = getCookie('cmt_email');
if (savedName) { nameEl.value = savedName; }
if (savedEmail) { emailEl.value = savedEmail; }
var form = document.getElementById('comment-form');
if (form) {
form.addEventListener('submit', function () {
if (nameEl.value.trim()) { setCookie('cmt_name', nameEl.value.trim()); }
if (emailEl.value.trim()) { setCookie('cmt_email', emailEl.value.trim()); }
});
}
}());
</script>
+3
View File
@@ -166,6 +166,9 @@ $_layoutCurrentCat = trim($_GET['cat'] ?? '');
<?php if (!empty($shareBar ?? false)): ?> <?php if (!empty($shareBar ?? false)): ?>
<script src="<?= _av($_pub, 'js/share.js') ?>" defer></script> <script src="<?= _av($_pub, 'js/share.js') ?>" defer></script>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($aiEditor ?? false)): ?>
<script src="<?= _av($_pub, 'js/ai-editor.js') ?>"></script>
<?php endif; ?>
</body> </body>
</html> </html>
+1 -2
View File
@@ -115,8 +115,7 @@ $slugOriginal = $postSlug;
</label> </label>
<input type="text" class="form-control form-control-sm font-monospace" id="confirm-slug" name="slug" <input type="text" class="form-control form-control-sm font-monospace" id="confirm-slug" name="slug"
value="<?= htmlspecialchars($slugDefault) ?>" value="<?= htmlspecialchars($slugDefault) ?>"
pattern="[a-z0-9][a-z0-9\-]*" pattern="[a-z0-9][a-z0-9\-]*">
oninput="document.getElementById('slug-display').textContent=this.value">
<?php if ($titleChanged && $autoSlug !== $slugOriginal): ?> <?php if ($titleChanged && $autoSlug !== $slugOriginal): ?>
<div class="mt-2 d-flex align-items-center gap-2 flex-wrap"> <div class="mt-2 d-flex align-items-center gap-2 flex-wrap">
<small class="text-muted">Slug recalculé depuis le nouveau titre. Slug initial :</small> <small class="text-muted">Slug recalculé depuis le nouveau titre. Slug initial :</small>
+33
View File
@@ -9,6 +9,7 @@ $dateValue = isset($published_at)
?> ?>
<?php if ($action === 'edit'): ?> <?php if ($action === 'edit'): ?>
<?php $aiEditor = true; ?>
<div id="vl-page" <div id="vl-page"
data-uuid="<?= htmlspecialchars($uuid) ?>" data-uuid="<?= htmlspecialchars($uuid) ?>"
data-insert-url="<?= htmlspecialchars($insertUrl ?? '') ?>" data-insert-url="<?= htmlspecialchars($insertUrl ?? '') ?>"
@@ -221,6 +222,38 @@ $dateValue = isset($published_at)
<hr class="my-3"> <hr class="my-3">
<div class="mb-3">
<p class="fw-semibold small mb-2">IA</p>
<div class="d-flex flex-column gap-2">
<button type="button" id="btn-ai-critique"
class="btn btn-outline-secondary btn-sm">
Analyse critique
</button>
<button type="button" id="btn-ai-rewrite"
class="btn btn-outline-secondary btn-sm">
Réécrire l'article
</button>
</div>
<div id="ai-result-panel" class="mt-3" style="display:none">
<div class="d-flex align-items-center justify-content-between mb-1">
<span id="ai-result-label" class="fw-semibold small"></span>
<button type="button" id="btn-ai-close" class="btn-close btn-sm"
aria-label="Fermer"></button>
</div>
<div id="ai-result-content"
class="border rounded p-2 small"
style="max-height:400px;overflow-y:auto;white-space:pre-wrap;font-family:inherit;background:#f8f9fa">
</div>
<button type="button" id="btn-ai-apply"
class="btn btn-warning btn-sm mt-2"
style="display:none">
Appliquer dans l'éditeur
</button>
</div>
</div>
<hr class="my-3">
<?php if (!empty($existingFiles)): ?> <?php if (!empty($existingFiles)): ?>
<?php $coverFile = $article['cover'] ?? ''; ?> <?php $coverFile = $article['cover'] ?? ''; ?>
<?php $filesMeta = $article['files_meta'] ?? []; ?> <?php $filesMeta = $article['files_meta'] ?? []; ?>
+3 -1
View File
@@ -26,7 +26,9 @@ $_formAction = '/edit/' . rawurlencode($uuid) . '/6';
<div class="d-flex gap-2 flex-wrap align-items-center"> <div class="d-flex gap-2 flex-wrap align-items-center">
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a> <a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
<button type="button" class="btn btn-outline-danger btn-sm" <button type="button" class="btn btn-outline-danger btn-sm"
onclick="if(confirm('Abandonner les modifications et supprimer ce brouillon ?')) window.location='/edit/<?= rawurlencode($uuid) ?>/discard'"> data-confirm-discard
data-discard-url="/edit/<?= rawurlencode($uuid) ?>/discard"
data-confirm-msg="Abandonner les modifications et supprimer ce brouillon ?">
Abandonner Abandonner
</button> </button>
<button type="submit" class="btn btn-success">✓ Confirmer et enregistrer</button> <button type="submit" class="btn btn-success">✓ Confirmer et enregistrer</button>