mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-01 23:59:09 +00:00
🚧 New version popup
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
99
src/hooks/useLocalStorage.ts
Normal file
99
src/hooks/useLocalStorage.ts
Normal 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];
|
||||
}
|
||||
@@ -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
13
src/lib/durationToMs.ts
Normal 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
38
src/lib/queries.ts
Normal 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
|
||||
})
|
||||
};
|
||||
Reference in New Issue
Block a user