Audit fixes: VIP self-grant, draft leak, onboarding flag, hero personalization, gated downloads
Deploy GitHub Pages / deploy (push) Canceled after 0s
Deploy GitHub Pages / deploy (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export type DeckSlide = {
|
||||
kicker: string
|
||||
index: string
|
||||
title: React.ReactNode
|
||||
body: string
|
||||
format: React.ReactNode
|
||||
link?: { href: string; label: string }
|
||||
meta?: React.ReactNode
|
||||
}
|
||||
|
||||
const V1_SLIDES: DeckSlide[] = [
|
||||
{
|
||||
kicker: 'دليل حصري · المستوى المتوسط',
|
||||
index: '01',
|
||||
title: 'ابنِ وكيلك الأول دون فوضى',
|
||||
body: 'خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.',
|
||||
format: <><span className="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</>,
|
||||
link: { href: '/posts/agent-loop-stop-condition', label: 'شاهد المعاينة ←' },
|
||||
},
|
||||
{
|
||||
kicker: 'مقال تطبيقي · للمبتدئين',
|
||||
index: '02',
|
||||
title: 'كيف تختار نموذجاً لمهمتك؟',
|
||||
body: 'مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.',
|
||||
format: <>قراءة · 12 دقيقة</>,
|
||||
link: { href: '/posts/article-agent-guide', label: 'ابدأ القراءة ←' },
|
||||
},
|
||||
{
|
||||
kicker: 'مسار متقدم · للأعضاء',
|
||||
index: '03',
|
||||
title: <>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</>,
|
||||
body: 'صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.',
|
||||
format: <><span className="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</>,
|
||||
link: { href: '/subscription', label: 'شاهد للأعضاء ←' },
|
||||
},
|
||||
]
|
||||
|
||||
const DOT_LABELS = ['الشريحة الأولى', 'الشريحة الثانية', 'الشريحة الثالثة']
|
||||
|
||||
/* Featured content carousel. Manual selection pauses rotation until resumed. */
|
||||
export default function CatalogDeck({ slides = V1_SLIDES }: { slides?: DeckSlide[] }) {
|
||||
const [activeSlide, setActiveSlide] = useState(0)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [playing, setPlaying] = useState(true)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const current = slides.length ? activeSlide % slides.length : 0
|
||||
const advancing = ready && playing && !hovered && !focused && slides.length > 1
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = Number(localStorage.getItem('midad-slide'))
|
||||
if (Number.isInteger(stored) && stored >= 0) setActiveSlide(stored)
|
||||
} catch { /* Persistence is optional. */ }
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
if (motion.matches) setPlaying(false)
|
||||
const onMotion = () => { if (motion.matches) setPlaying(false) }
|
||||
motion.addEventListener('change', onMotion)
|
||||
setReady(true)
|
||||
return () => motion.removeEventListener('change', onMotion)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return
|
||||
try { localStorage.setItem('midad-slide', String(current)) } catch { /* optional persistence */ }
|
||||
}, [current, ready])
|
||||
|
||||
useEffect(() => {
|
||||
if (!advancing) return
|
||||
const timer = setTimeout(() => setActiveSlide(index => (index + 1) % slides.length), 6500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [advancing, current, slides.length])
|
||||
|
||||
function selectSlide(index: number) {
|
||||
setActiveSlide((index + slides.length) % slides.length)
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
if (!slides.length) return null
|
||||
|
||||
return (
|
||||
<div className="deck" data-od-id="featured-deck" role="region" aria-roledescription="عرض شرائح" aria-label="مختارات مميزة"
|
||||
onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
|
||||
onFocusCapture={() => setFocused(true)}
|
||||
onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false) }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'ArrowLeft') { event.preventDefault(); selectSlide(current + 1) }
|
||||
if (event.key === 'ArrowRight') { event.preventDefault(); selectSlide(current - 1) }
|
||||
}}>
|
||||
|
||||
<div className="deck-frame" aria-live={advancing ? 'off' : 'polite'}>
|
||||
{slides.map((s, i) => (
|
||||
<article className={`deck-slide${i === current ? ' active' : ''}`} hidden={i !== current} aria-label={`${i + 1} من ${slides.length}`} data-slide={i} key={i}>
|
||||
<div className="deck-slide-top"><span className="deck-slide-kicker">{s.kicker}</span><span className="deck-slide-index num">{s.index}</span></div>
|
||||
<div className="deck-slide-body"><h2>{s.title}</h2><p>{s.body}</p></div>
|
||||
<div className="deck-slide-foot">
|
||||
<span className="deck-slide-format">{s.format}</span>
|
||||
{s.link
|
||||
? <Link className="deck-slide-link" href={s.link.href}>{s.link.label}</Link>
|
||||
: <span className="meta">{s.meta}</span>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="deck-controls">
|
||||
<div className="deck-dots" aria-label="اختيار الشريحة">
|
||||
{slides.map((_, i) => (
|
||||
<button type="button" className={`deck-dot${i === current ? ' active' : ''}`} data-dot={i} aria-label={DOT_LABELS[i] ?? `الشريحة ${i + 1}`} aria-current={i === current ? 'true' : undefined} onClick={() => selectSlide(i)} key={i}></button>
|
||||
))}
|
||||
</div>
|
||||
<div className="deck-arrows">
|
||||
<button type="button" className="icon-btn" id="prevSlide" aria-label="الشريحة السابقة" disabled={slides.length < 2} onClick={() => selectSlide(current - 1)}>→</button>
|
||||
<button type="button" className="icon-btn" id="nextSlide" aria-label="الشريحة التالية" disabled={slides.length < 2} onClick={() => selectSlide(current + 1)}>←</button>
|
||||
{slides.length > 1 && <button type="button" className="icon-btn deck-play" aria-label={playing ? 'إيقاف العرض التلقائي' : 'تشغيل العرض التلقائي'} onClick={() => setPlaying(value => !value)}>{playing ? 'Ⅱ' : '▶'}</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/* Filter bar for the editors' picks. Cards carry space-separated topics. */
|
||||
export default function CatalogFilters() {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const filters = [...document.querySelectorAll('.filter')] as HTMLElement[]
|
||||
const cards = [...document.querySelectorAll('.catalog-card')] as HTMLElement[]
|
||||
const empty = document.getElementById('emptyState')
|
||||
|
||||
function filterCatalog(topic: string) {
|
||||
let visible = 0
|
||||
filters.forEach(button => {
|
||||
const selected = (button as HTMLElement).dataset.filter === topic
|
||||
button.classList.toggle('active', selected)
|
||||
button.setAttribute('aria-pressed', String(selected))
|
||||
})
|
||||
cards.forEach(card => {
|
||||
const show = topic === 'all' || (card.dataset.topic || '').split(' ').includes(topic)
|
||||
card.hidden = !show
|
||||
if (show) visible += 1
|
||||
})
|
||||
empty?.classList.toggle('show', visible === 0)
|
||||
}
|
||||
|
||||
filters.forEach(button => button.addEventListener('click', () => {
|
||||
button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
filterCatalog((button as HTMLElement).dataset.filter!)
|
||||
}, { signal: controller.signal }))
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="filterbar" role="group" aria-label="تصفية المحتوى" data-od-id="catalog-filters">
|
||||
<button type="button" className="filter active" data-filter="all" aria-pressed="true">الكل</button>
|
||||
<button type="button" className="filter" data-filter="agents" aria-pressed="false">الوكلاء</button>
|
||||
<button type="button" className="filter" data-filter="models" aria-pressed="false">النماذج</button>
|
||||
<button type="button" className="filter" data-filter="automation" aria-pressed="false">الأتمتة</button>
|
||||
<button type="button" className="filter" data-filter="foundations" aria-pressed="false">الأساسيات</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
/* QA-07: /catalog was a stale duplicate library — /articles is canonical.
|
||||
Deck/filter components in this folder are still used by the homepage. */
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function CatalogPage() {
|
||||
redirect('/articles')
|
||||
}
|
||||
Reference in New Issue
Block a user