Skip to content

Commit e47959a

Browse files
[v5] feat(web): Add integrated changelog system (#1227)
1 parent 5a0dd95 commit e47959a

20 files changed

Lines changed: 572 additions & 232 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
- Added progress bar when navigating between pages. [#1204](https://github.com/sourcebot-dev/sourcebot/pull/1204)
12+
- Added a integrated changelog into the sidebar. [#1227](https://github.com/sourcebot-dev/sourcebot/pull/1227)
1213

1314
### Changed
1415
- Redesigned the app layout with a new collapsible sidebar navigation, replacing the previous top navigation bar. [#1097](https://github.com/sourcebot-dev/sourcebot/pull/1097)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- CreateTable
2+
CREATE TABLE "ChangelogEntry" (
3+
"slug" TEXT NOT NULL,
4+
"title" TEXT NOT NULL,
5+
"publishedAt" TIMESTAMP(3) NOT NULL,
6+
"summary" TEXT NOT NULL,
7+
"version" TEXT NOT NULL,
8+
"bodyMarkdown" TEXT NOT NULL,
9+
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
11+
CONSTRAINT "ChangelogEntry_pkey" PRIMARY KEY ("slug")
12+
);
13+
14+
-- CreateIndex
15+
CREATE INDEX "ChangelogEntry_publishedAt_idx" ON "ChangelogEntry"("publishedAt");

packages/db/prisma/schema.prisma

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,3 +623,19 @@ model OAuthToken {
623623
createdAt DateTime @default(now())
624624
lastUsedAt DateTime?
625625
}
626+
627+
/// Local cache of changelog entries fetched from the public feed at
628+
/// `CHANGELOG_FEED_URL`. Shared across all users of an instance.
629+
model ChangelogEntry {
630+
slug String @id
631+
title String
632+
publishedAt DateTime
633+
summary String
634+
version String
635+
bodyMarkdown String
636+
637+
/// Updated each time the entry is upserted from the feed.
638+
fetchedAt DateTime @default(now()) @updatedAt
639+
640+
@@index([publishedAt])
641+
}

packages/shared/src/env.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ const options = {
221221
// Misc UI flags
222222
SECURITY_CARD_ENABLED: booleanSchema.default('false'),
223223

224+
// Changelog feed
225+
CHANGELOG_ENABLED: booleanSchema.default('true'),
226+
CHANGELOG_FEED_URL: z.string().url().default('https://static.sourcebot.dev/changelog/index.json'),
227+
224228
// EE License
225229
SOURCEBOT_EE_LICENSE_KEY: z.string().optional(),
226230
SOURCEBOT_EE_AUDIT_LOGGING_ENABLED: booleanSchema.default('true'),

packages/shared/src/index.client.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,10 @@ export {
44
} from "./env.client.js";
55
export {
66
SOURCEBOT_VERSION,
7-
} from "./version.js";
7+
} from "./version.js";
8+
export {
9+
parseVersion,
10+
formatVersion,
11+
compareVersions,
12+
} from "./versionUtils.js";
13+
export type { Version } from "./versionUtils.js";

packages/shared/src/index.server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,10 @@ export {
6767
} from "./smtp.js";
6868
export {
6969
SOURCEBOT_VERSION,
70-
} from "./version.js";
70+
} from "./version.js";
71+
export {
72+
parseVersion,
73+
formatVersion,
74+
compareVersions,
75+
} from "./versionUtils.js";
76+
export type { Version } from "./versionUtils.js";
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
const SEMVER_REGEX = /^v(\d+)\.(\d+)\.(\d+)$/;
2+
3+
export type Version = {
4+
major: number;
5+
minor: number;
6+
patch: number;
7+
};
8+
9+
export const parseVersion = (version: string): Version | null => {
10+
const match = version.match(SEMVER_REGEX);
11+
if (!match) {
12+
return null;
13+
}
14+
return {
15+
major: parseInt(match[1]),
16+
minor: parseInt(match[2]),
17+
patch: parseInt(match[3]),
18+
};
19+
};
20+
21+
export const formatVersion = (version: Version): string => {
22+
return `v${version.major}.${version.minor}.${version.patch}`;
23+
};
24+
25+
/**
26+
* Returns < 0 if `a < b`, 0 if equal, > 0 if `a > b`.
27+
*/
28+
export const compareVersions = (a: Version, b: Version): number => {
29+
if (a.major !== b.major) {
30+
return a.major - b.major;
31+
}
32+
if (a.minor !== b.minor) {
33+
return a.minor - b.minor;
34+
}
35+
return a.patch - b.patch;
36+
};
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"use client";
2+
3+
import { Badge } from "@/components/ui/badge";
4+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
5+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
6+
import type { ChangelogEntryDto } from "@/features/changelog/listEntriesApi";
7+
import { cn } from "@/lib/utils";
8+
import { format } from "date-fns";
9+
import { ArrowUpRight } from "lucide-react";
10+
import { useEffect, useMemo, useState } from "react";
11+
import { createPortal } from "react-dom";
12+
import Markdown from "react-markdown";
13+
import rehypeRaw from "rehype-raw";
14+
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
15+
import remarkGfm from "remark-gfm";
16+
import { compareVersions, parseVersion, SOURCEBOT_VERSION } from "@sourcebot/shared/client";
17+
18+
const VIDEO_EXTENSIONS_RE = /\.(mp4|webm|ogg|mov)$/i;
19+
const ABSOLUTE_URL_RE = /^(?:[a-z][a-z0-9+\-.]*:|\/\/|#)/i;
20+
21+
// Allow <video> + the attributes we'll set on it. rehypeSanitize otherwise strips them.
22+
const SANITIZE_SCHEMA = {
23+
...defaultSchema,
24+
tagNames: [...(defaultSchema.tagNames ?? []), "video", "source"],
25+
attributes: {
26+
...defaultSchema.attributes,
27+
video: ["src", "controls", "poster", "width", "height", "className", "preload", "loop", "muted", "playsInline"],
28+
source: ["src", "type"],
29+
},
30+
};
31+
32+
const buildUrlTransform = (entriesBaseUrl: string) => (url: string): string => {
33+
if (ABSOLUTE_URL_RE.test(url)) {
34+
return url;
35+
}
36+
try {
37+
return new URL(url, entriesBaseUrl).toString();
38+
} catch {
39+
return url;
40+
}
41+
};
42+
43+
interface ZoomableImageProps {
44+
src: string;
45+
alt?: string | undefined;
46+
}
47+
48+
const ZoomableImage = ({ src, alt }: ZoomableImageProps) => {
49+
const [zoomed, setZoomed] = useState(false);
50+
const [mounted, setMounted] = useState(false);
51+
52+
// Portal target is only available after mount.
53+
useEffect(() => {
54+
setMounted(true);
55+
}, []);
56+
57+
// Intercept Escape during zoom so the changelog dialog doesn't close along with the zoom.
58+
useEffect(() => {
59+
if (!zoomed) {
60+
return;
61+
}
62+
const handleKey = (e: KeyboardEvent) => {
63+
if (e.key === "Escape") {
64+
e.stopPropagation();
65+
setZoomed(false);
66+
}
67+
};
68+
document.addEventListener("keydown", handleKey, true);
69+
return () => document.removeEventListener("keydown", handleKey, true);
70+
}, [zoomed]);
71+
72+
const overlay = (
73+
<div
74+
className={cn(
75+
"fixed inset-0 z-[100] flex items-center justify-center bg-black/80 transition-opacity duration-200",
76+
zoomed ? "opacity-100 pointer-events-auto cursor-zoom-out" : "opacity-0 pointer-events-none"
77+
)}
78+
onClick={() => setZoomed(false)}
79+
>
80+
{/* eslint-disable-next-line @next/next/no-img-element */}
81+
<img
82+
src={src}
83+
alt={alt}
84+
className={cn(
85+
"max-w-[90vw] max-h-[90vh] object-contain rounded-lg transition-transform duration-200",
86+
zoomed ? "scale-100" : "scale-95"
87+
)}
88+
/>
89+
</div>
90+
);
91+
92+
return (
93+
<>
94+
{/* eslint-disable-next-line @next/next/no-img-element */}
95+
<img
96+
src={src}
97+
alt={alt}
98+
className="cursor-zoom-in"
99+
onClick={() => setZoomed(true)}
100+
/>
101+
{mounted && createPortal(overlay, document.body)}
102+
</>
103+
);
104+
};
105+
106+
interface ChangelogEntryDialogProps {
107+
entry: ChangelogEntryDto | null;
108+
entriesBaseUrl: string;
109+
open: boolean;
110+
onOpenChange: (open: boolean) => void;
111+
}
112+
113+
export function ChangelogEntryDialog({ entry, entriesBaseUrl, open, onOpenChange }: ChangelogEntryDialogProps) {
114+
const urlTransform = useMemo(() => buildUrlTransform(entriesBaseUrl), [entriesBaseUrl]);
115+
116+
const upgradeAvailable = useMemo(() => {
117+
if (!entry) {
118+
return false;
119+
}
120+
const entryVersion = parseVersion(entry.version);
121+
const currentVersion = parseVersion(SOURCEBOT_VERSION);
122+
if (!entryVersion || !currentVersion) {
123+
return false;
124+
}
125+
return compareVersions(entryVersion, currentVersion) > 0;
126+
}, [entry]);
127+
128+
return (
129+
<Dialog open={open} onOpenChange={onOpenChange}>
130+
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0 focus:outline-none">
131+
{entry && (
132+
<>
133+
<DialogHeader className="px-6 pt-4 pb-4 border-b space-y-1">
134+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
135+
<span>{format(new Date(entry.publishedAt), "MMM d")}</span>
136+
{upgradeAvailable && (
137+
<>
138+
<span>·</span>
139+
<Tooltip>
140+
<TooltipTrigger asChild>
141+
<a
142+
href={`https://github.com/sourcebot-dev/sourcebot/releases/tag/${entry.version}`}
143+
target="_blank"
144+
rel="noopener noreferrer"
145+
>
146+
<Badge className="bg-purple-500/20 text-purple-400 border-purple-500/30 hover:bg-purple-500/30 gap-0.5">
147+
Upgrade
148+
<ArrowUpRight className="h-3 w-3" />
149+
</Badge>
150+
</a>
151+
</TooltipTrigger>
152+
<TooltipContent side="bottom">
153+
<div className="grid grid-cols-[auto_auto] gap-x-3 gap-y-1 text-sm items-center">
154+
<span className="text-muted-foreground">Current version</span>
155+
<span className="font-mono text-[11px] bg-muted rounded px-1.5 py-0.5 justify-self-start">{SOURCEBOT_VERSION}</span>
156+
<span className="text-muted-foreground">Required version</span>
157+
<span className="font-mono text-[11px] bg-muted rounded px-1.5 py-0.5 justify-self-start">{entry.version}</span>
158+
</div>
159+
</TooltipContent>
160+
</Tooltip>
161+
</>
162+
)}
163+
</div>
164+
<DialogTitle className="sr-only">{entry.title}</DialogTitle>
165+
</DialogHeader>
166+
<div className="overflow-y-auto px-6 py-5">
167+
<div
168+
className={cn(
169+
"prose dark:prose-invert max-w-none",
170+
"prose-p:text-foreground prose-li:text-foreground prose-headings:text-foreground",
171+
"prose-headings:mt-6 prose-p:my-3 prose-img:rounded-md prose-img:my-4 prose-hr:my-6",
172+
"prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-h4:text-base",
173+
"prose-headings:font-semibold",
174+
"prose-p:text-sm prose-li:text-sm",
175+
"prose-p:leading-normal prose-li:leading-normal",
176+
"prose-li:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:marker:text-foreground",
177+
"prose-a:text-link prose-a:no-underline hover:prose-a:underline",
178+
"prose-blockquote:not-italic prose-blockquote:font-normal",
179+
"prose-code:before:content-none prose-code:after:content-none prose-code:font-normal",
180+
"prose-code:bg-muted prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:text-xs",
181+
"prose-pre:bg-muted prose-pre:text-foreground prose-pre:leading-snug",
182+
"[&_video]:rounded-md [&_video]:my-4 [&_video]:w-full",
183+
"[&>*:first-child]:mt-0"
184+
)}
185+
>
186+
<Markdown
187+
remarkPlugins={[remarkGfm]}
188+
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA]]}
189+
urlTransform={urlTransform}
190+
components={{
191+
img: ({ src, alt }) => {
192+
if (typeof src !== "string") {
193+
return null;
194+
}
195+
if (VIDEO_EXTENSIONS_RE.test(src)) {
196+
return <video src={src} controls className="aspect-video" />;
197+
}
198+
return <ZoomableImage src={src} alt={alt} />;
199+
},
200+
}}
201+
>
202+
{entry.bodyMarkdown}
203+
</Markdown>
204+
</div>
205+
</div>
206+
</>
207+
)}
208+
</DialogContent>
209+
</Dialog>
210+
);
211+
}

packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ export async function DefaultSidebar() {
5454
session={session}
5555
collapsible="icon"
5656
isValidLicenseActive={licenseActive}
57-
isOwner={isOwner}
5857
headerContent={
5958
<Nav
6059
isSettingsNotificationVisible={isSettingsNotificationVisible}

packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import { SidebarBase } from "../sidebarBase";
66
import { Nav } from "./nav";
77
import { SettingsSidebarHeader } from "./header";
88
import { isValidLicenseActive } from "@/lib/entitlements";
9-
import { getAuthContext } from "@/middleware/withAuth";
10-
import { OrgRole } from "@prisma/client";
119

1210
export async function SettingsSidebar() {
1311
const session = await auth();
@@ -19,15 +17,11 @@ export async function SettingsSidebar() {
1917

2018
const licenseActive = await isValidLicenseActive();
2119

22-
const authContext = await getAuthContext();
23-
const isOwner = !isServiceError(authContext) && authContext.role === OrgRole.OWNER;
24-
2520
return (
2621
<SidebarBase
2722
session={session}
2823
collapsible="none"
2924
isValidLicenseActive={licenseActive}
30-
isOwner={isOwner}
3125
headerContent={<SettingsSidebarHeader />}
3226
>
3327
<Nav groups={sidebarNavGroups} />

0 commit comments

Comments
 (0)