Files
ProxmoxVE-Local/src/app/_components/ReleaseNotesModal.tsx
Michel Roegl-Brunner 7833d5d408 Fix type errors
2025-11-28 13:21:37 +01:00

223 lines
9.3 KiB
TypeScript

'use client';
import { useState, useEffect, startTransition } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { X, ExternalLink, Calendar, Tag, Loader2 } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface ReleaseNotesModalProps {
isOpen: boolean;
onClose: () => void;
highlightVersion?: string;
}
interface Release {
tagName: string;
name: string;
publishedAt: string;
htmlUrl: string;
body: string;
}
// Helper functions for localStorage
const getLastSeenVersion = (): string | null => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('LAST_SEEN_RELEASE_VERSION');
};
const markVersionAsSeen = (version: string): void => {
if (typeof window === 'undefined') return;
localStorage.setItem('LAST_SEEN_RELEASE_VERSION', version);
};
export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: ReleaseNotesModalProps) {
useRegisterModal(isOpen, { id: 'release-notes-modal', allowEscape: true, onClose });
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
const { data: releasesData, isLoading, error } = api.version.getAllReleases.useQuery(undefined, {
enabled: isOpen
});
const { data: versionData } = api.version.getCurrentVersion.useQuery(undefined, {
enabled: isOpen
});
// Get current version when modal opens
useEffect(() => {
if (isOpen && versionData?.success && versionData.version) {
startTransition(() => {
setCurrentVersion(versionData.version);
});
}
}, [isOpen, versionData]);
// Mark version as seen when modal closes
const handleClose = () => {
if (currentVersion) {
markVersionAsSeen(currentVersion);
}
onClose();
};
if (!isOpen) return null;
const releases: Release[] = releasesData?.success ? releasesData.releases ?? [] : [];
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<Tag className="h-6 w-6 text-primary" />
<h2 className="text-2xl font-bold text-card-foreground">Release Notes</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<div className="flex items-center gap-3">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span className="text-muted-foreground">Loading release notes...</span>
</div>
</div>
) : error || !releasesData?.success ? (
<div className="flex items-center justify-center p-8">
<div className="text-center">
<p className="text-destructive mb-2">Failed to load release notes</p>
<p className="text-sm text-muted-foreground">
{releasesData?.error ?? 'Please try again later'}
</p>
</div>
</div>
) : releases.length === 0 ? (
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground">No releases found</p>
</div>
) : (
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{releases.map((release, index) => {
const isHighlighted = highlightVersion && release.tagName.replace('v', '') === highlightVersion;
const isLatest = index === 0;
return (
<div
key={release.tagName}
className={`border rounded-lg p-6 ${
isHighlighted
? 'border-primary bg-primary/10'
: 'border-border bg-card'
} ${isLatest ? 'ring-2 ring-primary/20' : ''}`}
>
{/* Release Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold text-card-foreground">
{release.name || release.tagName}
</h3>
{isLatest && (
<Badge variant="default" className="text-xs">
Latest
</Badge>
)}
{isHighlighted && (
<Badge variant="secondary" className="text-xs bg-primary/10 text-primary">
New
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Tag className="h-4 w-4" />
<span>{release.tagName}</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>
{new Date(release.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</span>
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
asChild
className="h-8 w-8 p-0"
>
<a
href={release.htmlUrl}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
{/* Release Body */}
{release.body && (
<div className="prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({children}) => <h1 className="text-2xl font-bold text-card-foreground mb-4 mt-6">{children}</h1>,
h2: ({children}) => <h2 className="text-xl font-semibold text-card-foreground mb-3 mt-5">{children}</h2>,
h3: ({children}) => <h3 className="text-lg font-medium text-card-foreground mb-2 mt-4">{children}</h3>,
p: ({children}) => <p className="text-card-foreground mb-3 leading-relaxed">{children}</p>,
ul: ({children}) => <ul className="list-disc list-inside text-card-foreground mb-3 space-y-1">{children}</ul>,
ol: ({children}) => <ol className="list-decimal list-inside text-card-foreground mb-3 space-y-1">{children}</ol>,
li: ({children}) => <li className="text-card-foreground">{children}</li>,
a: ({href, children}) => <a href={href} className="text-info hover:text-info/80 underline" target="_blank" rel="noopener noreferrer">{children}</a>,
strong: ({children}) => <strong className="font-semibold text-card-foreground">{children}</strong>,
em: ({children}) => <em className="italic text-card-foreground">{children}</em>,
}}
>
{release.body}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-border bg-muted/30">
<div className="text-sm text-muted-foreground">
{currentVersion && (
<span>Current version: <span className="font-medium text-card-foreground">v{currentVersion}</span></span>
)}
</div>
<Button onClick={handleClose} variant="default">
Close
</Button>
</div>
</div>
</div>
);
}
// Export helper functions for use in other components
export { getLastSeenVersion, markVersionAsSeen };