mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Merge pull request #48 from Portabase/feat/github-updater
Feat/GitHub updater
This commit is contained in:
@@ -28,3 +28,6 @@ AUTH_GOOGLE_METHOD=
|
||||
|
||||
# Retention
|
||||
RETENTION_CRON="* * * * *"
|
||||
|
||||
# Updates
|
||||
NEXT_PUBLIC_UPDATE_CHANNEL=stable
|
||||
|
||||
+1
-1
@@ -26,5 +26,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.2.4-rc.5
|
||||
version: 1.2.4-rc.8
|
||||
date-released: "2026-01-28"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.2.4-rc.5",
|
||||
"version": "1.2.4-rc.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sideba
|
||||
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
|
||||
import {env} from "@/env.mjs";
|
||||
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button.server";
|
||||
import {UpdateNotification} from "@/features/updates/components/update-notification";
|
||||
|
||||
export function AppSidebar() {
|
||||
const projectName = env.PROJECT_NAME;
|
||||
@@ -31,6 +32,8 @@ export function AppSidebar() {
|
||||
<SidebarMenuCustomMain/>
|
||||
</SidebarContent>
|
||||
|
||||
<UpdateNotification />
|
||||
|
||||
<SidebarMenu className="mb-2">
|
||||
<SidebarMenuItem className="p-2">
|
||||
<LoggedInButton/>
|
||||
|
||||
+2
-1
@@ -44,10 +44,11 @@ export const env = createEnv({
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
|
||||
NEXT_PUBLIC_UPDATE_CHANNEL: z.enum(["stable", "beta", "rc"]).default("stable"),
|
||||
},
|
||||
runtimeEnv: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
|
||||
NEXT_PUBLIC_UPDATE_CHANNEL: process.env.NEXT_PUBLIC_UPDATE_CHANNEL || "stable",
|
||||
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useUpdateCheck } from "../hooks/use-update-check";
|
||||
import { useSidebar, SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
import { X, ArrowUpCircle, MoveRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const UpdateNotification = () => {
|
||||
const { isUpdateAvailable, latestRelease, dismissUpdate } = useUpdateCheck();
|
||||
const { state } = useSidebar();
|
||||
|
||||
if (!isUpdateAvailable || !latestRelease || state !== "expanded") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGroup className="py-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem className="px-2">
|
||||
<div className="relative flex flex-col gap-2 rounded-lg border bg-primary/5 p-3 text-sidebar-foreground border-primary/20">
|
||||
<button
|
||||
onClick={dismissUpdate}
|
||||
className="absolute right-2 top-2 rounded-md p-0.5 text-muted-foreground/50 hover:bg-sidebar-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
<span className="sr-only">Dismiss</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<ArrowUpCircle className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[12px] font-semibold leading-none">Update available</span>
|
||||
<span className="text-[10px] text-muted-foreground font-medium px-1.5 py-0.5 bg-primary/10 rounded-full">
|
||||
v{latestRelease.tag_name.replace(/^v/, "")}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={latestRelease.html_url}
|
||||
target="_blank"
|
||||
className="group inline-flex items-center gap-1 text-[11px] text-primary hover:underline font-medium mt-1"
|
||||
>
|
||||
See what's new
|
||||
<MoveRight className="size-3 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getLatestRelease } from "../services/github";
|
||||
import { env } from "@/env.mjs";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const DISMISS_KEY = "portabase-update-dismissed";
|
||||
const DISMISS_DURATION = 1000 * 60 * 60 * 24;
|
||||
|
||||
export const useUpdateCheck = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(true);
|
||||
|
||||
const { data: latestRelease, isLoading } = useQuery({
|
||||
queryKey: ["latest-release", env.NEXT_PUBLIC_UPDATE_CHANNEL],
|
||||
queryFn: () => getLatestRelease(env.NEXT_PUBLIC_UPDATE_CHANNEL as any),
|
||||
staleTime: 1000 * 60 * 60,
|
||||
});
|
||||
|
||||
const currentVersion = env.NEXT_PUBLIC_PROJECT_VERSION;
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestRelease) return;
|
||||
|
||||
const latestVersion = latestRelease.tag_name.replace(/^v/, "");
|
||||
const cleanCurrentVersion = currentVersion?.replace(/^v/, "");
|
||||
|
||||
if (latestVersion && cleanCurrentVersion && latestVersion !== cleanCurrentVersion) {
|
||||
const dismissedData = localStorage.getItem(DISMISS_KEY);
|
||||
if (dismissedData) {
|
||||
const { version, timestamp } = JSON.parse(dismissedData);
|
||||
const now = Date.now();
|
||||
if (version === latestRelease.tag_name && now - timestamp < DISMISS_DURATION) {
|
||||
setIsDismissed(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setIsDismissed(false);
|
||||
} else {
|
||||
setIsDismissed(true);
|
||||
}
|
||||
}, [latestRelease, currentVersion]);
|
||||
|
||||
const dismissUpdate = () => {
|
||||
if (latestRelease) {
|
||||
localStorage.setItem(DISMISS_KEY, JSON.stringify({
|
||||
version: latestRelease.tag_name,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
setIsDismissed(true);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
latestRelease,
|
||||
isLoading,
|
||||
isUpdateAvailable: !isDismissed && latestRelease,
|
||||
dismissUpdate
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface GitHubRelease {
|
||||
tag_name: string;
|
||||
html_url: string;
|
||||
prerelease: boolean;
|
||||
name: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export const getLatestRelease = async (channel: "stable" | "beta" | "rc" = "stable"): Promise<GitHubRelease | null> => {
|
||||
try {
|
||||
const response = await fetch("https://api.github.com/repos/Portabase/portabase/releases");
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const releases: GitHubRelease[] = await response.json();
|
||||
|
||||
if (channel === "stable") {
|
||||
return releases.find(r => !r.prerelease) || null;
|
||||
}
|
||||
|
||||
if (channel === "beta") {
|
||||
return releases.find(r => r.tag_name.includes("beta") || !r.prerelease) || null;
|
||||
}
|
||||
|
||||
if (channel === "rc") {
|
||||
return releases.find(r => r.tag_name.includes("rc") || !r.prerelease) || null;
|
||||
}
|
||||
|
||||
return releases[0] || null;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch latest release", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user