Add protected training content admin
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
<!doctype html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#f5f7f2">
|
||||
<title>إدارة المحتوى — SportPath</title>
|
||||
<link rel="stylesheet" href="/assets/app.css">
|
||||
<link rel="stylesheet" href="/assets/admin.css">
|
||||
<script src="/assets/admin.js" defer></script>
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
<header class="topbar admin-topbar wrap">
|
||||
<a class="brand" href="/"><span class="brand-mark">S</span> SportPath <span class="admin-label">إدارة المحتوى</span></a>
|
||||
<button class="text-button hidden" id="logout-button" type="button">تسجيل الخروج</button>
|
||||
</header>
|
||||
<main class="wrap admin-main">
|
||||
<section class="admin-intro">
|
||||
<p class="eyebrow">مساحة التحرير</p>
|
||||
<h1>محتوى واضح،<br><em>وتدريب مسؤول.</em></h1>
|
||||
<p>أدر مكتبة الحركات والخطط الأسبوعية. لا تُنشر التغييرات إلا باختيارك، وتسجل كل عملية في سجل التدقيق.</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel" id="login-panel">
|
||||
<h2>دخول المشرف</h2>
|
||||
<p class="panel-note">يتطلب حسابًا موجودًا بدور مالك أو مدير محتوى، ورمز تحقق صالحًا.</p>
|
||||
<form id="otp-request-form" class="admin-form">
|
||||
<label>رقم الهاتف الدولي
|
||||
<input id="admin-phone" type="tel" inputmode="tel" placeholder="+9627xxxxxxxx" autocomplete="tel" required>
|
||||
</label>
|
||||
<button class="button" type="submit">إرسال رمز الدخول <span aria-hidden="true">←</span></button>
|
||||
</form>
|
||||
<form id="otp-verify-form" class="admin-form hidden">
|
||||
<label>رمز التحقق
|
||||
<input id="admin-code" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required>
|
||||
</label>
|
||||
<button class="button" type="submit">تأكيد الدخول <span aria-hidden="true">←</span></button>
|
||||
</form>
|
||||
<p class="status-message" id="login-status" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section class="admin-workspace hidden" id="workspace">
|
||||
<div class="workspace-heading">
|
||||
<div><p class="eyebrow">لوحة التحكم</p><h2>مكتبة التدريب</h2></div>
|
||||
<button class="outline-button" type="button" id="reload-button">تحديث المحتوى</button>
|
||||
</div>
|
||||
<p class="status-message" id="workspace-status" role="status" aria-live="polite"></p>
|
||||
<div class="workspace-grid">
|
||||
<section class="admin-panel">
|
||||
<div class="panel-heading"><h3>التمارين</h3><button class="text-button" type="button" id="new-exercise">تمرين جديد +</button></div>
|
||||
<div class="content-list" id="exercise-list"></div>
|
||||
</section>
|
||||
<section class="admin-panel">
|
||||
<div class="panel-heading"><h3>الخطط</h3><button class="text-button" type="button" id="new-plan">خطة جديدة +</button></div>
|
||||
<div class="content-list" id="plan-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="admin-panel editor-panel hidden" id="editor-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow" id="editor-kind">تحرير</p><h3 id="editor-title">محتوى جديد</h3></div><button class="text-button" type="button" id="close-editor">إغلاق ×</button></div>
|
||||
<form class="admin-form" id="content-form">
|
||||
<input id="entity-type" type="hidden">
|
||||
<input id="entity-uuid" type="hidden">
|
||||
<label>المعرّف النصي (slug)
|
||||
<input id="slug" required maxlength="100" dir="ltr" pattern="[a-z0-9]+(?:-[a-z0-9]+)*" placeholder="gentle-mobility">
|
||||
</label>
|
||||
<label>الاسم بالعربية
|
||||
<input id="title-ar" required maxlength="160">
|
||||
</label>
|
||||
<div id="exercise-fields" class="form-grid hidden">
|
||||
<label>نوع الحركة
|
||||
<select id="movement-type"><option value="strength">قوة</option><option value="mobility">مرونة</option><option value="cardio">لياقة قلبية</option><option value="recovery">استشفاء</option></select>
|
||||
</label>
|
||||
<label>المستوى
|
||||
<select id="difficulty"><option value="beginner">مبتدئ</option><option value="intermediate">متوسط</option><option value="advanced">متقدم</option></select>
|
||||
</label>
|
||||
<label>رابط GIF (HTTPS)
|
||||
<input id="gif-url" type="url" dir="ltr" placeholder="https://…/movement.gif">
|
||||
</label>
|
||||
<label>رابط صورة بديلة (HTTPS)
|
||||
<input id="poster-url" type="url" dir="ltr" placeholder="https://…/poster.webp">
|
||||
</label>
|
||||
<label>مدة الحركة بالثواني
|
||||
<input id="exercise-duration" type="number" min="1" max="3600">
|
||||
</label>
|
||||
<label>التكرارات الافتراضية
|
||||
<input id="repetitions" maxlength="80" placeholder="8–12">
|
||||
</label>
|
||||
<label class="full-width">تعليمات الأداء — كل خطوة بسطر
|
||||
<textarea id="instructions" rows="4"></textarea>
|
||||
</label>
|
||||
<label>العضلات المستهدفة — سطر لكل عنصر
|
||||
<textarea id="muscles" rows="3"></textarea>
|
||||
</label>
|
||||
<label>الأدوات — سطر لكل عنصر
|
||||
<textarea id="equipment" rows="3"></textarea>
|
||||
</label>
|
||||
<label class="full-width">تنبيهات السلامة — تنبيه لكل سطر
|
||||
<textarea id="exercise-safety" rows="3"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div id="plan-fields" class="form-grid hidden">
|
||||
<label>الهدف
|
||||
<select id="goal"><option value="general_fitness">لياقة عامة</option><option value="weight_management">إدارة الوزن</option><option value="mobility">مرونة وحركة</option><option value="endurance">التحمل</option></select>
|
||||
</label>
|
||||
<label>المستوى
|
||||
<select id="plan-level"><option value="beginner">مبتدئ</option><option value="intermediate">متوسط</option><option value="advanced">متقدم</option></select>
|
||||
</label>
|
||||
<label>عدد الأسابيع
|
||||
<input id="weeks" type="number" min="1" max="52" value="4" required>
|
||||
</label>
|
||||
<label class="full-width">نبذة الخطة
|
||||
<textarea id="summary" rows="3"></textarea>
|
||||
</label>
|
||||
<label class="full-width">المصادر/ملاحظات المراجعة — سطر لكل مصدر
|
||||
<textarea id="sources" rows="3" placeholder="اسم الجهة أو الدراسة؛ الرابط أو المرجع الكامل"></textarea>
|
||||
</label>
|
||||
<label class="full-width">تنبيهات السلامة الخاصة بالخطة — سطر لكل تنبيه
|
||||
<textarea id="plan-safety" rows="3"></textarea>
|
||||
</label>
|
||||
<label class="full-width">جدول الجلسات (JSON)
|
||||
<textarea id="sessions-json" rows="13" dir="ltr" spellcheck="false" placeholder='[{"week":1,"day":1,"title_ar":"جلسة تجريبية","type":"walking","duration_minutes":20,"intensity":"easy","notes_ar":"","exercises":[]}]'></textarea>
|
||||
<small>كل عنصر يمثل أسبوعًا/يومًا. يمكن ربط التمارين المنشورة باستخدام exercise_uuid. راجع JSON قبل الحفظ.</small>
|
||||
</label>
|
||||
</div>
|
||||
<label class="publish-toggle"><input id="is-published" type="checkbox"> نشر هذا المحتوى ليظهر للمستخدمين</label>
|
||||
<div class="editor-actions"><button class="button" type="submit">حفظ المحتوى <span aria-hidden="true">←</span></button><span id="form-status" class="status-message" role="status"></span></div>
|
||||
</form>
|
||||
</section>
|
||||
<p class="privacy-note">مفاتيح قاعدة البيانات وOTP والذكاء الاصطناعي لا تظهر في لوحة المحتوى ولا تُخزن فيها. ملفات GIF يجب أن تكون مرخصة ومراجعة قبل النشر.</p>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="wrap footer"><a class="brand" href="/"><span class="brand-mark">S</span> SportPath</a><span>إدارة آمنة للمحتوى</span></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
if (!in_array($method, ['GET', 'POST'], true)) {
|
||||
header('Allow: GET, POST');
|
||||
api_json(['error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
$auth = ApiAuth::bearerClaims();
|
||||
ApiAuth::requireRole($auth, ['owner', 'content_manager']);
|
||||
|
||||
function content_json_value($value, int $maxBytes = 20000): string
|
||||
{
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false || strlen($json) > $maxBytes) {
|
||||
api_json(['error' => 'content_value_too_large_or_invalid'], 400);
|
||||
}
|
||||
return $json;
|
||||
}
|
||||
|
||||
function content_string_list($value, string $field, int $maxItems = 30): array
|
||||
{
|
||||
if (!is_array($value) || count($value) > $maxItems) {
|
||||
api_json(['error' => 'invalid_' . $field], 400);
|
||||
}
|
||||
foreach ($value as $item) {
|
||||
if (!is_string($item) || trim($item) === '' || mb_strlen($item) > 500) {
|
||||
api_json(['error' => 'invalid_' . $field], 400);
|
||||
}
|
||||
}
|
||||
return array_values(array_map('trim', $value));
|
||||
}
|
||||
|
||||
function content_text($value, string $field, int $maxLength, bool $required = true): ?string
|
||||
{
|
||||
if ($value === null && !$required) return null;
|
||||
if (!is_string($value)) api_json(['error' => 'invalid_' . $field], 400);
|
||||
$value = trim($value);
|
||||
if (($required && $value === '') || mb_strlen($value) > $maxLength) {
|
||||
api_json(['error' => 'invalid_' . $field], 400);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
function content_enum($value, string $field, array $allowed): string
|
||||
{
|
||||
if (!is_string($value) || !in_array($value, $allowed, true)) {
|
||||
api_json(['error' => 'invalid_' . $field], 400);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
function content_https_url($value, string $field): ?string
|
||||
{
|
||||
if ($value === null || $value === '') return null;
|
||||
if (!is_string($value) || strlen($value) > 500) api_json(['error' => 'invalid_' . $field], 400);
|
||||
$parts = parse_url($value);
|
||||
if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])) {
|
||||
api_json(['error' => 'invalid_' . $field], 400);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
if ($method === 'GET') {
|
||||
$connection = $db->getConnection();
|
||||
$exerciseResult = $connection->query('SELECT exercise_uuid, slug, title_ar, instructions_ar, target_muscles, equipment, difficulty, movement_type, gif_url, gif_poster_url, duration_seconds, repetitions, safety_notes_ar, is_published, content_revision FROM exercises ORDER BY updated_at DESC LIMIT 500');
|
||||
$exercises = [];
|
||||
while ($row = $exerciseResult->fetch_assoc()) {
|
||||
foreach (['instructions_ar', 'target_muscles', 'equipment', 'safety_notes_ar'] as $key) {
|
||||
$row[$key] = json_decode($row[$key], true) ?? [];
|
||||
}
|
||||
$row['is_published'] = (bool) $row['is_published'];
|
||||
$row['content_revision'] = (int) $row['content_revision'];
|
||||
$exercises[] = $row;
|
||||
}
|
||||
|
||||
$planResult = $connection->query('SELECT id, plan_uuid, slug, title_ar, summary_ar, goal, level, weeks_duration, source_notes, safety_notes_ar, is_published, content_revision FROM training_plans ORDER BY updated_at DESC LIMIT 200');
|
||||
$plans = [];
|
||||
$planIds = [];
|
||||
while ($row = $planResult->fetch_assoc()) {
|
||||
$row['source_notes'] = json_decode($row['source_notes'] ?? '[]', true) ?? [];
|
||||
$row['safety_notes_ar'] = json_decode($row['safety_notes_ar'], true) ?? [];
|
||||
$row['is_published'] = (bool) $row['is_published'];
|
||||
$row['content_revision'] = (int) $row['content_revision'];
|
||||
$plans[$row['plan_uuid']] = $row;
|
||||
$planIds[(int) $row['id']] = $row['plan_uuid'];
|
||||
}
|
||||
if ($planIds) {
|
||||
$sessionResult = $connection->query('SELECT s.id, s.plan_id, s.week_number, s.day_number, s.title_ar, s.session_type, s.duration_minutes, s.intensity, s.notes_ar, e.exercise_uuid, se.sort_order, se.sets, se.reps, se.duration_seconds, se.rest_seconds FROM training_plan_sessions s LEFT JOIN training_session_exercises se ON se.session_id = s.id LEFT JOIN exercises e ON e.id = se.exercise_id ORDER BY s.plan_id, s.week_number, s.day_number, se.sort_order');
|
||||
foreach ($plans as &$plan) $plan['sessions'] = [];
|
||||
unset($plan);
|
||||
while ($row = $sessionResult->fetch_assoc()) {
|
||||
$planUuid = $planIds[(int) $row['plan_id']] ?? null;
|
||||
if ($planUuid === null) continue;
|
||||
$sessionKey = $row['week_number'] . ':' . $row['day_number'];
|
||||
if (!isset($plans[$planUuid]['sessions'][$sessionKey])) {
|
||||
$plans[$planUuid]['sessions'][$sessionKey] = [
|
||||
'week' => (int) $row['week_number'],
|
||||
'day' => (int) $row['day_number'],
|
||||
'title_ar' => $row['title_ar'],
|
||||
'type' => $row['session_type'],
|
||||
'duration_minutes' => (int) $row['duration_minutes'],
|
||||
'intensity' => $row['intensity'],
|
||||
'notes_ar' => $row['notes_ar'],
|
||||
'exercises' => [],
|
||||
];
|
||||
}
|
||||
if ($row['exercise_uuid'] !== null) {
|
||||
$plans[$planUuid]['sessions'][$sessionKey]['exercises'][] = [
|
||||
'exercise_uuid' => $row['exercise_uuid'],
|
||||
'sets' => $row['sets'] === null ? null : (int) $row['sets'],
|
||||
'reps' => $row['reps'],
|
||||
'duration_seconds' => $row['duration_seconds'] === null ? null : (int) $row['duration_seconds'],
|
||||
'rest_seconds' => (int) $row['rest_seconds'],
|
||||
];
|
||||
}
|
||||
}
|
||||
foreach ($plans as &$plan) $plan['sessions'] = array_values($plan['sessions']);
|
||||
unset($plan);
|
||||
}
|
||||
foreach ($plans as &$plan) {
|
||||
unset($plan['id']);
|
||||
$plan['sessions'] = $plan['sessions'] ?? [];
|
||||
}
|
||||
unset($plan);
|
||||
api_json(['exercises' => $exercises, 'plans' => array_values($plans)]);
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input') ?: '', true);
|
||||
if (!is_array($body) || !in_array($body['entity'] ?? null, ['exercise', 'plan'], true)) {
|
||||
api_json(['error' => 'invalid_content_payload'], 400);
|
||||
}
|
||||
$entity = $body['entity'];
|
||||
$data = $body['content'] ?? null;
|
||||
if (!is_array($data)) api_json(['error' => 'invalid_content_payload'], 400);
|
||||
$uuid = $data['uuid'] ?? null;
|
||||
if ($uuid !== null && (!is_string($uuid) || !preg_match('/^[0-9a-f-]{36}$/i', $uuid))) {
|
||||
api_json(['error' => 'invalid_content_uuid'], 400);
|
||||
}
|
||||
$uuid = $uuid ?: sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||
$published = $data['is_published'] ?? false;
|
||||
if (!is_bool($published)) api_json(['error' => 'invalid_publication_state'], 400);
|
||||
|
||||
$connection = $db->getConnection();
|
||||
$connection->begin_transaction();
|
||||
$previous = null;
|
||||
if ($entity === 'exercise') {
|
||||
$slug = content_text($data['slug'] ?? null, 'slug', 100);
|
||||
if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug)) api_json(['error' => 'invalid_slug'], 400);
|
||||
$title = content_text($data['title_ar'] ?? null, 'title_ar', 160);
|
||||
$instructions = content_string_list($data['instructions_ar'] ?? [], 'instructions_ar');
|
||||
$muscles = content_string_list($data['target_muscles'] ?? [], 'target_muscles');
|
||||
$equipment = content_string_list($data['equipment'] ?? [], 'equipment');
|
||||
$safety = content_string_list($data['safety_notes_ar'] ?? [], 'safety_notes_ar');
|
||||
$difficulty = content_enum($data['difficulty'] ?? 'beginner', 'difficulty', ['beginner', 'intermediate', 'advanced']);
|
||||
$movement = content_enum($data['movement_type'] ?? null, 'movement_type', ['strength', 'mobility', 'cardio', 'recovery']);
|
||||
$gif = content_https_url($data['gif_url'] ?? null, 'gif_url');
|
||||
$poster = content_https_url($data['gif_poster_url'] ?? null, 'gif_poster_url');
|
||||
$duration = $data['duration_seconds'] ?? null;
|
||||
if ($duration !== null && (!is_int($duration) || $duration < 1 || $duration > 3600)) api_json(['error' => 'invalid_duration_seconds'], 400);
|
||||
$repetitions = content_text($data['repetitions'] ?? null, 'repetitions', 80, false);
|
||||
$alternative = $data['alternative_exercise_uuid'] ?? null;
|
||||
if ($alternative !== null && (!is_string($alternative) || !preg_match('/^[0-9a-f-]{36}$/i', $alternative))) api_json(['error' => 'invalid_alternative_exercise_uuid'], 400);
|
||||
$check = $db->prepare('SELECT exercise_uuid, slug, title_ar, instructions_ar, target_muscles, equipment, difficulty, movement_type, gif_url, gif_poster_url, duration_seconds, repetitions, safety_notes_ar, is_published FROM exercises WHERE exercise_uuid = ? FOR UPDATE');
|
||||
$check->bind_param('s', $uuid);
|
||||
$check->execute();
|
||||
$previous = $check->get_result()->fetch_assoc() ?: null;
|
||||
$check->close();
|
||||
$slugCheck = $db->prepare('SELECT exercise_uuid FROM exercises WHERE slug = ? AND exercise_uuid <> ? LIMIT 1');
|
||||
$slugCheck->bind_param('ss', $slug, $uuid);
|
||||
$slugCheck->execute();
|
||||
$slugTaken = $slugCheck->get_result()->fetch_assoc();
|
||||
$slugCheck->close();
|
||||
if ($slugTaken) api_json(['error' => 'slug_already_exists'], 409);
|
||||
if (!$published && $previous !== null) {
|
||||
$usage = $db->prepare('SELECT COUNT(*) AS total FROM training_session_exercises se JOIN training_plan_sessions s ON s.id = se.session_id JOIN training_plans p ON p.id = s.plan_id JOIN exercises e ON e.id = se.exercise_id WHERE e.exercise_uuid = ? AND p.is_published = 1');
|
||||
$usage->bind_param('s', $uuid);
|
||||
$usage->execute();
|
||||
$linked = (int) $usage->get_result()->fetch_assoc()['total'];
|
||||
$usage->close();
|
||||
if ($linked > 0) api_json(['error' => 'exercise_used_by_published_plan'], 409);
|
||||
}
|
||||
$altId = null;
|
||||
if ($alternative !== null) {
|
||||
$altQuery = $db->prepare('SELECT id FROM exercises WHERE exercise_uuid = ? LIMIT 1');
|
||||
$altQuery->bind_param('s', $alternative);
|
||||
$altQuery->execute();
|
||||
$altRow = $altQuery->get_result()->fetch_assoc();
|
||||
$altQuery->close();
|
||||
if (!$altRow) api_json(['error' => 'alternative_exercise_not_found'], 400);
|
||||
$altId = (int) $altRow['id'];
|
||||
}
|
||||
$instructionJson = content_json_value($instructions);
|
||||
$muscleJson = content_json_value($muscles);
|
||||
$equipmentJson = content_json_value($equipment);
|
||||
$safetyJson = content_json_value($safety);
|
||||
if ($previous === null) {
|
||||
$write = $db->prepare('INSERT INTO exercises (exercise_uuid, slug, title_ar, instructions_ar, target_muscles, equipment, difficulty, movement_type, gif_url, gif_poster_url, duration_seconds, repetitions, safety_notes_ar, alternative_exercise_id, is_published) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
$write->bind_param('ssssssssssissii', $uuid, $slug, $title, $instructionJson, $muscleJson, $equipmentJson, $difficulty, $movement, $gif, $poster, $duration, $repetitions, $safetyJson, $altId, $published);
|
||||
} else {
|
||||
$write = $db->prepare('UPDATE exercises SET slug = ?, title_ar = ?, instructions_ar = ?, target_muscles = ?, equipment = ?, difficulty = ?, movement_type = ?, gif_url = ?, gif_poster_url = ?, duration_seconds = ?, repetitions = ?, safety_notes_ar = ?, alternative_exercise_id = ?, is_published = ?, content_revision = content_revision + 1 WHERE exercise_uuid = ?');
|
||||
$write->bind_param('sssssssssissiis', $slug, $title, $instructionJson, $muscleJson, $equipmentJson, $difficulty, $movement, $gif, $poster, $duration, $repetitions, $safetyJson, $altId, $published, $uuid);
|
||||
}
|
||||
$write->execute();
|
||||
$write->close();
|
||||
$newValue = ['uuid' => $uuid, 'slug' => $slug, 'title_ar' => $title, 'instructions_ar' => $instructions, 'target_muscles' => $muscles, 'equipment' => $equipment, 'difficulty' => $difficulty, 'movement_type' => $movement, 'gif_url' => $gif, 'gif_poster_url' => $poster, 'duration_seconds' => $duration, 'repetitions' => $repetitions, 'safety_notes_ar' => $safety, 'alternative_exercise_uuid' => $alternative, 'is_published' => $published];
|
||||
} else {
|
||||
$slug = content_text($data['slug'] ?? null, 'slug', 100);
|
||||
if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug)) api_json(['error' => 'invalid_slug'], 400);
|
||||
$title = content_text($data['title_ar'] ?? null, 'title_ar', 160);
|
||||
$summary = content_text($data['summary_ar'] ?? '', 'summary_ar', 4000, false) ?? '';
|
||||
$goal = content_enum($data['goal'] ?? null, 'goal', ['general_fitness', 'weight_management', 'mobility', 'endurance']);
|
||||
$level = content_enum($data['level'] ?? 'beginner', 'level', ['beginner', 'intermediate', 'advanced']);
|
||||
$weeks = $data['weeks_duration'] ?? null;
|
||||
if (!is_int($weeks) || $weeks < 1 || $weeks > 52) api_json(['error' => 'invalid_weeks_duration'], 400);
|
||||
$sourceNotes = content_string_list($data['source_notes'] ?? [], 'source_notes');
|
||||
$safety = content_string_list($data['safety_notes_ar'] ?? [], 'safety_notes_ar');
|
||||
$sessions = $data['sessions'] ?? [];
|
||||
if (!is_array($sessions) || count($sessions) > 364) api_json(['error' => 'invalid_sessions'], 400);
|
||||
$check = $db->prepare('SELECT plan_uuid, slug, title_ar, summary_ar, goal, level, weeks_duration, source_notes, safety_notes_ar, is_published FROM training_plans WHERE plan_uuid = ? FOR UPDATE');
|
||||
$check->bind_param('s', $uuid);
|
||||
$check->execute();
|
||||
$previous = $check->get_result()->fetch_assoc() ?: null;
|
||||
$check->close();
|
||||
$slugCheck = $db->prepare('SELECT plan_uuid FROM training_plans WHERE slug = ? AND plan_uuid <> ? LIMIT 1');
|
||||
$slugCheck->bind_param('ss', $slug, $uuid);
|
||||
$slugCheck->execute();
|
||||
$slugTaken = $slugCheck->get_result()->fetch_assoc();
|
||||
$slugCheck->close();
|
||||
if ($slugTaken) api_json(['error' => 'slug_already_exists'], 409);
|
||||
$sourceJson = content_json_value($sourceNotes);
|
||||
$safetyJson = content_json_value($safety);
|
||||
if ($previous === null) {
|
||||
$write = $db->prepare('INSERT INTO training_plans (plan_uuid, slug, title_ar, summary_ar, goal, level, weeks_duration, source_notes, safety_notes_ar, is_published) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
$write->bind_param('ssssssissi', $uuid, $slug, $title, $summary, $goal, $level, $weeks, $sourceJson, $safetyJson, $published);
|
||||
} else {
|
||||
$write = $db->prepare('UPDATE training_plans SET slug = ?, title_ar = ?, summary_ar = ?, goal = ?, level = ?, weeks_duration = ?, source_notes = ?, safety_notes_ar = ?, is_published = ?, content_revision = content_revision + 1 WHERE plan_uuid = ?');
|
||||
$write->bind_param('sssssissis', $slug, $title, $summary, $goal, $level, $weeks, $sourceJson, $safetyJson, $published, $uuid);
|
||||
}
|
||||
$write->execute();
|
||||
$planId = (int) $connection->insert_id;
|
||||
if ($previous !== null || $planId === 0) {
|
||||
$idQuery = $db->prepare('SELECT id FROM training_plans WHERE plan_uuid = ? LIMIT 1');
|
||||
$idQuery->bind_param('s', $uuid);
|
||||
$idQuery->execute();
|
||||
$planId = (int) $idQuery->get_result()->fetch_assoc()['id'];
|
||||
$idQuery->close();
|
||||
}
|
||||
$write->close();
|
||||
$remove = $db->prepare('DELETE FROM training_plan_sessions WHERE plan_id = ?');
|
||||
$remove->bind_param('i', $planId);
|
||||
$remove->execute();
|
||||
$remove->close();
|
||||
$sessionInsert = $db->prepare('INSERT INTO training_plan_sessions (plan_id, week_number, day_number, title_ar, session_type, duration_minutes, intensity, notes_ar, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
$exerciseLookup = $db->prepare('SELECT id FROM exercises WHERE exercise_uuid = ? AND is_published = 1 LIMIT 1');
|
||||
$linkInsert = $db->prepare('INSERT INTO training_session_exercises (session_id, exercise_id, sort_order, sets, reps, duration_seconds, rest_seconds) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
$sessionKeys = [];
|
||||
foreach ($sessions as $sessionIndex => $session) {
|
||||
if (!is_array($session)) api_json(['error' => 'invalid_session'], 400);
|
||||
$week = $session['week'] ?? null;
|
||||
$day = $session['day'] ?? null;
|
||||
$minutes = $session['duration_minutes'] ?? 0;
|
||||
if (!is_int($week) || $week < 1 || $week > $weeks || !is_int($day) || $day < 1 || $day > 7 || !is_int($minutes) || $minutes < 0 || $minutes > 600) api_json(['error' => 'invalid_session_schedule'], 400);
|
||||
$key = $week . ':' . $day;
|
||||
if (isset($sessionKeys[$key])) api_json(['error' => 'duplicate_session_day'], 400);
|
||||
$sessionKeys[$key] = true;
|
||||
$sessionTitle = content_text($session['title_ar'] ?? null, 'session_title_ar', 160);
|
||||
$sessionType = content_enum($session['type'] ?? null, 'session_type', ['strength', 'walking', 'mobility', 'rest']);
|
||||
$intensity = content_enum($session['intensity'] ?? 'easy', 'intensity', ['easy', 'moderate', 'vigorous']);
|
||||
$notes = content_text($session['notes_ar'] ?? null, 'session_notes_ar', 1000, false);
|
||||
$exerciseItems = $session['exercises'] ?? [];
|
||||
if (!is_array($exerciseItems) || count($exerciseItems) > 20) api_json(['error' => 'invalid_session_exercises'], 400);
|
||||
$exerciseUuids = [];
|
||||
$order = (int) $sessionIndex;
|
||||
$sessionInsert->bind_param('iiississi', $planId, $week, $day, $sessionTitle, $sessionType, $minutes, $intensity, $notes, $order);
|
||||
$sessionInsert->execute();
|
||||
$sessionId = (int) $connection->insert_id;
|
||||
foreach ($exerciseItems as $exerciseIndex => $item) {
|
||||
if (!is_array($item) || !is_string($item['exercise_uuid'] ?? null)) api_json(['error' => 'invalid_session_exercise'], 400);
|
||||
if (isset($exerciseUuids[$item['exercise_uuid']])) api_json(['error' => 'duplicate_session_exercise'], 400);
|
||||
$exerciseUuids[$item['exercise_uuid']] = true;
|
||||
$exerciseLookup->bind_param('s', $item['exercise_uuid']);
|
||||
$exerciseLookup->execute();
|
||||
$exerciseRow = $exerciseLookup->get_result()->fetch_assoc();
|
||||
if (!$exerciseRow) api_json(['error' => 'exercise_not_published'], 400);
|
||||
$exerciseId = (int) $exerciseRow['id'];
|
||||
$sort = (int) $exerciseIndex;
|
||||
$sets = $item['sets'] ?? null;
|
||||
if ($sets !== null && (!is_int($sets) || $sets < 1 || $sets > 50)) api_json(['error' => 'invalid_sets'], 400);
|
||||
$reps = content_text($item['reps'] ?? null, 'reps', 60, false);
|
||||
$durationSeconds = $item['duration_seconds'] ?? null;
|
||||
$rest = $item['rest_seconds'] ?? 45;
|
||||
if (($durationSeconds !== null && (!is_int($durationSeconds) || $durationSeconds < 1 || $durationSeconds > 3600)) || !is_int($rest) || $rest < 0 || $rest > 3600) api_json(['error' => 'invalid_exercise_prescription'], 400);
|
||||
$linkInsert->bind_param('iiiisii', $sessionId, $exerciseId, $sort, $sets, $reps, $durationSeconds, $rest);
|
||||
$linkInsert->execute();
|
||||
}
|
||||
}
|
||||
$sessionInsert->close();
|
||||
$exerciseLookup->close();
|
||||
$linkInsert->close();
|
||||
$newValue = ['uuid' => $uuid, 'slug' => $slug, 'title_ar' => $title, 'summary_ar' => $summary, 'goal' => $goal, 'level' => $level, 'weeks_duration' => $weeks, 'source_notes' => $sourceNotes, 'safety_notes_ar' => $safety, 'is_published' => $published, 'sessions' => $sessions];
|
||||
}
|
||||
|
||||
$previousJson = $previous === null ? null : content_json_value($previous, 65535);
|
||||
$newJson = content_json_value($newValue, 65535);
|
||||
$action = $previous === null ? 'create' : (($previous['is_published'] ?? '0') != ($published ? '1' : '0') ? ($published ? 'publish' : 'unpublish') : 'update');
|
||||
$audit = $db->prepare('INSERT INTO training_content_audit (entity_type, entity_uuid, action, previous_value, new_value, actor_user_id) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
$audit->bind_param('sssssi', $entity, $uuid, $action, $previousJson, $newJson, $auth['user_id']);
|
||||
$audit->execute();
|
||||
$audit->close();
|
||||
$connection->commit();
|
||||
api_json(['status' => 'saved', 'entity' => $entity, 'uuid' => $uuid]);
|
||||
} catch (Throwable $exception) {
|
||||
if (isset($connection) && $connection instanceof mysqli) {
|
||||
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
||||
}
|
||||
error_log('Admin content operation failed: ' . $exception->getMessage());
|
||||
api_json(['error' => 'service_unavailable'], 503);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.admin-body{background:#f5f7f2}.admin-topbar{height:76px}.admin-label{font-size:11px;color:#778078;font-weight:450;border-inline-start:1px solid #dce3d9;padding-inline-start:13px;letter-spacing:0}.admin-main{padding-block:54px 90px}.admin-intro{max-width:660px;margin-bottom:34px}.admin-intro h1{font-size:clamp(38px,5vw,58px);margin-bottom:16px}.admin-intro>p:last-child{max-width:580px;color:#69746d;font-size:14px;line-height:1.9}.admin-panel{background:#fffefa;border:1px solid #e2e7df;border-radius:12px;padding:clamp(20px,3vw,32px);margin-bottom:20px;box-shadow:0 12px 32px #26382d08}.admin-panel h2,.admin-panel h3{margin:0;font-weight:620}.admin-panel h2{font-size:22px}.admin-panel h3{font-size:18px}.panel-note{color:#69746d;font-size:12px;margin:8px 0 22px}.admin-form{display:grid;gap:17px}.admin-form label{display:grid;gap:7px;font-size:12px;font-weight:590;color:#26382d}.admin-form input,.admin-form textarea,.admin-form select{width:100%;min-height:44px;padding:10px 12px;border:1px solid #dce3d9;border-radius:6px;background:white;color:#17251e;font:inherit;font-size:13px;line-height:1.55;outline:none}.admin-form textarea{resize:vertical}.admin-form input:focus,.admin-form textarea:focus,.admin-form select:focus{border-color:#456c52;box-shadow:0 0 0 3px #456c521a}.admin-form small{font-size:10px;color:#778078;font-weight:400}.admin-form .button{justify-self:start;border:0;cursor:pointer;font:inherit;font-size:13px}.status-message{min-height:20px;font-size:12px;color:#456c52;margin:0}.status-message[data-error="true"]{color:#a03d31}.hidden{display:none!important}.text-button,.outline-button{border:0;background:none;color:#456c52;padding:8px 10px;font:inherit;font-size:12px;cursor:pointer}.outline-button{border:1px solid #ced9ca;border-radius:4px}.workspace-heading,.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.workspace-heading{margin:34px 0 16px}.workspace-heading h2{font-size:26px;margin:0;font-weight:600}.workspace-heading .eyebrow,.panel-heading .eyebrow{margin:0 0 4px}.workspace-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.workspace-grid .admin-panel{min-width:0}.content-list{display:grid;gap:8px;margin-top:18px}.content-item{border:1px solid #e3e8e1;border-radius:7px;padding:12px;display:flex;align-items:center;gap:12px}.content-item-main{min-width:0;flex:1}.content-item strong{display:block;font-size:12px;font-weight:600;overflow-wrap:anywhere}.content-item small{display:block;color:#778078;font-size:10px;direction:ltr;text-align:right;overflow-wrap:anywhere}.content-state{font-size:10px;color:#a06d28;white-space:nowrap}.content-state.is-published{color:#456c52}.content-item button{border:0;background:#edf2e9;color:#456c52;border-radius:4px;padding:7px 9px;font:inherit;font-size:10px;cursor:pointer}.empty-list{font-size:12px;color:#778078;padding:14px 0}.editor-panel{margin-top:22px}.editor-panel>.panel-heading{margin-bottom:24px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.form-grid .full-width{grid-column:1/-1}.publish-toggle{display:flex!important;grid-template-columns:18px 1fr;align-items:center;gap:9px!important;margin-top:17px}.publish-toggle input{width:17px;height:17px;min-height:17px;accent-color:#456c52}.editor-actions{display:flex;align-items:center;gap:14px;margin-top:4px}.editor-actions .button{font:inherit;font-size:13px;border:0;cursor:pointer}.privacy-note{font-size:11px;color:#778078;line-height:1.8;margin:20px 2px}.admin-topbar .text-button{font-size:12px}@media(max-width:760px){.admin-main{padding-block:36px 56px}.workspace-grid{grid-template-columns:1fr}.admin-intro h1{font-size:43px}.workspace-heading{align-items:flex-end}.form-grid{grid-template-columns:1fr}.form-grid .full-width{grid-column:auto}}@media(prefers-reduced-motion:reduce){.admin-body *{scroll-behavior:auto!important;transition:none!important}}
|
||||
@@ -0,0 +1,292 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const api = '/api/v1';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const state = { challengeId: '', phone: '', exercises: [], plans: [] };
|
||||
const tokenKey = 'sportpath_admin_access';
|
||||
const refreshKey = 'sportpath_admin_refresh';
|
||||
|
||||
function message(element, text, isError = false) {
|
||||
element.textContent = text;
|
||||
element.dataset.error = isError ? 'true' : 'false';
|
||||
}
|
||||
|
||||
async function request(path, options = {}, mayRefresh = true) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.set('Accept', 'application/json');
|
||||
if (options.body) headers.set('Content-Type', 'application/json');
|
||||
const token = sessionStorage.getItem(tokenKey);
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
let response = await fetch(`${api}${path}`, { ...options, headers, cache: 'no-store' });
|
||||
if (response.status === 401 && mayRefresh && sessionStorage.getItem(refreshKey)) {
|
||||
const refreshed = await fetch(`${api}/auth/refresh.php`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: sessionStorage.getItem(refreshKey) }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const refreshBody = await refreshed.json().catch(() => ({}));
|
||||
if (refreshed.ok && refreshBody.access_token && refreshBody.refresh_token) {
|
||||
storeSession(refreshBody);
|
||||
headers.set('Authorization', `Bearer ${refreshBody.access_token}`);
|
||||
response = await fetch(`${api}${path}`, { ...options, headers, cache: 'no-store' });
|
||||
} else {
|
||||
clearSession();
|
||||
showLogin('انتهت الجلسة؛ سجّل الدخول مجددًا.', true);
|
||||
}
|
||||
}
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const errors = {
|
||||
unauthorized: 'تعذر التحقق من الجلسة.',
|
||||
forbidden: 'هذا الحساب ليس مالكًا أو مدير محتوى.',
|
||||
otp_provider_not_configured: 'إرسال OTP غير مهيأ على الخادم بعد.',
|
||||
otp_delivery_failed: 'تعذر إرسال الرمز؛ تحقق من إعداد مزود الرسائل.',
|
||||
rate_limited: 'تم تجاوز حد المحاولات؛ حاول لاحقًا.',
|
||||
service_unavailable: 'الخدمة غير متاحة مؤقتًا.',
|
||||
slug_already_exists: 'هذا المعرّف مستخدم بالفعل؛ اختر معرّفًا آخر.',
|
||||
exercise_used_by_published_plan: 'لا يمكن إخفاء تمرين مرتبط بخطة منشورة؛ أزل ارتباطه أو أوقف نشر الخطة أولًا.',
|
||||
exercise_not_published: 'كل تمرين مرتبط بالخطة يجب أن يكون منشورًا أولًا.',
|
||||
duplicate_session_day: 'يوجد أكثر من جلسة في اليوم نفسه من الأسبوع.',
|
||||
duplicate_session_exercise: 'لا تكرر الحركة نفسها في الجلسة الواحدة.',
|
||||
};
|
||||
throw new Error(errors[body.error] || body.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function storeSession(body) {
|
||||
sessionStorage.setItem(tokenKey, body.access_token);
|
||||
sessionStorage.setItem(refreshKey, body.refresh_token);
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
sessionStorage.removeItem(tokenKey);
|
||||
sessionStorage.removeItem(refreshKey);
|
||||
}
|
||||
|
||||
function showLogin(text = '', isError = false) {
|
||||
$('login-panel').classList.remove('hidden');
|
||||
$('workspace').classList.add('hidden');
|
||||
$('logout-button').classList.add('hidden');
|
||||
$('otp-verify-form').classList.add('hidden');
|
||||
if (text) message($('login-status'), text, isError);
|
||||
}
|
||||
|
||||
function showWorkspace() {
|
||||
$('login-panel').classList.add('hidden');
|
||||
$('workspace').classList.remove('hidden');
|
||||
$('logout-button').classList.remove('hidden');
|
||||
return loadContent();
|
||||
}
|
||||
|
||||
function asLines(text) {
|
||||
return text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function setLines(id, values) {
|
||||
$(id).value = Array.isArray(values) ? values.join('\n') : '';
|
||||
}
|
||||
|
||||
function safeElement(tag, className, text) {
|
||||
const element = document.createElement(tag);
|
||||
if (className) element.className = className;
|
||||
if (text !== undefined) element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
|
||||
function renderItems(containerId, items, entity) {
|
||||
const list = $(containerId);
|
||||
list.replaceChildren();
|
||||
if (!items.length) {
|
||||
list.append(safeElement('p', 'empty-list', 'لا يوجد محتوى بعد.'));
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => {
|
||||
const row = safeElement('div', 'content-item');
|
||||
const main = safeElement('div', 'content-item-main');
|
||||
main.append(safeElement('strong', '', item.title_ar || 'بدون عنوان'));
|
||||
main.append(safeElement('small', '', item.slug || ''));
|
||||
const stateLabel = safeElement('span', `content-state${item.is_published ? ' is-published' : ''}`, item.is_published ? 'منشور' : 'مسودة');
|
||||
const edit = safeElement('button', '', 'تحرير');
|
||||
edit.type = 'button';
|
||||
edit.addEventListener('click', () => openEditor(entity, item));
|
||||
row.append(main, stateLabel, edit);
|
||||
list.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadContent() {
|
||||
try {
|
||||
const data = await request('/admin/content.php');
|
||||
state.exercises = data.exercises || [];
|
||||
state.plans = data.plans || [];
|
||||
renderItems('exercise-list', state.exercises, 'exercise');
|
||||
renderItems('plan-list', state.plans, 'plan');
|
||||
message($('workspace-status'), `تم تحميل ${state.exercises.length} تمرين و${state.plans.length} خطة.`);
|
||||
} catch (error) {
|
||||
message($('workspace-status'), error.message, true);
|
||||
if (/جلسة|دخول|مالكًا/.test(error.message)) showLogin(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor(entity, item = null) {
|
||||
const isExercise = entity === 'exercise';
|
||||
$('entity-type').value = entity;
|
||||
$('entity-uuid').value = item?.exercise_uuid || item?.plan_uuid || '';
|
||||
$('editor-kind').textContent = isExercise ? 'مكتبة الحركات' : 'برامج أسبوعية';
|
||||
$('editor-title').textContent = item ? 'تحرير المحتوى' : (isExercise ? 'إضافة تمرين' : 'إنشاء خطة');
|
||||
$('exercise-fields').classList.toggle('hidden', !isExercise);
|
||||
$('plan-fields').classList.toggle('hidden', isExercise);
|
||||
$('slug').value = item?.slug || '';
|
||||
$('title-ar').value = item?.title_ar || '';
|
||||
$('is-published').checked = item?.is_published || false;
|
||||
$('form-status').textContent = '';
|
||||
if (isExercise) {
|
||||
$('movement-type').value = item?.movement_type || 'strength';
|
||||
$('difficulty').value = item?.difficulty || 'beginner';
|
||||
$('gif-url').value = item?.gif_url || '';
|
||||
$('poster-url').value = item?.gif_poster_url || '';
|
||||
$('exercise-duration').value = item?.duration_seconds || '';
|
||||
$('repetitions').value = item?.repetitions || '';
|
||||
setLines('instructions', item?.instructions_ar);
|
||||
setLines('muscles', item?.target_muscles);
|
||||
setLines('equipment', item?.equipment);
|
||||
setLines('exercise-safety', item?.safety_notes_ar);
|
||||
} else {
|
||||
$('goal').value = item?.goal || 'general_fitness';
|
||||
$('plan-level').value = item?.level || 'beginner';
|
||||
$('weeks').value = item?.weeks_duration || 4;
|
||||
$('summary').value = item?.summary_ar || '';
|
||||
setLines('sources', item?.source_notes);
|
||||
setLines('plan-safety', item?.safety_notes_ar);
|
||||
$('sessions-json').value = JSON.stringify(item?.sessions || [], null, 2);
|
||||
}
|
||||
$('editor-panel').classList.remove('hidden');
|
||||
$('editor-panel').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
$('slug').focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function buildContent() {
|
||||
const isExercise = $('entity-type').value === 'exercise';
|
||||
const uuid = $('entity-uuid').value || null;
|
||||
const published = $('is-published').checked;
|
||||
if (isExercise) {
|
||||
const durationValue = $('exercise-duration').value;
|
||||
return {
|
||||
entity: 'exercise',
|
||||
content: {
|
||||
uuid,
|
||||
slug: $('slug').value.trim(),
|
||||
title_ar: $('title-ar').value.trim(),
|
||||
movement_type: $('movement-type').value,
|
||||
difficulty: $('difficulty').value,
|
||||
gif_url: $('gif-url').value.trim() || null,
|
||||
gif_poster_url: $('poster-url').value.trim() || null,
|
||||
duration_seconds: durationValue ? Number(durationValue) : null,
|
||||
repetitions: $('repetitions').value.trim() || null,
|
||||
instructions_ar: asLines($('instructions').value),
|
||||
target_muscles: asLines($('muscles').value),
|
||||
equipment: asLines($('equipment').value),
|
||||
safety_notes_ar: asLines($('exercise-safety').value),
|
||||
is_published: published,
|
||||
},
|
||||
};
|
||||
}
|
||||
let sessions;
|
||||
try {
|
||||
sessions = JSON.parse($('sessions-json').value || '[]');
|
||||
} catch (_) {
|
||||
throw new Error('JSON الجلسات غير صالح؛ راجعه ثم حاول مجددًا.');
|
||||
}
|
||||
if (!Array.isArray(sessions)) throw new Error('يجب أن تكون الجلسات قائمة JSON.');
|
||||
return {
|
||||
entity: 'plan',
|
||||
content: {
|
||||
uuid,
|
||||
slug: $('slug').value.trim(),
|
||||
title_ar: $('title-ar').value.trim(),
|
||||
summary_ar: $('summary').value.trim(),
|
||||
goal: $('goal').value,
|
||||
level: $('plan-level').value,
|
||||
weeks_duration: Number($('weeks').value),
|
||||
source_notes: asLines($('sources').value),
|
||||
safety_notes_ar: asLines($('plan-safety').value),
|
||||
sessions,
|
||||
is_published: published,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
$('otp-request-form').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
state.phone = $('admin-phone').value.trim();
|
||||
message($('login-status'), 'جارٍ إرسال رمز التحقق…');
|
||||
try {
|
||||
const body = await request('/auth/request-otp.php', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone_e164: state.phone, purpose: 'login' }),
|
||||
}, false);
|
||||
state.challengeId = body.challenge_id;
|
||||
$('otp-verify-form').classList.remove('hidden');
|
||||
$('admin-code').focus();
|
||||
message($('login-status'), 'أرسلنا الرمز إن كان الحساب موجودًا ومسموحًا له بالدخول.');
|
||||
} catch (error) {
|
||||
message($('login-status'), error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
$('otp-verify-form').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
message($('login-status'), 'جارٍ التحقق…');
|
||||
try {
|
||||
const body = await request('/auth/verify-otp.php', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
challenge_id: state.challengeId,
|
||||
code: $('admin-code').value.trim(),
|
||||
device_uuid: crypto.randomUUID(),
|
||||
platform: 'web',
|
||||
display_name: 'SportPath Admin Web',
|
||||
}),
|
||||
}, false);
|
||||
if (!['owner', 'content_manager'].includes(body.user?.account_role)) {
|
||||
clearSession();
|
||||
throw new Error('الدخول متاح فقط لحساب المالك أو مدير المحتوى.');
|
||||
}
|
||||
storeSession(body);
|
||||
await showWorkspace();
|
||||
} catch (error) {
|
||||
message($('login-status'), error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
$('content-form').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const status = $('form-status');
|
||||
try {
|
||||
const payload = buildContent();
|
||||
message(status, 'جارٍ الحفظ…');
|
||||
await request('/admin/content.php', { method: 'POST', body: JSON.stringify(payload) });
|
||||
message(status, 'حُفظ المحتوى وسُجلت العملية.');
|
||||
await loadContent();
|
||||
window.setTimeout(() => $('editor-panel').classList.add('hidden'), 500);
|
||||
} catch (error) {
|
||||
message(status, error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
$('new-exercise').addEventListener('click', () => openEditor('exercise'));
|
||||
$('new-plan').addEventListener('click', () => openEditor('plan'));
|
||||
$('close-editor').addEventListener('click', () => $('editor-panel').classList.add('hidden'));
|
||||
$('reload-button').addEventListener('click', loadContent);
|
||||
$('logout-button').addEventListener('click', async () => {
|
||||
try { await request('/auth/logout.php', { method: 'POST', body: '{}' }); } catch (_) {}
|
||||
clearSession();
|
||||
showLogin('تم تسجيل الخروج.');
|
||||
});
|
||||
|
||||
if (sessionStorage.getItem(tokenKey)) showWorkspace();
|
||||
else showLogin();
|
||||
})();
|
||||
Reference in New Issue
Block a user