diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx
index a34267207..42f6d6113 100644
--- a/src/renderer/app.tsx
+++ b/src/renderer/app.tsx
@@ -12,7 +12,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import i18n from '/@/i18n/i18n';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
-import { IsUpdatedDialog } from '/@/renderer/is-updated-dialog';
+import { ReleaseNotesModal } from './release-notes-modal';
import { AppRouter } from '/@/renderer/router/app-router';
import { useCssSettings, useHotkeySettings, useSettingsStore } from '/@/renderer/store';
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
@@ -89,7 +89,7 @@ export const App = () => {
-
+
);
};
diff --git a/src/renderer/is-updated-dialog.tsx b/src/renderer/is-updated-dialog.tsx
deleted file mode 100644
index 5c43f32ae..000000000
--- a/src/renderer/is-updated-dialog.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-
-import packageJson from '../../package.json';
-
-import { Button } from '/@/shared/components/button/button';
-import { Dialog } from '/@/shared/components/dialog/dialog';
-import { Group } from '/@/shared/components/group/group';
-import { Icon } from '/@/shared/components/icon/icon';
-import { Stack } from '/@/shared/components/stack/stack';
-import { Text } from '/@/shared/components/text/text';
-import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
-
-export function IsUpdatedDialog() {
- const { version } = packageJson;
- const { t } = useTranslation();
-
- const [value, setValue] = useLocalStorage({ key: 'version' });
-
- const handleDismiss = useCallback(() => {
- setValue(version);
- }, [setValue, version]);
-
- return (
-
- );
-}
diff --git a/src/renderer/release-notes-modal.module.css b/src/renderer/release-notes-modal.module.css
new file mode 100644
index 000000000..c72a264bb
--- /dev/null
+++ b/src/renderer/release-notes-modal.module.css
@@ -0,0 +1,86 @@
+.markdown-content {
+ color: var(--mantine-color-text);
+}
+
+.markdown-content h1,
+.markdown-content h2,
+.markdown-content h3,
+.markdown-content h4,
+.markdown-content h5,
+.markdown-content h6 {
+ margin-top: var(--mantine-spacing-md);
+ margin-bottom: var(--mantine-spacing-xs);
+ font-weight: 600;
+ line-height: 1.25;
+}
+
+.markdown-content h1 {
+ font-size: 2em;
+}
+
+.markdown-content h2 {
+ font-size: 1.5em;
+}
+
+.markdown-content h3 {
+ font-size: 1.25em;
+}
+
+.markdown-content p {
+ margin-top: 0;
+ margin-bottom: var(--mantine-spacing-sm);
+}
+
+.markdown-content ul,
+.markdown-content ol {
+ padding-left: var(--mantine-spacing-xl);
+ margin-top: 0;
+ margin-bottom: var(--mantine-spacing-sm);
+}
+
+.markdown-content li {
+ margin-bottom: var(--mantine-spacing-xs);
+}
+
+.markdown-content code {
+ padding: 2px 6px;
+ font-family: 'Courier New', monospace;
+ font-size: 0.9em;
+ background-color: var(--mantine-color-dark-6);
+ border-radius: var(--mantine-radius-sm);
+}
+
+.markdown-content pre {
+ padding: var(--mantine-spacing-md);
+ margin-top: var(--mantine-spacing-md);
+ margin-bottom: var(--mantine-spacing-md);
+ overflow-x: auto;
+ background-color: var(--mantine-color-dark-6);
+ border-radius: var(--mantine-radius-md);
+}
+
+.markdown-content pre code {
+ padding: 0;
+ background-color: transparent;
+}
+
+.markdown-content blockquote {
+ padding-left: var(--mantine-spacing-md);
+ margin-top: var(--mantine-spacing-md);
+ margin-bottom: var(--mantine-spacing-md);
+ border-left: 3px solid var(--mantine-color-gray-6);
+}
+
+.markdown-content a {
+ color: var(--mantine-color-blue-6);
+ text-decoration: none;
+}
+
+.markdown-content a:hover {
+ text-decoration: underline;
+}
+
+.markdown-content img {
+ max-width: 100%;
+ border-radius: var(--mantine-radius-md);
+}
diff --git a/src/renderer/release-notes-modal.tsx b/src/renderer/release-notes-modal.tsx
new file mode 100644
index 000000000..59a4e44d9
--- /dev/null
+++ b/src/renderer/release-notes-modal.tsx
@@ -0,0 +1,194 @@
+import { closeAllModals, openModal } from '@mantine/modals';
+import { useQuery } from '@tanstack/react-query';
+import axios from 'axios';
+import DOMPurify from 'dompurify';
+import { useCallback, useEffect, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import packageJson from '../../package.json';
+import styles from './release-notes-modal.module.css';
+
+import { Button } from '/@/shared/components/button/button';
+import { Center } from '/@/shared/components/center/center';
+import { Group } from '/@/shared/components/group/group';
+import { Icon } from '/@/shared/components/icon/icon';
+import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
+import { Spinner } from '/@/shared/components/spinner/spinner';
+import { Stack } from '/@/shared/components/stack/stack';
+import { Text } from '/@/shared/components/text/text';
+import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
+
+interface ReleaseNotesContentProps {
+ onDismiss: () => void;
+ version: string;
+}
+
+const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) => {
+ const { t } = useTranslation();
+
+ // Fetch release notes from GitHub API
+ const {
+ data: releaseData,
+ isError,
+ isLoading,
+ } = useQuery({
+ queryFn: async () => {
+ const response = await axios.get(
+ `https://api.github.com/repos/jeffvli/feishin/releases/tags/v${version}`,
+ );
+ return response.data;
+ },
+ queryKey: ['github-release', version],
+ retry: 2,
+ });
+
+ // Convert markdown to HTML using GitHub's markdown API
+ const { data: htmlContent, isLoading: isConverting } = useQuery({
+ enabled: !!releaseData?.body,
+ queryFn: async () => {
+ const response = await axios.post(
+ 'https://api.github.com/markdown',
+ {
+ mode: 'gfm',
+ text: releaseData.body,
+ },
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ responseType: 'text',
+ },
+ );
+ return response.data;
+ },
+ queryKey: ['github-markdown', releaseData?.body],
+ retry: 2,
+ });
+
+ const sanitizedHtml = useMemo(() => {
+ if (!htmlContent) return '';
+ return DOMPurify.sanitize(htmlContent, {
+ ALLOWED_ATTR: ['alt', 'href', 'src', 'title'],
+ ALLOWED_TAGS: [
+ 'a',
+ 'blockquote',
+ 'br',
+ 'code',
+ 'em',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'img',
+ 'li',
+ 'ol',
+ 'p',
+ 'pre',
+ 'strong',
+ 'u',
+ 'ul',
+ ],
+ });
+ }, [htmlContent]);
+
+ if (isLoading || isConverting) {
+ return (
+
+
+
+ {t('common.loading', { postProcess: 'sentenceCase' })}
+
+
+ );
+ }
+
+ if (isError || !releaseData) {
+ return (
+
+ {t('common.newVersion', { postProcess: 'sentenceCase', version })}
+
+ {t('error.genericError', { postProcess: 'sentenceCase' })}
+
+
+ }
+ target="_blank"
+ variant="filled"
+ >
+ {t('common.viewReleaseNotes', { postProcess: 'sentenceCase' })}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ }
+ target="_blank"
+ variant="subtle"
+ >
+ {t('action.viewMore', { postProcess: 'sentenceCase' })}
+
+
+
+
+ );
+};
+
+export const ReleaseNotesModal = () => {
+ const { version } = packageJson;
+ const { t } = useTranslation();
+
+ const [value, setValue] = useLocalStorage({ key: 'version' });
+
+ const handleDismiss = useCallback(() => {
+ setValue(version);
+ closeAllModals();
+ }, [setValue, version]);
+
+ useEffect(() => {
+ if (value !== version) {
+ openModal({
+ children: ,
+ size: 'xl',
+ styles: {
+ body: {
+ padding: 'var(--mantine-spacing-xl)',
+ },
+ },
+ title: t('common.newVersion', {
+ postProcess: 'sentenceCase',
+ version,
+ }) as string,
+ });
+ }
+ }, [handleDismiss, value, version, t]);
+
+ return null;
+};