Add protected training content admin

This commit is contained in:
Hamza-Ayed
2026-10-04 01:59:41 +03:00
parent 66efcdac6a
commit d892e3d981
6 changed files with 773 additions and 1 deletions
+1
View File
@@ -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}}
+292
View File
@@ -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();
})();