Skip to content

Commit 789cbe7

Browse files
feat: fullscreen present mode with arrow-key nav (#7)
1 parent a13a807 commit 789cbe7

4 files changed

Lines changed: 278 additions & 0 deletions

File tree

src/app/d/[id]/present/page.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Metadata } from 'next';
2+
3+
import { PresentMode } from '@/present/PresentMode';
4+
import '@/present/present.css';
5+
6+
export const metadata: Metadata = {
7+
title: 'Present',
8+
};
9+
10+
type Params = Promise<{ id: string }>;
11+
12+
export default async function PresentDeckPage({ params }: { params: Params }) {
13+
const { id } = await params;
14+
return <PresentMode deckId={id} />;
15+
}

src/editor/Editor.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,15 @@ export function Editor({ deckId }: Props) {
279279
>
280280
Design
281281
</button>
282+
{deckId ? (
283+
<Link
284+
href={`/d/${deckId}/present`}
285+
className="editor__nav-link editor__nav-link--button"
286+
prefetch={false}
287+
>
288+
Present
289+
</Link>
290+
) : null}
282291
<ExportPdf className="editor__cta" />
283292
</div>
284293
</header>

src/present/PresentMode.tsx

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
'use client';
2+
3+
import { useRouter } from 'next/navigation';
4+
import { useEffect, useMemo, useRef, useState } from 'react';
5+
6+
import { ParseError, parseDeck } from '@/ir/parse';
7+
import { planDeck } from '@/ir/plan';
8+
import type { Deck } from '@/ir/schema';
9+
import { DeckRenderer } from '@/render/DeckRenderer';
10+
import { getDeck } from '@/storage/deck-store';
11+
12+
type Props = { deckId: string };
13+
14+
export function PresentMode({ deckId }: Props) {
15+
const router = useRouter();
16+
const [deck, setDeck] = useState<Deck | null>(null);
17+
const [error, setError] = useState<string | null>(null);
18+
const [index, setIndex] = useState(0);
19+
const stageRef = useRef<HTMLDivElement>(null);
20+
21+
useEffect(() => {
22+
let cancelled = false;
23+
getDeck(deckId).then((stored) => {
24+
if (cancelled) return;
25+
if (!stored) {
26+
router.replace('/');
27+
return;
28+
}
29+
try {
30+
const parsed = parseDeck(stored.source, { theme: stored.theme, brand: stored.brand });
31+
const planned = planDeck(parsed);
32+
setDeck(planned);
33+
} catch (e) {
34+
const message = e instanceof ParseError ? e.message : (e as Error).message;
35+
setError(message);
36+
}
37+
});
38+
return () => {
39+
cancelled = true;
40+
};
41+
}, [deckId, router]);
42+
43+
const total = deck?.slides.length ?? 0;
44+
45+
useEffect(() => {
46+
const onKey = (e: KeyboardEvent) => {
47+
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
48+
e.preventDefault();
49+
setIndex((i) => Math.min(total - 1, i + 1));
50+
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
51+
e.preventDefault();
52+
setIndex((i) => Math.max(0, i - 1));
53+
} else if (e.key === 'Home') {
54+
setIndex(0);
55+
} else if (e.key === 'End') {
56+
setIndex(total - 1);
57+
} else if (e.key === 'Escape') {
58+
router.push(`/d/${deckId}/edit`);
59+
} else if (/^[0-9]$/.test(e.key)) {
60+
const n = Number(e.key);
61+
if (n >= 1 && n <= total) setIndex(n - 1);
62+
}
63+
};
64+
window.addEventListener('keydown', onKey);
65+
return () => window.removeEventListener('keydown', onKey);
66+
}, [total, router, deckId]);
67+
68+
useEffect(() => {
69+
const el = stageRef.current;
70+
if (!el) return;
71+
if (!document.fullscreenElement) {
72+
void el.requestFullscreen?.().catch(() => {});
73+
}
74+
return () => {
75+
if (document.fullscreenElement) {
76+
void document.exitFullscreen?.().catch(() => {});
77+
}
78+
};
79+
}, []);
80+
81+
const visibleDeck = useMemo<Deck | null>(() => {
82+
if (!deck) return null;
83+
const slide = deck.slides[index];
84+
if (!slide) return deck;
85+
return { ...deck, slides: [slide] };
86+
}, [deck, index]);
87+
88+
if (error) {
89+
return (
90+
<div className="present-error">
91+
<strong>Could not present this deck</strong>
92+
<pre>{error}</pre>
93+
<button type="button" onClick={() => router.push(`/d/${deckId}/edit`)}>
94+
Back to editor
95+
</button>
96+
</div>
97+
);
98+
}
99+
100+
if (!deck || !visibleDeck) {
101+
return (
102+
<div className="present-loading">
103+
<span>Loading…</span>
104+
</div>
105+
);
106+
}
107+
108+
return (
109+
<div className="present-stage" ref={stageRef}>
110+
<div className="present-frame">
111+
<DeckRenderer deck={visibleDeck} />
112+
</div>
113+
<div className="present-hud" aria-hidden="true">
114+
<span className="present-hud__page">
115+
{String(index + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}
116+
</span>
117+
</div>
118+
<button
119+
type="button"
120+
className="present-nav present-nav--prev"
121+
aria-label="Previous slide"
122+
onClick={() => setIndex((i) => Math.max(0, i - 1))}
123+
disabled={index === 0}
124+
>
125+
126+
</button>
127+
<button
128+
type="button"
129+
className="present-nav present-nav--next"
130+
aria-label="Next slide"
131+
onClick={() => setIndex((i) => Math.min(total - 1, i + 1))}
132+
disabled={index === total - 1}
133+
>
134+
135+
</button>
136+
</div>
137+
);
138+
}

src/present/present.css

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
.present-stage {
2+
position: fixed;
3+
inset: 0;
4+
background: #050505;
5+
display: grid;
6+
place-items: center;
7+
overflow: hidden;
8+
z-index: 9999;
9+
}
10+
11+
.present-frame {
12+
width: 100vw;
13+
height: 100vh;
14+
display: grid;
15+
place-items: center;
16+
}
17+
18+
.present-frame .deck {
19+
padding: 0;
20+
gap: 0;
21+
width: 100%;
22+
height: 100%;
23+
}
24+
25+
.present-frame .slide-frame {
26+
width: min(100vw, calc(100vh * 16 / 9));
27+
height: min(100vh, calc(100vw * 9 / 16));
28+
max-width: none;
29+
border-radius: 0;
30+
border: none;
31+
box-shadow: none;
32+
}
33+
34+
.present-hud {
35+
position: absolute;
36+
bottom: 24px;
37+
left: 50%;
38+
transform: translateX(-50%);
39+
font-family: var(--font-mono, ui-monospace, monospace);
40+
font-size: 12px;
41+
color: rgba(255, 255, 255, 0.6);
42+
letter-spacing: 0.12em;
43+
font-feature-settings: 'tnum' 1;
44+
pointer-events: none;
45+
}
46+
47+
.present-nav {
48+
position: absolute;
49+
top: 50%;
50+
transform: translateY(-50%);
51+
background: transparent;
52+
border: none;
53+
color: rgba(255, 255, 255, 0.4);
54+
font-size: 48px;
55+
line-height: 1;
56+
width: 64px;
57+
height: 96px;
58+
cursor: pointer;
59+
transition: color 0.15s ease;
60+
display: grid;
61+
place-items: center;
62+
}
63+
64+
.present-nav:hover:not(:disabled) {
65+
color: rgba(255, 255, 255, 0.9);
66+
}
67+
68+
.present-nav:disabled {
69+
opacity: 0;
70+
pointer-events: none;
71+
}
72+
73+
.present-nav--prev {
74+
left: 16px;
75+
}
76+
77+
.present-nav--next {
78+
right: 16px;
79+
}
80+
81+
.present-loading,
82+
.present-error {
83+
position: fixed;
84+
inset: 0;
85+
display: grid;
86+
place-items: center;
87+
background: #050505;
88+
color: #fff;
89+
font-family: var(--font-body, system-ui);
90+
gap: 12px;
91+
}
92+
93+
.present-error {
94+
padding: 32px;
95+
text-align: center;
96+
}
97+
98+
.present-error pre {
99+
background: rgba(255, 255, 255, 0.05);
100+
padding: 12px 16px;
101+
border-radius: 6px;
102+
max-width: 80ch;
103+
overflow: auto;
104+
font-size: 13px;
105+
}
106+
107+
.present-error button {
108+
margin-top: 8px;
109+
padding: 8px 16px;
110+
background: #fff;
111+
color: #050505;
112+
border: none;
113+
border-radius: 999px;
114+
font-weight: 500;
115+
cursor: pointer;
116+
}

0 commit comments

Comments
 (0)