Skip to content

Commit 6b55397

Browse files
feat(changelog): add download links and improve version header
1 parent cf8d6b4 commit 6b55397

File tree

1 file changed

+304
-50
lines changed

1 file changed

+304
-50
lines changed

apps/web/src/routes/_view/changelog/$slug.tsx

Lines changed: 304 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import { MDXContent } from "@content-collections/mdx/react";
22
import { Icon } from "@iconify-icon/react";
33
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
4-
import { ChevronLeft } from "lucide-react";
4+
import { Download } from "lucide-react";
5+
import { useState } from "react";
56
import semver from "semver";
67

7-
import { getChangelogBySlug, getChangelogList } from "@/changelog";
8+
import { cn } from "@hypr/utils";
9+
10+
import {
11+
type ChangelogWithMeta,
12+
getChangelogBySlug,
13+
getChangelogList,
14+
} from "@/changelog";
815
import { defaultMDXComponents } from "@/components/mdx";
916
import { NotFoundContent } from "@/components/not-found";
17+
import { getDownloadLinks, groupDownloadLinks } from "@/utils/download";
1018

1119
export const Route = createFileRoute("/_view/changelog/$slug")({
1220
component: Component,
@@ -67,71 +75,56 @@ export const Route = createFileRoute("/_view/changelog/$slug")({
6775
});
6876

6977
function Component() {
70-
const { changelog, diffUrl } = Route.useLoaderData();
78+
const { changelog, allChangelogs, diffUrl } = Route.useLoaderData();
7179

7280
const currentVersion = semver.parse(changelog.version);
7381
const isPrerelease = !!(
7482
currentVersion && currentVersion.prerelease.length > 0
7583
);
76-
const isLatest = changelog.newerSlug === null;
77-
78-
let prereleaseType = "";
79-
let buildNumber = "";
80-
if (isPrerelease && currentVersion && currentVersion.prerelease.length > 0) {
81-
prereleaseType = currentVersion.prerelease[0]?.toString() || "";
82-
buildNumber = currentVersion.prerelease[1]?.toString() || "";
83-
}
8484

8585
return (
8686
<main
8787
className="flex-1 bg-linear-to-b from-white via-stone-50/20 to-white min-h-screen"
8888
style={{ backgroundImage: "url(/patterns/dots.svg)" }}
8989
>
9090
<div className="max-w-6xl mx-auto border-x border-neutral-100 bg-white">
91-
<div className="max-w-3xl mx-auto px-6 py-16 lg:py-24">
92-
<div className="text-center">
93-
<Link
94-
to="/changelog"
95-
className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700 mb-8 transition-colors"
96-
>
97-
<ChevronLeft className="w-4 h-4" />
98-
All versions
99-
</Link>
91+
<div className="max-w-3xl mx-auto px-6 pt-16 lg:pt-24 pb-8">
92+
<div className="hidden md:flex md:flex-col md:items-center gap-12">
93+
<div className="flex flex-col items-center gap-6">
94+
<img
95+
src={
96+
isPrerelease
97+
? "/api/images/icons/nightly-icon.png"
98+
: "/api/images/icons/stable-icon.png"
99+
}
100+
alt="Hyprnote"
101+
className="size-32 rounded-2xl"
102+
/>
103+
<h1 className="text-3xl sm:text-4xl font-mono font-medium text-stone-600">
104+
{changelog.version}
105+
</h1>
106+
</div>
100107

101-
<div className="flex flex-wrap items-center justify-center gap-3 mb-8">
102-
<h1 className="text-3xl sm:text-4xl font-serif tracking-tight text-stone-600">
108+
<DownloadLinksHero version={changelog.version} />
109+
</div>
110+
111+
<div className="md:hidden text-center">
112+
<div className="flex flex-col items-center gap-3 mb-8">
113+
<img
114+
src={
115+
isPrerelease
116+
? "/api/images/icons/nightly-icon.png"
117+
: "/api/images/icons/stable-icon.png"
118+
}
119+
alt="Hyprnote"
120+
className="size-16 rounded-2xl"
121+
/>
122+
<h1 className="text-3xl font-mono font-medium text-stone-600">
103123
{changelog.version}
104124
</h1>
105-
{isLatest && (
106-
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-linear-to-t from-amber-200 to-amber-100 text-amber-900 rounded-full">
107-
<Icon icon="ri:rocket-fill" className="text-xs" />
108-
Latest
109-
</span>
110-
)}
111-
{prereleaseType && (
112-
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-linear-to-b from-[#03BCF1] to-[#127FE5] text-white rounded-full">
113-
<Icon icon="ri:moon-fill" className="text-xs" />
114-
{prereleaseType}
115-
</span>
116-
)}
117-
{buildNumber && (
118-
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-linear-to-t from-neutral-200 to-neutral-100 text-neutral-900 rounded-full">
119-
#{buildNumber}
120-
</span>
121-
)}
122125
</div>
123126

124-
{diffUrl && (
125-
<a
126-
href={diffUrl}
127-
target="_blank"
128-
rel="noopener noreferrer"
129-
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium bg-linear-to-t from-stone-600 to-stone-500 text-white rounded-full shadow-md hover:shadow-lg hover:scale-[102%] active:scale-[98%] transition-all"
130-
>
131-
<Icon icon="mdi:github" className="text-base" />
132-
View Diff
133-
</a>
134-
)}
127+
<DownloadLinksHeroMobile version={changelog.version} />
135128
</div>
136129

137130
<article className="mt-12 prose prose-stone prose-headings:font-serif prose-headings:font-semibold prose-h2:text-2xl prose-h2:mt-8 prose-h2:mb-4 prose-h3:text-xl prose-h3:mt-6 prose-h3:mb-3 prose-h4:text-lg prose-h4:mt-4 prose-h4:mb-2 prose-a:text-stone-600 prose-a:underline prose-a:decoration-dotted hover:prose-a:text-stone-800 prose-code:bg-stone-50 prose-code:border prose-code:border-neutral-200 prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:font-mono prose-code:text-stone-700 prose-pre:bg-stone-50 prose-pre:border prose-pre:border-neutral-200 prose-pre:rounded-sm prose-pre:prose-code:bg-transparent prose-pre:prose-code:border-0 prose-pre:prose-code:p-0 prose-img:rounded-lg prose-img:border prose-img:border-neutral-200 prose-img:my-6 max-w-none">
@@ -141,7 +134,268 @@ function Component() {
141134
/>
142135
</article>
143136
</div>
137+
138+
{diffUrl && (
139+
<>
140+
<div className="border-t border-neutral-100" />
141+
<div className="max-w-3xl mx-auto px-6 py-16 flex flex-col items-center text-center">
142+
<h2 className="text-3xl font-serif text-stone-600 mb-2">
143+
View the Code
144+
</h2>
145+
<p className="text-neutral-600 mb-6">
146+
Curious about what changed? See the full diff on GitHub.
147+
</p>
148+
<a
149+
href={diffUrl}
150+
target="_blank"
151+
rel="noopener noreferrer"
152+
className="inline-flex items-center gap-2 px-6 h-12 text-base font-medium bg-linear-to-t from-neutral-800 to-neutral-700 text-white rounded-full shadow-md hover:shadow-lg hover:scale-[102%] active:scale-[98%] transition-all"
153+
>
154+
<Icon icon="mdi:github" className="text-xl" />
155+
View Diff on GitHub
156+
</a>
157+
</div>
158+
</>
159+
)}
160+
161+
<div className="border-t border-neutral-100" />
162+
163+
<div className="max-w-3xl mx-auto px-6 py-16">
164+
<RelatedReleases
165+
currentSlug={changelog.slug}
166+
allChangelogs={allChangelogs}
167+
/>
168+
</div>
144169
</div>
145170
</main>
146171
);
147172
}
173+
174+
function DownloadLinksHero({ version }: { version: string }) {
175+
const links = getDownloadLinks(version);
176+
const grouped = groupDownloadLinks(links);
177+
178+
return (
179+
<div className="flex items-start gap-8">
180+
<div className="flex flex-col items-center gap-2">
181+
<h3 className="flex items-center gap-1.5 text-xs font-medium text-stone-500 uppercase tracking-wider">
182+
<Icon icon="simple-icons:apple" className="text-sm" />
183+
macOS
184+
</h3>
185+
<div className="flex flex-col gap-1.5">
186+
{grouped.macos.map((link) => (
187+
<a
188+
key={link.url}
189+
href={link.url}
190+
className={cn([
191+
"flex items-center justify-center gap-2 px-4 h-8 text-sm rounded-full transition-all",
192+
"bg-linear-to-b from-white to-stone-50 text-neutral-700",
193+
"border border-neutral-300",
194+
"hover:shadow-sm hover:scale-[102%] active:scale-[98%]",
195+
])}
196+
>
197+
<Download className="size-3.5 shrink-0" />
198+
<span>{link.label}</span>
199+
</a>
200+
))}
201+
</div>
202+
</div>
203+
204+
<div className="flex flex-col items-center gap-2">
205+
<h3 className="flex items-center gap-1.5 text-xs font-medium text-stone-500 uppercase tracking-wider">
206+
<Icon icon="simple-icons:linux" className="text-sm" />
207+
Linux
208+
</h3>
209+
<div className="flex flex-col gap-1.5">
210+
{grouped.linux.map((link) => (
211+
<a
212+
key={link.url}
213+
href={link.url}
214+
className={cn([
215+
"flex items-center justify-center gap-2 px-4 h-8 text-sm rounded-full transition-all",
216+
"bg-linear-to-b from-white to-stone-50 text-neutral-700",
217+
"border border-neutral-300",
218+
"hover:shadow-sm hover:scale-[102%] active:scale-[98%]",
219+
])}
220+
>
221+
<Download className="size-3.5 shrink-0" />
222+
<span>{link.label}</span>
223+
</a>
224+
))}
225+
</div>
226+
</div>
227+
</div>
228+
);
229+
}
230+
231+
function DownloadLinksHeroMobile({ version }: { version: string }) {
232+
const links = getDownloadLinks(version);
233+
const grouped = groupDownloadLinks(links);
234+
const [activeIndex, setActiveIndex] = useState(0);
235+
236+
const allLinks = [...grouped.macos, ...grouped.linux];
237+
238+
return (
239+
<div className="w-full max-w-sm">
240+
<div className="relative">
241+
<div className="overflow-hidden">
242+
<div
243+
className="flex transition-transform duration-300 ease-in-out"
244+
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
245+
>
246+
{allLinks.map((link) => (
247+
<div key={link.url} className="w-full flex-shrink-0 px-2">
248+
<a
249+
href={link.url}
250+
className={cn([
251+
"flex flex-col items-center gap-2 px-6 py-4 rounded-2xl transition-all",
252+
"bg-linear-to-b from-white to-stone-50 text-neutral-700",
253+
"border border-neutral-300",
254+
"hover:shadow-sm active:scale-[98%]",
255+
])}
256+
>
257+
<Download className="size-5 shrink-0" />
258+
<div className="text-center">
259+
<div className="text-xs font-medium text-stone-500 uppercase tracking-wider mb-1">
260+
{link.platform}
261+
</div>
262+
<div className="text-sm font-medium">{link.label}</div>
263+
</div>
264+
</a>
265+
</div>
266+
))}
267+
</div>
268+
</div>
269+
270+
<div className="flex justify-center gap-2 mt-3">
271+
{allLinks.map((_, index) => (
272+
<button
273+
key={index}
274+
onClick={() => setActiveIndex(index)}
275+
className={cn([
276+
"h-1.5 rounded-full transition-all",
277+
activeIndex === index
278+
? "w-6 bg-stone-600"
279+
: "w-1.5 bg-stone-300 hover:bg-stone-400",
280+
])}
281+
/>
282+
))}
283+
</div>
284+
</div>
285+
</div>
286+
);
287+
}
288+
289+
function RelatedReleases({
290+
currentSlug,
291+
allChangelogs,
292+
}: {
293+
currentSlug: string;
294+
allChangelogs: ChangelogWithMeta[];
295+
}) {
296+
const currentIndex = allChangelogs.findIndex((c) => c.slug === currentSlug);
297+
if (currentIndex === -1) return null;
298+
299+
const total = allChangelogs.length;
300+
let startIndex: number;
301+
let endIndex: number;
302+
303+
if (total <= 5) {
304+
startIndex = 0;
305+
endIndex = total;
306+
} else if (currentIndex <= 2) {
307+
startIndex = 0;
308+
endIndex = 5;
309+
} else if (currentIndex >= total - 2) {
310+
startIndex = total - 5;
311+
endIndex = total;
312+
} else {
313+
startIndex = currentIndex - 2;
314+
endIndex = currentIndex + 3;
315+
}
316+
317+
const relatedChangelogs = allChangelogs.slice(startIndex, endIndex);
318+
319+
return (
320+
<section>
321+
<div className="text-center mb-8">
322+
<h2 className="text-3xl font-serif text-stone-600 mb-2">
323+
Other Releases
324+
</h2>
325+
<p className="text-neutral-600">Explore more versions of Hyprnote</p>
326+
</div>
327+
328+
<div className="grid gap-4 grid-cols-5">
329+
{relatedChangelogs.map((release) => {
330+
const version = semver.parse(release.version);
331+
const isPrerelease = version && version.prerelease.length > 0;
332+
const nightlyNumber =
333+
isPrerelease && version?.prerelease[0] === "nightly"
334+
? version.prerelease[1]
335+
: null;
336+
const isCurrent = release.slug === currentSlug;
337+
338+
return (
339+
<Link
340+
key={release.slug}
341+
to="/changelog/$slug"
342+
params={{ slug: release.slug }}
343+
className={cn([
344+
"group block",
345+
isCurrent && "pointer-events-none",
346+
])}
347+
>
348+
<article
349+
className={cn([
350+
"flex flex-col items-center gap-2 p-4 rounded-lg transition-all duration-300",
351+
isCurrent ? "bg-stone-100" : "hover:bg-stone-50",
352+
])}
353+
>
354+
<img
355+
src={
356+
isPrerelease
357+
? "/api/images/icons/nightly-icon.png"
358+
: "/api/images/icons/stable-icon.png"
359+
}
360+
alt="Hyprnote"
361+
className={cn([
362+
"size-12 rounded-xl transition-all duration-300",
363+
!isCurrent && "group-hover:scale-110",
364+
])}
365+
/>
366+
367+
<div className="flex items-center gap-1.5">
368+
<h3
369+
className={cn([
370+
"text-sm font-mono font-medium text-stone-600 transition-colors",
371+
!isCurrent && "group-hover:text-stone-800",
372+
])}
373+
>
374+
{version
375+
? `${version.major}.${version.minor}.${version.patch}`
376+
: release.version}
377+
</h3>
378+
{nightlyNumber !== null && (
379+
<span className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-stone-200 text-stone-600 rounded-full">
380+
#{nightlyNumber}
381+
</span>
382+
)}
383+
</div>
384+
</article>
385+
</Link>
386+
);
387+
})}
388+
</div>
389+
390+
<div className="text-center mt-8">
391+
<Link
392+
to="/changelog"
393+
className="inline-flex items-center gap-2 px-6 h-12 text-base font-medium bg-linear-to-b from-white to-stone-50 text-neutral-700 border border-neutral-300 rounded-full shadow-sm hover:shadow-md hover:scale-[102%] active:scale-[98%] transition-all"
394+
>
395+
View all releases
396+
<Icon icon="mdi:arrow-right" className="text-base" />
397+
</Link>
398+
</div>
399+
</section>
400+
);
401+
}

0 commit comments

Comments
 (0)