Files
fitness/public/assets/admin.js
T

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();
})();