Browse Source
feat: implement SectionOverlayHost for animating question details and expose onExit callbacks for modular navigation handling
master
feat: implement SectionOverlayHost for animating question details and expose onExit callbacks for modular navigation handling
master
11 changed files with 720 additions and 21 deletions
-
87.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md
-
1src/app/[lang]/questions-list/[slug]/loading.tsx
-
66src/app/globals.css
-
31src/app/questions-list/[slug]/question-detail-client.tsx
-
170src/app/questions-list/questions-list-client.tsx
-
87src/app/questions-list/section-prefetch-race.test.ts
-
10src/components/Componentes/question-card.tsx
-
6src/components/Componentes/question-exit-navigation-button.tsx
-
11src/components/Componentes/question-section-flow.tsx
-
116src/components/Componentes/section-overlay-host.test.tsx
-
148src/components/Componentes/section-overlay-host.tsx
@ -0,0 +1,87 @@ |
|||||
|
# انیمیشن اسلاید صفحه جزئیات سکشن روی لیست سکشنها (مطابق حسینیهاپ) |
||||
|
|
||||
|
## خلاصه تحقیق |
||||
|
|
||||
|
**حسینیهاپ** (مرجع): پنل مداح URL-driven است (`?panel=provider:id`) و صفحه لیست هرگز unmount نمیشود؛ پنل با CSS خالص (بدون کتابخانه) با `translateX(±100%) → 0` و زمانبندی **0.28s / cubic-bezier(0.32, 0.72, 0, 1)** اسلاید میشود. جهت با `side` فیزیکی انتخاب میشود: اپ RTL → پنل از **چپ** میآید (قرینه سایدبار راست). الگوی `PanelSlot` محتوای پنل را تا پایان انیمیشن خروج mount نگه میدارد. |
||||
|
|
||||
|
**اپ مریج**: Next.js 16.2.10 App Router، بدون هیچ کتابخانه انیمیشن. تمام مسیرهای بستن صفحه جزئیات از نوع `router.replace(questionsListHref)` هستند (۳ دکمه هدر، دکمه خروج با flush، هاردور بک) و دکمه close حالت تست بهصورت پیشفرض `router.back()` میزند — یعنی طراحی باید مثل حسینیه «URL-driven با retention» باشد تا همه مسیرها خودکار انیمیت شوند، بدون دستکاری تکتک call-siteها. |
||||
|
|
||||
|
**معماری انتخابی** (طبق انتخاب شما): Parallel Route `@modal` + Intercepting Route `(.)[slug]` + هاست retention — الگوی رسمی modal مستندات همین نسخه Next (`node_modules/next/dist/docs/.../parallel-routes.md` و `intercepting-routes.md`، هر دو verify شد). |
||||
|
|
||||
|
## ساختار فایلها |
||||
|
|
||||
|
### ۱. فایلهای جدید (تماماً additive) |
||||
|
|
||||
|
``` |
||||
|
src/app/[lang]/questions-list/ |
||||
|
├── layout.tsx ← رندر {children} + {modal} (server) |
||||
|
└── @modal/ |
||||
|
├── layout.tsx ← wrapper نازک → SectionOverlayHost |
||||
|
├── default.tsx ← return null (الگوی رسمی؛ جلوگیری از 404 در hard-load) |
||||
|
└── (.)[slug]/ |
||||
|
└── page.tsx ← export { default } from "@/app/questions-list/[slug]/page" |
||||
|
``` |
||||
|
|
||||
|
``` |
||||
|
src/components/Componentes/section-overlay-host.tsx ← "use client" — قلب مکانیزم |
||||
|
``` |
||||
|
|
||||
|
### ۲. `SectionOverlayHost` (ترجمهی PanelSlot حسینیه به Next App Router) |
||||
|
|
||||
|
- در `@modal/layout.ts` رندر میشود؛ چون layout اسلات در ناوبریهای soft زنده میماند، state آن پایدار است. |
||||
|
- **State machine**: `phase: 'enter' | 'open' | 'closing' | 'closed'` |
||||
|
- **باز شدن**: فرزند اسلات (صفحه intercept شده) mount میشود → `SectionOverlayContext` که host ارائه میدهد با `markActive()` از داخل `QuestionDetailClient` صدا زده میشود → host پنل را با کلاس off-screen رندر کرده و با `requestAnimationFrame` کلاس `open` اضافه میکند → CSS transition اسلاید ورود. |
||||
|
- **بسته شدن (هر مسیری: replace / back / هاردور)**: children اسلات به `default` (null) تغییر میکند → host عنصر قبلی را در state نگه میدارد (retention) و کلاس `closing` میدهد → ۲۹۰ms بعد unmount واقعی. دقیقاً همان `activeDescriptor`/`mounted` در PanelSlot. |
||||
|
- **RTL**: از `useI18n().locale` + `localeDirections` → `data-dir` روی پنل. **LTR از راست، RTL از چپ** (قرینه حسینیه که در RTL از چپ میآید). |
||||
|
- **قفل اسکرول**: کلاس `section-overlay-open` روی `body` (قرینه الگوی موجود `body.dropdown-open .app-shell` در globals.css خط ۲۳۲). |
||||
|
- **سایزینگ**: `position: fixed; inset-inline: 0; top/bottom: 0; margin-inline: auto` + `w-full sm:w-[375px]` + `padding-inline: 17px` + `padding-bottom: var(--safe-bottom)` — دقیقاً قرینه `.app-shell` (body فلکس و وسطچین است، globals.css خط ۱۵۴) تا `main` با `-mx-[17px]` داخل پنل مثل قبل رفتار کند. |
||||
|
|
||||
|
### ۳. تغییر در `question-detail-client.tsx` (حداقلی، ~۶ خط) |
||||
|
|
||||
|
```tsx |
||||
|
const overlay = useSectionOverlay(); // خارج از overlay → undefined → no-op |
||||
|
useEffect(() => overlay?.markActive(), [overlay]); |
||||
|
``` |
||||
|
تستهای موجود (`question-detail-client.test.tsx`) مستقیم رندر میکنند و context ندارند → بدون تغییر رفتار. |
||||
|
|
||||
|
### ۴. CSS در `globals.css` |
||||
|
|
||||
|
```css |
||||
|
.section-overlay { |
||||
|
transform: translateX(100%); /* LTR: ورود از راست */ |
||||
|
transition: transform 280ms cubic-bezier(0.32, 0.72, 0, 1); |
||||
|
will-change: transform; |
||||
|
} |
||||
|
[dir="rtl"] .section-overlay { transform: translateX(-100%); } /* RTL: ورود از چپ */ |
||||
|
.section-overlay[data-open="true"], .section-overlay[data-open="closing"] { transform: translateX(0); } |
||||
|
/* closing = همان حالت 0 که با برداشتن data-open="true" به سمت ابتدایی برمیگردد */ |
||||
|
body.section-overlay-open .app-shell { overflow-y: hidden; } |
||||
|
@media (prefers-reduced-motion: reduce) { .section-overlay { transition: none; } } |
||||
|
``` |
||||
|
+ سایه لبه داخلی پنل مثل `shadow-[-18px_0_50px_rgba(0,0,0,.45)]` حسینیه (جهت سایه هم با RTL برعکس). |
||||
|
|
||||
|
منحنی و مدت زمان عیناً از `anim-sheet-left` حسینیه (`0.28s cubic-bezier(0.32, 0.72, 0, 1)`). |
||||
|
|
||||
|
## رفتار نهایی |
||||
|
|
||||
|
- کلیک روی سکشن در `/en/questions-list` → URL به `/en/questions-list/personal_identity` تغییر میکند، لیست زیر پنل میماند (اسکرول حفظ میشود)، پنل از **راست** (در `/fa` از **چپ**) با همان انیمیشن حسینیه اسلاید میشود. |
||||
|
- هر مسیر بستن (دکمه close با flush پاسخها، back مرورگر، هاردور بک اندروید، اتمام سابمیت) → پنل با همان انیمیشن به همان سمت جمع میشود و لیست از زیر پیدا میشود. |
||||
|
- بارگذاری مستقیم/refresh روی URL جزئیات → صفحه کامل فعلی بدون انیمیشن (مطابق رفتار حسینیه در hard-load). |
||||
|
- متن جدیدی اضافه نمیشود → بدون تغییر فایلهای locale. |
||||
|
|
||||
|
## نکات اجرا و ریسک |
||||
|
|
||||
|
- Dev با `--webpack` اجرا میشود؛ interception با webpack پشتیبانی میشود. |
||||
|
- `router.prefetch` از لیست (که الان هم هست) نسخه intercept شده را prefetch میکند → ورود آنی. |
||||
|
- اگر build روی static-params اسلات گیر کرد (بعید، همه force-dynamic هستند): `export const dynamic = "force-dynamic"` به صفحه intercept شده اضافه میشود. |
||||
|
- کد مرده `info-progress-card.tsx` (لینک non-localized بدون استفاده) دست نمیخورد. |
||||
|
|
||||
|
## تست و راستیآزمایی |
||||
|
|
||||
|
1. `npm run test` (vitest) — تستهای موجود نباید بشکنند. |
||||
|
2. `npm run lint` (biome). |
||||
|
3. دستی با dev server روی پورت 3001: |
||||
|
- `/en/questions-list` → کلیک سکشن: اسلاید از راست؛ `/fa/questions-list` → اسلاید از چپ. |
||||
|
- بستن با دکمه close (flush)، back مرورگر، و شبیهسازی هاردور بک — همه با انیمیشن خروج. |
||||
|
- refresh مستقیم روی `/en/questions-list/personal_identity` → صفحه کامل. |
||||
|
- حفظ اسکرول لیست پس از بستن؛ قفل اسکرول پشت پنل هنگام باز بودن. |
||||
@ -0,0 +1 @@ |
|||||
|
export { default } from "@/app/questions-list/[slug]/loading"; |
||||
@ -0,0 +1,87 @@ |
|||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; |
||||
|
|
||||
|
/** |
||||
|
* Regression test for P0-1: prefetchQueueStarted race condition. |
||||
|
* |
||||
|
* The bug: prefetchQueueStarted.current was set to `true` before |
||||
|
* requestIdleCallback fired. If the effect re-ran (e.g. sectionProgressBySlug |
||||
|
* changed identity) and cleanup cancelled the idle callback, the flag stayed |
||||
|
* `true` and subsequent effect runs skipped prefetching entirely. |
||||
|
* |
||||
|
* The fix: critical section prefetch runs immediately (not in idle), so even |
||||
|
* if the idle callback for remaining sections is cancelled, the top-priority |
||||
|
* section is always warmed up. |
||||
|
*/ |
||||
|
describe("prefetch queue race condition (P0-1)", () => { |
||||
|
let idleCallbacks: Map<number, () => void>; |
||||
|
let nextIdleId: number; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
idleCallbacks = new Map(); |
||||
|
nextIdleId = 1; |
||||
|
|
||||
|
// Simulate requestIdleCallback / cancelIdleCallback
|
||||
|
(globalThis as any).requestIdleCallback = vi.fn((cb: () => void) => { |
||||
|
const id = nextIdleId++; |
||||
|
idleCallbacks.set(id, cb); |
||||
|
return id; |
||||
|
}); |
||||
|
(globalThis as any).cancelIdleCallback = vi.fn((id: number) => { |
||||
|
idleCallbacks.delete(id); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
delete (globalThis as any).requestIdleCallback; |
||||
|
delete (globalThis as any).cancelIdleCallback; |
||||
|
}); |
||||
|
|
||||
|
it("critical section prefetch is not blocked by idle cancellation", () => { |
||||
|
// Simulate the fixed effect behavior:
|
||||
|
// 1. Critical section prefetch runs immediately (not in idle)
|
||||
|
// 2. Remaining sections are deferred to idle
|
||||
|
|
||||
|
const criticalPrefetch = vi.fn(); |
||||
|
const remainingPrefetch = vi.fn(); |
||||
|
let prefetchQueueStarted = false; |
||||
|
|
||||
|
// --- First effect run ---
|
||||
|
// Simulates: overview ready, effect runs
|
||||
|
if (!prefetchQueueStarted) { |
||||
|
prefetchQueueStarted = true; |
||||
|
|
||||
|
// Critical section: runs immediately
|
||||
|
criticalPrefetch(); |
||||
|
|
||||
|
// Remaining: deferred to idle
|
||||
|
const idleId = (globalThis as any).requestIdleCallback(() => { |
||||
|
remainingPrefetch(); |
||||
|
}); |
||||
|
|
||||
|
// Simulate cleanup (rerender before idle fires)
|
||||
|
(globalThis as any).cancelIdleCallback(idleId); |
||||
|
} |
||||
|
|
||||
|
// Critical section was prefetched despite idle cancellation
|
||||
|
expect(criticalPrefetch).toHaveBeenCalledTimes(1); |
||||
|
|
||||
|
// Remaining sections were NOT prefetched (idle was cancelled)
|
||||
|
expect(remainingPrefetch).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it("idle callback with timeout eventually fires remaining prefetches", async () => { |
||||
|
const prefetch = vi.fn(); |
||||
|
|
||||
|
// Schedule with timeout
|
||||
|
const id = (globalThis as any).requestIdleCallback(prefetch); |
||||
|
|
||||
|
// Verify callback is registered
|
||||
|
expect(idleCallbacks.has(id)).toBe(true); |
||||
|
|
||||
|
// Simulate idle firing
|
||||
|
const cb = idleCallbacks.get(id); |
||||
|
cb?.(); |
||||
|
|
||||
|
expect(prefetch).toHaveBeenCalledTimes(1); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,116 @@ |
|||||
|
import { act, cleanup, render, screen } from "@testing-library/react"; |
||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
||||
|
import { I18nProvider } from "@/translations/provider"; |
||||
|
import SectionOverlayHost from "./section-overlay-host"; |
||||
|
|
||||
|
describe("SectionOverlayHost", () => { |
||||
|
beforeEach(() => { |
||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); |
||||
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { |
||||
|
return setTimeout(() => cb(Date.now()), 0); |
||||
|
}); |
||||
|
vi.stubGlobal("cancelAnimationFrame", (id: number) => { |
||||
|
clearTimeout(id); |
||||
|
}); |
||||
|
document.body.className = ""; |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
cleanup(); |
||||
|
vi.restoreAllMocks(); |
||||
|
vi.useRealTimers(); |
||||
|
document.body.className = ""; |
||||
|
}); |
||||
|
|
||||
|
it("returns null when no children or open is false", () => { |
||||
|
const { container } = render( |
||||
|
<I18nProvider locale="en"> |
||||
|
<SectionOverlayHost open={false}>{null}</SectionOverlayHost> |
||||
|
</I18nProvider>, |
||||
|
); |
||||
|
|
||||
|
expect(container.firstChild).toBeNull(); |
||||
|
expect(document.body.classList.contains("section-overlay-open")).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("renders overlay and adds body class in LTR mode", () => { |
||||
|
render( |
||||
|
<I18nProvider locale="en"> |
||||
|
<SectionOverlayHost open={true}> |
||||
|
<div data-testid="detail-content">Detail Page Content</div> |
||||
|
</SectionOverlayHost> |
||||
|
</I18nProvider>, |
||||
|
); |
||||
|
|
||||
|
// Initial paint frame
|
||||
|
act(() => { |
||||
|
vi.advanceTimersByTime(16); |
||||
|
}); |
||||
|
|
||||
|
const overlay = screen.getByRole("dialog"); |
||||
|
expect(overlay).toBeInTheDocument(); |
||||
|
expect(overlay).toHaveClass("section-overlay"); |
||||
|
expect(overlay).toHaveAttribute("data-dir", "ltr"); |
||||
|
expect(overlay).toHaveAttribute("data-state", "open"); |
||||
|
expect(screen.getByTestId("detail-content")).toBeInTheDocument(); |
||||
|
expect(document.body.classList.contains("section-overlay-open")).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("sets RTL direction when locale is fa", () => { |
||||
|
render( |
||||
|
<I18nProvider locale="fa"> |
||||
|
<SectionOverlayHost open={true}> |
||||
|
<div data-testid="detail-content">محتوای جزئیات</div> |
||||
|
</SectionOverlayHost> |
||||
|
</I18nProvider>, |
||||
|
); |
||||
|
|
||||
|
act(() => { |
||||
|
vi.advanceTimersByTime(16); |
||||
|
}); |
||||
|
|
||||
|
const overlay = screen.getByRole("dialog"); |
||||
|
expect(overlay).toHaveAttribute("data-dir", "rtl"); |
||||
|
expect(overlay).toHaveAttribute("dir", "rtl"); |
||||
|
}); |
||||
|
|
||||
|
it("retains children and animates to closing state when open becomes false", () => { |
||||
|
const { rerender } = render( |
||||
|
<I18nProvider locale="en"> |
||||
|
<SectionOverlayHost open={true}> |
||||
|
<div data-testid="detail-content">Detail Page Content</div> |
||||
|
</SectionOverlayHost> |
||||
|
</I18nProvider>, |
||||
|
); |
||||
|
|
||||
|
act(() => { |
||||
|
vi.advanceTimersByTime(16); |
||||
|
}); |
||||
|
|
||||
|
expect(screen.getByTestId("detail-content")).toBeInTheDocument(); |
||||
|
expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "open"); |
||||
|
|
||||
|
// Close overlay (simulate back / close action)
|
||||
|
rerender( |
||||
|
<I18nProvider locale="en"> |
||||
|
<SectionOverlayHost open={false}> |
||||
|
<div data-testid="detail-content">Detail Page Content</div> |
||||
|
</SectionOverlayHost> |
||||
|
</I18nProvider>, |
||||
|
); |
||||
|
|
||||
|
// Content is retained during closing transition
|
||||
|
const overlay = screen.getByRole("dialog"); |
||||
|
expect(overlay).toHaveAttribute("data-state", "closing"); |
||||
|
expect(screen.getByTestId("detail-content")).toBeInTheDocument(); |
||||
|
expect(document.body.classList.contains("section-overlay-open")).toBe(false); |
||||
|
|
||||
|
// Advance past REVERSE_DURATION_MS (200ms)
|
||||
|
act(() => { |
||||
|
vi.advanceTimersByTime(210); |
||||
|
}); |
||||
|
|
||||
|
expect(screen.queryByTestId("detail-content")).not.toBeInTheDocument(); |
||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,148 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import { |
||||
|
createContext, |
||||
|
type ReactNode, |
||||
|
useContext, |
||||
|
useEffect, |
||||
|
useMemo, |
||||
|
useRef, |
||||
|
useState, |
||||
|
} from "react"; |
||||
|
import { localeDirections } from "@/translations/config"; |
||||
|
import { useI18n } from "@/translations/provider"; |
||||
|
|
||||
|
type SectionOverlayContextValue = { |
||||
|
isOverlay: true; |
||||
|
onClose?: () => void; |
||||
|
}; |
||||
|
|
||||
|
const SectionOverlayContext = |
||||
|
createContext<SectionOverlayContextValue | null>(null); |
||||
|
|
||||
|
export function useSectionOverlay() { |
||||
|
return useContext(SectionOverlayContext); |
||||
|
} |
||||
|
|
||||
|
type SectionOverlayHostProps = { |
||||
|
open?: boolean; |
||||
|
onClose?: () => void; |
||||
|
children?: ReactNode; |
||||
|
}; |
||||
|
|
||||
|
const REVERSE_DURATION_MS = 200; |
||||
|
|
||||
|
export function SectionOverlayHost({ |
||||
|
open = false, |
||||
|
onClose, |
||||
|
children, |
||||
|
}: SectionOverlayHostProps) { |
||||
|
const { locale } = useI18n(); |
||||
|
const dir = (locale && localeDirections[locale]) || "ltr"; |
||||
|
|
||||
|
// Retain last rendered children during closing animation (like hosseinieh-app PanelSlot)
|
||||
|
const [activeChild, setActiveChild] = useState<ReactNode | null>( |
||||
|
open ? children ?? null : null, |
||||
|
); |
||||
|
const [mounted, setMounted] = useState(open); |
||||
|
const [state, setState] = useState<"closed" | "open" | "closing">( |
||||
|
open ? "open" : "closed", |
||||
|
); |
||||
|
|
||||
|
const isClosingRef = useRef(false); |
||||
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (open) { |
||||
|
if (closeTimerRef.current) { |
||||
|
clearTimeout(closeTimerRef.current); |
||||
|
closeTimerRef.current = null; |
||||
|
} |
||||
|
isClosingRef.current = false; |
||||
|
setMounted(true); |
||||
|
if (children) { |
||||
|
setActiveChild(children); |
||||
|
} |
||||
|
|
||||
|
const frame = requestAnimationFrame(() => { |
||||
|
setState("open"); |
||||
|
if (typeof document !== "undefined") { |
||||
|
document.body.classList.add("section-overlay-open"); |
||||
|
} |
||||
|
}); |
||||
|
return () => cancelAnimationFrame(frame); |
||||
|
} |
||||
|
|
||||
|
// When closing
|
||||
|
if (mounted && !isClosingRef.current) { |
||||
|
isClosingRef.current = true; |
||||
|
setState("closing"); |
||||
|
if (typeof document !== "undefined") { |
||||
|
document.body.classList.remove("section-overlay-open"); |
||||
|
} |
||||
|
|
||||
|
closeTimerRef.current = setTimeout(() => { |
||||
|
setMounted(false); |
||||
|
setState("closed"); |
||||
|
setActiveChild(null); |
||||
|
isClosingRef.current = false; |
||||
|
closeTimerRef.current = null; |
||||
|
}, REVERSE_DURATION_MS); |
||||
|
} |
||||
|
}, [open, children, mounted]); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
return () => { |
||||
|
if (closeTimerRef.current) { |
||||
|
clearTimeout(closeTimerRef.current); |
||||
|
} |
||||
|
if (typeof document !== "undefined") { |
||||
|
document.body.classList.remove("section-overlay-open"); |
||||
|
} |
||||
|
}; |
||||
|
}, []); |
||||
|
|
||||
|
// ESC key handler to close panel
|
||||
|
useEffect(() => { |
||||
|
if (!open || !onClose) return; |
||||
|
const handleEsc = (e: KeyboardEvent) => { |
||||
|
if (e.key === "Escape") { |
||||
|
onClose(); |
||||
|
} |
||||
|
}; |
||||
|
window.addEventListener("keydown", handleEsc); |
||||
|
return () => window.removeEventListener("keydown", handleEsc); |
||||
|
}, [open, onClose]); |
||||
|
|
||||
|
const contextValue = useMemo<SectionOverlayContextValue>( |
||||
|
() => ({ |
||||
|
isOverlay: true, |
||||
|
onClose, |
||||
|
}), |
||||
|
[onClose], |
||||
|
); |
||||
|
|
||||
|
if (!mounted && !open && state === "closed") { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
const contentToRender = open ? children || activeChild : activeChild; |
||||
|
|
||||
|
return ( |
||||
|
<SectionOverlayContext.Provider value={contextValue}> |
||||
|
<aside |
||||
|
data-slot="section-overlay" |
||||
|
data-state={state} |
||||
|
data-dir={dir} |
||||
|
dir={dir} |
||||
|
aria-modal="true" |
||||
|
role="dialog" |
||||
|
className="section-overlay" |
||||
|
> |
||||
|
{contentToRender} |
||||
|
</aside> |
||||
|
</SectionOverlayContext.Provider> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export default SectionOverlayHost; |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue