🚧 New version popup

This commit is contained in:
Fred KISSIE
2025-11-05 06:55:08 +01:00
parent a26a441d56
commit 2f1abfbef8
7 changed files with 247 additions and 17 deletions

View File

@@ -1279,6 +1279,9 @@
"settingsErrorUpdateDescription": "An error occurred while updating settings",
"sidebarCollapse": "Collapse",
"sidebarExpand": "Expand",
"pangolinUpdateAvailable": "New version available",
"pangolinUpdateAvailableInfo": "Version {version} is ready to install",
"pangolinUpdateAvailableReleaseNotes": "View release notes",
"newtUpdateAvailable": "Update Available",
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
"domainPickerEnterDomain": "Domain",

View File

@@ -32,7 +32,11 @@ import {
import { build } from "@server/build";
import SidebarLicenseButton from "./SidebarLicenseButton";
import { SidebarSupportButton } from "./SidebarSupportButton";
import ProductUpdates from "./ProductUpdates";
import dynamic from "next/dynamic";
const ProductUpdates = dynamic(() => import("./ProductUpdates"), {
ssr: false
});
interface LayoutSidebarProps {
orgId?: string;
@@ -135,9 +139,7 @@ export function LayoutSidebar({
</div>
<div className="p-4 flex flex-col gap-4 shrink-0">
<div className="mb-3">
<ProductUpdates />
</div>
<ProductUpdates />
{build === "enterprise" && (
<div className="mb-3">

View File

@@ -1,20 +1,87 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLocalStorage } from "@app/hooks/useLocalStorage";
import { cn } from "@app/lib/cn";
import { versionsQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, BellIcon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
interface ProductUpdatesSectionProps {}
interface ProductUpdatesProps {}
const data = {};
export default function ProductUpdates({}: ProductUpdatesSectionProps) {
const versions = useQuery({
queryKey: []
});
export default function ProductUpdates({}: ProductUpdatesProps) {
return (
<>
<small className="text-xs text-muted-foreground flex items-center gap-2">
3 more updates
</small>
</>
<div className="flex flex-col gap-1">
{/* <small className="text-xs text-muted-foreground flex items-center gap-1">
<BellIcon className="flex-none size-3" />
<span>3 more updates</span>
</small> */}
<NewVersionAvailable />
</div>
);
}
function NewVersionAvailable() {
const { env } = useEnvContext();
const t = useTranslations();
const { data: version } = useQuery(versionsQueries.latestVersion());
const [ignoredVersionUpdate, setIgnoredVersionUpdate] = useLocalStorage<
string | null
>("ignored-version", null);
const showNewVersionPopup =
version?.data &&
ignoredVersionUpdate !== version.data.pangolin.latestVersion;
return (
<div
className={cn(
"rounded-md border bg-muted p-2 py-3 w-full flex items-start gap-2 text-sm",
"transition duration-500",
"opacity-0 h-0 pointer-events-none",
showNewVersionPopup && "opacity-100 h-full pointer-events-auto"
)}
>
{version?.data && (
<>
<div className="rounded-md bg-muted-foreground/20 p-2">
<BellIcon className="flex-none size-4" />
</div>
<div className="flex flex-col gap-2">
<p className="font-medium">
{t("pangolinUpdateAvailable")}
</p>
<small className="text-muted-foreground">
{t("pangolinUpdateAvailableInfo", {
version: version.data.pangolin.latestVersion
})}
</small>
<a
href={version?.data?.pangolin.releaseNotes}
target="_blank"
className="inline-flex items-center gap-0.5 text-xs font-medium"
>
<span>
{t("pangolinUpdateAvailableReleaseNotes")}
</span>
<ArrowRight className="flex-none size-3" />
</a>
</div>
<button
className="p-1 cursor-pointer"
onClick={() =>
setIgnoredVersionUpdate(
version?.data?.pangolin.latestVersion ?? null
)
}
>
<XIcon className="size-4 flex-none" />
</button>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,99 @@
import {
useState,
useEffect,
useCallback,
Dispatch,
SetStateAction
} from "react";
type SetValue<T> = Dispatch<SetStateAction<T>>;
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, SetValue<T>] {
// Get initial value from localStorage or use the provided initial value
const readValue = useCallback((): T => {
// Prevent build error "window is undefined" during SSR
if (typeof window === "undefined") {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
}, [initialValue, key]);
// State to store our value
const [storedValue, setStoredValue] = useState<T>(readValue);
// Return a wrapped version of useState's setter function that
// persists the new value to localStorage
const setValue: SetValue<T> = useCallback(
(value) => {
// Prevent build error "window is undefined" during SSR
if (typeof window === "undefined") {
console.warn(
`Tried setting localStorage key "${key}" even though environment is not a client`
);
}
try {
// Allow value to be a function so we have the same API as useState
const newValue =
value instanceof Function ? value(storedValue) : value;
// Save to local storage
window.localStorage.setItem(key, JSON.stringify(newValue));
// Save state
setStoredValue(newValue);
// Dispatch a custom event so every useLocalStorage hook is notified
window.dispatchEvent(new Event("local-storage"));
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
},
[key, storedValue]
);
// Listen for changes to this key from other tabs/windows
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
try {
setStoredValue(JSON.parse(e.newValue));
} catch (error) {
console.warn(
`Error parsing localStorage value for key "${key}":`,
error
);
}
}
};
// Listen for storage events (changes from other tabs)
window.addEventListener("storage", handleStorageChange);
// Listen for custom event (changes from same tab)
const handleLocalStorageChange = () => {
setStoredValue(readValue());
};
window.addEventListener("local-storage", handleLocalStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener(
"local-storage",
handleLocalStorageChange
);
};
}, [key, readValue]);
return [storedValue, setValue];
}

View File

@@ -51,6 +51,15 @@ export const internal = axios.create({
}
});
export const remote = axios.create({
baseURL: `${process.env.NEXT_PUBLIC_FOSSORIAL_REMOTE_API_URL}/api/v1`,
timeout: 10000,
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": "x-csrf-protection"
}
});
export const priv = axios.create({
baseURL: `http://localhost:${process.env.SERVER_INTERNAL_PORT}/api/v1`,
timeout: 10000,
@@ -60,4 +69,3 @@ export const priv = axios.create({
});
export * from "./formatAxiosError";

13
src/lib/durationToMs.ts Normal file
View File

@@ -0,0 +1,13 @@
export function durationToMs(
value: number,
unit: "seconds" | "minutes" | "hours" | "days" | "weeks"
): number {
const multipliers = {
seconds: 1000,
minutes: 60 * 1000,
hours: 60 * 60 * 1000,
days: 24 * 60 * 60 * 1000,
weeks: 7 * 24 * 60 * 60 * 1000
};
return value * multipliers[unit];
}

38
src/lib/queries.ts Normal file
View File

@@ -0,0 +1,38 @@
import {
type InfiniteData,
type QueryClient,
keepPreviousData,
queryOptions,
type skipToken
} from "@tanstack/react-query";
import { durationToMs } from "./durationToMs";
import { build } from "@server/build";
import { remote } from "./api";
import type ResponseT from "@server/types/Response";
export const versionsQueries = {
latestVersion: () =>
queryOptions({
queryKey: ["LATEST_VERSION"] as const,
queryFn: async ({ signal }) => {
const data = await remote.get<
ResponseT<{
pangolin: {
latestVersion: string;
releaseNotes: string;
};
}>
>("/latest-version");
return data.data;
},
placeholderData: keepPreviousData,
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "minutes");
}
return false;
},
enabled: build === "oss" || build === "enterprise" // disabled in cloud version
// because we don't need to listen for new versions there
})
};