diff --git a/messages/en-US.json b/messages/en-US.json index 97272c6f..1b5c5e87 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 29ba342b..32bae1e8 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -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({
-
- -
+ {build === "enterprise" && (
diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 2c1806c5..81f1fdb4 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -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 ( - <> - - 3 more updates - - +
+ {/* + + 3 more updates + */} + +
+ ); +} + +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 ( +
+ {version?.data && ( + <> +
+ +
+
+

+ {t("pangolinUpdateAvailable")} +

+ + {t("pangolinUpdateAvailableInfo", { + version: version.data.pangolin.latestVersion + })} + + + + {t("pangolinUpdateAvailableReleaseNotes")} + + + +
+ + + )} +
); } diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000..e7fdc353 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,99 @@ +import { + useState, + useEffect, + useCallback, + Dispatch, + SetStateAction +} from "react"; + +type SetValue = Dispatch>; + +export function useLocalStorage( + key: string, + initialValue: T +): [T, SetValue] { + // 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(readValue); + + // Return a wrapped version of useState's setter function that + // persists the new value to localStorage + const setValue: SetValue = 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]; +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 5cef9f0e..a75d3abe 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -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"; - diff --git a/src/lib/durationToMs.ts b/src/lib/durationToMs.ts new file mode 100644 index 00000000..172bae15 --- /dev/null +++ b/src/lib/durationToMs.ts @@ -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]; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts new file mode 100644 index 00000000..37ec0e76 --- /dev/null +++ b/src/lib/queries.ts @@ -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 + }) +};