1import React, { useState, useEffect, useCallback } from "react";
2import { api } from "../services/api";
3import { VersionInfo, CommitInfo } from "../types";
4
5interface VersionCheckerProps {
6 onUpdateAvailable?: (hasUpdate: boolean) => void;
7}
8
9interface VersionModalProps {
10 isOpen: boolean;
11 onClose: () => void;
12 versionInfo: VersionInfo | null;
13 isLoading: boolean;
14}
15
16function VersionModal({ isOpen, onClose, versionInfo, isLoading }: VersionModalProps) {
17 const [commits, setCommits] = useState<CommitInfo[]>([]);
18 const [loadingCommits, setLoadingCommits] = useState(false);
19 const [upgrading, setUpgrading] = useState(false);
20 const [restarting, setRestarting] = useState(false);
21 const [upgradeMessage, setUpgradeMessage] = useState<string | null>(null);
22 const [upgradeError, setUpgradeError] = useState<string | null>(null);
23
24 useEffect(() => {
25 if (isOpen && versionInfo?.has_update && versionInfo.current_tag && versionInfo.latest_tag) {
26 loadCommits(versionInfo.current_tag, versionInfo.latest_tag);
27 }
28 }, [isOpen, versionInfo]);
29
30 const loadCommits = async (currentTag: string, latestTag: string) => {
31 setLoadingCommits(true);
32 try {
33 const result = await api.getChangelog(currentTag, latestTag);
34 setCommits(result || []);
35 } catch (err) {
36 console.error("Failed to load changelog:", err);
37 setCommits([]);
38 } finally {
39 setLoadingCommits(false);
40 }
41 };
42
43 const handleUpgrade = async () => {
44 setUpgrading(true);
45 setUpgradeError(null);
46 setUpgradeMessage(null);
47 try {
48 const result = await api.upgrade();
49 setUpgradeMessage(result.message);
50 } catch (err) {
51 const message = err instanceof Error ? err.message : "Unknown error";
52 setUpgradeError(message);
53 } finally {
54 setUpgrading(false);
55 }
56 };
57
58 const handleExit = async () => {
59 setRestarting(true);
60 try {
61 await api.exit();
62 setTimeout(() => {
63 window.location.reload();
64 }, 2000);
65 } catch {
66 setTimeout(() => {
67 window.location.reload();
68 }, 2000);
69 }
70 };
71
72 if (!isOpen) return null;
73
74 const formatDateTime = (dateStr: string) => {
75 const date = new Date(dateStr);
76 return date.toLocaleString(undefined, {
77 year: "numeric",
78 month: "short",
79 day: "numeric",
80 hour: "2-digit",
81 minute: "2-digit",
82 timeZoneName: "short",
83 });
84 };
85
86 const getCommitUrl = (sha: string) => {
87 return `https://github.com/boldsoftware/shelley/commit/${sha}`;
88 };
89
90 return (
91 <div className="version-modal-overlay" onClick={onClose}>
92 <div className="version-modal" onClick={(e) => e.stopPropagation()}>
93 <div className="version-modal-header">
94 <h2>Version</h2>
95 <button onClick={onClose} className="version-modal-close" aria-label="Close">
96 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
97 <path
98 strokeLinecap="round"
99 strokeLinejoin="round"
100 strokeWidth={2}
101 d="M6 18L18 6M6 6l12 12"
102 />
103 </svg>
104 </button>
105 </div>
106
107 <div className="version-modal-content">
108 {isLoading ? (
109 <div className="version-loading">Checking for updates...</div>
110 ) : versionInfo ? (
111 <>
112 <div className="version-info-row">
113 <span className="version-label">Current:</span>
114 <span className="version-value">
115 {versionInfo.current_tag || versionInfo.current_version || "dev"}
116 </span>
117 {versionInfo.current_commit_time && (
118 <span className="version-date">
119 ({formatDateTime(versionInfo.current_commit_time)})
120 </span>
121 )}
122 </div>
123
124 {versionInfo.latest_tag && (
125 <div className="version-info-row">
126 <span className="version-label">Latest:</span>
127 <span className="version-value">{versionInfo.latest_tag}</span>
128 {versionInfo.published_at && (
129 <span className="version-date">
130 ({formatDateTime(versionInfo.published_at)})
131 </span>
132 )}
133 </div>
134 )}
135
136 {versionInfo.error && (
137 <div className="version-error">
138 <span>Error: {versionInfo.error}</span>
139 </div>
140 )}
141
142 {/* Changelog */}
143 {versionInfo.has_update && (
144 <div className="version-changelog">
145 <h3>
146 <a
147 href={`https://github.com/boldsoftware/shelley/compare/${versionInfo.current_tag}...${versionInfo.latest_tag}`}
148 target="_blank"
149 rel="noopener noreferrer"
150 className="changelog-link"
151 >
152 Changelog
153 </a>
154 </h3>
155 {loadingCommits ? (
156 <div className="version-loading">Loading...</div>
157 ) : commits.length > 0 ? (
158 <ul className="commit-list">
159 {commits.map((commit) => (
160 <li key={commit.sha} className="commit-item">
161 <a
162 href={getCommitUrl(commit.sha)}
163 target="_blank"
164 rel="noopener noreferrer"
165 className="commit-sha"
166 >
167 {commit.sha}
168 </a>
169 <span className="commit-message">{commit.message}</span>
170 </li>
171 ))}
172 </ul>
173 ) : (
174 <div className="version-no-commits">No commits found</div>
175 )}
176 </div>
177 )}
178
179 {/* Upgrade/Restart buttons */}
180 {versionInfo.has_update && versionInfo.download_url && (
181 <div className="version-actions">
182 {upgradeMessage && (
183 <div className="version-success">
184 Upgraded {versionInfo.executable_path || "shelley"}
185 </div>
186 )}
187 {upgradeError && <div className="version-error">{upgradeError}</div>}
188
189 {!upgradeMessage ? (
190 <button
191 onClick={handleUpgrade}
192 disabled={upgrading}
193 className="version-btn version-btn-primary"
194 >
195 {upgrading
196 ? "Upgrading..."
197 : `Upgrade ${versionInfo.executable_path || "shelley"} in place`}
198 </button>
199 ) : (
200 <button
201 onClick={handleExit}
202 disabled={restarting}
203 className="version-btn version-btn-primary"
204 >
205 {restarting
206 ? versionInfo.running_under_systemd
207 ? "Restarting..."
208 : "Killing..."
209 : versionInfo.running_under_systemd
210 ? "Restart"
211 : "Kill Shelley Server"}
212 </button>
213 )}
214 </div>
215 )}
216 </>
217 ) : (
218 <div className="version-loading">Loading...</div>
219 )}
220 </div>
221 </div>
222 </div>
223 );
224}
225
226export function useVersionChecker({ onUpdateAvailable }: VersionCheckerProps = {}) {
227 const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
228 const [showModal, setShowModal] = useState(false);
229 const [isLoading, setIsLoading] = useState(false);
230 const [shouldNotify, setShouldNotify] = useState(false);
231
232 const checkVersion = useCallback(async () => {
233 setIsLoading(true);
234 try {
235 // Always force refresh when checking
236 const info = await api.checkVersion(true);
237 setVersionInfo(info);
238 setShouldNotify(info.should_notify);
239 onUpdateAvailable?.(info.should_notify);
240 } catch (err) {
241 console.error("Failed to check version:", err);
242 } finally {
243 setIsLoading(false);
244 }
245 }, [onUpdateAvailable]);
246
247 // Check version on mount (uses cache)
248 useEffect(() => {
249 const checkInitial = async () => {
250 try {
251 const info = await api.checkVersion(false);
252 setVersionInfo(info);
253 setShouldNotify(info.should_notify);
254 onUpdateAvailable?.(info.should_notify);
255 } catch (err) {
256 console.error("Failed to check version:", err);
257 }
258 };
259 checkInitial();
260 }, [onUpdateAvailable]);
261
262 const openModal = useCallback(() => {
263 setShowModal(true);
264 // Always check for new version when opening modal
265 checkVersion();
266 }, [checkVersion]);
267
268 const closeModal = useCallback(() => {
269 setShowModal(false);
270 }, []);
271
272 const VersionModalComponent = (
273 <VersionModal
274 isOpen={showModal}
275 onClose={closeModal}
276 versionInfo={versionInfo}
277 isLoading={isLoading}
278 />
279 );
280
281 return {
282 hasUpdate: shouldNotify, // For red dot indicator (5+ days apart)
283 versionInfo,
284 openModal,
285 closeModal,
286 isLoading,
287 VersionModal: VersionModalComponent,
288 };
289}
290
291export default useVersionChecker;