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 && (
+ <>
+
+
+
+
+
+ >
+ )}
+
);
}
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
+ })
+};