Skip to content

Commit 68f3711

Browse files
feat: implement release notes modal system
- Add getAllReleases API endpoint to fetch GitHub releases with notes - Create ReleaseNotesModal component with localStorage version tracking - Add sticky Footer component with release notes link - Make version badge clickable to open release notes - Auto-show modal after updates when version changes - Track last seen version in localStorage to prevent repeated shows - Highlight new version in modal when opened after update - Add manual access via footer and version badge clicks
1 parent c975beb commit 68f3711

File tree

6 files changed

+356
-5
lines changed

6 files changed

+356
-5
lines changed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.2.9
1+
0.3.0

src/app/_components/Footer.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use client';
2+
3+
import { api } from '~/trpc/react';
4+
import { Button } from './ui/button';
5+
import { ExternalLink, FileText } from 'lucide-react';
6+
7+
interface FooterProps {
8+
onOpenReleaseNotes: () => void;
9+
}
10+
11+
export function Footer({ onOpenReleaseNotes }: FooterProps) {
12+
const { data: versionData } = api.version.getCurrentVersion.useQuery();
13+
14+
return (
15+
<footer className="sticky bottom-0 mt-auto border-t border-border bg-muted/30 py-6 backdrop-blur-sm">
16+
<div className="container mx-auto px-4">
17+
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
18+
<div className="flex items-center gap-4">
19+
<span>© 2024 PVE Scripts Local</span>
20+
{versionData?.success && versionData.version && (
21+
<Button
22+
variant="ghost"
23+
size="sm"
24+
onClick={onOpenReleaseNotes}
25+
className="h-auto p-1 text-xs hover:text-foreground"
26+
>
27+
v{versionData.version}
28+
</Button>
29+
)}
30+
</div>
31+
32+
<div className="flex items-center gap-4">
33+
<Button
34+
variant="ghost"
35+
size="sm"
36+
onClick={onOpenReleaseNotes}
37+
className="h-auto p-2 text-xs hover:text-foreground"
38+
>
39+
<FileText className="h-3 w-3 mr-1" />
40+
Release Notes
41+
</Button>
42+
43+
<Button
44+
variant="ghost"
45+
size="sm"
46+
asChild
47+
className="h-auto p-2 text-xs hover:text-foreground"
48+
>
49+
<a
50+
href="https://github.com/community-scripts/ProxmoxVE-Local"
51+
target="_blank"
52+
rel="noopener noreferrer"
53+
className="flex items-center gap-1"
54+
>
55+
<ExternalLink className="h-3 w-3" />
56+
GitHub
57+
</a>
58+
</Button>
59+
</div>
60+
</div>
61+
</div>
62+
</footer>
63+
);
64+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
'use client';
2+
3+
import { useState, useEffect } from 'react';
4+
import { api } from '~/trpc/react';
5+
import { Button } from './ui/button';
6+
import { Badge } from './ui/badge';
7+
import { X, ExternalLink, Calendar, Tag, Loader2 } from 'lucide-react';
8+
9+
interface ReleaseNotesModalProps {
10+
isOpen: boolean;
11+
onClose: () => void;
12+
highlightVersion?: string;
13+
}
14+
15+
interface Release {
16+
tagName: string;
17+
name: string;
18+
publishedAt: string;
19+
htmlUrl: string;
20+
body: string;
21+
}
22+
23+
// Helper functions for localStorage
24+
const getLastSeenVersion = (): string | null => {
25+
if (typeof window === 'undefined') return null;
26+
return localStorage.getItem('LAST_SEEN_RELEASE_VERSION');
27+
};
28+
29+
const markVersionAsSeen = (version: string): void => {
30+
if (typeof window === 'undefined') return;
31+
localStorage.setItem('LAST_SEEN_RELEASE_VERSION', version);
32+
};
33+
34+
export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: ReleaseNotesModalProps) {
35+
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
36+
const { data: releasesData, isLoading, error } = api.version.getAllReleases.useQuery(undefined, {
37+
enabled: isOpen
38+
});
39+
const { data: versionData } = api.version.getCurrentVersion.useQuery(undefined, {
40+
enabled: isOpen
41+
});
42+
43+
// Get current version when modal opens
44+
useEffect(() => {
45+
if (isOpen && versionData?.success && versionData.version) {
46+
setCurrentVersion(versionData.version);
47+
}
48+
}, [isOpen, versionData]);
49+
50+
// Mark version as seen when modal closes
51+
const handleClose = () => {
52+
if (currentVersion) {
53+
markVersionAsSeen(currentVersion);
54+
}
55+
onClose();
56+
};
57+
58+
if (!isOpen) return null;
59+
60+
const releases: Release[] = releasesData?.success ? releasesData.releases : [];
61+
62+
return (
63+
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
64+
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col border border-border">
65+
{/* Header */}
66+
<div className="flex items-center justify-between p-6 border-b border-border">
67+
<div className="flex items-center gap-3">
68+
<Tag className="h-6 w-6 text-blue-600" />
69+
<h2 className="text-2xl font-bold text-card-foreground">Release Notes</h2>
70+
</div>
71+
<Button
72+
variant="ghost"
73+
size="sm"
74+
onClick={handleClose}
75+
className="h-8 w-8 p-0"
76+
>
77+
<X className="h-4 w-4" />
78+
</Button>
79+
</div>
80+
81+
{/* Content */}
82+
<div className="flex-1 overflow-hidden flex flex-col">
83+
{isLoading ? (
84+
<div className="flex items-center justify-center p-8">
85+
<div className="flex items-center gap-3">
86+
<Loader2 className="h-6 w-6 animate-spin text-primary" />
87+
<span className="text-muted-foreground">Loading release notes...</span>
88+
</div>
89+
</div>
90+
) : error || !releasesData?.success ? (
91+
<div className="flex items-center justify-center p-8">
92+
<div className="text-center">
93+
<p className="text-destructive mb-2">Failed to load release notes</p>
94+
<p className="text-sm text-muted-foreground">
95+
{releasesData?.error || 'Please try again later'}
96+
</p>
97+
</div>
98+
</div>
99+
) : releases.length === 0 ? (
100+
<div className="flex items-center justify-center p-8">
101+
<p className="text-muted-foreground">No releases found</p>
102+
</div>
103+
) : (
104+
<div className="flex-1 overflow-y-auto p-6 space-y-6">
105+
{releases.map((release, index) => {
106+
const isHighlighted = highlightVersion && release.tagName.replace('v', '') === highlightVersion;
107+
const isLatest = index === 0;
108+
109+
return (
110+
<div
111+
key={release.tagName}
112+
className={`border rounded-lg p-6 ${
113+
isHighlighted
114+
? 'border-blue-500 bg-blue-50/10 dark:bg-blue-950/10'
115+
: 'border-border bg-card'
116+
} ${isLatest ? 'ring-2 ring-primary/20' : ''}`}
117+
>
118+
{/* Release Header */}
119+
<div className="flex items-start justify-between mb-4">
120+
<div className="flex-1">
121+
<div className="flex items-center gap-3 mb-2">
122+
<h3 className="text-xl font-semibold text-card-foreground">
123+
{release.name || release.tagName}
124+
</h3>
125+
{isLatest && (
126+
<Badge variant="default" className="text-xs">
127+
Latest
128+
</Badge>
129+
)}
130+
{isHighlighted && (
131+
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
132+
New
133+
</Badge>
134+
)}
135+
</div>
136+
<div className="flex items-center gap-4 text-sm text-muted-foreground">
137+
<div className="flex items-center gap-1">
138+
<Tag className="h-4 w-4" />
139+
<span>{release.tagName}</span>
140+
</div>
141+
<div className="flex items-center gap-1">
142+
<Calendar className="h-4 w-4" />
143+
<span>
144+
{new Date(release.publishedAt).toLocaleDateString('en-US', {
145+
year: 'numeric',
146+
month: 'long',
147+
day: 'numeric'
148+
})}
149+
</span>
150+
</div>
151+
</div>
152+
</div>
153+
<Button
154+
variant="ghost"
155+
size="sm"
156+
asChild
157+
className="h-8 w-8 p-0"
158+
>
159+
<a
160+
href={release.htmlUrl}
161+
target="_blank"
162+
rel="noopener noreferrer"
163+
title="View on GitHub"
164+
>
165+
<ExternalLink className="h-4 w-4" />
166+
</a>
167+
</Button>
168+
</div>
169+
170+
{/* Release Body */}
171+
{release.body && (
172+
<div className="prose prose-sm max-w-none dark:prose-invert">
173+
<div className="whitespace-pre-wrap text-sm text-card-foreground leading-relaxed">
174+
{release.body}
175+
</div>
176+
</div>
177+
)}
178+
</div>
179+
);
180+
})}
181+
</div>
182+
)}
183+
</div>
184+
185+
{/* Footer */}
186+
<div className="flex items-center justify-between p-6 border-t border-border bg-muted/30">
187+
<div className="text-sm text-muted-foreground">
188+
{currentVersion && (
189+
<span>Current version: <span className="font-medium text-card-foreground">v{currentVersion}</span></span>
190+
)}
191+
</div>
192+
<Button onClick={handleClose} variant="default">
193+
Close
194+
</Button>
195+
</div>
196+
</div>
197+
</div>
198+
);
199+
}
200+
201+
// Export helper functions for use in other components
202+
export { getLastSeenVersion, markVersionAsSeen };

src/app/_components/VersionDisplay.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { ContextualHelpIcon } from "./ContextualHelpIcon";
88
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
99
import { useState, useEffect, useRef } from "react";
1010

11+
interface VersionDisplayProps {
12+
onOpenReleaseNotes?: () => void;
13+
}
14+
1115
// Loading overlay component with log streaming
1216
function LoadingOverlay({
1317
isNetworkError = false,
@@ -73,7 +77,7 @@ function LoadingOverlay({
7377
);
7478
}
7579

76-
export function VersionDisplay() {
80+
export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) {
7781
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
7882
const [isUpdating, setIsUpdating] = useState(false);
7983
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
@@ -231,7 +235,11 @@ export function VersionDisplay() {
231235
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
232236

233237
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
234-
<Badge variant={isUpToDate ? "default" : "secondary"} className="text-xs">
238+
<Badge
239+
variant={isUpToDate ? "default" : "secondary"}
240+
className={`text-xs ${onOpenReleaseNotes ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
241+
onClick={onOpenReleaseNotes}
242+
>
235243
v{currentVersion}
236244
</Badge>
237245

src/app/page.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
'use client';
33

4-
import { useState, useRef } from 'react';
4+
import { useState, useRef, useEffect } from 'react';
55
import { ScriptsGrid } from './_components/ScriptsGrid';
66
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
77
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
@@ -13,18 +13,47 @@ import { HelpButton } from './_components/HelpButton';
1313
import { VersionDisplay } from './_components/VersionDisplay';
1414
import { Button } from './_components/ui/button';
1515
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
16+
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
17+
import { Footer } from './_components/Footer';
1618
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
1719
import { api } from '~/trpc/react';
1820

1921
export default function Home() {
2022
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
2123
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
24+
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
25+
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
2226
const terminalRef = useRef<HTMLDivElement>(null);
2327

2428
// Fetch data for script counts
2529
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
2630
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
2731
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
32+
const { data: versionData } = api.version.getCurrentVersion.useQuery();
33+
34+
// Auto-show release notes modal after update
35+
useEffect(() => {
36+
if (versionData?.success && versionData.version) {
37+
const currentVersion = versionData.version;
38+
const lastSeenVersion = getLastSeenVersion();
39+
40+
// If we have a current version and either no last seen version or versions don't match
41+
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
42+
setHighlightVersion(currentVersion);
43+
setReleaseNotesOpen(true);
44+
}
45+
}
46+
}, [versionData]);
47+
48+
const handleOpenReleaseNotes = () => {
49+
setHighlightVersion(undefined);
50+
setReleaseNotesOpen(true);
51+
};
52+
53+
const handleCloseReleaseNotes = () => {
54+
setReleaseNotesOpen(false);
55+
setHighlightVersion(undefined);
56+
};
2857

2958
// Calculate script counts
3059
const scriptCounts = {
@@ -85,7 +114,7 @@ export default function Home() {
85114
Manage and execute Proxmox helper scripts locally with live output streaming
86115
</p>
87116
<div className="flex justify-center px-2">
88-
<VersionDisplay />
117+
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
89118
</div>
90119
</div>
91120

@@ -191,6 +220,16 @@ export default function Home() {
191220
<InstalledScriptsTab />
192221
)}
193222
</div>
223+
224+
{/* Footer */}
225+
<Footer onOpenReleaseNotes={handleOpenReleaseNotes} />
226+
227+
{/* Release Notes Modal */}
228+
<ReleaseNotesModal
229+
isOpen={releaseNotesOpen}
230+
onClose={handleCloseReleaseNotes}
231+
highlightVersion={highlightVersion}
232+
/>
194233
</main>
195234
);
196235
}

0 commit comments

Comments
 (0)