Skip to content

Commit a224358

Browse files
Merge pull request #48 from Portabase/feat/github-updater
Feat/GitHub updater
2 parents ebb0a4c + 6231002 commit a224358

File tree

8 files changed

+161
-3
lines changed

8 files changed

+161
-3
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,6 @@ AUTH_GOOGLE_METHOD=
2828

2929
# Retention
3030
RETENTION_CRON="* * * * *"
31+
32+
# Updates
33+
NEXT_PUBLIC_UPDATE_CHANNEL=stable

CITATION.cff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,5 @@ keywords:
2626
- web-ui
2727
- agent
2828
license: Apache-2.0
29-
version: 1.2.4-rc.5
29+
version: 1.2.4-rc.8
3030
date-released: "2026-01-28"

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "portabase",
3-
"version": "1.2.4-rc.5",
3+
"version": "1.2.4-rc.8",
44
"private": true,
55
"scripts": {
66
"dev": "next dev --turbopack -p 8887",

src/components/wrappers/dashboard/common/sidebar/app-sidebar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sideba
1111
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
1212
import {env} from "@/env.mjs";
1313
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button.server";
14+
import {UpdateNotification} from "@/features/updates/components/update-notification";
1415

1516
export function AppSidebar() {
1617
const projectName = env.PROJECT_NAME;
@@ -31,6 +32,8 @@ export function AppSidebar() {
3132
<SidebarMenuCustomMain/>
3233
</SidebarContent>
3334

35+
<UpdateNotification />
36+
3437
<SidebarMenu className="mb-2">
3538
<SidebarMenuItem className="p-2">
3639
<LoggedInButton/>

src/env.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ export const env = createEnv({
4444
},
4545
client: {
4646
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
47-
47+
NEXT_PUBLIC_UPDATE_CHANNEL: z.enum(["stable", "beta", "rc"]).default("stable"),
4848
},
4949
runtimeEnv: {
5050
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
51+
NEXT_PUBLIC_UPDATE_CHANNEL: process.env.NEXT_PUBLIC_UPDATE_CHANNEL || "stable",
5152

5253
PROJECT_NAME: process.env.PROJECT_NAME,
5354
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client";
2+
3+
import { useUpdateCheck } from "../hooks/use-update-check";
4+
import { useSidebar, SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton } from "@/components/ui/sidebar";
5+
import { X, ArrowUpCircle, MoveRight } from "lucide-react";
6+
import Link from "next/link";
7+
8+
export const UpdateNotification = () => {
9+
const { isUpdateAvailable, latestRelease, dismissUpdate } = useUpdateCheck();
10+
const { state } = useSidebar();
11+
12+
if (!isUpdateAvailable || !latestRelease || state !== "expanded") {
13+
return null;
14+
}
15+
16+
return (
17+
<SidebarGroup className="py-0">
18+
<SidebarGroupContent>
19+
<SidebarMenu>
20+
<SidebarMenuItem className="px-2">
21+
<div className="relative flex flex-col gap-2 rounded-lg border bg-primary/5 p-3 text-sidebar-foreground border-primary/20">
22+
<button
23+
onClick={dismissUpdate}
24+
className="absolute right-2 top-2 rounded-md p-0.5 text-muted-foreground/50 hover:bg-sidebar-accent hover:text-foreground transition-colors"
25+
>
26+
<X className="size-3" />
27+
<span className="sr-only">Dismiss</span>
28+
</button>
29+
30+
<div className="flex items-center gap-2.5">
31+
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
32+
<ArrowUpCircle className="size-4" />
33+
</div>
34+
<div className="flex flex-col min-w-0">
35+
<div className="flex items-center gap-1.5">
36+
<span className="text-[12px] font-semibold leading-none">Update available</span>
37+
<span className="text-[10px] text-muted-foreground font-medium px-1.5 py-0.5 bg-primary/10 rounded-full">
38+
v{latestRelease.tag_name.replace(/^v/, "")}
39+
</span>
40+
</div>
41+
<Link
42+
href={latestRelease.html_url}
43+
target="_blank"
44+
className="group inline-flex items-center gap-1 text-[11px] text-primary hover:underline font-medium mt-1"
45+
>
46+
See what's new
47+
<MoveRight className="size-3 transition-transform group-hover:translate-x-0.5" />
48+
</Link>
49+
</div>
50+
</div>
51+
</div>
52+
</SidebarMenuItem>
53+
</SidebarMenu>
54+
</SidebarGroupContent>
55+
</SidebarGroup>
56+
);
57+
};
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import { useQuery } from "@tanstack/react-query";
4+
import { getLatestRelease } from "../services/github";
5+
import { env } from "@/env.mjs";
6+
import { useEffect, useState } from "react";
7+
8+
const DISMISS_KEY = "portabase-update-dismissed";
9+
const DISMISS_DURATION = 1000 * 60 * 60 * 24;
10+
11+
export const useUpdateCheck = () => {
12+
const [isDismissed, setIsDismissed] = useState(true);
13+
14+
const { data: latestRelease, isLoading } = useQuery({
15+
queryKey: ["latest-release", env.NEXT_PUBLIC_UPDATE_CHANNEL],
16+
queryFn: () => getLatestRelease(env.NEXT_PUBLIC_UPDATE_CHANNEL as any),
17+
staleTime: 1000 * 60 * 60,
18+
});
19+
20+
const currentVersion = env.NEXT_PUBLIC_PROJECT_VERSION;
21+
22+
useEffect(() => {
23+
if (!latestRelease) return;
24+
25+
const latestVersion = latestRelease.tag_name.replace(/^v/, "");
26+
const cleanCurrentVersion = currentVersion?.replace(/^v/, "");
27+
28+
if (latestVersion && cleanCurrentVersion && latestVersion !== cleanCurrentVersion) {
29+
const dismissedData = localStorage.getItem(DISMISS_KEY);
30+
if (dismissedData) {
31+
const { version, timestamp } = JSON.parse(dismissedData);
32+
const now = Date.now();
33+
if (version === latestRelease.tag_name && now - timestamp < DISMISS_DURATION) {
34+
setIsDismissed(true);
35+
return;
36+
}
37+
}
38+
setIsDismissed(false);
39+
} else {
40+
setIsDismissed(true);
41+
}
42+
}, [latestRelease, currentVersion]);
43+
44+
const dismissUpdate = () => {
45+
if (latestRelease) {
46+
localStorage.setItem(DISMISS_KEY, JSON.stringify({
47+
version: latestRelease.tag_name,
48+
timestamp: Date.now()
49+
}));
50+
setIsDismissed(true);
51+
}
52+
};
53+
54+
return {
55+
latestRelease,
56+
isLoading,
57+
isUpdateAvailable: !isDismissed && latestRelease,
58+
dismissUpdate
59+
};
60+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export interface GitHubRelease {
2+
tag_name: string;
3+
html_url: string;
4+
prerelease: boolean;
5+
name: string;
6+
body: string;
7+
}
8+
9+
export const getLatestRelease = async (channel: "stable" | "beta" | "rc" = "stable"): Promise<GitHubRelease | null> => {
10+
try {
11+
const response = await fetch("https://api.github.com/repos/Portabase/portabase/releases");
12+
if (!response.ok) {
13+
return null;
14+
}
15+
const releases: GitHubRelease[] = await response.json();
16+
17+
if (channel === "stable") {
18+
return releases.find(r => !r.prerelease) || null;
19+
}
20+
21+
if (channel === "beta") {
22+
return releases.find(r => r.tag_name.includes("beta") || !r.prerelease) || null;
23+
}
24+
25+
if (channel === "rc") {
26+
return releases.find(r => r.tag_name.includes("rc") || !r.prerelease) || null;
27+
}
28+
29+
return releases[0] || null;
30+
} catch (error) {
31+
console.error("Failed to fetch latest release", error);
32+
return null;
33+
}
34+
};

0 commit comments

Comments
 (0)