Add protected training content admin
This commit is contained in:
@@ -74,7 +74,9 @@ Git: مصدر الإصدار → SSH:2101 → نسخة إصدار → تبديل
|
|||||||
|
|
||||||
يفضل نطاق واحد ببنية `/` للموقع و`/api/v1/` للواجهات و`/admin/` للإدارة. ملفات رفع الصور خارج `public/` ولا يمكن طلبها بعنوان مباشر؛ الوصول عبر endpoint يتحقق من ملكية المستخدم. واجهة PHP العامة تبدأ من مجلد `public`، بينما إعدادات الخادم والمكتبات والنسخ والملفات الخاصة تبقى فوق document root.
|
يفضل نطاق واحد ببنية `/` للموقع و`/api/v1/` للواجهات و`/admin/` للإدارة. ملفات رفع الصور خارج `public/` ولا يمكن طلبها بعنوان مباشر؛ الوصول عبر endpoint يتحقق من ملكية المستخدم. واجهة PHP العامة تبدأ من مجلد `public`، بينما إعدادات الخادم والمكتبات والنسخ والملفات الخاصة تبقى فوق document root.
|
||||||
|
|
||||||
ملفات PHP الداخلية باقية داخل `backend/` خارج document root، وأضفنا `public/` كنقطة عرض للموقع وواجهات JSON أولية. يتضمن الموقع صفحة عربية RTL متجاوبة، ويقدم `/api/v1/health.php` فحصًا لا يكشف تفاصيل الاتصال، و`/api/v1/config.php` الإعدادات العامة فقط، و`/api/v1/plans.php` الخطط المنشورة وتمارينها ووسائط GIF والبدائل. أضيفت جداول مكتبة التمارين والخطط والجلسات ومهاجرة `003_training_content.sql`. فشل قاعدة البيانات يعاد كـ503 دون تسريب تفاصيلها. هذه واجهات تأسيسية؛ لا توجد بعد مصادقة مستخدم أو لوحة إدارة ولا ينبغي نشرها كمنتج مكتمل. لا نضع `backend/schema.sql` وحده كترحيل تلقائي على قاعدة إنتاج.
|
ملفات PHP الداخلية باقية داخل `backend/` خارج document root، وأضفنا `public/` كنقطة عرض للموقع وواجهات JSON أولية. يتضمن الموقع صفحة عربية RTL متجاوبة، ويقدم `/api/v1/health.php` فحصًا لا يكشف تفاصيل الاتصال، و`/api/v1/config.php` الإعدادات العامة فقط، و`/api/v1/plans.php` الخطط المنشورة وتمارينها ووسائط GIF والبدائل. أضيفت جداول مكتبة التمارين والخطط والجلسات ومهاجرة `003_training_content.sql`. فشل قاعدة البيانات يعاد كـ503 دون تسريب تفاصيلها. واجهات المحتوى والإدارة أولية وتحتاج staging ومراجعة أمنية قبل الإنتاج. لا نضع `backend/schema.sql` وحده كترحيل تلقائي على قاعدة إنتاج.
|
||||||
|
|
||||||
|
أضيفت الآن صفحة `/admin/` عربية لإدارة الحركات والخطط، ودخول OTP للحساب الموجود مسبقًا. لا تسمح الواجهة بالمستخدم العادي؛ يتحقق الخادم من دور `owner` أو `content_manager` لكل طلب. واجهة `/api/v1/admin/content.php` تقرأ وتكتب التمارين والخطط والجلسات وبيانات الوسائط، وتقيّد روابط GIF إلى HTTPS، وتتحقق من القيم والحدود، كما تمنع سحب نشر تمرين مرتبط بخطة منشورة. تسجل تغييرات المحتوى في `training_content_audit` عبر migration `004_content_audit.sql`. هذه لوحة تحرير أولية: جدول جلسات الخطة يحرر حاليًا كـJSON، ولا تتضمن بعد محرر رفع ملفات أو مراجعة مصادر مدمجة. يجب تطبيق migrations بالترتيب `001` إلى `004` على staging بعد النسخ الاحتياطي، وإنشاء حساب مالك بدور صحيح خارج الواجهة؛ لم تُطبق بعد على قاعدة حية.
|
||||||
|
|
||||||
قاعدة البيانات في CloudPanel تنشأ باسم ومستخدم مخصصين من واجهة الإدارة، بصلاحيات قاعدة التطبيق فقط، واتصال محلي إن كانت PHP وMySQL على المضيف نفسه. تحفظ نسخة احتياطية دورية وتختبر استعادتها. اسم قاعدة البيانات والمستخدم وكلمة المرور الفعلية تأتي من بيئة الخادم؛ لا تضاف إلى Flutter أو Git.
|
قاعدة البيانات في CloudPanel تنشأ باسم ومستخدم مخصصين من واجهة الإدارة، بصلاحيات قاعدة التطبيق فقط، واتصال محلي إن كانت PHP وMySQL على المضيف نفسه. تحفظ نسخة احتياطية دورية وتختبر استعادتها. اسم قاعدة البيانات والمستخدم وكلمة المرور الفعلية تأتي من بيئة الخادم؛ لا تضاف إلى Flutter أو Git.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Audit every administrative create/update/publish action on training content.
|
||||||
|
-- Apply after 003_training_content.sql and after taking a database backup.
|
||||||
|
|
||||||
|
CREATE TABLE training_content_audit (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
entity_type ENUM('exercise', 'plan') NOT NULL,
|
||||||
|
entity_uuid CHAR(36) NOT NULL,
|
||||||
|
action ENUM('create', 'update', 'publish', 'unpublish') NOT NULL,
|
||||||
|
previous_value JSON NULL,
|
||||||
|
new_value JSON NOT NULL,
|
||||||
|
actor_user_id INT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_content_audit_entity_time (entity_type, entity_uuid, created_at),
|
||||||
|
INDEX idx_content_audit_actor_time (actor_user_id, created_at),
|
||||||
|
CONSTRAINT fk_content_audit_actor FOREIGN KEY (actor_user_id)
|
||||||
|
REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -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