From e4e241819f2489afbb9b7aefc0d31b7291ba9c9a Mon Sep 17 00:00:00 2001 From: Al-Faris Mujahid AlZakwani Date: Thu, 2 Jul 2026 14:03:42 +0400 Subject: [PATCH] Add shared roadmap tests for HTML and CSS asset loading - Introduced a new test script to validate the inclusion of the shared roadmap JavaScript and CSS files in various HTML files. - Ensured that the controller derives its locale from the page language and retains bilingual suggestion labels. - Verified that essential functions are present in the controller and that service worker precaching is correctly configured. - Confirmed the presence of key selectors in the shared stylesheet. --- ar/roadmap.html | 3241 +++--------------------------- assets/css/roadmap.css | 1230 ++++++++++++ assets/js/roadmap.js | 1330 ++++++++++++ package.json | 3 +- roadmap.html | 3241 +++--------------------------- scripts/counter-coverage-test.js | 8 +- scripts/roadmap-shared-test.js | 53 + service-worker.js | 2 + 8 files changed, 3254 insertions(+), 5854 deletions(-) create mode 100644 assets/css/roadmap.css create mode 100644 assets/js/roadmap.js create mode 100644 scripts/roadmap-shared-test.js diff --git a/ar/roadmap.html b/ar/roadmap.html index 6624566..774a6b8 100644 --- a/ar/roadmap.html +++ b/ar/roadmap.html @@ -54,2973 +54,366 @@ - - - - - - - -
- شعار NexCore Labs - -
-
- - - -
-
-
-
-

خارطة الطريق العامة

-

شارك في تشكيل
ما نبنيه لاحقاً

-

صوّت على الميزات، واقترح الأفكار، وتابع التقدم — ملاحظاتك تساعد في تحديد ما نطوره لاحقاً في NexCore Labs.

-
-
الطلبات
-
إجمالي الأصوات
-
تم إطلاقه
-
-
-
-
-
-
-

مسار الحالة

-

نظرة مباشرة على الميزات

-
-
مخطط
-
قيد التنفيذ
-
مكتمل
-
-
-
-
-
-
- -
-
-
- - - -
-
- - - -
-
- -
-
-
-
-
-
-
-
- - -
- - - - - - - - - - - - - - - - - - - -
- NexCore Labs -
-
- - - - - - --> - - - - - - - - - - + + -function buildCard(feature, index) { - const voted = state.user ? state.userVotes.has(feature.id) : state.anonVotes.has(feature.id); - const commentCount = Number(feature.feature_comments?.[0]?.count ?? 0); - const statusLabel = normalizeStatusLabel(feature.status); - const date = new Date(feature.created_at).toLocaleDateString('ar-OM', { month: 'short', day: 'numeric', year: 'numeric' }); - const isExpanded = !isMobileCommentsFallback() && state.expandedFeatureId === feature.id; - const isOwner = !!state.user && feature.created_by === state.user.id; - - const card = document.createElement('div'); - card.className = `feature-card reveal${isExpanded ? ' expanded' : ''}`; - card.dataset.id = feature.id; - card.style.animationDelay = `${index * 45}ms`; - - card.innerHTML = ` -
- - ${feature.votes_count} - ${!state.user ? 'تصويت ضيف' : ''} -
-
-

${escHtml(feature.title)}

-

${escHtml(feature.description || '')}

- - ${state.isAdmin ? ` -
- - تعيين الحالة: -
- - - -
-
` : ''} -
-
-
-

${escHtml(feature.description || 'لا توجد تفاصيل إضافية بعد.')}

-
- اقتُرحت في ${date} - ${feature.votes_count} تصويت - ${statusLabel} -
-
-
- التعليقات - ${commentCount} -
-
-
جار تحميل التعليقات...
-
- ${state.user ? ` -
- - -
` : ` -
- - سجّل الدخول للتعليق - -
`} -
-
-
- `; - - card.querySelector('.vote-btn').addEventListener('click', (e) => { - e.stopPropagation(); - handleVote(feature.id); - }); - - card.querySelector('.comment-chip').addEventListener('click', (e) => { - e.stopPropagation(); - if (isMobileCommentsFallback()) { - openComments(feature.id, feature.title); - return; - } - toggleFeatureExpand(feature.id, true); - }); - - card.querySelector('.expand-btn').addEventListener('click', (e) => { - e.stopPropagation(); - if (isMobileCommentsFallback()) { - openComments(feature.id, feature.title); - return; - } - toggleFeatureExpand(feature.id); - }); - - card.querySelector('.card-body').addEventListener('click', (e) => { - if (e.target.closest('button, select, textarea, input, a, label')) return; - if (isMobileCommentsFallback()) { - openComments(feature.id, feature.title); - return; - } - toggleFeatureExpand(feature.id); - }); - - if (state.isAdmin) { - card.querySelectorAll('.status-pick').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - updateFeatureStatus(feature.id, btn.dataset.status); - }); - }); - } - - card.querySelector(`[data-owner-edit="${feature.id}"]`)?.addEventListener('click', (e) => { - e.stopPropagation(); - openSuggestModalForEdit(feature.id); - }); - card.querySelector(`[data-owner-delete="${feature.id}"]`)?.addEventListener('click', async (e) => { - e.stopPropagation(); - await deleteOwnFeature(feature.id); - }); - - card.querySelector('.inline-signin-btn')?.addEventListener('click', () => openAuthModal()); - card.querySelector(`#inline-comment-btn-${feature.id}`)?.addEventListener('click', () => submitInlineComment(feature.id)); - card.querySelector(`#inline-comment-input-${feature.id}`)?.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submitInlineComment(feature.id); - }); - - if (isExpanded) { - requestAnimationFrame(() => { - setExpandedState(card, true, false); - loadInlineComments(feature.id); - }); - } - - return card; -} +
+ شعار NexCore Labs -function buildPendingCard(feature, index) { - const card = document.createElement('div'); - card.className = 'feature-card pending-card reveal'; - card.dataset.id = feature.id; - card.style.animationDelay = `${index * 45}ms`; - const submitterName = escHtml(feature.submitter_name || ''); - const submitterEmail = escHtml(feature.submitter_email || ''); +
+
- card.innerHTML = ` -
- -

${escHtml(feature.description || '')}

- ${feature.submitter_name || feature.submitter_email ? ` -

- - ${submitterName} ${feature.submitter_email ? `<${submitterEmail}>` : ''} -

` : '

إرسال مجهول

'} -
- - + - btn.disabled = true; - btn.textContent = '…'; +
+
+
+
+

خارطة الطريق العامة

+

شارك في تشكيل
ما نبنيه لاحقاً

+

صوّت على الميزات، واقترح الأفكار، وتابع التقدم — ملاحظاتك تساعد في تحديد ما نطوره لاحقاً في NexCore Labs.

+
+
الطلبات
+
إجمالي الأصوات
+
تم إطلاقه
+
+
+
+
+
+
+

مسار الحالة

+

نظرة مباشرة على الميزات

+
+
مخطط
+
قيد التنفيذ
+
مكتمل
+
+
+
+
+
+
- const { data, error } = await db.from('feature_comments').insert({ - feature_id: featureId, - user_id: state.user.id, - content, - }).select().single(); +
+
+
+ + + +
+
+ + + +
+
- btn.disabled = false; - btn.textContent = 'إرسال'; +
+
+
+
+
+
+
+
- if (error) { - showToast('فشل نشر التعليق.', ''); - return; - } + +
- input.value = ''; - const cached = state.commentsByFeature[featureId] || []; - cached.push(data); - state.commentsByFeature[featureId] = cached; + + - const list = $(`#inline-comments-${featureId}`); - if (list) { - list.innerHTML = renderCommentCollection(cached, featureId); - list.scrollTop = list.scrollHeight; - } + + - if (state.activeFeatureId === featureId) { - const modalList = $('#comments-list'); - if (modalList && !$('#comments-modal')?.classList.contains('hidden')) { - modalList.innerHTML = renderCommentCollection(cached, featureId); - modalList.scrollTop = modalList.scrollHeight; - } - } + + - updateCommentCountUI(featureId, cached.length); - refreshExpandedHeight(featureId); - showToast('تم نشر التعليق!', ''); -} + + -// ── TABS & FILTERS ───────────────────────────────────────── -function bindTabs() { - $$('.tab-btn').forEach(btn => { - btn.addEventListener('click', () => { - if (btn.dataset.tab === state.currentTab) return; - $$('.tab-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - state.currentTab = btn.dataset.tab; + + - const filterBar = $('#status-filter'); - if (filterBar) { - filterBar.style.display = state.currentTab === 'roadmap' ? 'flex' : 'none'; - } + + - if (state.currentTab !== 'roadmap') { - state.statusFilter = 'all'; - $$('.chip').forEach(c => c.classList.toggle('active', c.dataset.filter === 'all')); - } - loadFeatures(); - }); - }); -} +
+ NexCore Labs +
+
-function bindFilters() { - $$('.chip').forEach(chip => { - chip.addEventListener('click', () => { - if (chip.dataset.filter === state.statusFilter) return; - $$('.chip').forEach(c => c.classList.remove('active')); - chip.classList.add('active'); - state.statusFilter = chip.dataset.filter; - loadFeatures(); - }); - }); -} +
+ +
-// ── SKELETON ─────────────────────────────────────────────── -function showSkeleton(visible) { - const skel = $('#skeleton-wrap'); - if (!skel) return; - if (visible) { - skel.style.display = 'flex'; - skel.style.flexDirection = 'column'; - skel.style.gap = '10px'; - } else { - skel.style.display = 'none'; - } - $$('.feature-card').forEach(c => { - if (visible) c.remove(); - }); -} + -// ── TOAST ────────────────────────────────────────────────── -let toastTimer; -function showToast(message, iconHtml = '') { - const toast = $('#toast'); - const iconEl = $('#toast-icon'); - const msgEl = $('#toast-msg'); - if (!toast) return; + --> - iconEl.innerHTML = iconHtml; - msgEl.textContent = message; - toast.classList.remove('hidden'); + + - clearTimeout(toastTimer); - toastTimer = setTimeout(() => toast.classList.add('hidden'), 3200); -} + -// ── FORM ERROR ───────────────────────────────────────────── -function showFormError(el, msg) { - if (!el) return; - el.textContent = msg; - el.classList.remove('hidden'); -} + -// ── BOOT ─────────────────────────────────────────────────── -document.addEventListener('DOMContentLoaded', init); - + + diff --git a/assets/css/roadmap.css b/assets/css/roadmap.css new file mode 100644 index 0000000..477ba58 --- /dev/null +++ b/assets/css/roadmap.css @@ -0,0 +1,1230 @@ +/* ─── HERO ─── */ +.hero { + display: flex; + align-items: center; + position: relative; + padding-bottom: 60px; + overflow: hidden; +} + +.hero-inner { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 0 24px; + display: flex; + gap: 40px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} + +.hero-left { flex: 1; min-width: 300px; max-width: 640px; } + +.hero-eyebrow { + font-size: 12px; + font-weight: 600; + letter-spacing: 2px; + text-transform: uppercase; + color: #6ee7f3; + margin-bottom: 14px; + opacity: 0.85; +} + +.title { + font-size: 44px; + line-height: 1.05; + margin: 0 0 20px; + font-weight: 800; + letter-spacing: -0.6px; + color: #eaf6fb; +} + +.text-accent { color: #6ee7f3 !important; } + +.lead { + color: var(--muted); + font-size: 17px; + margin-bottom: 36px; + max-width: 54ch; + line-height: 1.65; +} + +/* KPI stats row */ +.kpis { + display: flex; + gap: 12px; + flex-wrap: wrap; + border-radius: 10px; + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); +} + +.kpis .stat { + background: rgba(255,255,255,0.02); + padding: 12px 18px; + border-radius: 10px; + text-align: center; + min-width: 80px; + border: 1px solid rgba(255,255,255,0.04); + +} + +.kpis strong { + display: block; + font-size: 22px; + color: #fff; + font-weight: 700; +} + +.kpis span { + display: block; + color: var(--muted); + font-size: 12px; + margin-top: 3px; +} + +.kpis .stat:hover { + transform: scale(1.04); + background: rgba(110,231,243,0.07); +} + +/* Hero right — glass card */ +.hero-right { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.glass-card { + width: 320px; + border-radius: 18px; + padding: 22px; + background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); + border: 1px solid rgba(255,255,255,0.06); + box-shadow: var(--card-shadow); + position: relative; + overflow: visible; + transform: translateY(0); + transition: transform .45s cubic-bezier(.2,.9,.3,1); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); +} + +.glass-card:hover { transform: translateY(-8px) rotate(-0.2deg); } + +.card-wave { + position: absolute; + right: -30%; top: -40%; + width: 200%; height: 200%; + background: + radial-gradient(circle at 20% 20%, rgba(115,189,215,0.07), transparent 8%), + radial-gradient(circle at 60% 60%, rgba(172,217,229,0.05), transparent 10%); + transform: rotate(8deg); + pointer-events: none; +} + +.card-content h4 { + margin: 0 0 6px; + font-size: 16px; + color: #eaf6fb; +} + +/* Status overview inside card */ +.status-overview { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 14px; +} + +.so-row { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--muted); +} + +.so-row strong { + margin-left: auto; + color: #eaf6fb; + font-size: 15px; +} + +.so-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.so-planned { background: #4a9eff; box-shadow: 0 0 6px rgba(74,158,255,0.5); } +.so-inprog { background: #f5a623; box-shadow: 0 0 6px rgba(245,166,35,0.5); animation: dotPulse 2s ease-in-out infinite; } +.so-done { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.5); } + +@keyframes dotPulse { + 0%, 100% { box-shadow: 0 0 4px rgba(245,166,35,0.4); } + 50% { box-shadow: 0 0 10px rgba(245,166,35,0.8); } +} + +/* ─── MAIN / ROADMAP WRAP ─── */ +main { + padding: 0 24px 0px; +} + +.roadmap-wrap { + padding-top: 20px; + overflow-x: clip; +} + +/* ─── CONTROLS BAR ─── */ +.controls-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 24px; + padding-bottom: 20px; + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +.tabs-group { + display: flex; + background: rgba(255,255,255,0.02); + border: 1px solid rgba(255,255,255,0.05); + border-radius: 10px; + padding: 4px; + gap: 4px; + text-align: center; + justify-content: center; + flex-wrap: wrap; + overflow: visible; +} + +.tab-btn { + background: transparent; + border: none; + color: var(--muted); + font-family: Inter, sans-serif; + font-size: 14px; + font-weight: 500; + padding: 8px 18px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + letter-spacing: 0.3px; + +} + +.tab-btn.active { + background: rgba(110,231,243,0.1); + color: #6ee7f3; + border: 1px solid rgba(110,231,243,0.2); +} + +.tab-btn:hover:not(.active) { + color: #eaf6fb; + background: rgba(255,255,255,0.04); +} + +.filter-chips { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.chip { + background: transparent; + border: 1px solid rgba(255,255,255,0.1); + color: var(--muted); + font-family: Inter, sans-serif; + font-size: 12px; + font-weight: 500; + padding: 5px 14px; + border-radius: 20px; + cursor: pointer; + letter-spacing: 0.3px; +} + +.chip.active, .chip:hover { + border-color: rgba(110,231,243,0.45); + color: #6ee7f3; + background: rgba(110,231,243,0.08); +} + +/* ─── FEATURE LIST ─── */ +.features-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* ─── FEATURE CARD ─── */ +.feature-card { + display: grid; + grid-template-columns: 68px 1fr; + min-width: 0; + background: linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0.01)); + border: 1px solid rgba(255,255,255,0.05); + border-radius: var(--radius); + box-shadow: 0 6px 24px rgba(2,6,23,0.5); + overflow: hidden; + animation: cardIn 300ms ease both; + transition: border-color .25s ease, transform .25s ease, box-shadow .25s ease; + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); +} + +@keyframes cardIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.feature-card:hover { + border-color: rgba(110,231,243,0.15); + transform: translateY(-2px); + box-shadow: 0 12px 40px rgba(2,6,23,0.7); +} + +/* Vote column */ +.card-vote { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 18px 8px; + border-right: 1px solid rgba(255,255,255,0.05); + gap: 5px; +} + +.vote-btn { + background: transparent; + border: 1px solid rgba(255,255,255,0.1); + color: var(--muted); + width: 36px; + height: 36px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + transition: all .2s ease; +} + +.vote-btn:hover { + border-color: rgba(110,231,243,0.5); + color: #6ee7f3; + background: rgba(110,231,243,0.08); + transform: translateY(-2px); + box-shadow: 0 0 10px rgba(110,231,243,0.2); +} + +.vote-btn.voted { + border-color: #6ee7f3; + color: #6ee7f3; + background: rgba(110,231,243,0.12); + box-shadow: 0 0 12px rgba(110,231,243,0.3); +} + +.vote-btn.voted:hover { + border-color: rgba(110,231,243,0.3); + background: rgba(110,231,243,0.04); + color: var(--muted); + box-shadow: none; +} + +.vote-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + transform: none; +} + +.vote-count { + font-size: 15px; + font-weight: 700; + color: #eaf6fb; + line-height: 1; + transition: all .25s ease; +} + +.vote-count.bump { animation: countBump 350ms ease; } + +@keyframes countBump { + 0% { transform: scale(1); color: #eaf6fb; } + 40% { transform: scale(1.4); color: #6ee7f3; } + 100% { transform: scale(1); color: #eaf6fb; } +} + +/* Card body */ +.card-body { + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.card-title { + font-size: 15px; + font-weight: 600; + color: #eaf6fb; + letter-spacing: -0.1px; + line-height: 1.35; +} + +.card-desc { + font-size: 13.5px; + color: var(--muted); + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.card-footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-top: 2px; +} + +.card-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +/* Status badges */ +.badge { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + padding: 3px 10px; + border-radius: 20px; + text-transform: uppercase; + white-space: nowrap; +} + +.badge--planned { + background: rgba(74,158,255,0.12); + color: #4a9eff; + border: 1px solid rgba(74,158,255,0.25); +} + +.badge--in_progress { + background: rgba(245,166,35,0.12); + color: #f5a623; + border: 1px solid rgba(245,166,35,0.25); + animation: badgePulse 2.5s ease-in-out infinite; +} + +.badge--completed { + background: rgba(74,222,128,0.12); + color: #4ade80; + border: 1px solid rgba(74,222,128,0.25); +} + +@keyframes badgePulse { + 0%, 100% { box-shadow: 0 0 4px rgba(245,166,35,0.1); } + 50% { box-shadow: 0 0 10px rgba(245,166,35,0.3); } +} + +/* ── ADMIN CONTROLS ── */ +.admin-controls { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin-top: 6px; + background: rgba(245,166,35,0.04); + border: 1px solid rgba(245,166,35,0.15); + border-radius: 8px; + flex-wrap: wrap; +} + +.admin-label { + font-size: 11px; + color: #f5a623; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.admin-status-btns { + display: flex; + gap: 5px; + flex-wrap: wrap; +} + +.status-pick { + background: transparent; + border: 1px solid rgba(255,255,255,0.08); + color: var(--muted); + font-family: inherit; + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: 999px; + cursor: pointer; + letter-spacing: 0.3px; + transition: all 0.2s ease; +} + +.status-pick:hover { + border-color: rgba(110,231,243,0.4); + color: #6ee7f3; + background: rgba(110,231,243,0.06); +} + +.status-pick.active { + border-color: #6ee7f3; + color: #6ee7f3; + background: rgba(110,231,243,0.1); +} + +/* ── EXPANDABLE CARDS ── */ +.card-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.comment-chip { + background: transparent; + border: 1px solid rgba(255,255,255,0.08); + color: var(--muted); + font-family: Inter, sans-serif; + font-size: 12px; + padding: 4px 10px; + border-radius: 20px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; +} + +.comment-chip:hover { + border-color: rgba(110,231,243,0.3); + color: #6ee7f3; + background: rgba(110,231,243,0.06); +} + +.expand-btn { + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.02); + color: var(--muted); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all .24s ease; +} + +.expand-btn i { + transition: transform .24s ease; +} + +.feature-card.expanded .expand-btn i { + transform: rotate(180deg); +} + +.expand-btn:hover { + border-color: rgba(110,231,243,0.4); + color: #6ee7f3; + background: rgba(110,231,243,0.08); +} + +.card-date { + font-size: 11px; + color: var(--muted); + opacity: 0.6; +} + +.feature-details { + grid-column: 1 / -1; + max-height: 0; + overflow: hidden; + transition: max-height .35s cubic-bezier(.2,.9,.3,1); + background: rgba(255,255,255,0.02); + border-top: 1px solid rgba(110,231,243,0.15); +} + +.feature-details-inner { + padding: 14px 18px 16px 86px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.feature-full-desc { + margin: 0; + font-size: 13.5px; + color: #c8d5e4; + line-height: 1.62; +} + +.feature-meta-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.meta-pill { + font-size: 11px; + color: var(--muted); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 20px; + padding: 4px 10px; + background: rgba(255,255,255,0.02); + white-space: nowrap; +} + +.inline-comments { + border: 1px solid rgba(255,255,255,0.06); + border-radius: 12px; + background: rgba(4,8,18,0.45); + overflow: hidden; +} + +.inline-comments-head { + padding: 10px 12px; + border-bottom: 1px solid rgba(255,255,255,0.05); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: #eaf6fb; + font-size: 12px; + font-weight: 600; +} + +.inline-comments-list { + max-height: 220px; + overflow-y: auto; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.inline-comments-list::-webkit-scrollbar { width: 4px; } +.inline-comments-list::-webkit-scrollbar-thumb { background: rgba(110,231,243,0.25); border-radius: 2px; } + +.inline-comments-compose { + border-top: 1px solid rgba(255,255,255,0.05); + padding: 10px 12px 12px; + display: flex; + gap: 8px; + align-items: flex-end; +} + +.inline-comments-compose .form-textarea { + resize: none; + min-height: 64px; + flex: 1; +} + +.inline-auth-nudge { + border-top: 1px solid rgba(255,255,255,0.05); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--muted); + font-size: 12.5px; +} + +.feature-card.removing { + pointer-events: none; + animation: cardOut 260ms ease forwards; +} + +@keyframes cardOut { + to { + opacity: 0; + transform: translateY(-8px) scale(0.985); + } +} + +/* ─── SKELETON ─── */ +.skeleton-card { + height: 96px; + background: rgba(255,255,255,0.025); + border: 1px solid rgba(255,255,255,0.04); + border-radius: var(--radius); + position: relative; + overflow: hidden; +} + +.skeleton-card::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.04) 50%, transparent 100%); + animation: shimmer 1.4s infinite; +} + +@keyframes shimmer { + from { transform: translateX(-100%); } + to { transform: translateX(100%); } +} + +/* ─── EMPTY STATE ─── */ +.empty-state { + text-align: center; + padding: 80px 20px; + color: var(--muted); +} + +/* ─── FLOATING ACTION BUTTON ─── */ +.fab { + position: fixed; + bottom: 18px; + right: 19px; + z-index: 99; + background: linear-gradient(65deg, #6ee7f3 0%, #acd9e5 50%, #6bd3c9 100%); + background-size: 200% 100%; + background-position: 0% 50%; + color: #031017; + border: none; + border-radius: 999px; + padding: 12px 20px; + font-family: Inter, sans-serif; + font-size: 14px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + box-shadow: 0 8px 30px rgba(110,231,243,0.3), 0 4px 12px rgba(0,0,0,0.4); + letter-spacing: 0.5px; + transition: background-position 2s cubic-bezier(.2,.9,.3,1), box-shadow .3s, transform .2s; +} + +.fab:hover { + background-position: 100% 50%; + box-shadow: 0 0 25px rgba(115,189,215,0.5), 0 4px 16px rgba(0,0,0,0.5); + transform: scale(1.02); +} + +.fab:active { transform: scale(0.97); } + +/* ─── MODALS ─── */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(2,6,23,0.75); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 220ms ease, visibility 220ms ease; +} + +.modal-overlay.hidden { + display: flex !important; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.modal-overlay.is-open { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.modal-overlay.is-closing { + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.modal-panel { + background: rgba(10,11,28,0.95); + border: 1px solid rgba(110,231,243,0.12); + border-radius: 18px; + width: 100%; + max-width: 460px; + max-height: 90vh; + overflow-y: auto; + position: relative; + box-shadow: 0 24px 80px rgba(0,0,0,0.8), inset 0 0 0 1px rgba(255,255,255,0.04); + opacity: 0; + transform: scale(0.96) translateY(14px); + transition: transform 260ms cubic-bezier(.2,.9,.3,1), opacity 220ms ease; +} + +.modal-panel::before { + content: ''; + position: absolute; + top: 0; + left: 10%; + right: 10%; + height: 1px; + background: linear-gradient(90deg, transparent, #6ee7f3, transparent); + opacity: 0.4; + border-radius: 18px 18px 0 0; + pointer-events: none; +} + +.modal-wide { max-width: 560px; } +.modal-overlay.is-open .modal-panel { + opacity: 1; + transform: scale(1) translateY(0); +} + +.modal-close { + position: absolute; + top: 14px; + right: 14px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.08); + color: var(--muted); + width: 28px; + height: 28px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + z-index: 2; +} + +.modal-close:hover { + background: rgba(255,255,255,0.08); + color: #eaf6fb; + border-color: rgba(255,255,255,0.15); +} + +.modal-header { + padding: 26px 26px 0; +} + +.modal-header h2 { + font-size: 20px; + color: #eaf6fb; + margin: 0 0 6px; + font-weight: 700; + display: flex; + align-items: center; + gap: 8px; +} + +.modal-header p { + font-size: 13.5px; + color: var(--muted); + margin: 0; +} + +.modal-body { + padding: 20px 26px 26px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Auth tabs */ +.auth-tabs { + display: flex; + gap: 0; + padding: 16px 26px 0; + border-bottom: 1px solid rgba(255,255,255,0.07); + margin: 8px 0 0; +} + +.auth-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--muted); + font-family: Inter, sans-serif; + font-size: 14px; + font-weight: 500; + padding: 8px 16px; + cursor: pointer; + transition: all .25s ease; + margin-bottom: -1px; +} + +.auth-tab.active { + color: #6ee7f3; + border-bottom-color: #6ee7f3; +} + +.auth-tab:hover:not(.active) { color: #eaf6fb; } + +/* ─── FORM ELEMENTS ─── */ +.field { + display: flex; + flex-direction: column; + gap: 6px; + position: relative; +} + +.field-label { + font-size: 12px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.form-input { + box-sizing: border-box; + width: 100%; + font-size: 14px; + font-family: Inter, sans-serif; + padding: 12px 16px; + color: #eaf6fb; + border: 1px solid rgba(255,255,255,0.08); + border-bottom: 1px solid #6ee7f3; + background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0)); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + border-radius: 12px; + box-shadow: 0 4px 16px rgba(0,0,0,0.3); + resize: vertical; + max-width: 100%; +} + +.form-input::placeholder { color: rgba(154,163,178,0.6); } + +.form-input:focus { + outline: none; + border-color: rgba(110,231,243,0.5); + box-shadow: 0 0 0 3px rgba(110,231,243,0.08), 0 4px 16px rgba(0,0,0,0.3); + font-size: 15px; +} + +.form-input:hover { border-bottom-color: #6bd3c9; } + +.form-textarea { min-height: 80px; resize: vertical; } + +.char-count { + font-size: 11px; + color: var(--muted); + text-align: right; + opacity: 0.6; +} + +.form-error { + font-size: 13px; + color: #ff7878; + background: rgba(255,107,107,0.08); + border: 1px solid rgba(255,107,107,0.2); + border-radius: 8px; + padding: 10px 14px; +} + +/* ─── BADGE / PILL ─── */ +.badge-pill { + background: rgba(110,231,243,0.1); + border: 1px solid rgba(110,231,243,0.25); + color: #6ee7f3; + font-size: 12px; + font-weight: 600; + padding: 2px 10px; + border-radius: 20px; +} + +/* ─── COMMENTS ─── */ +.comments-list { + min-height: 100px; + max-height: 340px; + overflow-y: auto; + padding: 8px 26px; + display: flex; + flex-direction: column; + gap: 14px; + border-top: 1px solid rgba(255,255,255,0.05); +} + +.comments-list::-webkit-scrollbar { width: 4px; } +.comments-list::-webkit-scrollbar-thumb { background: rgba(110,231,243,0.25); border-radius: 2px; } + +.comments-loading { + color: var(--muted); + font-size: 13px; + text-align: center; + padding: 28px 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.comments-empty { + color: var(--muted); + font-size: 13px; + text-align: center; + padding: 24px 0; +} + +.comment-item { + display: flex; + gap: 10px; + animation: cardIn 200ms ease both; +} + +.comment-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: linear-gradient(135deg, #6ee7f3, #3ac5c0); + color: #031017; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} + +.comment-body { flex: 1; } + +.comment-meta { + font-size: 11px; + color: var(--muted); + display: flex; + gap: 8px; + margin-bottom: 4px; +} + +.comment-author { color: #acd9e5; font-weight: 600; } + +.comment-text { + font-size: 13.5px; + color: var(--muted); + line-height: 1.5; +} + +.comment-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.comment-action-btn { + border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.02); + color: var(--muted); + border-radius: 999px; + font-size: 11px; + line-height: 1; + padding: 5px 8px; + cursor: pointer; +} + +.comment-action-btn:hover { + border-color: rgba(110,231,243,0.35); + color: #6ee7f3; + background: rgba(110,231,243,0.08); +} + +.owner-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.owner-action-btn { + border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.02); + color: var(--muted); + border-radius: 999px; + font-size: 11px; + padding: 4px 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.owner-action-btn:hover { + border-color: rgba(110,231,243,0.35); + color: #6ee7f3; + background: rgba(110,231,243,0.08); +} + +.comments-compose { + padding: 14px 26px 26px; + display: flex; + gap: 10px; + align-items: flex-end; + border-top: 1px solid rgba(255,255,255,0.05); +} + +.comments-compose .form-textarea { resize: none; flex: 1; } + +.comments-auth-nudge { + padding: 14px 26px 26px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + border-top: 1px solid rgba(255,255,255,0.05); + font-size: 13px; + color: var(--muted); +} + +/* Option D additions */ +.anon-notice { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + background: rgba(110,231,243,0.06); + border: 1px solid rgba(110,231,243,0.18); + border-radius: 8px; + font-size: 13px; + color: #acd9e5; + margin-bottom: 12px; + line-height: 1.5; +} +.anon-notice i { color: #6ee7f3; flex-shrink: 0; margin-top: 2px; } + +.anon-comment-block { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 10px; + font-size: 13.5px; + color: var(--muted); + flex-wrap: wrap; +} +.anon-comment-block i { color: #6ee7f3; } + +.badge--pending { + background: rgba(255,204,102,0.12); + border: 1px solid rgba(255,204,102,0.35); + color: #ffcc66; +} + +.submitter-info { + font-size: 12px; + color: var(--muted); + margin: 6px 0 10px; + display: flex; + align-items: center; + gap: 6px; +} + +.pending-actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.guest-vote-label { + font-size: 10px; + color: var(--muted); + opacity: 0.6; + letter-spacing: 0.3px; +} + +/* ─── TOAST ─── */ +.toast { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%) translateY(10px); + background: rgba(10,11,28,0.95); + border: 1px solid rgba(110,231,243,0.2); + color: #eaf6fb; + font-size: 13.5px; + font-weight: 500; + padding: 11px 22px; + border-radius: 999px; + box-shadow: 0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px rgba(110,231,243,0.06); + display: flex; + align-items: center; + gap: 8px; + z-index: 999; + animation: toastIn 300ms cubic-bezier(.2,.9,.3,1) forwards; + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); +} + +.toast.hidden { display: none; } + +@keyframes toastIn { + from { opacity: 0; transform: translateX(-50%) translateY(20px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +/* ─── REVEAL ANIMATION ─── */ +.reveal { + opacity: 0; + transform: translateY(12px) scale(0.997); + transition: all .68s cubic-bezier(.2,.9,.3,1); + will-change: transform, opacity; +} + +.reveal.visible { + opacity: 1; + transform: translateY(0) scale(1); +} + +@keyframes flow { + 0%, 100% { opacity: 0.4; transform: scaleX(0.8); } + 50% { opacity: 1; transform: scaleX(1); } +} + +/* ─── UTILITY ─── */ +.hidden { display: none !important; } + +/* ─── RESPONSIVE ─── */ +@media (max-width: 780px) { + .hero { padding: 100px 12px 60px; } + .hero-inner { gap: 28px; } + .title { font-size: 32px; } + .lead { font-size: 16px; } + .hero-right { display: none; } + .kpis { gap: 8px; } + .kpis .stat { padding: 8px 12px; min-width: 70px; } + .kpis strong { font-size: 18px; } + .controls-bar { flex-direction: column; align-items: center; justify-content: center; gap: 12px; } + .fab { bottom: 18px; right: 19px; padding: 12px;} + .fab span { display: none; } + main { padding: 0 12px; } + .feature-details-inner { padding: 12px 14px 14px 14px; } +} + +@media (max-width: 480px) { + .modal-panel { max-width: 100%; border-radius: 16px; } + .hero-inner { padding: 0px; } + .modal-header, .modal-body { padding-left: 18px; padding-right: 18px; } + .comments-list, .comments-compose, .comments-auth-nudge { padding-left: 18px; padding-right: 18px; } + .auth-tabs { padding-left: 18px; padding-right: 18px; } + .fab { bottom: 13px; right: 13px; } + .feature-card { grid-template-columns: 56px 1fr; } + .inline-comments-compose { flex-direction: column; align-items: stretch; } + .inline-auth-nudge { flex-wrap: wrap; } +} diff --git a/assets/js/roadmap.js b/assets/js/roadmap.js new file mode 100644 index 0000000..1f93c1c --- /dev/null +++ b/assets/js/roadmap.js @@ -0,0 +1,1330 @@ +"use strict"; + +(() => { + const roadmapLocale = document.documentElement.lang.toLowerCase().startsWith("ar") ? "ar" : "en"; + const roadmapText = (english, arabic) => roadmapLocale === "ar" ? arabic : english; + +function getAnonFingerprint() { + const KEY = 'nexcore_anon_fp'; + let fp = localStorage.getItem(KEY); + if (!fp || fp.length < 8) { + fp = Array.from(crypto.getRandomValues(new Uint8Array(16))) + .map((b) => b.toString(16).padStart(2, '0')).join(''); + localStorage.setItem(KEY, fp); + } + return fp; +} + +const state = { + currentTab: 'roadmap', + statusFilter: 'all', + user: null, + isAdmin: false, + features: [], + pendingFeatures: [], + userVotes: new Set(), + anonFingerprint: getAnonFingerprint(), + anonVotes: new Set(), + commentsByFeature: {}, + activeFeatureId: null, + expandedFeatureId: null, + editingFeatureId: null, + pendingConfirmAction: null, + pendingConfirmLabel: roadmapText("Delete", "حذف"), +}; + +// ── HELPERS ──────────────────────────────────────────────── +const db = window.supabaseClient; +const $ = (sel, root = document) => root.querySelector(sel); +const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; + +function escHtml(str) { + const d = document.createElement('div'); + d.appendChild(document.createTextNode(str || '')); + return d.innerHTML; +} + +function isMobileCommentsFallback() { + return window.matchMedia('(max-width: 639px)').matches; +} + +function normalizeStatusLabel(status) { + const labels = { planned: roadmapText("Planned", "مخطط"), in_progress: roadmapText("In Progress", "قيد التنفيذ"), completed: roadmapText("Completed", "مكتمل") }; + return labels[status] || status; +} + +// ── ADMIN STATUS MANAGEMENT ─────────────────────────────── +async function checkAdminStatus() { + if (!state.user) return false; + try { + const { data, error } = await db.rpc('get_my_admin_status'); + if (error) { + console.warn('Admin check failed:', error.message); + return false; + } + return data === true; + } catch (e) { + console.warn('Admin check exception:', e); + return false; + } +} + +// ── INIT ─────────────────────────────────────────────────── +async function init() { + if (!db) { + console.error('Supabase client is unavailable. Check script loading order.'); + showToast(roadmapText("Roadmap is temporarily unavailable.", "تم تعطيل خريطة الطريق مؤقتاً."), ''); + return; + } + + // Footer year + const yr = document.getElementById('year'); + if (yr) yr.textContent = new Date().getFullYear(); + + // Auth session + const { data: { session } } = await db.auth.getSession(); + if (session?.user) { + state.user = session.user; + await loadUserVotes(); + state.isAdmin = await checkAdminStatus(); + } + renderAuthNav(); + + db.auth.onAuthStateChange(async (event, session) => { + state.user = session?.user ?? null; + if (state.user) { + await loadUserVotes(); + state.isAdmin = await checkAdminStatus(); + } else { + state.userVotes.clear(); + state.isAdmin = false; + } + renderAuthNav(); + await loadFeatures(); + }); + + // Bind everything + bindTabs(); + bindFilters(); + bindFAB(); + bindModalClosers(); + bindAuthModal(); + bindSuggestModal(); + bindCommentsModal(); + bindCommentActions(); + bindConfirmModal(); + bindReveal(); + window.addEventListener('resize', () => { + if (isMobileCommentsFallback() && state.expandedFeatureId) { + state.expandedFeatureId = null; + renderFeatures(); + } + }); + + // Load data + loadAnonVotes(); + await loadFeatures(); +} + +// ── REVEAL OBSERVER ──────────────────────────────────────── +function bindReveal() { + const obs = new IntersectionObserver((entries) => { + entries.forEach(e => { + if (e.isIntersecting) { + e.target.classList.add('visible'); + obs.unobserve(e.target); + } + }); + }, { threshold: 0.1 }); + $$('.reveal').forEach(el => obs.observe(el)); +} + +// ── NAV DROPDOWN (core-menu dots) ────────────────────────── +function bindNavDropdown() { + const menu = $('#coreMenu'); + const drop = $('#myDropdown'); + if (!menu || !drop) return; + + menu.addEventListener('click', () => { + menu.classList.toggle('active'); + const isOpen = menu.classList.contains('active'); + drop.style.visibility = isOpen ? 'visible' : 'hidden'; + drop.style.opacity = isOpen ? '1' : '0'; + drop.style.transform = isOpen ? 'translateY(10px)' : 'translateY(0)'; + }); + + document.addEventListener('click', (e) => { + if (!menu.contains(e.target) && !drop.contains(e.target)) { + menu.classList.remove('active'); + drop.style.visibility = 'hidden'; + drop.style.opacity = '0'; + drop.style.transform = 'translateY(0)'; + } + }); +} + +// ── AUTH NAV ─────────────────────────────────────────────── +function renderAuthNav() { + const suggestBtn = $('#fab-suggest'); + const pendingTabBtn = $('#pending-tab-btn'); + if (!suggestBtn) return; + suggestBtn.title = state.user + ? roadmapText("Suggest a feature", "اقترح ميزة") + : roadmapText("Suggest anonymously", "اقترح بشكل مجهول"); + if (pendingTabBtn) { + pendingTabBtn.classList.toggle('hidden', !state.isAdmin); + if (!state.isAdmin && state.currentTab === 'pending') { + state.currentTab = 'roadmap'; + $$('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === 'roadmap')); + } + } +} + +async function handleSignOut() { + await db.auth.signOut(); + showToast(roadmapText("Signed out successfully.", "تم تسجيل الخروج بنجاح."), ''); +} + +// ── AUTH MODAL ───────────────────────────────────────────── +function openAuthModal() { + openModal('auth-modal'); +} + +function bindAuthModal() { + $('#google-signin-btn')?.addEventListener('click', async () => { + const btn = $('#google-signin-btn'); + const txt = $('#google-btn-text'); + const errEl = $('#auth-error'); + errEl?.classList.add('hidden'); + btn.disabled = true; + if (txt) txt.textContent = roadmapText("Redirecting…", "يتم التحويل…"); + + try { + sessionStorage.setItem('auth_mode', 'login'); + const { error } = await db.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo: `${window.location.origin}${roadmapText("/dashboard.html", "/ar/dashboard.html")}` + } + }); + if (error) throw error; + } catch (error) { + showFormError(errEl, error?.message || roadmapText("Failed to sign in with Google.", "فشل تسجيل الدخول مع Google.")); + btn.disabled = false; + if (txt) txt.textContent = roadmapText("Continue with Google", "المتابعة بواسطة جوجل"); + } + }); +} + +// ── FEATURES ────────────────────────────────────────────── +async function loadFeatures() { + showSkeleton(true); + + if (state.currentTab === 'pending') { + showSkeleton(false); + state.pendingFeatures = await loadPendingFeatures(); + state.features = []; + renderFeatures(); + updateStats(); + return; + } + + let query = db.from('features').select(` + id, title, description, status, votes_count, created_at, created_by, + feature_comments(count) + `); + + if (state.currentTab === 'roadmap') { + query = query.neq('status', 'completed'); + } else { + query = query.eq('status', 'completed'); + } + + if (state.currentTab === 'roadmap' && state.statusFilter !== 'all') { + query = query.eq('status', state.statusFilter); + } + + const orderCol = state.currentTab === 'implemented' ? 'created_at' : 'votes_count'; + query = query.order(orderCol, { ascending: false }); + + const { data, error } = await query; + + showSkeleton(false); + + if (error) { + console.error('Error loading features:', error); + showToast(roadmapText("Failed to load features.", "فشل تحميل الميزات."), ''); + return; + } + + state.features = data || []; + updateStats(); + renderFeatures(); +} + +async function loadPendingFeatures() { + if (!state.isAdmin) return []; + const { data, error } = await db.rpc('get_pending_suggestions'); + if (error) { + console.warn('Pending suggestions fetch failed:', error.message); + return []; + } + return data || []; +} + +async function loadUserVotes() { + if (!state.user) return; + const { data } = await db + .from('feature_votes') + .select('feature_id') + .eq('user_id', state.user.id); + state.userVotes = new Set((data || []).map(v => v.feature_id)); +} + +function loadAnonVotes() { + const raw = localStorage.getItem('nexcore_anon_votes'); + state.anonVotes = new Set(raw ? JSON.parse(raw) : []); +} + +function saveAnonVotes() { + localStorage.setItem('nexcore_anon_votes', JSON.stringify([...state.anonVotes])); +} + +function renderFeatures() { + const list = $('#features-list'); + const empty = $('#empty-state'); + // Clear everything except skeleton (already hidden) + $$('.feature-card', list).forEach(c => c.remove()); + + if (state.currentTab === 'pending') { + const pending = state.pendingFeatures || []; + if (!pending.length) { + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + pending.forEach((feature, i) => { + const card = buildPendingCard(feature, i); + list.appendChild(card); + }); + bindReveal(); + return; + } + + if (state.expandedFeatureId && !state.features.some(f => f.id === state.expandedFeatureId)) { + state.expandedFeatureId = null; + } + + if (!state.features.length) { + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + + state.features.forEach((feature, i) => { + const card = buildCard(feature, i); + list.appendChild(card); + }); + + // Re-observe new cards for reveal + bindReveal(); +} + +function buildCard(feature, index) { + const voted = state.user ? state.userVotes.has(feature.id) : state.anonVotes.has(feature.id); + const commentCount = Number(feature.feature_comments?.[0]?.count ?? 0); + const statusLabel = normalizeStatusLabel(feature.status); + const date = new Date(feature.created_at).toLocaleDateString(roadmapText("en-US", "ar-OM"), { month: 'short', day: 'numeric', year: 'numeric' }); + const isExpanded = !isMobileCommentsFallback() && state.expandedFeatureId === feature.id; + const isOwner = !!state.user && feature.created_by === state.user.id; + + const card = document.createElement('div'); + card.className = `feature-card reveal${isExpanded ? ' expanded' : ''}`; + card.dataset.id = feature.id; + card.style.animationDelay = `${index * 45}ms`; + + card.innerHTML = ` +
+ + ${feature.votes_count} + ${!state.user ? roadmapText("guest vote", "تصويت ضيف") : ''} +
+
+

${escHtml(feature.title)}

+

${escHtml(feature.description || '')}

+ +
+ + \n ", "\" aria-label=\"توسيع تفاصيل الميزة\">\n \n \n ")}${date} +
+
+ ${state.isAdmin ? `${roadmapText("\n
\n \n Set status:\n
\n \n \n \n \n \n
\n
", "\" data-status=\"completed\">مكتمل\n
\n
")}` : ''} + +
+
+

${escHtml(feature.description || roadmapText("No extra details provided yet.", "لا توجد تفاصيل إضافية بعد."))}${roadmapText("

\n
\n Suggested on ", "

\n
\n اقتُرحت في ")}${date} + ${feature.votes_count}${roadmapText(" votes\n \n ${statusLabel}${roadmapText("\n
\n
\n
\n Comments\n \n
\n
\n
\n التعليقات\n ${commentCount} +
+
\n
Loading comments…
\n
\n ", "\">\n
جار تحميل التعليقات...
\n
\n ")}${state.user ? ` +
+ \n \n
", "\" style=\"padding:10px 16px;white-space:nowrap;\">إرسال\n
")}` : `${roadmapText("\n
\n \n Sign in to comment\n \n
", "\" style=\"padding:6px 14px;font-size:13px;\">تسجيل الدخول\n
")}`} +
+
+ + `; + + card.querySelector('.vote-btn').addEventListener('click', (e) => { + e.stopPropagation(); + handleVote(feature.id); + }); + + card.querySelector('.comment-chip').addEventListener('click', (e) => { + e.stopPropagation(); + if (isMobileCommentsFallback()) { + openComments(feature.id, feature.title); + return; + } + toggleFeatureExpand(feature.id, true); + }); + + card.querySelector('.expand-btn').addEventListener('click', (e) => { + e.stopPropagation(); + if (isMobileCommentsFallback()) { + openComments(feature.id, feature.title); + return; + } + toggleFeatureExpand(feature.id); + }); + + card.querySelector('.card-body').addEventListener('click', (e) => { + if (e.target.closest('button, select, textarea, input, a, label')) return; + if (isMobileCommentsFallback()) { + openComments(feature.id, feature.title); + return; + } + toggleFeatureExpand(feature.id); + }); + + if (state.isAdmin) { + card.querySelectorAll('.status-pick').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + updateFeatureStatus(feature.id, btn.dataset.status); + }); + }); + } + + card.querySelector(`[data-owner-edit="${feature.id}"]`)?.addEventListener('click', (e) => { + e.stopPropagation(); + openSuggestModalForEdit(feature.id); + }); + card.querySelector(`[data-owner-delete="${feature.id}"]`)?.addEventListener('click', async (e) => { + e.stopPropagation(); + await deleteOwnFeature(feature.id); + }); + + card.querySelector('.inline-signin-btn')?.addEventListener('click', () => openAuthModal()); + card.querySelector(`#inline-comment-btn-${feature.id}`)?.addEventListener('click', () => submitInlineComment(feature.id)); + card.querySelector(`#inline-comment-input-${feature.id}`)?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submitInlineComment(feature.id); + }); + + if (isExpanded) { + requestAnimationFrame(() => { + setExpandedState(card, true, false); + loadInlineComments(feature.id); + }); + } + + return card; +} + +function buildPendingCard(feature, index) { + const card = document.createElement('div'); + card.className = 'feature-card pending-card reveal'; + card.dataset.id = feature.id; + card.style.animationDelay = `${index * 45}ms`; + const submitterName = escHtml(feature.submitter_name || ''); + const submitterEmail = escHtml(feature.submitter_email || ''); + + card.innerHTML = ` +
+ \n

", "\n قيد المراجعة\n

\n

")}${escHtml(feature.description || '')}

+ ${feature.submitter_name || feature.submitter_email ? ` +

+ + ${submitterName} ${feature.submitter_email ? `<${submitterEmail}>` : ''} +

` : roadmapText("

Anonymous submission

", "

إرسال مجهول

")} +
+ \n \n \n
\n \n ", "\" style=\"border-color:rgba(255,120,120,0.35);color:#ff9a9a;\">\n رفض\n \n \n \n ")}`; + + card.querySelector(`[data-approve="${feature.id}"]`)?.addEventListener('click', () => moderateSuggestion(feature.id, true)); + card.querySelector(`[data-reject="${feature.id}"]`)?.addEventListener('click', () => moderateSuggestion(feature.id, false)); + return card; +} + +// ── EXPANDABLE CARDS ────────────────────────────────────── +function setExpandedState(card, expanded, animate = true) { + const details = $('.feature-details', card); + if (!details) return; + + card.classList.toggle('expanded', expanded); + if (!expanded) { + details.style.maxHeight = '0px'; + return; + } + + if (!animate) { + details.style.maxHeight = 'none'; + return; + } + + details.style.maxHeight = `${details.scrollHeight}px`; +} + +function refreshExpandedHeight(featureId) { + const card = $(`.feature-card[data-id="${featureId}"]`); + if (!card || !card.classList.contains('expanded')) return; + const details = $('.feature-details', card); + if (!details) return; + details.style.maxHeight = `${details.scrollHeight}px`; +} + +function toggleFeatureExpand(featureId, forceOpen = false) { + if (isMobileCommentsFallback()) return; + const card = $(`.feature-card[data-id="${featureId}"]`); + if (!card) return; + const shouldOpen = forceOpen || state.expandedFeatureId !== featureId; + + if (!shouldOpen) { + setExpandedState(card, false); + state.expandedFeatureId = null; + return; + } + + if (state.expandedFeatureId && state.expandedFeatureId !== featureId) { + const prev = $(`.feature-card[data-id="${state.expandedFeatureId}"]`); + if (prev) setExpandedState(prev, false); + } + + state.expandedFeatureId = featureId; + setExpandedState(card, true); + loadInlineComments(featureId); +} + +// ── ADMIN STATUS MANAGEMENT ─────────────────────────────── +function removeFeatureFromCurrentView(featureId) { + const idx = state.features.findIndex(f => f.id === featureId); + if (idx === -1) return; + state.features.splice(idx, 1); + if (state.expandedFeatureId === featureId) state.expandedFeatureId = null; + const card = $(`.feature-card[data-id="${featureId}"]`); + if (!card) { + renderFeatures(); + updateStats(); + return; + } + card.classList.add('removing'); + setTimeout(() => { + card.remove(); + if (!state.features.length) $('#empty-state')?.classList.remove('hidden'); + updateStats(); + }, 250); +} + +async function updateFeatureStatus(featureId, newStatus) { + if (!state.isAdmin) return; + const statusLabels = { planned: roadmapText("Planned", "مخطط"), in_progress: roadmapText("In Progress", "قيد التنفيذ"), completed: roadmapText("Completed", "مكتمل") }; + + const feature = state.features.find(f => f.id === featureId); + if (!feature) return; + const prevStatus = feature.status; + + // Optimistic: update badge immediately + const badge = document.querySelector(`.feature-card[data-id="${featureId}"] .badge`); + if (badge) { + badge.className = `badge badge--${newStatus}`; + badge.textContent = statusLabels[newStatus]; + } + const detailBadge = document.querySelector(`#detail-status-${featureId}`); + if (detailBadge) { + detailBadge.className = `badge badge--${newStatus}`; + detailBadge.textContent = statusLabels[newStatus]; + } + + // Highlight active button + document.querySelectorAll(`.status-pick[data-id="${featureId}"]`).forEach(b => { + b.classList.toggle('active', b.dataset.status === newStatus); + }); + + const { error } = await db + .from('features') + .update({ status: newStatus }) + .eq('id', featureId); + + if (error) { + feature.status = prevStatus; + showToast(roadmapText("Failed to update status.", "فشل تحديث الحالة."), ''); + await loadFeatures(); + return; + } + + // Update local state + feature.status = newStatus; + + const shouldRemove = + (state.currentTab === 'roadmap' && newStatus === 'completed') || + (state.currentTab === 'implemented' && newStatus !== 'completed') || + (state.currentTab === 'roadmap' && state.statusFilter !== 'all' && newStatus !== state.statusFilter); + + if (shouldRemove) { + const card = document.querySelector(`.feature-card[data-id="${featureId}"]`); + if (card) { + card.style.opacity = '0'; + card.style.transform = 'scale(0.97)'; + card.style.transition = 'all 0.4s ease'; + setTimeout(() => { + card.remove(); + state.features = state.features.filter(f => f.id !== featureId); + if (!state.features.length) $('#empty-state')?.classList.remove('hidden'); + updateStats(); + }, 420); + } + } + + showToast(`${roadmapText("Status updated to ", "تم تحديث الحالة إلى ")}${statusLabels[newStatus]}`, ''); + updateStats(); +} + +async function moderateSuggestion(featureId, approve) { + const { error } = await db.rpc('moderate_suggestion', { + p_feature_id: featureId, + p_approve: approve + }); + if (error) { + showToast(roadmapText("Action failed.", "فشل الإجراء."), ''); + return; + } + showToast( + approve ? roadmapText("Suggestion approved and now live!", "تم قبول الاقتراح وأصبح ظاهرًا الآن!") : roadmapText("Suggestion rejected.", "تم رفض الاقتراح."), + approve ? '' : '' + ); + + const card = document.querySelector(`.feature-card[data-id="${featureId}"]`); + if (card) { + card.style.opacity = '0'; + card.style.transition = 'opacity 0.3s ease'; + setTimeout(() => card.remove(), 320); + } + state.pendingFeatures = state.pendingFeatures.filter((f) => f.id !== featureId); + if (!state.pendingFeatures.length && state.currentTab === 'pending') { + $('#empty-state')?.classList.remove('hidden'); + } + if (approve) { + await loadFeatures(); + } + updateStats(); +} + +async function updateStats() { + const { data } = await db.from('features').select('status,votes_count'); + if (!data) return; + const total = data.length; + const votes = data.reduce((s, f) => s + Number(f.votes_count || 0), 0); + const counts = { planned: 0, in_progress: 0, completed: 0 }; + data.forEach(r => { if (counts[r.status] !== undefined) counts[r.status]++; }); + + const animateStat = (selector, value) => { + const element = $(selector); + if (!element) return; + window.CountUp.animate(element, { + end: value, + format: (current) => window.CountUp.formatInteger(current, roadmapText("en", "ar-OM")) + }); + }; + animateStat('#stat-total', total); + animateStat('#stat-votes', votes); + animateStat('#count-planned', counts.planned); + animateStat('#count-inprog', counts.in_progress); + animateStat('#count-done', counts.completed); + animateStat('#stat-done', counts.completed); +} + +// ── VOTING ───────────────────────────────────────────────── +async function handleVote(featureId) { + const card = document.querySelector(`.feature-card[data-id="${featureId}"]`); + const countEl = card?.querySelector('.vote-count'); + const btn = card?.querySelector('.vote-btn'); + const votesMeta = $(`#meta-votes-${featureId}`); + + if (state.user) { + const alreadyVoted = state.userVotes.has(featureId); + const delta = alreadyVoted ? -1 : 1; + if (countEl) countEl.textContent = parseInt(countEl.textContent, 10) + delta; + if (btn) btn.classList.toggle('voted', !alreadyVoted); + + const { data, error } = await db.rpc('toggle_feature_vote', { + p_feature_id: featureId, + p_user_id: state.user.id + }); + if (error) { renderFeatures(); return; } + + data.voted ? state.userVotes.add(featureId) : state.userVotes.delete(featureId); + if (countEl) countEl.textContent = data.votes_count; + if (votesMeta) votesMeta.innerHTML = ` ${data.votes_count}${roadmapText(" votes", " تصويت")}`; + const feat = state.features.find(f => f.id === featureId); + if (feat) feat.votes_count = data.votes_count; + } else { + const alreadyVoted = state.anonVotes.has(featureId); + const delta = alreadyVoted ? -1 : 1; + if (countEl) countEl.textContent = parseInt(countEl.textContent, 10) + delta; + if (btn) btn.classList.toggle('voted', !alreadyVoted); + + const { data, error } = await db.rpc('toggle_anon_vote', { + p_feature_id: featureId, + p_fingerprint: state.anonFingerprint + }); + if (error) { renderFeatures(); return; } + + data.voted ? state.anonVotes.add(featureId) : state.anonVotes.delete(featureId); + saveAnonVotes(); + if (countEl) countEl.textContent = data.votes_count; + if (votesMeta) votesMeta.innerHTML = ` ${data.votes_count}${roadmapText(" votes", " تصويت")}`; + const feat = state.features.find(f => f.id === featureId); + if (feat) feat.votes_count = data.votes_count; + } + + updateStats(); +} + +// ── SUGGEST FEATURE ──────────────────────────────────────── +function bindFAB() { + $('#fab-suggest')?.addEventListener('click', openSuggestModal); +} + +function bindSuggestModal() { + $('#feature-title')?.addEventListener('input', function() { + $('#title-count').textContent = `${this.value.length} / 100`; + }); + $('#feature-desc')?.addEventListener('input', function() { + $('#desc-count').textContent = `${this.value.length} / 500`; + }); + $('#submit-feature')?.addEventListener('click', submitFeature); +} + +function openSuggestModal() { + state.editingFeatureId = null; + $('#submit-feature .btn-text') && ($('#submit-feature .btn-text').textContent = roadmapText("Submit Feature", "إرسال الميزة")); + const header = $('#suggest-modal .modal-header h2'); + if (header) header.innerHTML = roadmapText(" Suggest a Feature", " اقترح ميزة"); + $('#feature-title').value = ''; + $('#feature-desc').value = ''; + $('#anonName') && ($('#anonName').value = ''); + $('#anonEmail') && ($('#anonEmail').value = ''); + $('#title-count').textContent = '0 / 100'; + $('#desc-count').textContent = '0 / 500'; + $('#suggest-error')?.classList.add('hidden'); + $('#anonFields')?.classList.toggle('hidden', !!state.user); + openModal('suggest-modal'); +} + +function openSuggestModalForEdit(featureId) { + const feature = state.features.find(f => f.id === featureId); + if (!feature || !state.user || feature.created_by !== state.user.id) return; + + state.editingFeatureId = featureId; + $('#feature-title').value = feature.title || ''; + $('#feature-desc').value = feature.description || ''; + $('#title-count').textContent = `${(feature.title || '').length} / 100`; + $('#desc-count').textContent = `${(feature.description || '').length} / 500`; + $('#suggest-error')?.classList.add('hidden'); + const header = $('#suggest-modal .modal-header h2'); + if (header) header.innerHTML = roadmapText(" Edit Your Suggestion", " عدّل اقتراحك"); + $('#submit-feature .btn-text') && ($('#submit-feature .btn-text').textContent = roadmapText("Save Changes", "حفظ التغييرات")); + $('#anonFields')?.classList.add('hidden'); + openModal('suggest-modal'); +} + +async function deleteOwnFeature(featureId) { + const feature = state.features.find(f => f.id === featureId); + if (!feature || !state.user || feature.created_by !== state.user.id) return; + openConfirmModal({ + title: roadmapText("Delete Suggestion", "حذف الاقتراح"), + message: roadmapText("This will permanently remove your feature suggestion.", "سيؤدي هذا إلى حذف اقتراح الميزة نهائيًا."), + confirmText: roadmapText("Delete Suggestion", "حذف الاقتراح"), + onConfirm: async () => { + const { error, count } = await db + .from('features') + .delete({ count: 'exact' }) + .eq('id', featureId) + .eq('created_by', state.user.id); + + if (error) { + showToast(roadmapText("Failed to delete suggestion.", "فشل حذف الاقتراح."), ''); + throw error; + } + if (count === 0) { + // Soft delete happened, row not actually removed but state changes + } + + if (state.activeFeatureId === featureId) { + closeModal('comments-modal'); + state.activeFeatureId = null; + } + + removeFeatureFromCurrentView(featureId); + showToast(roadmapText("Suggestion deleted.", "تم حذف الاقتراح."), ''); + } + }); +} + +async function submitFeature() { + const title = $('#feature-title')?.value.trim(); + const desc = $('#feature-desc')?.value.trim(); + const errEl = $('#suggest-error'); + const wasEditing = Boolean(state.editingFeatureId); + + if (!title || title.length < 3) { showFormError(errEl, roadmapText("Title must be at least 3 characters.", "العنوان يجب أن يكون 3 أحرف على الأقل.")); return; } + if (!desc) { showFormError(errEl, roadmapText("Description is required.", "الوصف مطلوب.")); return; } + + const btn = $('#submit-feature'); + const txtEl = btn.querySelector('.btn-text'); + const spinEl = btn.querySelector('.btn-spinner'); + btn.disabled = true; + txtEl?.classList.add('hidden'); + spinEl?.classList.remove('hidden'); + + let data; + let error; + if (state.editingFeatureId) { + ({ data, error } = await db + .from('features') + .update({ title, description: desc }) + .eq('id', state.editingFeatureId) + .eq('created_by', state.user.id) + .select() + .single()); + } else if (state.user) { + ({ data, error } = await db + .from('features') + .insert({ + title, + description: desc, + status: 'planned', + created_by: state.user.id, + is_approved: true, + is_anonymous: false, + }) + .select() + .single()); + } else { + const name = $('#anonName')?.value.trim() || null; + const email = $('#anonEmail')?.value.trim() || null; + ({ error } = await db.rpc('submit_anon_suggestion', { + p_title: title, + p_description: desc, + p_submitter_name: name, + p_submitter_email: email + })); + } + + btn.disabled = false; + txtEl?.classList.remove('hidden'); + spinEl?.classList.add('hidden'); + + if (error) { + showFormError(errEl, roadmapText("Submission failed. Please try again.", "فشل الإرسال. حاول مرة أخرى.")); + return; + } + + $('#feature-title').value = ''; + $('#feature-desc').value = ''; + $('#title-count').textContent = '0 / 100'; + $('#desc-count').textContent = '0 / 500'; + errEl?.classList.add('hidden'); + state.editingFeatureId = null; + $('#submit-feature .btn-text') && ($('#submit-feature .btn-text').textContent = roadmapText("Submit Feature", "إرسال الميزة")); + const header = $('#suggest-modal .modal-header h2'); + if (header) header.innerHTML = roadmapText(" Suggest a Feature", " اقترح ميزة"); + + closeModal('suggest-modal'); + if (!state.user && !wasEditing) { + showToast(roadmapText("Suggestion submitted! It will appear after review.", "تم إرسال الاقتراح! سيظهر بعد المراجعة."), ''); + } else { + showToast(wasEditing ? roadmapText("Suggestion updated.", "تم تحديث الاقتراح.") : roadmapText("Feature added to the roadmap!", "تمت إضافة الميزة إلى خريطة الطريق!"), ''); + } + + if (data && state.user) { + if (state.features.some(f => f.id === data.id)) { + const idx = state.features.findIndex(f => f.id === data.id); + state.features[idx] = { ...state.features[idx], ...data }; + } else if (state.currentTab === 'roadmap') { + state.features.unshift({ ...data, feature_comments: [{ count: 0 }] }); + } + renderFeatures(); + updateStats(); + } +} + +// ── COMMENTS ─────────────────────────────────────────────── +function bindCommentsModal() { + $('#submit-comment')?.addEventListener('click', submitComment); + $('#comment-input')?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submitComment(); + }); +} + +function bindCommentActions() { + document.addEventListener('click', async (e) => { + const deleteBtn = e.target.closest('.comment-delete-btn'); + if (deleteBtn) { + await deleteOwnComment(deleteBtn.dataset.feature, deleteBtn.dataset.comment); + } + }); +} + +async function deleteOwnComment(featureId, commentId) { + if (!state.user || !featureId || !commentId) return; + const comments = state.commentsByFeature[featureId] || []; + const comment = comments.find(c => c.id === commentId && c.user_id === state.user.id); + if (!comment) { + showToast(roadmapText("You can only delete your own comment.", "يمكنك فقط حذف تعليقاتك الخاصة."), ''); + return; + } + openConfirmModal({ + title: roadmapText("Delete Comment", "حذف التعليق"), + message: roadmapText("This comment will be permanently deleted.", "سيتم حذف هذا التعليق بشكل دائم."), + confirmText: roadmapText("Delete Comment", "حذف التعليق"), + onConfirm: async () => { + const { error, count } = await db + .from('feature_comments') + .delete({ count: 'exact' }) + .eq('id', commentId) + .eq('user_id', state.user.id); + + if (error) { + showToast(roadmapText("Failed to delete comment.", "فشل حذف التعليق."), ''); + throw error; + } + if (count === 0) { + // Soft delete happened, row not actually removed but state changes + } + + state.commentsByFeature[featureId] = comments.filter(c => c.id !== commentId); + refreshCommentViews(featureId); + showToast(roadmapText("Comment deleted.", "تم حذف التعليق."), ''); + } + }); +} + +function bindConfirmModal() { + $('#confirm-modal-btn')?.addEventListener('click', async () => { + if (!state.pendingConfirmAction) return; + const btn = $('#confirm-modal-btn'); + if (btn.dataset.processing === '1') return; + const txt = $('#confirm-modal-btn-text'); + const err = $('#confirm-modal-error'); + btn.disabled = true; + btn.dataset.processing = '1'; + if (txt) txt.textContent = roadmapText("Processing...", "جاري المعالجة..."); + err?.classList.add('hidden'); + + try { + await Promise.resolve(state.pendingConfirmAction()); + state.pendingConfirmAction = null; + btn.dataset.processing = '0'; + btn.disabled = false; + if (txt) txt.textContent = state.pendingConfirmLabel || roadmapText("Delete", "حذف"); + closeModal('confirm-modal'); + } catch (e) { + if (err) { + err.textContent = e?.message || roadmapText("Failed to complete action.", "فشل إكمال الإجراء."); + err.classList.remove('hidden'); + } + } finally { + if (btn.dataset.processing !== '0') { + btn.disabled = false; + btn.dataset.processing = '0'; + if (txt) txt.textContent = state.pendingConfirmLabel || roadmapText("Delete", "حذف"); + } + } + }); +} + +function openConfirmModal({ title, message, confirmText = roadmapText("Delete", "حذف"), onConfirm }) { + state.pendingConfirmAction = onConfirm; + state.pendingConfirmLabel = confirmText; + const titleEl = $('#confirm-modal-title'); + const msgEl = $('#confirm-modal-message'); + const btn = $('#confirm-modal-btn'); + const txt = $('#confirm-modal-btn-text'); + const err = $('#confirm-modal-error'); + if (titleEl) titleEl.innerHTML = ` ${escHtml(title || roadmapText("Confirm Action", "تأكيد الإجراء"))}`; + if (msgEl) msgEl.textContent = message || roadmapText("Are you sure you want to continue?", "هل أنت متأكد أنك تريد المتابعة؟"); + if (txt) txt.textContent = confirmText; + if (btn) { + btn.disabled = false; + btn.dataset.processing = '0'; + } + err?.classList.add('hidden'); + openModal('confirm-modal'); +} + +function refreshCommentViews(featureId) { + const comments = state.commentsByFeature[featureId] || []; + const inlineList = $(`#inline-comments-${featureId}`); + if (inlineList) { + inlineList.innerHTML = renderCommentCollection(comments, featureId); + inlineList.scrollTop = inlineList.scrollHeight; + } + + if (state.activeFeatureId === featureId) { + const modalList = $('#comments-list'); + if (modalList) { + modalList.innerHTML = renderCommentCollection(comments, featureId); + modalList.scrollTop = modalList.scrollHeight; + } + } + + updateCommentCountUI(featureId, comments.length); + refreshExpandedHeight(featureId); +} + +async function fetchComments(featureId, force = false) { + if (!force && Array.isArray(state.commentsByFeature[featureId])) { + return { comments: state.commentsByFeature[featureId], error: null }; + } + + const { data, error } = await db + .from('feature_comments') + .select('id, content, created_at, user_id') + .eq('feature_id', featureId) + .order('created_at', { ascending: true }); + + if (!error) state.commentsByFeature[featureId] = data || []; + return { comments: data || [], error }; +} + +function renderCommentCollection(comments = [], featureId) { + if (!comments.length) { + return roadmapText("

No comments yet — be the first!
", "

لا توجد تعليقات بعد. كن أول من يعلّق!
"); + } + return comments.map(c => buildCommentEl(c, featureId).outerHTML).join(''); +} + +function updateCommentCountUI(featureId, nextCount) { + const count = Number(nextCount || 0); + const chip = $(`#cc-${featureId}`); + if (chip) chip.textContent = count; + const inlineBadge = $(`#inline-count-${featureId}`); + if (inlineBadge) inlineBadge.textContent = count; + const modalBadge = $('#comments-count-badge'); + if (state.activeFeatureId === featureId && modalBadge) modalBadge.textContent = count; + + const feature = state.features.find(f => f.id === featureId); + if (feature) { + if (!feature.feature_comments?.[0]) feature.feature_comments = [{ count: 0 }]; + feature.feature_comments[0].count = count; + } +} + +async function loadInlineComments(featureId) { + const list = $(`#inline-comments-${featureId}`); + if (!list) return; + + list.innerHTML = roadmapText("
Loading comments…
", "
جار تحميل التعليقات...
"); + const { comments, error } = await fetchComments(featureId); + if (error) { + list.innerHTML = roadmapText("
Failed to load comments.
", "
فشل تحميل التعليقات.
"); + return; + } + + list.innerHTML = renderCommentCollection(comments, featureId); + updateCommentCountUI(featureId, comments.length); + list.scrollTop = list.scrollHeight; + refreshExpandedHeight(featureId); +} + +async function openComments(featureId, title) { + state.activeFeatureId = featureId; + const titleEl = $('#comments-feature-title'); + if (titleEl) titleEl.innerHTML = ` ${escHtml(title)}`; + + openModal('comments-modal'); + + const compose = $('#comments-compose'); + const nudge = $('#comments-auth-nudge'); + + if (state.user) { + compose?.classList.remove('hidden'); + nudge?.classList.add('hidden'); + } else { + compose?.classList.add('hidden'); + nudge?.classList.remove('hidden'); + // Re-bind sign-in inside nudge + const siBtn = $('#comments-sign-in-btn'); + if (siBtn) { + siBtn.onclick = () => { closeModal('comments-modal'); openAuthModal(); }; + } + } + + await loadComments(featureId); +} + +async function loadComments(featureId) { + const list = $('#comments-list'); + list.innerHTML = roadmapText("
Loading comments…
", "
جار تحميل التعليقات...
"); + const { comments, error } = await fetchComments(featureId); + if (error) { + list.innerHTML = roadmapText("
Failed to load comments.
", "
فشل تحميل التعليقات.
"); + return; + } + + list.innerHTML = renderCommentCollection(comments, featureId); + updateCommentCountUI(featureId, comments.length); + list.scrollTop = list.scrollHeight; +} + +function buildCommentEl(comment, featureId) { + const el = document.createElement('div'); + el.className = 'comment-item'; + const uid = comment.user_id?.slice(0, 6) || '??????'; + const initial = uid[0]?.toUpperCase() || 'U'; + const date = new Date(comment.created_at).toLocaleDateString(roadmapText("en-US", "ar-OM"), { month: 'short', day: 'numeric' }); + const canManage = state.user && comment.user_id === state.user.id; + + el.innerHTML = ` +
${initial}${roadmapText("
\n
\n
\n User #", "
\n
\n
\n مستخدم #")}${uid} + ${date} + ${canManage ? ` + + \n ", "\" title=\"حذف التعليق\">\n ")}` : ''} +
+

${escHtml(comment.content)}

+
+ `; + return el; +} + +async function submitComment() { + if (!state.user || !state.activeFeatureId) return; + const input = $('#comment-input'); + const content = input?.value.trim(); + if (!content) return; + + const btn = $('#submit-comment'); + btn.disabled = true; + btn.textContent = '…'; + + const { data, error } = await db.from('feature_comments').insert({ + feature_id: state.activeFeatureId, + user_id: state.user.id, + content, + }).select().single(); + + btn.disabled = false; + btn.textContent = roadmapText("Post", "إرسال"); + + if (error) { + showToast(roadmapText("Failed to post comment.", "فشل نشر التعليق."), ''); + return; + } + + input.value = ''; + const cached = state.commentsByFeature[state.activeFeatureId] || []; + cached.push(data); + state.commentsByFeature[state.activeFeatureId] = cached; + await loadComments(state.activeFeatureId); + const inlineList = $(`#inline-comments-${state.activeFeatureId}`); + if (inlineList) inlineList.innerHTML = renderCommentCollection(cached, state.activeFeatureId); + updateCommentCountUI(state.activeFeatureId, cached.length); + showToast(roadmapText("Comment posted!", "تم نشر التعليق!"), ''); +} + +async function submitInlineComment(featureId) { + if (!state.user) { + openAuthModal(); + return; + } + const input = $(`#inline-comment-input-${featureId}`); + const btn = $(`#inline-comment-btn-${featureId}`); + const content = input?.value.trim(); + if (!content || !btn) return; + + btn.disabled = true; + btn.textContent = '…'; + + const { data, error } = await db.from('feature_comments').insert({ + feature_id: featureId, + user_id: state.user.id, + content, + }).select().single(); + + btn.disabled = false; + btn.textContent = roadmapText("Post", "إرسال"); + + if (error) { + showToast(roadmapText("Failed to post comment.", "فشل نشر التعليق."), ''); + return; + } + + input.value = ''; + const cached = state.commentsByFeature[featureId] || []; + cached.push(data); + state.commentsByFeature[featureId] = cached; + + const list = $(`#inline-comments-${featureId}`); + if (list) { + list.innerHTML = renderCommentCollection(cached, featureId); + list.scrollTop = list.scrollHeight; + } + + if (state.activeFeatureId === featureId) { + const modalList = $('#comments-list'); + if (modalList && !$('#comments-modal')?.classList.contains('hidden')) { + modalList.innerHTML = renderCommentCollection(cached, featureId); + modalList.scrollTop = modalList.scrollHeight; + } + } + + updateCommentCountUI(featureId, cached.length); + refreshExpandedHeight(featureId); + showToast(roadmapText("Comment posted!", "تم نشر التعليق!"), ''); +} + +// ── TABS & FILTERS ───────────────────────────────────────── +function bindTabs() { + $$('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (btn.dataset.tab === state.currentTab) return; + $$('.tab-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.currentTab = btn.dataset.tab; + + const filterBar = $('#status-filter'); + if (filterBar) { + filterBar.style.display = state.currentTab === 'roadmap' ? 'flex' : 'none'; + } + + if (state.currentTab !== 'roadmap') { + state.statusFilter = 'all'; + $$('.chip').forEach(c => c.classList.toggle('active', c.dataset.filter === 'all')); + } + loadFeatures(); + }); + }); +} + +function bindFilters() { + $$('.chip').forEach(chip => { + chip.addEventListener('click', () => { + if (chip.dataset.filter === state.statusFilter) return; + $$('.chip').forEach(c => c.classList.remove('active')); + chip.classList.add('active'); + state.statusFilter = chip.dataset.filter; + loadFeatures(); + }); + }); +} + +// ── MODAL UTILITIES ──────────────────────────────────────── +function openModal(id) { + const modal = $(`#${id}`); + if (!modal) return; + modal.classList.remove('hidden', 'is-closing'); + requestAnimationFrame(() => modal.classList.add('is-open')); + document.body.style.overflow = 'hidden'; +} + +function closeModal(id) { + const modal = $(`#${id}`); + if (!modal || modal.classList.contains('hidden')) return; + if (id === 'confirm-modal') { + const confirmBtn = $('#confirm-modal-btn'); + if (confirmBtn?.dataset.processing === '1') return; + } + if (id === 'confirm-modal') state.pendingConfirmAction = null; + modal.classList.remove('is-open'); + modal.classList.add('is-closing'); + setTimeout(() => { + modal.classList.add('hidden'); + modal.classList.remove('is-closing'); + if (!$$('.modal-overlay.is-open').length) { + document.body.style.overflow = ''; + } + }, 240); +} + +function bindModalClosers() { + $$('[data-close]').forEach(btn => { + btn.addEventListener('click', () => closeModal(btn.dataset.close)); + }); + $$('.modal-overlay').forEach(overlay => { + overlay.addEventListener('click', (e) => { + if (e.target === overlay) closeModal(overlay.id); + }); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + $$('.modal-overlay:not(.hidden)').forEach(o => closeModal(o.id)); + } + }); +} + +// ── SKELETON ─────────────────────────────────────────────── +function showSkeleton(visible) { + const skel = $('#skeleton-wrap'); + if (!skel) return; + if (visible) { + skel.style.display = 'flex'; + skel.style.flexDirection = 'column'; + skel.style.gap = '10px'; + } else { + skel.style.display = 'none'; + } + $$('.feature-card').forEach(c => { + if (visible) c.remove(); + }); +} + +// ── TOAST ────────────────────────────────────────────────── +let toastTimer; +function showToast(message, iconHtml = '') { + const toast = $('#toast'); + const iconEl = $('#toast-icon'); + const msgEl = $('#toast-msg'); + if (!toast) return; + + iconEl.innerHTML = iconHtml; + msgEl.textContent = message; + toast.classList.remove('hidden'); + + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toast.classList.add('hidden'), 3200); +} + +// ── FORM ERROR ───────────────────────────────────────────── +function showFormError(el, msg) { + if (!el) return; + el.textContent = msg; + el.classList.remove('hidden'); +} + +// ── BOOT ─────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', init); +})(); diff --git a/package.json b/package.json index 4f6eb41..97a63fa 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "lib": "lib" }, "scripts": { - "test": "npm run release:test && npm run pricing:test && npm run branding:test && npm run seo:test && npm run auth-nav:test && npm run anchor-nav:test && npm run initiatives:test && npm run initiatives-admin:test && npm run homepage-metrics:test && npm run counter-coverage:test && npm run account-ux:test && npm run team-id-cards:test", + "test": "npm run release:test && npm run pricing:test && npm run branding:test && npm run seo:test && npm run auth-nav:test && npm run anchor-nav:test && npm run initiatives:test && npm run initiatives-admin:test && npm run roadmap:test && npm run homepage-metrics:test && npm run counter-coverage:test && npm run account-ux:test && npm run team-id-cards:test", "release": "node scripts/sync-release-version.js", "release:test": "node scripts/release-version-test.js", "security:audit": "npm audit --omit=dev --audit-level=high", @@ -18,6 +18,7 @@ "anchor-nav:test": "node scripts/anchor-navigation-test.js", "initiatives:test": "node scripts/initiatives-test.js", "initiatives-admin:test": "node scripts/initiatives-admin-test.js", + "roadmap:test": "node scripts/roadmap-shared-test.js", "homepage-metrics:test": "node scripts/homepage-metrics-test.js", "counter-coverage:test": "node scripts/counter-coverage-test.js", "account-ux:test": "node scripts/account-ux-test.js", diff --git a/roadmap.html b/roadmap.html index a6b96f0..4279353 100644 --- a/roadmap.html +++ b/roadmap.html @@ -54,2977 +54,370 @@ - - - - - - - -
- NexCore Labs Logo - -
-
- - - -
-
-
-
-

Public Roadmap

-

Shape What We
Build Next

-

Vote on features, suggest ideas, and track progress — your input drives what ships next at NexCore Labs.

-
-
Requests
-
Total Votes
-
Shipped
-
-
-
-
-
-
-

Status Pipeline

-

Live feature overview

-
-
Planned
-
In Progress
-
Completed
-
-
-
-
-
-
- -
-
-
- - - -
-
- - - -
-
- -
-
-
-
-
-
-
-
- - -
- - - - - - - - - - - - - - - - - - - -
- NexCore Labs -
-
- - - - - - --> - - - - - - - - - - + + -function buildCard(feature, index) { - const voted = state.user ? state.userVotes.has(feature.id) : state.anonVotes.has(feature.id); - const commentCount = Number(feature.feature_comments?.[0]?.count ?? 0); - const statusLabel = normalizeStatusLabel(feature.status); - const date = new Date(feature.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - const isExpanded = !isMobileCommentsFallback() && state.expandedFeatureId === feature.id; - const isOwner = !!state.user && feature.created_by === state.user.id; - - const card = document.createElement('div'); - card.className = `feature-card reveal${isExpanded ? ' expanded' : ''}`; - card.dataset.id = feature.id; - card.style.animationDelay = `${index * 45}ms`; - - card.innerHTML = ` -
- - ${feature.votes_count} - ${!state.user ? 'guest vote' : ''} -
-
-

${escHtml(feature.title)}

-

${escHtml(feature.description || '')}

- - ${state.isAdmin ? ` -
- - Set status: -
- - - -
-
` : ''} -
-
-
-

${escHtml(feature.description || 'No extra details provided yet.')}

-
- Suggested on ${date} - ${feature.votes_count} votes - ${statusLabel} -
-
-
- Comments - ${commentCount} -
-
-
Loading comments…
-
- ${state.user ? ` -
- - -
` : ` -
- - Sign in to comment - -
`} -
-
-
- `; - - card.querySelector('.vote-btn').addEventListener('click', (e) => { - e.stopPropagation(); - handleVote(feature.id); - }); - - card.querySelector('.comment-chip').addEventListener('click', (e) => { - e.stopPropagation(); - if (isMobileCommentsFallback()) { - openComments(feature.id, feature.title); - return; - } - toggleFeatureExpand(feature.id, true); - }); - - card.querySelector('.expand-btn').addEventListener('click', (e) => { - e.stopPropagation(); - if (isMobileCommentsFallback()) { - openComments(feature.id, feature.title); - return; - } - toggleFeatureExpand(feature.id); - }); - - card.querySelector('.card-body').addEventListener('click', (e) => { - if (e.target.closest('button, select, textarea, input, a, label')) return; - if (isMobileCommentsFallback()) { - openComments(feature.id, feature.title); - return; - } - toggleFeatureExpand(feature.id); - }); - - if (state.isAdmin) { - card.querySelectorAll('.status-pick').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - updateFeatureStatus(feature.id, btn.dataset.status); - }); - }); - } - - card.querySelector(`[data-owner-edit="${feature.id}"]`)?.addEventListener('click', (e) => { - e.stopPropagation(); - openSuggestModalForEdit(feature.id); - }); - card.querySelector(`[data-owner-delete="${feature.id}"]`)?.addEventListener('click', async (e) => { - e.stopPropagation(); - await deleteOwnFeature(feature.id); - }); - - card.querySelector('.inline-signin-btn')?.addEventListener('click', () => openAuthModal()); - card.querySelector(`#inline-comment-btn-${feature.id}`)?.addEventListener('click', () => submitInlineComment(feature.id)); - card.querySelector(`#inline-comment-input-${feature.id}`)?.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submitInlineComment(feature.id); - }); - - if (isExpanded) { - requestAnimationFrame(() => { - setExpandedState(card, true, false); - loadInlineComments(feature.id); - }); - } - - return card; -} +
+ NexCore Labs Logo -function buildPendingCard(feature, index) { - const card = document.createElement('div'); - card.className = 'feature-card pending-card reveal'; - card.dataset.id = feature.id; - card.style.animationDelay = `${index * 45}ms`; - const submitterName = escHtml(feature.submitter_name || ''); - const submitterEmail = escHtml(feature.submitter_email || ''); +
+
- card.innerHTML = ` -
- -

${escHtml(feature.description || '')}

- ${feature.submitter_name || feature.submitter_email ? ` -

- - ${submitterName} ${feature.submitter_email ? `<${submitterEmail}>` : ''} -

` : '

Anonymous submission

'} -
- - + - btn.disabled = false; - btn.textContent = 'Post'; +
+
+
+
+

Public Roadmap

+

Shape What We
Build Next

+

Vote on features, suggest ideas, and track progress — your input drives what ships next at NexCore Labs.

+
+
Requests
+
Total Votes
+
Shipped
+
+
+
+
+
+
+

Status Pipeline

+

Live feature overview

+
+
Planned
+
In Progress
+
Completed
+
+
+
+
+
+
- if (error) { - showToast('Failed to post comment.', ''); - return; - } +
+
+
+ + + +
+
+ + + +
+
- input.value = ''; - const cached = state.commentsByFeature[state.activeFeatureId] || []; - cached.push(data); - state.commentsByFeature[state.activeFeatureId] = cached; - await loadComments(state.activeFeatureId); - const inlineList = $(`#inline-comments-${state.activeFeatureId}`); - if (inlineList) inlineList.innerHTML = renderCommentCollection(cached, state.activeFeatureId); - updateCommentCountUI(state.activeFeatureId, cached.length); - showToast('Comment posted!', ''); -} +
+
+
+
+
+
+
+
-async function submitInlineComment(featureId) { - if (!state.user) { - openAuthModal(); - return; - } - const input = $(`#inline-comment-input-${featureId}`); - const btn = $(`#inline-comment-btn-${featureId}`); - const content = input?.value.trim(); - if (!content || !btn) return; + +
- btn.disabled = true; - btn.textContent = '…'; + + - const { data, error } = await db.from('feature_comments').insert({ - feature_id: featureId, - user_id: state.user.id, - content, - }).select().single(); + + - btn.disabled = false; - btn.textContent = 'Post'; + + - if (error) { - showToast('Failed to post comment.', ''); - return; - } + + - input.value = ''; - const cached = state.commentsByFeature[featureId] || []; - cached.push(data); - state.commentsByFeature[featureId] = cached; + + - const list = $(`#inline-comments-${featureId}`); - if (list) { - list.innerHTML = renderCommentCollection(cached, featureId); - list.scrollTop = list.scrollHeight; - } + + - if (state.activeFeatureId === featureId) { - const modalList = $('#comments-list'); - if (modalList && !$('#comments-modal')?.classList.contains('hidden')) { - modalList.innerHTML = renderCommentCollection(cached, featureId); - modalList.scrollTop = modalList.scrollHeight; - } - } +
+ NexCore Labs +
+
- updateCommentCountUI(featureId, cached.length); - refreshExpandedHeight(featureId); - showToast('Comment posted!', ''); -} + -// ── SKELETON ─────────────────────────────────────────────── -function showSkeleton(visible) { - const skel = $('#skeleton-wrap'); - if (!skel) return; - if (visible) { - skel.style.display = 'flex'; - skel.style.flexDirection = 'column'; - skel.style.gap = '10px'; - } else { - skel.style.display = 'none'; - } - $$('.feature-card').forEach(c => { - if (visible) c.remove(); - }); -} + -// ── TOAST ────────────────────────────────────────────────── -let toastTimer; -function showToast(message, iconHtml = '') { - const toast = $('#toast'); - const iconEl = $('#toast-icon'); - const msgEl = $('#toast-msg'); - if (!toast) return; + --> - iconEl.innerHTML = iconHtml; - msgEl.textContent = message; - toast.classList.remove('hidden'); + + - clearTimeout(toastTimer); - toastTimer = setTimeout(() => toast.classList.add('hidden'), 3200); -} + -// ── FORM ERROR ───────────────────────────────────────────── -function showFormError(el, msg) { - if (!el) return; - el.textContent = msg; - el.classList.remove('hidden'); -} + -// ── BOOT ─────────────────────────────────────────────────── -document.addEventListener('DOMContentLoaded', init); - + + diff --git a/scripts/counter-coverage-test.js b/scripts/counter-coverage-test.js index fc4359f..4271309 100644 --- a/scripts/counter-coverage-test.js +++ b/scripts/counter-coverage-test.js @@ -34,11 +34,9 @@ for (const file of ["project.html", "ar/project.html"]) { assert(read(file).includes("window.CountUp.animate(document.getElementById('viewCount')"), `${file} view counter must animate`); } -for (const file of ["roadmap.html", "ar/roadmap.html"]) { - const html = read(file); - for (const id of ["stat-total", "stat-votes", "stat-done", "count-planned", "count-inprog", "count-done"]) { - assert(html.includes(`animateStat('#${id}'`), `${file} must animate ${id}`); - } +const roadmapScript = read("assets/js/roadmap.js"); +for (const id of ["stat-total", "stat-votes", "stat-done", "count-planned", "count-inprog", "count-done"]) { + assert(roadmapScript.includes(`animateStat('#${id}'`), `Shared roadmap controller must animate ${id}`); } for (const file of ["admin-users.html", "ar/admin-users.html"]) { diff --git a/scripts/roadmap-shared-test.js b/scripts/roadmap-shared-test.js new file mode 100644 index 0000000..8cb216a --- /dev/null +++ b/scripts/roadmap-shared-test.js @@ -0,0 +1,53 @@ +"use strict"; + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +const root = path.resolve(__dirname, ".."); +const read = (file) => fs.readFileSync(path.join(root, file), "utf8"); +const controller = read("assets/js/roadmap.js"); + +new vm.Script(controller, { filename: "assets/js/roadmap.js" }); + +for (const [file, scriptSource, styleSource] of [ + ["roadmap.html", "assets/js/roadmap.js", "assets/css/roadmap.css"], + ["ar/roadmap.html", "../assets/js/roadmap.js", "../assets/css/roadmap.css"], +]) { + const html = read(file); + assert(html.includes(``), `${file} must load the shared roadmap controller`); + assert(html.includes(``), `${file} must load the shared roadmap stylesheet`); + assert(!html.includes("function getAnonFingerprint"), `${file} must not retain a duplicated inline roadmap controller`); + assert(!/