Add protected training content admin
This commit is contained in:
@@ -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