diff --git a/.env.example b/.env.example index 8e7061cd..3e925f21 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,6 @@ AUTH_GOOGLE_METHOD= # Retention RETENTION_CRON="* * * * *" + +# Updates +NEXT_PUBLIC_UPDATE_CHANNEL=stable diff --git a/CITATION.cff b/CITATION.cff index 17655d75..e298ca3d 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -26,5 +26,5 @@ keywords: - web-ui - agent license: Apache-2.0 -version: 1.2.3 -date-released: "2026-01-24" +version: 1.2.4-rc.6 +date-released: "2026-01-28" diff --git a/package.json b/package.json index 6ef1a702..f62087f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portabase", - "version": "1.2.3", + "version": "1.2.4-rc.6", "private": true, "scripts": { "dev": "next dev --turbopack -p 8887", diff --git a/src/components/wrappers/dashboard/common/sidebar/app-sidebar.tsx b/src/components/wrappers/dashboard/common/sidebar/app-sidebar.tsx index 6c5a5d94..5394c2af 100644 --- a/src/components/wrappers/dashboard/common/sidebar/app-sidebar.tsx +++ b/src/components/wrappers/dashboard/common/sidebar/app-sidebar.tsx @@ -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() { + + diff --git a/src/env.mjs b/src/env.mjs index 6565d048..29d76ae0 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -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, diff --git a/src/features/updates/components/update-notification.tsx b/src/features/updates/components/update-notification.tsx new file mode 100644 index 00000000..cdcd3538 --- /dev/null +++ b/src/features/updates/components/update-notification.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useUpdateCheck } from "../hooks/use-update-check"; +import { useSidebar, SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton } from "@/components/ui/sidebar"; +import { X, ArrowUpCircle } from "lucide-react"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; + +export const UpdateNotification = () => { + const { isUpdateAvailable, latestRelease, dismissUpdate } = useUpdateCheck(); + const { state } = useSidebar(); + + if (!isUpdateAvailable || !latestRelease || state !== "expanded") { + return null; + } + + return ( + + + + + + + + Dismiss + + + + + + + + Update available + + v{latestRelease.tag_name.replace(/^v/, "")} + + + + + + + See what's new + + + + + + + + ); +}; \ No newline at end of file diff --git a/src/features/updates/hooks/use-update-check.ts b/src/features/updates/hooks/use-update-check.ts new file mode 100644 index 00000000..7e58c0fa --- /dev/null +++ b/src/features/updates/hooks/use-update-check.ts @@ -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 + }; +}; diff --git a/src/features/updates/services/github.ts b/src/features/updates/services/github.ts new file mode 100644 index 00000000..d5befc30 --- /dev/null +++ b/src/features/updates/services/github.ts @@ -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 => { + 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; + } +};