293 lines
12 KiB
JavaScript
293 lines
12 KiB
JavaScript
(() => {
|
|
'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();
|
|
})();
|