diff --git a/frontend/src/entries/subpage.js b/frontend/src/entries/subpage.js deleted file mode 100644 index a2b0d8bb..00000000 --- a/frontend/src/entries/subpage.js +++ /dev/null @@ -1,20 +0,0 @@ -import { createApp } from 'vue'; -import Antd, { message } from 'ant-design-vue'; -import 'ant-design-vue/dist/reset.css'; - -// The sub page is served by the subscription HTTP server (sub/sub.go) -// at //?html=1. Go injects window.__SUB_PAGE_DATA__ -// with the parsed traffic/quota/expiry view-model and the rendered -// share links — the SPA reads those at mount. -import '@/composables/useTheme.js'; -import { i18n, readyI18n } from '@/i18n/index.js'; -import SubPage from '@/pages/sub/SubPage.vue'; - -const messageContainer = document.getElementById('message'); -if (messageContainer) { - message.config({ getContainer: () => messageContainer }); -} - -readyI18n().then(() => { - createApp(SubPage).use(Antd).use(i18n).mount('#app'); -}); diff --git a/frontend/src/entries/subpage.tsx b/frontend/src/entries/subpage.tsx new file mode 100644 index 00000000..fbe6ea75 --- /dev/null +++ b/frontend/src/entries/subpage.tsx @@ -0,0 +1,23 @@ +import { createRoot } from 'react-dom/client'; +import { message } from 'antd'; +import 'antd/dist/reset.css'; + +import { readyI18n } from '@/i18n/react'; +import { ThemeProvider } from '@/hooks/useTheme'; +import SubPage from '@/pages/sub/SubPage'; + +const messageContainer = document.getElementById('message'); +if (messageContainer) { + message.config({ getContainer: () => messageContainer }); +} + +readyI18n().then(() => { + const root = document.getElementById('app'); + if (root) { + createRoot(root).render( + + + , + ); + } +}); diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx new file mode 100644 index 00000000..b05d5c19 --- /dev/null +++ b/frontend/src/hooks/useTheme.tsx @@ -0,0 +1,129 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { theme as antdTheme } from 'antd'; +import type { ThemeConfig } from 'antd'; + +const STORAGE_DARK = 'dark-mode'; +const STORAGE_ULTRA = 'isUltraDarkThemeEnabled'; + +function readBool(key: string, fallback: boolean): boolean { + const raw = localStorage.getItem(key); + if (raw === null) return fallback; + return raw === 'true'; +} + +function applyDom(isDark: boolean, isUltra: boolean) { + document.body.setAttribute('class', isDark ? 'dark' : 'light'); + if (isUltra) { + document.documentElement.setAttribute('data-theme', 'ultra-dark'); + } else { + document.documentElement.removeAttribute('data-theme'); + } + const msg = document.getElementById('message'); + if (msg) msg.className = isDark ? 'dark' : 'light'; +} + +// Mirror the Vue useTheme module: apply current localStorage state at +// module load so the document is in the right theme before React mounts. +const initialDark = readBool(STORAGE_DARK, true); +const initialUltra = readBool(STORAGE_ULTRA, false); +applyDom(initialDark, initialUltra); + +const DARK_TOKENS = { + colorBgBase: '#1e1e1e', + colorBgLayout: '#1e1e1e', + colorBgContainer: '#252526', + colorBgElevated: '#2d2d30', +}; +const ULTRA_DARK_TOKENS = { + colorBgBase: '#000', + colorBgLayout: '#000', + colorBgContainer: '#0a0a0a', + colorBgElevated: '#141414', +}; +const DARK_LAYOUT_TOKENS = { + colorBgHeader: '#252526', + colorBgTrigger: '#333333', + colorBgBody: '#1e1e1e', +}; +const ULTRA_DARK_LAYOUT_TOKENS = { + colorBgHeader: '#0a0a0a', + colorBgTrigger: '#141414', + colorBgBody: '#000', +}; +const DARK_MENU_TOKENS = { + colorItemBg: '#252526', + colorSubItemBg: '#1e1e1e', + menuSubMenuBg: '#252526', +}; +const ULTRA_DARK_MENU_TOKENS = { + colorItemBg: '#0a0a0a', + colorSubItemBg: '#000', + menuSubMenuBg: '#0a0a0a', +}; + +export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig { + if (!isDark) { + return { algorithm: antdTheme.defaultAlgorithm }; + } + return { + algorithm: antdTheme.darkAlgorithm, + token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS, + components: { + Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS, + Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS, + }, + }; +} + +export function pauseAnimationsUntilLeave(elementId: string): void { + document.documentElement.setAttribute('data-theme-animations', 'off'); + const el = document.getElementById(elementId); + if (!el) return; + const restore = () => { + document.documentElement.removeAttribute('data-theme-animations'); + el.removeEventListener('mouseleave', restore); + el.removeEventListener('touchend', restore); + }; + el.addEventListener('mouseleave', restore); + el.addEventListener('touchend', restore); +} + +interface ThemeContextValue { + isDark: boolean; + isUltra: boolean; + toggleTheme: () => void; + toggleUltra: () => void; + antdThemeConfig: ThemeConfig; +} + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [isDark, setIsDark] = useState(initialDark); + const [isUltra, setIsUltra] = useState(initialUltra); + + useEffect(() => { + applyDom(isDark, isUltra); + localStorage.setItem(STORAGE_DARK, String(isDark)); + localStorage.setItem(STORAGE_ULTRA, String(isUltra)); + }, [isDark, isUltra]); + + const toggleTheme = useCallback(() => setIsDark((v) => !v), []); + const toggleUltra = useCallback(() => setIsUltra((v) => !v), []); + + const antdThemeConfig = useMemo(() => buildAntdThemeConfig(isDark, isUltra), [isDark, isUltra]); + + const value = useMemo( + () => ({ isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig }), + [isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig], + ); + + return {children}; +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used inside '); + return ctx; +} diff --git a/frontend/src/i18n/react.ts b/frontend/src/i18n/react.ts new file mode 100644 index 00000000..cb0bd521 --- /dev/null +++ b/frontend/src/i18n/react.ts @@ -0,0 +1,43 @@ +import i18next from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import { LanguageManager } from '@/utils'; +import enUS from '../../../web/translation/en-US.json'; + +const FALLBACK = 'en-US'; + +const lazyModules = import.meta.glob([ + '../../../web/translation/*.json', + '!../../../web/translation/en-US.json', +]); + +function moduleKeyFor(code: string): string { + return `../../../web/translation/${code}.json`; +} + +let active: string = LanguageManager.getLanguage(); +if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) { + active = FALLBACK; +} + +export async function readyI18n() { + await i18next.use(initReactI18next).init({ + lng: active, + fallbackLng: FALLBACK, + resources: { [FALLBACK]: { translation: enUS } }, + interpolation: { escapeValue: false }, + returnNull: false, + }); + if (active !== FALLBACK) { + const loader = lazyModules[moduleKeyFor(active)] as (() => Promise<{ default: Record }>) | undefined; + if (loader) { + const mod = await loader(); + const messages = (mod.default ?? mod) as Record; + i18next.addResourceBundle(active, 'translation', messages, true, true); + await i18next.changeLanguage(active); + } + } + return i18next; +} + +export { i18next as i18n }; diff --git a/frontend/src/pages/sub/SubPage.css b/frontend/src/pages/sub/SubPage.css new file mode 100644 index 00000000..b752662f --- /dev/null +++ b/frontend/src/pages/sub/SubPage.css @@ -0,0 +1,206 @@ +.subscription-page { + --bg-page: #e6e8ec; + --bg-card: #ffffff; + min-height: 100vh; + background: var(--bg-page); +} + +.subscription-page.is-dark { + --bg-page: #1e1e1e; + --bg-card: #252526; +} + +.subscription-page.is-dark.is-ultra { + --bg-page: #050505; + --bg-card: #0c0e12; +} + +.subscription-page .ant-layout, +.subscription-page .ant-layout-content { + background: transparent; +} + +.subscription-page .content { + padding: 24px 12px; +} + +.subscription-card { + margin-top: 8px; +} + +.qr-row { + margin-bottom: 12px; +} + +.qr-col { + display: flex; + justify-content: center; +} + +.qr-box { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 240px; +} + +.qr-tag { + width: 100%; + text-align: center; + margin: 0; +} + +.qr-code { + cursor: pointer; + padding: 0 !important; + background: #fff; + border-radius: 4px; +} + +.info-table { + margin-top: 12px; +} + +.info-table .ant-descriptions-view, +.info-table .ant-descriptions-view table, +.info-table .ant-descriptions-view th, +.info-table .ant-descriptions-view td { + border-color: rgba(0, 0, 0, 0.18) !important; +} + +.info-table tbody > tr > th, +.info-table tbody > tr > td { + border-bottom: 1px solid rgba(0, 0, 0, 0.18) !important; +} + +.info-table tbody > tr:last-child > th, +.info-table tbody > tr:last-child > td { + border-bottom: none !important; +} + +.is-dark .info-table .ant-descriptions-view, +.is-dark .info-table .ant-descriptions-view table, +.is-dark .info-table .ant-descriptions-view th, +.is-dark .info-table .ant-descriptions-view td { + border-color: rgba(255, 255, 255, 0.18) !important; +} + +.is-dark .info-table tbody > tr > th, +.is-dark .info-table tbody > tr > td { + border-bottom: 1px solid rgba(255, 255, 255, 0.18) !important; +} + +.is-dark .info-table tbody > tr:last-child > th, +.is-dark .info-table tbody > tr:last-child > td { + border-bottom: none !important; +} + +.links-section { + margin-top: 16px; +} + +.link-row { + position: relative; + margin-bottom: 16px; + text-align: center; +} + +.link-tag { + margin-bottom: -10px; + position: relative; + z-index: 2; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.link-box { + cursor: pointer; + border-radius: 12px; + padding: 22px 18px 14px; + margin-top: -10px; + word-break: break-all; + font-size: 13px; + line-height: 1.5; + text-align: left; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + transition: background 120ms ease, border-color 120ms ease; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.08); + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.08); +} + +.link-box:hover { + background: rgba(0, 0, 0, 0.05); + border-color: rgba(0, 0, 0, 0.14); +} + +.link-copy-icon { + margin-right: 6px; + opacity: 0.6; +} + +.is-dark .link-box { + background: rgba(0, 0, 0, 0.2); + border-color: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.85); +} + +.is-dark .link-box:hover { + background: rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.2); +} + +.apps-row { + margin-top: 24px; +} + +.app-col { + text-align: center; +} + +.settings-popover { + min-width: 220px; +} + +.theme-cycle { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid rgba(0, 0, 0, 0.08); + background: var(--bg-card); + color: rgba(0, 0, 0, 0.65); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + transition: background-color 0.2s, transform 0.15s, color 0.2s; +} + +.theme-cycle:hover, +.theme-cycle:focus-visible { + background-color: rgba(64, 150, 255, 0.1); + color: #4096ff; + transform: scale(1.05); + outline: none; +} + +.theme-cycle svg { + width: 16px; + height: 16px; +} + +.is-dark .theme-cycle { + border-color: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.85); +} + +.is-dark .theme-cycle:hover, +.is-dark .theme-cycle:focus-visible { + background-color: rgba(64, 150, 255, 0.1); + color: #4096ff; +} + +.lang-select { + width: 100%; +} diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx new file mode 100644 index 00000000..a6ba7da9 --- /dev/null +++ b/frontend/src/pages/sub/SubPage.tsx @@ -0,0 +1,383 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + Card, + Col, + ConfigProvider, + Descriptions, + Dropdown, + Layout, + message, + Popover, + QRCode, + Row, + Select, + Space, + Tag, +} from 'antd'; +import { + AndroidOutlined, + AppleOutlined, + CopyOutlined, + DownOutlined, + SettingOutlined, +} from '@ant-design/icons'; + +import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils'; +import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; +import './SubPage.css'; + +const QR_SIZE = 240; + +const subData = window.__SUB_PAGE_DATA__ || {}; + +const sId = subData.sId || ''; +const enabled = !!subData.enabled; +const download = subData.download || '0'; +const upload = subData.upload || '0'; +const total = subData.total || '∞'; +const used = subData.used || '0'; +const remained = subData.remained || ''; +const totalByte = Number(subData.totalByte || 0); +const expireMs = Number(subData.expire || 0) * 1000; +const lastOnlineMs = Number(subData.lastOnline || 0); +const subUrl = subData.subUrl || ''; +const subJsonUrl = subData.subJsonUrl || ''; +const subClashUrl = subData.subClashUrl || ''; +const subTitle = subData.subTitle || ''; +const links: string[] = Array.isArray(subData.links) ? subData.links : []; +const datepicker = subData.datepicker || 'gregorian'; + +const isUnlimited = totalByte <= 0 && expireMs === 0; +const isActive = (() => { + if (!enabled) return false; + if (totalByte > 0) { + const usedByteCalc = Number(subData.usedByte || 0) + || (Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0)); + if (usedByteCalc >= totalByte) return false; + } + if (expireMs > 0 && Date.now() >= expireMs) return false; + return true; +})(); + +function linkName(link: string, idx: number): string { + if (!link) return `Link ${idx + 1}`; + const hashIdx = link.indexOf('#'); + if (hashIdx >= 0 && hashIdx + 1 < link.length) { + try { + return decodeURIComponent(link.slice(hashIdx + 1)); + } catch { + return link.slice(hashIdx + 1); + } + } + const proto = link.split('://')[0]; + return `${proto.toUpperCase()} ${idx + 1}`; +} + +export default function SubPage() { + const { t } = useTranslation(); + const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme(); + + const [isMobile, setIsMobile] = useState(() => window.innerWidth < 576); + const [lang, setLang] = useState(() => LanguageManager.getLanguage()); + + useEffect(() => { + const onResize = () => setIsMobile(window.innerWidth < 576); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + const onLangChange = useCallback((next: string) => { + setLang(next); + LanguageManager.setLanguage(next); + }, []); + + const cycleTheme = useCallback(() => { + pauseAnimationsUntilLeave('sub-theme-cycle'); + if (!isDark) { + toggleTheme(); + if (isUltra) toggleUltra(); + } else if (!isUltra) { + toggleUltra(); + } else { + toggleUltra(); + toggleTheme(); + } + }, [isDark, isUltra, toggleTheme, toggleUltra]); + + const copy = useCallback(async (value: string) => { + if (!value) return; + const ok = await ClipboardManager.copyText(value); + if (ok) message.success(t('copied')); + }, [t]); + + const open = useCallback((url: string) => { + if (!url) return; + window.open(url, '_blank'); + }, []); + + const shadowrocketUrl = useMemo(() => { + if (!subUrl) return ''; + const separator = subUrl.includes('?') ? '&' : '?'; + const rawUrl = subUrl + separator + 'flag=shadowrocket'; + const base64Url = btoa(rawUrl).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const remark = encodeURIComponent(subTitle || sId || 'Subscription'); + return `shadowrocket://add/sub/${base64Url}?remark=${remark}`; + }, []); + + const v2boxUrl = useMemo( + () => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`, + [], + ); + const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []); + const happUrl = useMemo(() => `happ://add/${subUrl}`, []); + + const pageClass = useMemo(() => { + const classes = ['subscription-page']; + if (isDark) classes.push('is-dark'); + if (isUltra) classes.push('is-ultra'); + return classes.join(' '); + }, [isDark, isUltra]); + + const descriptionsItems = useMemo(() => { + const items = [ + { key: 'subId', label: t('subscription.subId'), children: sId }, + { + key: 'status', + label: t('subscription.status'), + children: !enabled + ? {t('subscription.inactive')} + : isUnlimited + ? {t('subscription.unlimited')} + : + {isActive ? t('subscription.active') : t('subscription.inactive')} + , + }, + { key: 'down', label: t('subscription.downloaded'), children: download }, + { key: 'up', label: t('subscription.uploaded'), children: upload }, + { key: 'used', label: t('usage'), children: used }, + { key: 'total', label: t('subscription.totalQuota'), children: total }, + ]; + if (totalByte > 0) { + items.push({ key: 'remained', label: t('remained'), children: remained }); + } + items.push({ + key: 'lastOnline', + label: t('lastOnline'), + children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker) : '-', + }); + items.push({ + key: 'expiry', + label: t('subscription.expiry'), + children: expireMs === 0 + ? t('subscription.noExpiry') + : IntlUtil.formatDate(expireMs, datepicker), + }); + return items; + }, [t]); + + const androidMenuItems = useMemo(() => [ + { + key: 'android-v2box', + label: 'V2Box', + onClick: () => open(`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`), + }, + { + key: 'android-v2rayng', + label: 'V2RayNG', + onClick: () => open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`), + }, + { key: 'android-singbox', label: 'Sing-box', onClick: () => copy(subUrl) }, + { key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) }, + { key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) }, + { key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) }, + ], [copy, open]); + + const iosMenuItems = useMemo(() => [ + { key: 'ios-shadowrocket', label: 'Shadowrocket', onClick: () => open(shadowrocketUrl) }, + { key: 'ios-v2box', label: 'V2Box', onClick: () => open(v2boxUrl) }, + { key: 'ios-streisand', label: 'Streisand', onClick: () => open(streisandUrl) }, + { key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) }, + { key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) }, + { key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) }, + ], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl]); + + const langOptions = useMemo( + () => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({ + value: l.value, + label: ( + <> + {l.icon} +   {l.name} + + ), + })), + [], + ); + + const themeIcon = !isDark ? ( + + ) : !isUltra ? ( + + ) : ( + + ); + + const cardTitle = ( + + {t('subscription.title')} + {sId} + + ); + + const cardExtra = ( + + + +