Skip to content

Commit 8830ef4

Browse files
committed
feat(whats-new): add Whats New modal and changelog fetching
Add useChangelog hook, parseChangelog util, data mapping and modal UI
1 parent a38a910 commit 8830ef4

9 files changed

Lines changed: 403 additions & 1 deletion

File tree

src/App.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
22
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
33
import { invoke } from "@tauri-apps/api/core";
44
import { MainLayout } from "./components/layout/MainLayout";
@@ -15,9 +15,13 @@ import { TaskManagerPage } from "./pages/TaskManagerPage";
1515
import { ConnectionHealthMonitor } from "./components/ConnectionHealthMonitor";
1616
import { UpdateNotificationModal } from "./components/modals/UpdateNotificationModal";
1717
import { CommunityModal } from "./components/modals/CommunityModal";
18+
import { WhatsNewModal } from "./components/modals/WhatsNewModal";
1819
import { useUpdate } from "./hooks/useUpdate";
20+
import { useChangelog } from "./hooks/useChangelog";
21+
import { APP_VERSION } from "./version";
1922

2023
const COMMUNITY_MODAL_KEY = "tabularis_community_modal_dismissed";
24+
const WHATS_NEW_VERSION_KEY = "tabularis_last_seen_version";
2125

2226
export function App() {
2327
const {
@@ -33,11 +37,29 @@ export function App() {
3337
() => !localStorage.getItem(COMMUNITY_MODAL_KEY),
3438
);
3539

40+
const lastSeenVersion = localStorage.getItem(WHATS_NEW_VERSION_KEY);
41+
const [isWhatsNewOpen, setIsWhatsNewOpen] = useState(
42+
() => lastSeenVersion !== null && lastSeenVersion !== APP_VERSION,
43+
);
44+
45+
const { entries: allEntries, isLoading: isChangelogLoading } = useChangelog();
46+
47+
const whatsNewEntries = useMemo(() => {
48+
if (!lastSeenVersion) return [];
49+
return allEntries.filter((entry) => entry.version > lastSeenVersion);
50+
}, [lastSeenVersion, allEntries]);
51+
3652
const dismissCommunityModal = useCallback(() => {
3753
localStorage.setItem(COMMUNITY_MODAL_KEY, "1");
54+
localStorage.setItem(WHATS_NEW_VERSION_KEY, APP_VERSION);
3855
setIsCommunityModalOpen(false);
3956
}, []);
4057

58+
const dismissWhatsNew = useCallback(() => {
59+
localStorage.setItem(WHATS_NEW_VERSION_KEY, APP_VERSION);
60+
setIsWhatsNewOpen(false);
61+
}, []);
62+
4163
useEffect(() => {
4264
invoke<boolean>("is_debug_mode").then((debugMode) => {
4365
setIsDebugMode(debugMode);
@@ -104,6 +126,13 @@ export function App() {
104126
isOpen={isCommunityModalOpen}
105127
onClose={dismissCommunityModal}
106128
/>
129+
130+
<WhatsNewModal
131+
isOpen={isWhatsNewOpen && !isCommunityModalOpen}
132+
onClose={dismissWhatsNew}
133+
entries={whatsNewEntries}
134+
isLoading={isChangelogLoading}
135+
/>
107136
</>
108137
);
109138
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { useTranslation } from "react-i18next";
2+
import { openUrl } from "@tauri-apps/plugin-opener";
3+
import {
4+
X,
5+
Sparkles,
6+
Bug,
7+
AlertTriangle,
8+
Rocket,
9+
ExternalLink,
10+
Loader2,
11+
Github,
12+
} from "lucide-react";
13+
import { Modal } from "../ui/Modal";
14+
import { DiscordIcon } from "../icons/DiscordIcon";
15+
import { type ChangelogEntry } from "../../utils/changelog";
16+
import { GITHUB_URL, DISCORD_URL } from "../../config/links";
17+
18+
interface WhatsNewModalProps {
19+
isOpen: boolean;
20+
onClose: () => void;
21+
entries: ChangelogEntry[];
22+
isLoading: boolean;
23+
}
24+
25+
const UTM_SUFFIX = "?utm_src=tabularis-app";
26+
27+
export const WhatsNewModal = ({
28+
isOpen,
29+
onClose,
30+
entries,
31+
isLoading,
32+
}: WhatsNewModalProps) => {
33+
const { t } = useTranslation();
34+
35+
return (
36+
<Modal isOpen={isOpen} onClose={onClose}>
37+
<div className="bg-elevated border border-strong rounded-xl shadow-2xl w-[600px] max-h-[90vh] overflow-hidden flex flex-col">
38+
{/* Header */}
39+
<div className="flex items-center justify-between p-4 border-b border-default bg-base">
40+
<div className="flex items-center gap-3">
41+
<div className="p-2 bg-purple-900/30 rounded-lg">
42+
<Sparkles size={20} className="text-purple-400" />
43+
</div>
44+
<div>
45+
<h2 className="text-lg font-semibold text-primary">
46+
{t("whatsNew.title")}
47+
</h2>
48+
{entries.length > 0 && (
49+
<p className="text-xs text-secondary">
50+
{t("whatsNew.subtitle", { version: entries[0].version })}
51+
</p>
52+
)}
53+
</div>
54+
</div>
55+
<button
56+
onClick={onClose}
57+
className="text-secondary hover:text-primary transition-colors"
58+
>
59+
<X size={20} />
60+
</button>
61+
</div>
62+
63+
{/* Content */}
64+
<div className="p-6 space-y-6 overflow-y-auto">
65+
{isLoading && (
66+
<div className="text-center py-8 text-muted">
67+
<Loader2 size={24} className="animate-spin mx-auto mb-2" />
68+
{t("common.loading")}
69+
</div>
70+
)}
71+
72+
{!isLoading &&
73+
entries.map((entry) => (
74+
<div key={entry.version} className="space-y-4">
75+
<div className="flex items-center justify-between">
76+
<div className="flex items-center gap-2">
77+
<span className="text-sm font-semibold text-primary">
78+
v{entry.version}
79+
</span>
80+
<span className="text-xs text-muted">
81+
{new Date(entry.date).toLocaleDateString()}
82+
</span>
83+
</div>
84+
{entry.url && (
85+
<button
86+
onClick={() =>
87+
openUrl(`${entry.url}${UTM_SUFFIX}`)
88+
}
89+
className="flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300 transition-colors"
90+
>
91+
{t("whatsNew.readMore")}
92+
<ExternalLink size={12} />
93+
</button>
94+
)}
95+
</div>
96+
97+
{entry.features.length > 0 && (
98+
<ChangelogSection
99+
icon={<Rocket size={14} className="text-green-400" />}
100+
label={t("whatsNew.features")}
101+
items={entry.features}
102+
dotColor="before:bg-green-400/60"
103+
/>
104+
)}
105+
106+
{entry.bugFixes.length > 0 && (
107+
<ChangelogSection
108+
icon={<Bug size={14} className="text-blue-400" />}
109+
label={t("whatsNew.bugFixes")}
110+
items={entry.bugFixes}
111+
dotColor="before:bg-blue-400/60"
112+
/>
113+
)}
114+
115+
{entry.breakingChanges.length > 0 && (
116+
<ChangelogSection
117+
icon={
118+
<AlertTriangle size={14} className="text-yellow-400" />
119+
}
120+
label={t("whatsNew.breakingChanges")}
121+
items={entry.breakingChanges}
122+
dotColor="before:bg-yellow-400/60"
123+
/>
124+
)}
125+
126+
{entries.indexOf(entry) < entries.length - 1 && (
127+
<div className="border-t border-default" />
128+
)}
129+
</div>
130+
))}
131+
</div>
132+
133+
{/* Footer */}
134+
<div className="p-4 border-t border-default bg-base/50 flex items-center justify-between">
135+
<div className="flex items-center gap-2">
136+
<button
137+
onClick={() => openUrl(GITHUB_URL)}
138+
className="p-2 text-secondary hover:text-primary hover:bg-surface-tertiary rounded-lg transition-colors"
139+
title="GitHub"
140+
>
141+
<Github size={18} />
142+
</button>
143+
<button
144+
onClick={() => openUrl(DISCORD_URL)}
145+
className="p-2 text-secondary hover:text-indigo-400 hover:bg-indigo-900/20 rounded-lg transition-colors"
146+
title="Discord"
147+
>
148+
<DiscordIcon size={18} />
149+
</button>
150+
</div>
151+
<button
152+
onClick={onClose}
153+
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors"
154+
>
155+
{t("whatsNew.dismiss")}
156+
</button>
157+
</div>
158+
</div>
159+
</Modal>
160+
);
161+
};
162+
163+
function ChangelogSection({
164+
icon,
165+
label,
166+
items,
167+
dotColor,
168+
}: {
169+
icon: React.ReactNode;
170+
label: string;
171+
items: string[];
172+
dotColor: string;
173+
}) {
174+
return (
175+
<div>
176+
<div className="flex items-center gap-2 mb-2">
177+
{icon}
178+
<span className="text-xs uppercase font-bold text-muted">{label}</span>
179+
</div>
180+
<ul className="space-y-1.5 overflow-hidden">
181+
{items.map((item, i) => (
182+
<li
183+
key={i}
184+
className={`text-sm text-secondary pl-5 relative break-words before:content-[''] before:absolute before:left-1.5 before:top-2 before:w-1.5 before:h-1.5 before:rounded-full ${dotColor}`}
185+
>
186+
{item}
187+
</li>
188+
))}
189+
</ul>
190+
</div>
191+
);
192+
}

src/components/settings/InfoTab.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useState } from "react";
12
import { useTranslation } from "react-i18next";
23
import { openUrl } from "@tauri-apps/plugin-opener";
34
import { invoke } from "@tauri-apps/api/core";
@@ -12,14 +13,17 @@ import {
1213
Loader2,
1314
ExternalLink,
1415
Activity,
16+
Sparkles,
1517
} from "lucide-react";
1618
import clsx from "clsx";
1719
import { useSettings } from "../../hooks/useSettings";
1820
import { useTheme } from "../../hooks/useTheme";
1921
import { useUpdate } from "../../hooks/useUpdate";
22+
import { useChangelog } from "../../hooks/useChangelog";
2023
import { APP_VERSION } from "../../version";
2124
import { ROADMAP } from "../../utils/settings";
2225
import { SettingRow, SettingSection, SettingToggle } from "./SettingControls";
26+
import { WhatsNewModal } from "../modals/WhatsNewModal";
2327

2428
export function InfoTab() {
2529
const { t } = useTranslation();
@@ -33,6 +37,11 @@ export function InfoTab() {
3337
isUpToDate,
3438
installationSource,
3539
} = useUpdate();
40+
const {
41+
entries: changelogEntries,
42+
isLoading: isChangelogLoading,
43+
} = useChangelog();
44+
const [isWhatsNewOpen, setIsWhatsNewOpen] = useState(false);
3645

3746
return (
3847
<div>
@@ -78,6 +87,13 @@ export function InfoTab() {
7887
{APP_VERSION} (Beta)
7988
</span>
8089
</div>
90+
<button
91+
onClick={() => setIsWhatsNewOpen(true)}
92+
className="flex items-center gap-2 bg-purple-900/20 hover:bg-purple-900/30 text-purple-400 px-4 py-2 rounded-lg font-medium transition-colors border border-purple-500/30"
93+
>
94+
<Sparkles size={18} />
95+
{t("whatsNew.title")}
96+
</button>
8197
</div>
8298
</div>
8399

@@ -267,6 +283,13 @@ export function InfoTab() {
267283
</button>
268284
</div>
269285
</SettingSection>
286+
287+
<WhatsNewModal
288+
isOpen={isWhatsNewOpen}
289+
onClose={() => setIsWhatsNewOpen(false)}
290+
entries={changelogEntries}
291+
isLoading={isChangelogLoading}
292+
/>
270293
</div>
271294
);
272295
}

src/data/changelog.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Manual mapping of version → web page URL.
3+
* The app appends ?utm_src=tabularis-app when opening the link.
4+
*/
5+
export const versionLinks: Record<string, string> = {
6+
"0.9.15": "https://tabularis.dev/blog/v0915-notebooks-multi-query-ai-rename",
7+
};

src/hooks/useChangelog.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useEffect, useState } from "react";
2+
import { type ChangelogEntry, parseChangelog } from "../utils/changelog";
3+
import { versionLinks } from "../data/changelog";
4+
import { GITHUB_URL } from "../config/links";
5+
6+
const CHANGELOG_RAW_URL = `${GITHUB_URL.replace("github.com", "raw.githubusercontent.com")}/main/CHANGELOG.md`;
7+
8+
interface UseChangelogResult {
9+
entries: ChangelogEntry[];
10+
isLoading: boolean;
11+
error: string | null;
12+
}
13+
14+
export function useChangelog(): UseChangelogResult {
15+
const [entries, setEntries] = useState<ChangelogEntry[]>([]);
16+
const [isLoading, setIsLoading] = useState(true);
17+
const [error, setError] = useState<string | null>(null);
18+
19+
useEffect(() => {
20+
let cancelled = false;
21+
22+
fetch(CHANGELOG_RAW_URL)
23+
.then((res) => {
24+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
25+
return res.text();
26+
})
27+
.then((md) => {
28+
if (!cancelled) {
29+
setEntries(parseChangelog(md, versionLinks));
30+
setIsLoading(false);
31+
}
32+
})
33+
.catch((err) => {
34+
if (!cancelled) {
35+
setError(err.message);
36+
setIsLoading(false);
37+
}
38+
});
39+
40+
return () => {
41+
cancelled = true;
42+
};
43+
}, []);
44+
45+
return { entries, isLoading, error };
46+
}

src/i18n/locales/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,15 @@
862862
"discordDesc": "Chat with the community, get help, suggest features",
863863
"dismiss": "Maybe later"
864864
},
865+
"whatsNew": {
866+
"title": "What's New",
867+
"subtitle": "Version {{version}}",
868+
"features": "New Features",
869+
"bugFixes": "Bug Fixes",
870+
"breakingChanges": "Breaking Changes",
871+
"readMore": "Read more",
872+
"dismiss": "Got it"
873+
},
865874
"dump": {
866875
"title": "Dump Database",
867876
"dumpDatabase": "Dump Database",

0 commit comments

Comments
 (0)