mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-21 16:54:31 +00:00
243 lines
8.4 KiB
TypeScript
243 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
import { useLocalStorage } from "@app/hooks/useLocalStorage";
|
|
import { cn } from "@app/lib/cn";
|
|
import { type ProductUpdate, productUpdatesQueries } from "@app/lib/queries";
|
|
import { useQueries } from "@tanstack/react-query";
|
|
import {
|
|
ArrowRight,
|
|
BellIcon,
|
|
ChevronRightIcon,
|
|
RocketIcon,
|
|
XIcon
|
|
} from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
import { Transition, TransitionChild } from "@headlessui/react";
|
|
import * as React from "react";
|
|
|
|
export default function ProductUpdates({
|
|
isCollapsed
|
|
}: {
|
|
isCollapsed?: boolean;
|
|
}) {
|
|
const data = useQueries({
|
|
queries: [
|
|
productUpdatesQueries.list,
|
|
productUpdatesQueries.latestVersion
|
|
],
|
|
combine(result) {
|
|
if (result[0].isLoading || result[1].isLoading) return null;
|
|
return {
|
|
updates: result[0].data?.data ?? [],
|
|
latestVersion: result[1].data
|
|
};
|
|
}
|
|
});
|
|
const { env } = useEnvContext();
|
|
const t = useTranslations();
|
|
const [showMoreUpdatesText, setShowMoreUpdatesText] = React.useState(false);
|
|
|
|
// we need to delay the initial
|
|
React.useEffect(() => {
|
|
const timeout = setTimeout(() => setShowMoreUpdatesText(true), 600);
|
|
return () => clearTimeout(timeout);
|
|
}, []);
|
|
|
|
const [ignoredVersionUpdate, setIgnoredVersionUpdate] = useLocalStorage<
|
|
string | null
|
|
>("ignored-version", null);
|
|
|
|
if (!data) return null;
|
|
|
|
const showNewVersionPopup = Boolean(
|
|
data?.latestVersion?.data &&
|
|
ignoredVersionUpdate !==
|
|
data.latestVersion.data?.pangolin.latestVersion &&
|
|
env.app.version !== data.latestVersion.data?.pangolin.latestVersion
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex flex-col gap-2 overflow-clip",
|
|
isCollapsed && "hidden"
|
|
)}
|
|
>
|
|
<>
|
|
<div className="flex flex-col gap-1">
|
|
<small
|
|
className={cn(
|
|
"text-xs text-muted-foreground flex items-center gap-1 mt-2",
|
|
showMoreUpdatesText
|
|
? "animate-in fade-in duration-300"
|
|
: "opacity-0"
|
|
)}
|
|
>
|
|
{data.updates.length > 0 && (
|
|
<>
|
|
<BellIcon className="flex-none size-3" />
|
|
<span>
|
|
{showNewVersionPopup
|
|
? t("productUpdateMoreInfo", {
|
|
noOfUpdates: data.updates.length
|
|
})
|
|
: t("productUpdateInfo", {
|
|
noOfUpdates: data.updates.length
|
|
})}
|
|
</span>
|
|
</>
|
|
)}
|
|
</small>
|
|
<ProductUpdatesPopup
|
|
updates={data.updates}
|
|
show={data.updates.length > 0}
|
|
/>
|
|
</div>
|
|
</>
|
|
|
|
<NewVersionAvailable
|
|
version={data.latestVersion?.data}
|
|
onClose={() => {
|
|
setIgnoredVersionUpdate(
|
|
data.latestVersion?.data?.pangolin.latestVersion ?? null
|
|
);
|
|
}}
|
|
show={showNewVersionPopup}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ProductUpdatesPopupProps = { updates: ProductUpdate[]; show: boolean };
|
|
|
|
function ProductUpdatesPopup({ updates, show }: ProductUpdatesPopupProps) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const t = useTranslations();
|
|
|
|
// we need to delay the initial opening state to have an animation on `appear`
|
|
React.useEffect(() => {
|
|
if (show) {
|
|
requestAnimationFrame(() => setOpen(true));
|
|
}
|
|
}, [show]);
|
|
|
|
return (
|
|
<Transition show={open}>
|
|
<div
|
|
className={cn(
|
|
"relative z-1",
|
|
"rounded-md border bg-muted p-2 py-3 w-full flex items-start gap-2 text-sm",
|
|
"transition duration-300 ease-in-out",
|
|
"data-closed:opacity-0 data-closed:translate-y-full"
|
|
)}
|
|
>
|
|
<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("productUpdateWhatsNew")}</p>
|
|
<small
|
|
className={cn(
|
|
"text-muted-foreground",
|
|
"overflow-hidden h-8",
|
|
"[-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]"
|
|
)}
|
|
>
|
|
{updates[0].contents}
|
|
</small>
|
|
</div>
|
|
<button
|
|
className="p-1 cursor-pointer"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
// onClose();
|
|
}}
|
|
>
|
|
<ChevronRightIcon className="size-4 flex-none" />
|
|
</button>
|
|
</div>
|
|
</Transition>
|
|
);
|
|
}
|
|
|
|
type NewVersionAvailableProps = {
|
|
onClose: () => void;
|
|
show: boolean;
|
|
version:
|
|
| Awaited<
|
|
ReturnType<
|
|
NonNullable<
|
|
typeof productUpdatesQueries.latestVersion.queryFn
|
|
>
|
|
>
|
|
>["data"]
|
|
| undefined;
|
|
};
|
|
|
|
function NewVersionAvailable({
|
|
version,
|
|
show,
|
|
onClose
|
|
}: NewVersionAvailableProps) {
|
|
const t = useTranslations();
|
|
const [open, setOpen] = React.useState(false);
|
|
|
|
// we need to delay the initial opening state to have an animation on `appear`
|
|
React.useEffect(() => {
|
|
if (show) {
|
|
requestAnimationFrame(() => setOpen(true));
|
|
}
|
|
}, [show]);
|
|
|
|
return (
|
|
<Transition show={open}>
|
|
<div
|
|
className={cn(
|
|
"relative z-2",
|
|
"rounded-md border bg-muted p-2 py-3 w-full flex items-start gap-2 text-sm",
|
|
"transition duration-300 ease-in-out",
|
|
"data-closed:opacity-0 data-closed:translate-y-full"
|
|
)}
|
|
>
|
|
{version && (
|
|
<>
|
|
<div className="rounded-md bg-muted-foreground/20 p-2">
|
|
<RocketIcon 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.pangolin.latestVersion
|
|
})}
|
|
</small>
|
|
<a
|
|
href={version.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={() => {
|
|
setOpen(false);
|
|
onClose();
|
|
}}
|
|
>
|
|
<XIcon className="size-4 flex-none" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Transition>
|
|
);
|
|
}
|