feat: add update-available detection
This commit is contained in:
@@ -16,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
@@ -175,6 +176,11 @@ export function NavigationRail({
|
||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
const updateSeverity = useUpdateStore((s) => s.status?.severity);
|
||||
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
|
||||
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
|
||||
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
|
||||
|
||||
// Account list for rail
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
@@ -345,7 +351,18 @@ export function NavigationRail({
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
<span className="relative">
|
||||
<Shield className="w-5 h-5" />
|
||||
{hasUpdate && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2 ring-background",
|
||||
updateImportant ? "bg-red-500" : "bg-amber-500",
|
||||
)}
|
||||
aria-label={updateImportant ? "Important update available" : "Update available"}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
|
||||
</a>
|
||||
)}
|
||||
@@ -515,10 +532,19 @@ export function NavigationRail({
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
href="/admin"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
|
||||
title={t("admin") || "Admin"}
|
||||
>
|
||||
<Shield className="w-[18px] h-[18px]" />
|
||||
{hasUpdate && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-2 right-2 w-2 h-2 rounded-full ring-2 ring-background",
|
||||
updateImportant ? "bg-red-500" : "bg-amber-500",
|
||||
)}
|
||||
aria-label={updateImportant ? "Important update available" : "Update available"}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useUpdateStore } from '@/stores/update-store';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||
|
||||
function VersionUpdateTag() {
|
||||
const status = useUpdateStore((s) => s.status);
|
||||
const startPolling = useUpdateStore((s) => s.startPolling);
|
||||
|
||||
useEffect(() => {
|
||||
startPolling();
|
||||
}, [startPolling]);
|
||||
|
||||
if (!status?.updateAvailable) return null;
|
||||
if (status.severity === 'unknown' || status.severity === 'none') return null;
|
||||
|
||||
const important = status.severity === 'security' || status.severity === 'deprecated';
|
||||
const label =
|
||||
status.severity === 'security' ? 'security'
|
||||
: status.severity === 'deprecated' ? 'deprecated'
|
||||
: status.latest ?? 'update';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium align-middle",
|
||||
important
|
||||
? "bg-red-500/15 text-red-700 dark:text-red-300"
|
||||
: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
{important ? label : `update: ${label}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutDataSettings() {
|
||||
const t = useTranslations('settings.advanced');
|
||||
const tCommon = useTranslations('common');
|
||||
@@ -106,6 +139,7 @@ export function AboutDataSettings() {
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
|
||||
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
|
||||
<VersionUpdateTag />
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Single-line, low-key update notice. Lives next to the version badge on the
|
||||
// login screen — deliberately understated so it doesn't distract from the
|
||||
// auth flow. Red variants (security / deprecated) still use red text but
|
||||
// stay the same compact shape.
|
||||
export function UpdateNotice({ className }: { className?: string }) {
|
||||
const banner = useUpdateStore(useShallow(selectBanner));
|
||||
const dismiss = useUpdateStore((s) => s.dismiss);
|
||||
const startPolling = useUpdateStore((s) => s.startPolling);
|
||||
|
||||
useEffect(() => {
|
||||
startPolling();
|
||||
}, [startPolling]);
|
||||
|
||||
if (!banner) return null;
|
||||
|
||||
const isRed = banner.variant === "red";
|
||||
|
||||
const text =
|
||||
banner.severity === "security"
|
||||
? `Security update${banner.latest ? `: ${banner.latest}` : ""}`
|
||||
: banner.severity === "deprecated"
|
||||
? "Version no longer supported"
|
||||
: `Update available: ${banner.latest ?? ""}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-[11px] leading-none",
|
||||
isRed
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-muted-foreground/60 hover:text-muted-foreground transition-colors",
|
||||
className,
|
||||
)}
|
||||
role={isRed ? "alert" : "status"}
|
||||
>
|
||||
{isRed && <AlertTriangle className="w-3 h-3 flex-shrink-0" />}
|
||||
{banner.url ? (
|
||||
<a
|
||||
href={banner.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
) : (
|
||||
<span>{text}</span>
|
||||
)}
|
||||
{banner.dismissible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="opacity-50 hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user