Skip to content

Commit 809b406

Browse files
committed
refactor: extract DocumentIndexLayout component, add scroll-to-top behavior, improve pricing page layout
- Add scrollBehavior to ViteSSG router config to restore scroll position or scroll to top on navigation - Extract shared document layout with sidebar navigation into DocumentIndexLayout component - Refactor PrivacyPolicyPage, TermsOfServicePage, and WhitepaperPage to use DocumentIndexLayout - Move markdown parsing, heading extraction, and sidebar navigation logic to shared component - Improve PricingPage layout: move "
1 parent c5922ea commit 809b406

17 files changed

Lines changed: 1350 additions & 209 deletions
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<script setup lang="ts">
2+
import { ref, computed, onMounted, onUnmounted } from "vue";
3+
import { marked } from "marked";
4+
5+
const props = defineProps<{
6+
title: string;
7+
markdownSource: string;
8+
}>();
9+
10+
function slugify(text: string): string {
11+
return text
12+
.toLowerCase()
13+
.replace(/[^\w\s-]/g, "")
14+
.replace(/\s+/g, "-")
15+
.replace(/-+/g, "-");
16+
}
17+
18+
const html = computed(() => {
19+
const renderer = new marked.Renderer();
20+
renderer.heading = ({ text, depth }) => {
21+
const id = slugify(text);
22+
return `<h${depth} id="${id}">${text}</h${depth}>`;
23+
};
24+
return marked.parse(props.markdownSource, { renderer });
25+
});
26+
27+
interface Heading {
28+
level: number;
29+
text: string;
30+
id: string;
31+
}
32+
33+
const headings = computed(() => {
34+
const result: Heading[] = [];
35+
const tokens = marked.lexer(props.markdownSource);
36+
for (const token of tokens) {
37+
if (token.type === "heading") {
38+
result.push({ level: token.depth, text: token.text, id: slugify(token.text) });
39+
}
40+
}
41+
return result;
42+
});
43+
44+
const activeHeading = ref("");
45+
let observer: IntersectionObserver | null = null;
46+
47+
onMounted(() => {
48+
const headingEls = document.querySelectorAll(
49+
"article[data-doc-index] :is(h1, h2, h3)"
50+
);
51+
if (headingEls.length === 0) return;
52+
53+
const visible = new Set<Element>();
54+
55+
observer = new IntersectionObserver(
56+
(entries) => {
57+
for (const entry of entries) {
58+
if (entry.isIntersecting) {
59+
visible.add(entry.target);
60+
} else {
61+
visible.delete(entry.target);
62+
}
63+
}
64+
65+
// Pick the first visible heading in document order
66+
for (const el of headingEls) {
67+
if (visible.has(el)) {
68+
activeHeading.value = el.id;
69+
break;
70+
}
71+
}
72+
},
73+
{
74+
rootMargin: "-64px 0px -70% 0px",
75+
threshold: 0,
76+
}
77+
);
78+
79+
for (const el of headingEls) {
80+
observer.observe(el);
81+
}
82+
});
83+
84+
onUnmounted(() => {
85+
observer?.disconnect();
86+
});
87+
88+
function scrollToHeading(id: string) {
89+
const el = document.getElementById(id);
90+
if (el) {
91+
el.scrollIntoView({ behavior: "smooth", block: "start" });
92+
activeHeading.value = id;
93+
}
94+
}
95+
</script>
96+
97+
<template>
98+
<div
99+
class="mx-auto flex max-w-6xl flex-col gap-8 px-4 py-16 md:flex-row md:px-6 md:py-24"
100+
>
101+
<!-- Sticky sidebar -->
102+
<aside class="hidden md:block md:w-64 md:shrink-0">
103+
<div class="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto">
104+
<nav class="flex flex-col gap-1">
105+
<button
106+
v-for="heading in headings"
107+
:key="heading.id"
108+
:class="[
109+
'rounded-md px-3 py-1.5 text-left text-sm transition-colors',
110+
heading.level === 1 ? 'font-semibold text-foreground' : '',
111+
heading.level === 2 ? 'pl-5 text-muted-foreground' : '',
112+
heading.level === 3 ? 'pl-7 text-sm text-muted-foreground' : '',
113+
activeHeading === heading.id
114+
? 'bg-muted text-foreground'
115+
: 'hover:bg-muted hover:text-foreground',
116+
]"
117+
@click="scrollToHeading(heading.id)"
118+
>
119+
{{ heading.text }}
120+
</button>
121+
</nav>
122+
</div>
123+
</aside>
124+
125+
<!-- Content -->
126+
<article class="min-w-0 flex-1" data-doc-index>
127+
<h1
128+
class="mb-10 text-center text-4xl font-bold tracking-tight md:text-5xl"
129+
>
130+
{{ title }}
131+
</h1>
132+
<div
133+
class="prose prose-neutral dark:prose-invert max-w-none prose-headings:scroll-mt-24 prose-a:text-primary prose-a:no-underline hover:prose-a:underline"
134+
v-html="html"
135+
/>
136+
</article>
137+
</div>
138+
</template>

new-deepnotes/apps/marketing/src/main.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,9 @@ import { ViteSSG } from "vite-ssg";
55
import App from "./App.vue";
66
import { routes } from "./router";
77

8-
export const createApp = ViteSSG(App, { routes });
8+
export const createApp = ViteSSG(App, {
9+
routes,
10+
scrollBehavior(_to, _from, savedPosition) {
11+
return savedPosition ?? { top: 0 };
12+
},
13+
});

new-deepnotes/apps/marketing/src/pages/PricingPage.vue

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -91,36 +91,38 @@ function periodLabel() {
9191
</div>
9292

9393
<!-- Billing toggle -->
94-
<div class="mt-10 flex items-center justify-center gap-3">
95-
<span
96-
:class="[
97-
'text-sm font-medium',
98-
billingFrequency === 'monthly'
99-
? 'text-foreground'
100-
: 'text-muted-foreground',
101-
]"
102-
>Monthly</span
103-
>
104-
<Switch
105-
:model-value="billingFrequency === 'yearly'"
106-
@update:model-value="
107-
(v) => (billingFrequency = v ? 'yearly' : 'monthly')
108-
"
109-
/>
110-
<span
111-
:class="[
112-
'text-sm font-medium',
113-
billingFrequency === 'yearly'
114-
? 'text-foreground'
115-
: 'text-muted-foreground',
116-
]"
117-
>Yearly</span
118-
>
94+
<div class="mt-10 flex flex-col items-center gap-2">
11995
<span
12096
v-if="billingFrequency === 'yearly'"
12197
class="rounded-full bg-primary px-2 py-0.5 text-xs font-medium text-primary-foreground"
12298
>Save 20%</span
12399
>
100+
<div class="flex items-center gap-3">
101+
<span
102+
:class="[
103+
'text-sm font-medium',
104+
billingFrequency === 'monthly'
105+
? 'text-foreground'
106+
: 'text-muted-foreground',
107+
]"
108+
>Monthly</span
109+
>
110+
<Switch
111+
:model-value="billingFrequency === 'yearly'"
112+
@update:model-value="
113+
(v) => (billingFrequency = v ? 'yearly' : 'monthly')
114+
"
115+
/>
116+
<span
117+
:class="[
118+
'text-sm font-medium',
119+
billingFrequency === 'yearly'
120+
? 'text-foreground'
121+
: 'text-muted-foreground',
122+
]"
123+
>Yearly</span
124+
>
125+
</div>
124126
</div>
125127

126128
<!-- Plan cards -->
@@ -133,11 +135,11 @@ function periodLabel() {
133135
plan.highlight ? 'border-primary ring-1 ring-primary' : '',
134136
]"
135137
>
136-
<CardHeader class="space-y-2">
137-
<CardTitle class="text-2xl">{{ plan.name }}</CardTitle>
138+
<CardHeader class="space-y-1.5 pb-4">
139+
<CardTitle class="text-xl">{{ plan.name }}</CardTitle>
138140
<CardDescription>{{ plan.description }}</CardDescription>
139-
<div class="mt-2 flex items-baseline gap-1">
140-
<span class="text-4xl font-bold">{{ priceLabel(plan) }}</span>
141+
<div class="mt-1 flex items-baseline gap-1">
142+
<span class="text-3xl font-bold">{{ priceLabel(plan) }}</span>
141143
<span
142144
v-if="priceLabel(plan) !== 'Free'"
143145
class="text-sm text-muted-foreground"
@@ -146,7 +148,7 @@ function periodLabel() {
146148
</div>
147149
</CardHeader>
148150
<CardContent class="flex-1">
149-
<ul class="space-y-3">
151+
<ul class="space-y-1.5">
150152
<li
151153
v-for="feature in plan.features"
152154
:key="feature"

new-deepnotes/apps/marketing/src/pages/PrivacyPolicyPage.vue

Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<script setup lang="ts">
2-
import { computed } from "vue";
32
import { useHead } from "@unhead/vue";
4-
import { marked } from "marked";
3+
import DocumentIndexLayout from "@/components/DocumentIndexLayout.vue";
54
65
useHead({
76
title: "Privacy Policy — DeepNotes",
@@ -61,53 +60,8 @@ You also have the option to delete your DeepNotes account at any time. When you
6160
If you have any questions or concerns about your privacy while using DeepNotes, please feel free to reach out to us at [email protected].
6261
We are always here to help and will get back to you as soon as we can.
6362
`;
64-
65-
const html = computed(() => marked.parse(markdownSource));
66-
67-
const headings = computed(() => {
68-
const result: { text: string; id: string }[] = [];
69-
const tokens = marked.lexer(markdownSource);
70-
for (const token of tokens) {
71-
if (token.type === "heading") {
72-
const id = token.text
73-
.toLowerCase()
74-
.replace(/[^\w\s-]/g, "")
75-
.replace(/\s+/g, "-")
76-
.replace(/-+/g, "-");
77-
result.push({ text: token.text, id });
78-
}
79-
}
80-
return result;
81-
});
8263
</script>
8364

8465
<template>
85-
<div class="mx-auto flex max-w-6xl flex-col gap-8 px-4 py-16 md:flex-row md:px-6 md:py-24">
86-
<!-- Sticky sidebar -->
87-
<aside class="hidden md:block md:w-64 md:shrink-0">
88-
<div class="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto">
89-
<nav class="flex flex-col gap-1">
90-
<a
91-
v-for="heading in headings"
92-
:key="heading.id"
93-
:href="'#' + heading.id"
94-
class="rounded-md px-3 py-1.5 text-left text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
95-
>
96-
{{ heading.text }}
97-
</a>
98-
</nav>
99-
</div>
100-
</aside>
101-
102-
<!-- Content -->
103-
<article class="min-w-0 flex-1">
104-
<h1 class="mb-10 text-center text-4xl font-bold tracking-tight md:text-5xl">
105-
Privacy Policy
106-
</h1>
107-
<div
108-
class="prose prose-neutral dark:prose-invert max-w-none prose-headings:scroll-mt-24 prose-a:text-primary prose-a:no-underline hover:prose-a:underline"
109-
v-html="html"
110-
/>
111-
</article>
112-
</div>
66+
<DocumentIndexLayout title="Privacy Policy" :markdown-source="markdownSource" />
11367
</template>

new-deepnotes/apps/marketing/src/pages/TermsOfServicePage.vue

Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<script setup lang="ts">
2-
import { computed } from "vue";
32
import { useHead } from "@unhead/vue";
4-
import { marked } from "marked";
3+
import DocumentIndexLayout from "@/components/DocumentIndexLayout.vue";
54
65
useHead({
76
title: "Terms of Service — DeepNotes",
@@ -65,53 +64,8 @@ By using DeepNotes, you understand and agree that we shall not be liable for any
6564
6665
For any questions or concerns, please email us at [email protected].
6766
`;
68-
69-
const html = computed(() => marked.parse(markdownSource));
70-
71-
const headings = computed(() => {
72-
const result: { text: string; id: string }[] = [];
73-
const tokens = marked.lexer(markdownSource);
74-
for (const token of tokens) {
75-
if (token.type === "heading") {
76-
const id = token.text
77-
.toLowerCase()
78-
.replace(/[^\w\s-]/g, "")
79-
.replace(/\s+/g, "-")
80-
.replace(/-+/g, "-");
81-
result.push({ text: token.text, id });
82-
}
83-
}
84-
return result;
85-
});
8667
</script>
8768

8869
<template>
89-
<div class="mx-auto flex max-w-6xl flex-col gap-8 px-4 py-16 md:flex-row md:px-6 md:py-24">
90-
<!-- Sticky sidebar -->
91-
<aside class="hidden md:block md:w-64 md:shrink-0">
92-
<div class="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto">
93-
<nav class="flex flex-col gap-1">
94-
<a
95-
v-for="heading in headings"
96-
:key="heading.id"
97-
:href="'#' + heading.id"
98-
class="rounded-md px-3 py-1.5 text-left text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
99-
>
100-
{{ heading.text }}
101-
</a>
102-
</nav>
103-
</div>
104-
</aside>
105-
106-
<!-- Content -->
107-
<article class="min-w-0 flex-1">
108-
<h1 class="mb-10 text-center text-4xl font-bold tracking-tight md:text-5xl">
109-
Terms of Service
110-
</h1>
111-
<div
112-
class="prose prose-neutral dark:prose-invert max-w-none prose-headings:scroll-mt-24 prose-a:text-primary prose-a:no-underline hover:prose-a:underline"
113-
v-html="html"
114-
/>
115-
</article>
116-
</div>
70+
<DocumentIndexLayout title="Terms of Service" :markdown-source="markdownSource" />
11771
</template>

0 commit comments

Comments
 (0)