mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-03 09:16:40 +00:00
🚧 New version popup
This commit is contained in:
@@ -1279,6 +1279,9 @@
|
|||||||
"settingsErrorUpdateDescription": "An error occurred while updating settings",
|
"settingsErrorUpdateDescription": "An error occurred while updating settings",
|
||||||
"sidebarCollapse": "Collapse",
|
"sidebarCollapse": "Collapse",
|
||||||
"sidebarExpand": "Expand",
|
"sidebarExpand": "Expand",
|
||||||
|
"pangolinUpdateAvailable": "New version available",
|
||||||
|
"pangolinUpdateAvailableInfo": "Version {version} is ready to install",
|
||||||
|
"pangolinUpdateAvailableReleaseNotes": "View release notes",
|
||||||
"newtUpdateAvailable": "Update Available",
|
"newtUpdateAvailable": "Update Available",
|
||||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||||
"domainPickerEnterDomain": "Domain",
|
"domainPickerEnterDomain": "Domain",
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ import {
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import SidebarLicenseButton from "./SidebarLicenseButton";
|
import SidebarLicenseButton from "./SidebarLicenseButton";
|
||||||
import { SidebarSupportButton } from "./SidebarSupportButton";
|
import { SidebarSupportButton } from "./SidebarSupportButton";
|
||||||
import ProductUpdates from "./ProductUpdates";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const ProductUpdates = dynamic(() => import("./ProductUpdates"), {
|
||||||
|
ssr: false
|
||||||
|
});
|
||||||
|
|
||||||
interface LayoutSidebarProps {
|
interface LayoutSidebarProps {
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
@@ -135,9 +139,7 @@ export function LayoutSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-4 shrink-0">
|
<div className="p-4 flex flex-col gap-4 shrink-0">
|
||||||
<div className="mb-3">
|
<ProductUpdates />
|
||||||
<ProductUpdates />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{build === "enterprise" && (
|
{build === "enterprise" && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
|
|||||||
@@ -1,20 +1,87 @@
|
|||||||
"use client";
|
"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 { 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({}: ProductUpdatesProps) {
|
||||||
|
|
||||||
export default function ProductUpdates({}: ProductUpdatesSectionProps) {
|
|
||||||
const versions = useQuery({
|
|
||||||
queryKey: []
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-1">
|
||||||
<small className="text-xs text-muted-foreground flex items-center gap-2">
|
{/* <small className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
3 more updates
|
<BellIcon className="flex-none size-3" />
|
||||||
</small>
|
<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({
|
export const priv = axios.create({
|
||||||
baseURL: `http://localhost:${process.env.SERVER_INTERNAL_PORT}/api/v1`,
|
baseURL: `http://localhost:${process.env.SERVER_INTERNAL_PORT}/api/v1`,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
@@ -60,4 +69,3 @@ export const priv = axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export * from "./formatAxiosError";
|
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