feat(sub): redesign the subscription page around usage, tabs and app imports
The info page was a long key/value table followed by every link and two app dropdowns, and it rendered left-to-right even for Persian and Arabic. It now leads with a usage ring, the remaining quota and a stats grid, and splits the rest into Subscription / Apps / Configs tabs. - Status tells expired, data-used-up and disabled apart instead of one "Inactive", replacing the hard-coded English expiry chip. - The Apps tab keeps every Android and iOS app with its existing deep link, preselects the visitor's platform and adds Windows: Hiddify and Clash Verge Rev import directly, v2rayN copies the link. - fa-IR and ar-EG render right-to-left; URLs, IDs and sizes stay LTR. - The footer shows the support link and the client refresh interval, so subPageContext now carries subUpdates (also in ?format=info). - Status, days-left and app deep-link logic lives in subPageModel.ts, with unit tests pinning the deep links the page already shipped.
@@ -15,6 +15,8 @@ interface SubPageData {
|
|||||||
subJsonUrl?: string;
|
subJsonUrl?: string;
|
||||||
subClashUrl?: string;
|
subClashUrl?: string;
|
||||||
subTitle?: string;
|
subTitle?: string;
|
||||||
|
subSupportUrl?: string;
|
||||||
|
subUpdates?: number;
|
||||||
links?: string[];
|
links?: string[];
|
||||||
emails?: string[];
|
emails?: string[];
|
||||||
datepicker?: 'gregorian' | 'jalalian';
|
datepicker?: 'gregorian' | 'jalalian';
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
.qr-panel-canvas .qr-code {
|
.qr-panel-canvas .qr-code {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export default function QrPanel({
|
|||||||
value={value}
|
value={value}
|
||||||
size={size}
|
size={size}
|
||||||
errorLevel="L"
|
errorLevel="L"
|
||||||
marginSize={4}
|
marginSize={2}
|
||||||
type="svg"
|
type="svg"
|
||||||
bordered={false}
|
bordered={false}
|
||||||
color="#000000"
|
color="#000000"
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Segmented } from 'antd';
|
||||||
|
import { AndroidOutlined, AppleOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { APP_ICONS } from './appIcons';
|
||||||
|
import type { AppPlatform, SubApp } from './subPageModel';
|
||||||
|
|
||||||
|
interface SubAppsTabProps {
|
||||||
|
apps: Record<AppPlatform, SubApp[]>;
|
||||||
|
initialPlatform: AppPlatform;
|
||||||
|
onOpen: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_OPTIONS = [
|
||||||
|
{ value: 'android' as const, label: 'Android', icon: <AndroidOutlined /> },
|
||||||
|
{ value: 'ios' as const, label: 'iOS', icon: <AppleOutlined /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
function AppIcon({ name }: { name: string }) {
|
||||||
|
const icon = APP_ICONS[name];
|
||||||
|
if (!icon) {
|
||||||
|
return (
|
||||||
|
<span className="sub-app-mark" aria-hidden="true">
|
||||||
|
{name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (icon.tinted) {
|
||||||
|
const mask = `url("${icon.src}")`;
|
||||||
|
return (
|
||||||
|
<span className="sub-app-mark" aria-hidden="true">
|
||||||
|
<span className="sub-app-glyph" style={{ maskImage: mask, WebkitMaskImage: mask }} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <img className="sub-app-logo" src={icon.src} alt="" width={32} height={32} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubAppsTab({ apps, initialPlatform, onOpen }: SubAppsTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [platform, setPlatform] = useState<AppPlatform>(initialPlatform);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-apps">
|
||||||
|
<Segmented<AppPlatform> value={platform} onChange={setPlatform} options={PLATFORM_OPTIONS} />
|
||||||
|
<div className="sub-app-grid">
|
||||||
|
{apps[platform].map((app) => (
|
||||||
|
<div key={app.name} className="sub-row">
|
||||||
|
<AppIcon name={app.name} />
|
||||||
|
<span className="sub-app-name">{app.name}</span>
|
||||||
|
<Button type="primary" size="small" onClick={() => onOpen(app.url)}>
|
||||||
|
{t('add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Tag } from 'antd';
|
||||||
|
import { CopyOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import ConfigBlock from '@/components/clients/ConfigBlock';
|
||||||
|
import {
|
||||||
|
amneziawgConfigFromLink,
|
||||||
|
isPostQuantumLink,
|
||||||
|
wireguardConfigFromLink,
|
||||||
|
} from '@/lib/xray/inbound-link';
|
||||||
|
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
|
||||||
|
import SubQrButton from './SubQrButton';
|
||||||
|
|
||||||
|
interface SubConfigsTabProps {
|
||||||
|
links: string[];
|
||||||
|
onCopy: (value: string, toast?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubConfigsTab({ links, onCopy }: SubConfigsTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-rows">
|
||||||
|
<div className="sub-configs-bar">
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => onCopy(links.join('\n'), t('subscription.copyAllConfigsCopied'))}
|
||||||
|
>
|
||||||
|
{t('subscription.copyAllConfigs')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{links.map((link, idx) => {
|
||||||
|
const parts = parseLinkParts(link);
|
||||||
|
const rowTitle = parts?.remark || `Link ${idx + 1}`;
|
||||||
|
const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://');
|
||||||
|
const isAmneziawgLink = link.startsWith('vpn://');
|
||||||
|
return (
|
||||||
|
<Fragment key={link}>
|
||||||
|
<div className="sub-row">
|
||||||
|
{parts ? <LinkTags parts={parts} /> : <Tag className="sub-row-tag">LINK</Tag>}
|
||||||
|
<span className="sub-row-title" dir="auto" title={rowTitle}>
|
||||||
|
{rowTitle}
|
||||||
|
</span>
|
||||||
|
<div className="sub-row-actions">
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => onCopy(link)}
|
||||||
|
aria-label={t('copy')}
|
||||||
|
title={t('copy')}
|
||||||
|
/>
|
||||||
|
{!isPostQuantumLink(link) && (
|
||||||
|
<SubQrButton value={link} label={rowTitle} onCopy={onCopy} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isWireguardLink && (
|
||||||
|
<ConfigBlock
|
||||||
|
label={t('pages.clients.wireguardConfig')}
|
||||||
|
text={wireguardConfigFromLink(link, rowTitle)}
|
||||||
|
fileName={`${rowTitle || 'peer'}.conf`}
|
||||||
|
qrRemark={rowTitle}
|
||||||
|
tagColor="cyan"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isAmneziawgLink && (
|
||||||
|
<ConfigBlock
|
||||||
|
label={t('pages.clients.amneziaWgConfig')}
|
||||||
|
text={amneziawgConfigFromLink(link)}
|
||||||
|
fileName={`${rowTitle || 'peer'}.conf`}
|
||||||
|
qrRemark={rowTitle}
|
||||||
|
tagColor="purple"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Menu, Popover, Space } from 'antd';
|
||||||
|
import {
|
||||||
|
MoonFilled,
|
||||||
|
MoonOutlined,
|
||||||
|
SunOutlined,
|
||||||
|
TranslationOutlined,
|
||||||
|
WifiOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { LanguageManager } from '@/utils';
|
||||||
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
|
|
||||||
|
interface SubHeaderProps {
|
||||||
|
title: string;
|
||||||
|
sId: string;
|
||||||
|
email: string;
|
||||||
|
lang: string;
|
||||||
|
onLangChange: (lang: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubHeader({ title, sId, email, lang, onLangChange }: SubHeaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||||
|
|
||||||
|
const cycleTheme = () => {
|
||||||
|
pauseAnimationsUntilLeave('sub-theme-cycle');
|
||||||
|
if (!isDark) {
|
||||||
|
toggleTheme();
|
||||||
|
if (isUltra) toggleUltra();
|
||||||
|
} else if (!isUltra) {
|
||||||
|
toggleUltra();
|
||||||
|
} else {
|
||||||
|
toggleUltra();
|
||||||
|
toggleTheme();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const langMenuItems = useMemo(
|
||||||
|
() =>
|
||||||
|
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
|
||||||
|
(l) => ({
|
||||||
|
key: l.value,
|
||||||
|
label: (
|
||||||
|
<Space size={8}>
|
||||||
|
<span aria-hidden="true">{l.icon}</span>
|
||||||
|
<span>{l.name}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
||||||
|
const initial = Array.from(title)[0]?.toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sub-header">
|
||||||
|
<div className="sub-brand">
|
||||||
|
<span className="sub-brand-mark" aria-hidden="true">
|
||||||
|
{initial ?? <WifiOutlined />}
|
||||||
|
</span>
|
||||||
|
<div className="sub-brand-text">
|
||||||
|
<div className="sub-brand-title" dir="auto">
|
||||||
|
{title || t('subscription.title')}
|
||||||
|
</div>
|
||||||
|
<div className="sub-brand-id">
|
||||||
|
<bdi>{email ? `${sId} - ${email}` : sId}</bdi>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sub-toolbar">
|
||||||
|
<Button
|
||||||
|
id="sub-theme-cycle"
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
className="toolbar-btn"
|
||||||
|
aria-label={t('menu.theme')}
|
||||||
|
title={t('menu.theme')}
|
||||||
|
icon={themeIcon}
|
||||||
|
onClick={cycleTheme}
|
||||||
|
/>
|
||||||
|
<Popover
|
||||||
|
rootClassName={isDark ? 'dark' : 'light'}
|
||||||
|
placement="bottomRight"
|
||||||
|
trigger="click"
|
||||||
|
styles={{ content: { padding: 4 } }}
|
||||||
|
content={
|
||||||
|
<Menu
|
||||||
|
mode="vertical"
|
||||||
|
selectable
|
||||||
|
selectedKeys={[lang]}
|
||||||
|
items={langMenuItems}
|
||||||
|
onClick={({ key }) => onLangChange(key)}
|
||||||
|
style={{ border: 'none', minWidth: 160 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
className="toolbar-btn"
|
||||||
|
aria-label={t('pages.settings.language')}
|
||||||
|
icon={<TranslationOutlined />}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Progress, Tag, theme } from 'antd';
|
||||||
|
|
||||||
|
import { IntlUtil } from '@/utils';
|
||||||
|
import type { CalendarKind } from '@/utils';
|
||||||
|
import { usagePercent } from './subPageModel';
|
||||||
|
import type { SubStatus } from './subPageModel';
|
||||||
|
|
||||||
|
interface SubHeroProps {
|
||||||
|
status: SubStatus;
|
||||||
|
daysLeft: number | null;
|
||||||
|
usedByte: number;
|
||||||
|
totalByte: number;
|
||||||
|
expireMs: number;
|
||||||
|
lastOnlineMs: number;
|
||||||
|
download: string;
|
||||||
|
upload: string;
|
||||||
|
used: string;
|
||||||
|
total: string;
|
||||||
|
remained: string;
|
||||||
|
datepicker: CalendarKind;
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_TAGS: Record<SubStatus, { color: string; label: string }> = {
|
||||||
|
active: { color: 'green', label: 'subscription.active' },
|
||||||
|
unlimited: { color: 'purple', label: 'subscription.unlimited' },
|
||||||
|
expired: { color: 'red', label: 'subscription.expired' },
|
||||||
|
depleted: { color: 'red', label: 'subscription.depleted' },
|
||||||
|
disabled: { color: 'red', label: 'subscription.inactive' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// FormatTraffic renders "37.60GB"; the amount and unit are sized apart.
|
||||||
|
function splitSize(label: string): [string, string] {
|
||||||
|
const match = /^([\d.,]+)\s*(\D*)$/.exec(label.trim());
|
||||||
|
return match ? [match[1], match[2]] : [label, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubHero({
|
||||||
|
status,
|
||||||
|
daysLeft,
|
||||||
|
usedByte,
|
||||||
|
totalByte,
|
||||||
|
expireMs,
|
||||||
|
lastOnlineMs,
|
||||||
|
download,
|
||||||
|
upload,
|
||||||
|
used,
|
||||||
|
total,
|
||||||
|
remained,
|
||||||
|
datepicker,
|
||||||
|
lang,
|
||||||
|
}: SubHeroProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const hasQuota = totalByte > 0;
|
||||||
|
const healthy = status === 'active' || status === 'unlimited';
|
||||||
|
const pct = usagePercent(usedByte, totalByte);
|
||||||
|
const ringColor =
|
||||||
|
!healthy || pct >= 90 ? token.colorError : pct >= 75 ? token.colorWarning : token.colorPrimary;
|
||||||
|
const [amount, unit] = splitSize(hasQuota ? remained : used);
|
||||||
|
const formatDate = (ms: number) => IntlUtil.formatDate(ms, datepicker, lang);
|
||||||
|
const statusTag = STATUS_TAGS[status];
|
||||||
|
|
||||||
|
const stats: { key: string; label: string; value: ReactNode }[] = [
|
||||||
|
{ key: 'days', label: t('subscription.daysLeft'), value: daysLeft ?? '∞' },
|
||||||
|
{
|
||||||
|
key: 'expiry',
|
||||||
|
label: t('subscription.expiry'),
|
||||||
|
value: expireMs > 0 ? formatDate(expireMs) : t('subscription.noExpiry'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: t('subscription.status'),
|
||||||
|
value: <Tag color={statusTag.color}>{t(statusTag.label)}</Tag>,
|
||||||
|
},
|
||||||
|
{ key: 'down', label: t('subscription.downloaded'), value: <bdi>{download}</bdi> },
|
||||||
|
{ key: 'up', label: t('subscription.uploaded'), value: <bdi>{upload}</bdi> },
|
||||||
|
{ key: 'total', label: t('subscription.totalQuota'), value: <bdi>{total}</bdi> },
|
||||||
|
{
|
||||||
|
key: 'lastOnline',
|
||||||
|
label: t('lastOnline'),
|
||||||
|
value: lastOnlineMs > 0 ? formatDate(lastOnlineMs) : '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={healthy ? 'sub-hero' : 'sub-hero is-alert'}>
|
||||||
|
<Progress
|
||||||
|
type="circle"
|
||||||
|
className="sub-ring"
|
||||||
|
percent={pct}
|
||||||
|
status="normal"
|
||||||
|
size={156}
|
||||||
|
strokeColor={ringColor}
|
||||||
|
format={() => (
|
||||||
|
<span className="sub-ring-center">
|
||||||
|
<span className="sub-ring-value">{hasQuota ? `${pct.toFixed(1)}%` : '∞'}</span>
|
||||||
|
<span className="sub-ring-label">
|
||||||
|
{hasQuota ? t('usage') : t('subscription.unlimited')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="sub-hero-summary">
|
||||||
|
<div className="sub-label">{hasQuota ? t('remained') : t('usage')}</div>
|
||||||
|
<bdi className="sub-big">
|
||||||
|
<span className="sub-big-num">{amount}</span>
|
||||||
|
{unit && <span className="sub-big-unit">{unit}</span>}
|
||||||
|
</bdi>
|
||||||
|
<div className="sub-muted">
|
||||||
|
{hasQuota ? t('subscription.ofTotal', { total }) : t('subscription.unlimited')}
|
||||||
|
</div>
|
||||||
|
<dl className="sub-stats">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<div key={stat.key} className="sub-stat">
|
||||||
|
<dt className="sub-label">{stat.label}</dt>
|
||||||
|
<dd className="sub-stat-value">{stat.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, QRCode, Tag } from 'antd';
|
||||||
|
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import SubQrButton from './SubQrButton';
|
||||||
|
|
||||||
|
interface SubLinksTabProps {
|
||||||
|
subUrl: string;
|
||||||
|
subJsonUrl: string;
|
||||||
|
subClashUrl: string;
|
||||||
|
onCopy: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
|
||||||
|
|
||||||
|
export default function SubLinksTab({ subUrl, subJsonUrl, subClashUrl, onCopy }: SubLinksTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const subLabel = t('pages.settings.subSettings');
|
||||||
|
const rows = [
|
||||||
|
{ kind: 'SUB', color: 'green', url: subUrl, title: subLabel, downloadable: false },
|
||||||
|
{
|
||||||
|
kind: 'JSON',
|
||||||
|
color: 'purple',
|
||||||
|
url: subJsonUrl,
|
||||||
|
title: `${subLabel} JSON`,
|
||||||
|
downloadable: true,
|
||||||
|
},
|
||||||
|
{ kind: 'CLASH', color: 'gold', url: subClashUrl, title: 'Clash / Mihomo', downloadable: true },
|
||||||
|
].filter((row) => row.url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-rows">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.kind} className="sub-row">
|
||||||
|
<Tag color={row.color} className="sub-row-tag">
|
||||||
|
{row.kind}
|
||||||
|
</Tag>
|
||||||
|
<div className="sub-row-main">
|
||||||
|
<a href={row.url} target="_blank" rel="noopener noreferrer" className="sub-row-title">
|
||||||
|
{row.title}
|
||||||
|
</a>
|
||||||
|
<div className="sub-row-url" dir="ltr" title={row.url}>
|
||||||
|
{row.url}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sub-row-actions">
|
||||||
|
{row.downloadable && (
|
||||||
|
<Button
|
||||||
|
href={appendRawView(row.url)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
aria-label={t('download')}
|
||||||
|
title={t('download')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => onCopy(row.url)}
|
||||||
|
aria-label={t('copy')}
|
||||||
|
title={t('copy')}
|
||||||
|
/>
|
||||||
|
<SubQrButton value={row.url} label={row.title} onCopy={onCopy} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{subUrl && (
|
||||||
|
<div className="sub-qr-card">
|
||||||
|
<div className="sub-qr-code">
|
||||||
|
<QRCode
|
||||||
|
value={subUrl}
|
||||||
|
size={112}
|
||||||
|
type="svg"
|
||||||
|
bordered={false}
|
||||||
|
color="#000000"
|
||||||
|
bgColor="#ffffff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="sub-qr-title">{t('subscription.scanTitle')}</div>
|
||||||
|
<div className="sub-muted">{t('subscription.scanHint')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,78 @@
|
|||||||
.subscription-page {
|
.subscription-page {
|
||||||
--bg-page: #e6e8ec;
|
/* --sub-grad-* paints graphics, --sub-ink-* paints text: the cyan end darkens
|
||||||
--bg-card: #ffffff;
|
so text clears 4.5:1. --sub-accent is ACCENT.primary in SubPage.tsx. */
|
||||||
|
--sub-grad-from: #8b5cf6;
|
||||||
|
--sub-grad-to: #06b6d4;
|
||||||
|
--sub-ink-from: #6d28d9;
|
||||||
|
--sub-ink-to: #0e7490;
|
||||||
|
--sub-accent: #7c3aed;
|
||||||
|
--bg-page: linear-gradient(135deg, #e9e4ff 0%, #ddeefc 52%, #e2f7f2 100%);
|
||||||
|
--sub-card-bg: rgba(255, 255, 255, 0.72);
|
||||||
|
--sub-card-border: rgba(255, 255, 255, 0.7);
|
||||||
|
--sub-card-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 20px 56px rgba(124, 58, 237, 0.16);
|
||||||
|
--sub-card-sheen: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(255, 255, 255, 0.75),
|
||||||
|
rgba(255, 255, 255, 0) 42%,
|
||||||
|
rgba(124, 58, 237, 0.22) 88%
|
||||||
|
);
|
||||||
|
--sub-blob-1: rgba(139, 92, 246, 0.55);
|
||||||
|
--sub-blob-2: rgba(6, 182, 212, 0.45);
|
||||||
|
--sub-grid: rgba(124, 58, 237, 0.06);
|
||||||
|
--sub-hairline: linear-gradient(90deg, rgba(139, 92, 246, 0.4), rgba(6, 182, 212, 0.4));
|
||||||
|
--sub-tile-bg: rgba(124, 58, 237, 0.05);
|
||||||
|
--sub-tile-border: rgba(124, 58, 237, 0.12);
|
||||||
|
--sub-row-bg: rgba(124, 58, 237, 0.045);
|
||||||
|
--sub-row-border: rgba(124, 58, 237, 0.11);
|
||||||
|
--sub-row-bg-hover: rgba(124, 58, 237, 0.09);
|
||||||
|
--sub-row-border-hover: rgba(124, 58, 237, 0.3);
|
||||||
|
--sub-row-glow: rgba(124, 58, 237, 0.28);
|
||||||
|
--sub-glass-bg: rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--bg-page);
|
background: var(--bg-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-page.is-dark {
|
.subscription-page.is-dark {
|
||||||
--bg-page: #1a1b1f;
|
--sub-grad-from: #a78bfa;
|
||||||
--bg-card: #23252b;
|
--sub-grad-to: #22d3ee;
|
||||||
|
--sub-ink-from: #c4b5fd;
|
||||||
|
--sub-ink-to: #67e8f9;
|
||||||
|
--sub-accent: #a78bfa;
|
||||||
|
--bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #1f1740 0%, #16171d 52%, #101116 100%);
|
||||||
|
--sub-card-bg: rgba(35, 37, 43, 0.62);
|
||||||
|
--sub-card-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 24px 64px rgba(109, 40, 217, 0.24);
|
||||||
|
--sub-card-sheen: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(255, 255, 255, 0.16),
|
||||||
|
rgba(255, 255, 255, 0) 42%,
|
||||||
|
rgba(167, 139, 250, 0.4) 88%
|
||||||
|
);
|
||||||
|
--sub-blob-1: rgba(139, 92, 246, 0.4);
|
||||||
|
--sub-blob-2: rgba(34, 211, 238, 0.26);
|
||||||
|
--sub-grid: rgba(255, 255, 255, 0.035);
|
||||||
|
--sub-hairline: linear-gradient(90deg, rgba(167, 139, 250, 0.45), rgba(34, 211, 238, 0.45));
|
||||||
|
--sub-tile-bg: rgba(167, 139, 250, 0.07);
|
||||||
|
--sub-tile-border: rgba(167, 139, 250, 0.14);
|
||||||
|
--sub-row-bg: rgba(167, 139, 250, 0.06);
|
||||||
|
--sub-row-border: rgba(167, 139, 250, 0.12);
|
||||||
|
--sub-row-bg-hover: rgba(167, 139, 250, 0.12);
|
||||||
|
--sub-row-border-hover: rgba(167, 139, 250, 0.35);
|
||||||
|
--sub-row-glow: rgba(139, 92, 246, 0.45);
|
||||||
|
--sub-glass-bg: rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-page.is-dark.is-ultra {
|
.subscription-page.is-dark.is-ultra {
|
||||||
--bg-page: #000;
|
--bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #120a2b 0%, #050509 55%, #000 100%);
|
||||||
--bg-card: #101013;
|
--sub-card-bg: rgba(16, 16, 19, 0.68);
|
||||||
|
--sub-card-border: rgba(255, 255, 255, 0.055);
|
||||||
|
--sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.6), 0 24px 64px rgba(88, 28, 135, 0.3);
|
||||||
|
--sub-blob-1: rgba(139, 92, 246, 0.22);
|
||||||
|
--sub-blob-2: rgba(34, 211, 238, 0.14);
|
||||||
|
--sub-grid: rgba(255, 255, 255, 0.022);
|
||||||
|
--sub-glass-bg: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-page .ant-layout,
|
.subscription-page .ant-layout,
|
||||||
@@ -20,104 +80,204 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-page .content {
|
/* aurora backdrop */
|
||||||
padding: 24px 12px;
|
.sub-aurora {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-card {
|
.sub-aurora-grid {
|
||||||
margin-top: 8px;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--sub-grid) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--sub-grid) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
background-position: center;
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
|
||||||
|
mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-tag {
|
.sub-aurora::before,
|
||||||
width: 100%;
|
.sub-aurora::after {
|
||||||
text-align: center;
|
content: '';
|
||||||
margin: 0;
|
position: absolute;
|
||||||
|
width: 70vmax;
|
||||||
|
height: 70vmax;
|
||||||
|
max-width: 820px;
|
||||||
|
max-height: 820px;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(80px);
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-table {
|
.sub-aurora::before {
|
||||||
margin-top: 4px;
|
top: -22vmax;
|
||||||
|
left: -16vmax;
|
||||||
|
background: radial-gradient(circle, var(--sub-blob-1) 0%, transparent 65%);
|
||||||
|
animation: sub-blob-a 28s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.links-section {
|
.sub-aurora::after {
|
||||||
display: flex;
|
bottom: -24vmax;
|
||||||
flex-direction: column;
|
right: -18vmax;
|
||||||
gap: 8px;
|
background: radial-gradient(circle, var(--sub-blob-2) 0%, transparent 65%);
|
||||||
|
animation: sub-blob-b 34s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-link-anchor {
|
@keyframes sub-blob-a {
|
||||||
color: inherit;
|
0% {
|
||||||
text-decoration: none;
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(16vw, 14vh) scale(1.18);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-link-anchor:hover {
|
@keyframes sub-blob-b {
|
||||||
text-decoration: underline;
|
0% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-14vw, -12vh) scale(1.15);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-link-row {
|
.sub-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-card {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-page .sub-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--sub-card-border);
|
||||||
|
background: var(--sub-card-bg);
|
||||||
|
box-shadow: var(--sub-card-shadow);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||||
|
backdrop-filter: blur(24px) saturate(180%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hairline gradient rim: a padded sheen layer with its own middle masked out. */
|
||||||
|
.subscription-page .sub-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: var(--sub-card-sheen);
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#000 0 0) content-box,
|
||||||
|
linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-card > .ant-card-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-label,
|
||||||
|
.sub-muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-muted {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient hairline shared by the header, the stats grid and the footer. */
|
||||||
|
.sub-header::after,
|
||||||
|
.sub-stats::before,
|
||||||
|
.sub-footer::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--sub-hairline);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* header */
|
||||||
|
.sub-header {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
padding: 8px 12px;
|
gap: 12px;
|
||||||
border-radius: 10px;
|
padding-bottom: 20px;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
margin-bottom: 24px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
||||||
transition:
|
|
||||||
background 120ms ease,
|
|
||||||
border-color 120ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-link-row:hover {
|
.sub-header::after {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
bottom: 0;
|
||||||
border-color: rgba(0, 0, 0, 0.14);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-dark .sub-link-row {
|
.sub-brand {
|
||||||
background: rgba(0, 0, 0, 0.2);
|
display: flex;
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
align-items: center;
|
||||||
}
|
gap: 12px;
|
||||||
|
|
||||||
.is-dark .sub-link-row:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-link-tag {
|
|
||||||
margin: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-link-title {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 13px;
|
}
|
||||||
|
|
||||||
|
.sub-brand-mark {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 13px;
|
||||||
|
background: linear-gradient(135deg, var(--sub-grad-from), var(--sub-grad-to));
|
||||||
|
box-shadow: 0 8px 20px -8px var(--sub-row-glow);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-brand-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-brand-title,
|
||||||
|
.sub-brand-id {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-link-actions {
|
.sub-brand-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-brand-id {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-link-qr-popover {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.apps-row {
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-col {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn {
|
.toolbar-btn {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -129,3 +289,436 @@
|
|||||||
.toolbar-btn .anticon {
|
.toolbar-btn .anticon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subscription-page .toolbar-btn {
|
||||||
|
border-color: var(--sub-row-border);
|
||||||
|
background: var(--sub-glass-bg);
|
||||||
|
color: var(--sub-accent);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-page .toolbar-btn:hover {
|
||||||
|
border-color: var(--sub-row-border-hover);
|
||||||
|
background: var(--sub-row-bg-hover);
|
||||||
|
color: var(--sub-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-announce {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* usage hero */
|
||||||
|
.sub-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 32px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-ring .ant-progress-text {
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-ring-center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-ring-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-ring-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-big {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 2px 0;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-big-num {
|
||||||
|
font-size: 44px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: linear-gradient(135deg, var(--sub-ink-from), var(--sub-ink-to));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
color: var(--sub-ink-from);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-big-unit {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--ant-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-hero.is-alert .sub-big-num {
|
||||||
|
background: none;
|
||||||
|
-webkit-text-fill-color: var(--ant-color-error);
|
||||||
|
color: var(--ant-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stats {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin: 20px 0 0;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stats::before {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stat {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--sub-tile-border);
|
||||||
|
background: var(--sub-tile-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stat-value {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stat-value .ant-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tabs */
|
||||||
|
.sub-tabs {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tabs.ant-tabs .ant-tabs-ink-bar {
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(90deg, var(--sub-grad-from), var(--sub-grad-to));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tab-count {
|
||||||
|
margin-inline-start: 6px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--sub-tile-bg);
|
||||||
|
border: 1px solid var(--sub-tile-border);
|
||||||
|
color: var(--sub-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--sub-row-bg);
|
||||||
|
border: 1px solid var(--sub-row-border);
|
||||||
|
transition:
|
||||||
|
background 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row:hover {
|
||||||
|
background: var(--sub-row-bg-hover);
|
||||||
|
border-color: var(--sub-row-border-hover);
|
||||||
|
box-shadow: 0 8px 20px -14px var(--sub-row-glow);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row-tag {
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row > .sub-row-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sub-row-title:hover {
|
||||||
|
color: var(--sub-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir='rtl'] .sub-row-url,
|
||||||
|
[dir='rtl'] .sub-row > .sub-row-title {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal .ant-modal-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal .ant-modal-close {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal-hint {
|
||||||
|
margin: 2px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal-code {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal-code canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal-link {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal-actions > .ant-btn:first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px dashed var(--sub-row-border-hover);
|
||||||
|
background: var(--sub-row-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-code {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-title {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* apps */
|
||||||
|
.sub-apps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-app-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-app-mark {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid var(--sub-tile-border);
|
||||||
|
background: linear-gradient(135deg, var(--sub-row-bg-hover), var(--sub-tile-bg));
|
||||||
|
color: var(--sub-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-app-glyph {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: currentColor;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
mask-position: center;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
mask-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-app-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 0 0 1px var(--sub-tile-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-app-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* configs */
|
||||||
|
.sub-configs-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* footer */
|
||||||
|
.sub-footer {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-top: 28px;
|
||||||
|
padding-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-footer::before {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-footer > span,
|
||||||
|
.sub-footer > a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sub-aurora::before,
|
||||||
|
.sub-aurora::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.sub-content {
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-card > .ant-card-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One static blob: two animated 70vmax blurs drop frames on low-end phones. */
|
||||||
|
.sub-aurora::before {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-aurora::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-hero {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-ring {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-big-num {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stats {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-app-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tabs .ant-tabs-tab-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,98 +1,89 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, Card, ConfigProvider, Layout, Tabs, message } from 'antd';
|
||||||
|
import type { TabsProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
Alert,
|
AppstoreOutlined,
|
||||||
Button,
|
ClockCircleOutlined,
|
||||||
Card,
|
CustomerServiceOutlined,
|
||||||
Col,
|
LinkOutlined,
|
||||||
ConfigProvider,
|
UnorderedListOutlined,
|
||||||
Descriptions,
|
|
||||||
Divider,
|
|
||||||
Dropdown,
|
|
||||||
Layout,
|
|
||||||
Menu,
|
|
||||||
message,
|
|
||||||
Popover,
|
|
||||||
QRCode,
|
|
||||||
Row,
|
|
||||||
Space,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
AndroidOutlined,
|
|
||||||
AppleOutlined,
|
|
||||||
CopyOutlined,
|
|
||||||
DownOutlined,
|
|
||||||
DownloadOutlined,
|
|
||||||
MoonFilled,
|
|
||||||
MoonOutlined,
|
|
||||||
QrcodeOutlined,
|
|
||||||
SunOutlined,
|
|
||||||
TranslationOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
import { ClipboardManager, LanguageManager } from '@/utils';
|
||||||
import {
|
|
||||||
amneziawgConfigFromLink,
|
|
||||||
isPostQuantumLink,
|
|
||||||
wireguardConfigFromLink,
|
|
||||||
} from '@/lib/xray/inbound-link';
|
|
||||||
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
|
|
||||||
import ConfigBlock from '@/components/clients/ConfigBlock';
|
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import SubAppsTab from './SubAppsTab';
|
||||||
import SubUsageSummary from './SubUsageSummary';
|
import SubConfigsTab from './SubConfigsTab';
|
||||||
|
import SubHeader from './SubHeader';
|
||||||
|
import SubHero from './SubHero';
|
||||||
|
import SubLinksTab from './SubLinksTab';
|
||||||
|
import { buildSubApps, daysUntil, detectPlatform, resolveSubStatus } from './subPageModel';
|
||||||
import './SubPage.css';
|
import './SubPage.css';
|
||||||
|
|
||||||
const QR_SIZE = 240;
|
|
||||||
|
|
||||||
const subData = window.__SUB_PAGE_DATA__ || {};
|
const subData = window.__SUB_PAGE_DATA__ || {};
|
||||||
|
|
||||||
const sId = subData.sId || '';
|
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 subUrl = subData.subUrl || '';
|
||||||
const subJsonUrl = subData.subJsonUrl || '';
|
const subJsonUrl = subData.subJsonUrl || '';
|
||||||
const subClashUrl = subData.subClashUrl || '';
|
const subClashUrl = subData.subClashUrl || '';
|
||||||
const subTitle = subData.subTitle || '';
|
const subTitle = subData.subTitle || '';
|
||||||
|
const subSupportUrl = subData.subSupportUrl || '';
|
||||||
|
const updateHours = Number(subData.subUpdates || 0);
|
||||||
|
const announce = subData.announce || '';
|
||||||
const links: string[] = Array.isArray(subData.links) ? subData.links : [];
|
const links: string[] = Array.isArray(subData.links) ? subData.links : [];
|
||||||
const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
|
const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
|
||||||
const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
|
const totalByte = Number(subData.totalByte || 0);
|
||||||
const datepicker = subData.datepicker || 'gregorian';
|
const usedByte =
|
||||||
const announce = subData.announce || '';
|
Number(subData.usedByte || 0) ||
|
||||||
|
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0);
|
||||||
|
const expireMs = Number(subData.expire || 0) * 1000;
|
||||||
|
const clientEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
|
||||||
|
const loadedAt = Date.now();
|
||||||
|
|
||||||
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
|
const heroData = {
|
||||||
|
status: resolveSubStatus({ enabled: !!subData.enabled, usedByte, totalByte, expireMs }, loadedAt),
|
||||||
|
daysLeft: daysUntil(expireMs, loadedAt),
|
||||||
|
usedByte,
|
||||||
|
totalByte,
|
||||||
|
expireMs,
|
||||||
|
lastOnlineMs: Number(subData.lastOnline || 0),
|
||||||
|
download: subData.download || '0',
|
||||||
|
upload: subData.upload || '0',
|
||||||
|
used: subData.used || '0',
|
||||||
|
total: subData.total || '∞',
|
||||||
|
remained: subData.remained || '',
|
||||||
|
datepicker: subData.datepicker || 'gregorian',
|
||||||
|
};
|
||||||
|
|
||||||
const isUnlimited = totalByte <= 0 && expireMs === 0;
|
const apps = buildSubApps({ subUrl, sId, subTitle });
|
||||||
const isActive = (() => {
|
const initialPlatform = detectPlatform(navigator.userAgent);
|
||||||
if (!enabled) return false;
|
const RTL_LANGUAGES = new Set(['fa-IR', 'ar-EG']);
|
||||||
if (totalByte > 0) {
|
|
||||||
const usedByteCalc =
|
// The sub page runs its own violet accent, so every antd control on it picks the
|
||||||
Number(subData.usedByte || 0) ||
|
// hue up instead of the panel blue useTheme pins. Mirrored in SubPage.css.
|
||||||
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0);
|
const ACCENT = {
|
||||||
if (usedByteCalc >= totalByte) return false;
|
light: {
|
||||||
}
|
primary: '#7c3aed',
|
||||||
if (expireMs > 0 && Date.now() >= expireMs) return false;
|
hover: '#8b5cf6',
|
||||||
return true;
|
active: '#6d28d9',
|
||||||
})();
|
rail: 'rgba(124, 58, 237, 0.16)',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
primary: '#a78bfa',
|
||||||
|
hover: '#c4b5fd',
|
||||||
|
active: '#8b5cf6',
|
||||||
|
rail: 'rgba(167, 139, 250, 0.18)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function SubPage() {
|
export default function SubPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessageInstance(messageApi);
|
setMessageInstance(messageApi);
|
||||||
}, [messageApi]);
|
}, [messageApi]);
|
||||||
const { isMobile } = useMediaQuery(576);
|
|
||||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
|
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
|
||||||
|
|
||||||
const onLangChange = useCallback((next: string) => {
|
const onLangChange = useCallback((next: string) => {
|
||||||
@@ -100,538 +91,124 @@ export default function SubPage() {
|
|||||||
LanguageManager.setLanguage(next, 'subscription');
|
LanguageManager.setLanguage(next, 'subscription');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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(
|
const copy = useCallback(
|
||||||
async (value: string) => {
|
async (value: string, toast?: string) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
const ok = await ClipboardManager.copyText(value);
|
const ok = await ClipboardManager.copyText(value);
|
||||||
if (ok) messageApi.success(t('copied'));
|
if (ok) messageApi.success(toast ?? t('copied'));
|
||||||
},
|
},
|
||||||
[t, messageApi],
|
[t, messageApi],
|
||||||
);
|
);
|
||||||
|
|
||||||
const copyAll = useCallback(async () => {
|
|
||||||
if (links.length === 0) return;
|
|
||||||
const allLinks = links.join('\n');
|
|
||||||
const ok = await ClipboardManager.copyText(allLinks);
|
|
||||||
if (ok) messageApi.success(t('subscription.copyAllConfigsCopied'));
|
|
||||||
}, [t, messageApi]);
|
|
||||||
|
|
||||||
const open = useCallback((url: string) => {
|
const open = useCallback((url: string) => {
|
||||||
if (!url) return;
|
if (url) window.open(url, '_blank');
|
||||||
window.open(url, '_blank');
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const shadowrocketUrl = useMemo(() => {
|
const tabs = useMemo(() => {
|
||||||
if (!subUrl) return '';
|
const items: NonNullable<TabsProps['items']> = [];
|
||||||
const separator = subUrl.includes('?') ? '&' : '?';
|
if (subUrl || subJsonUrl || subClashUrl) {
|
||||||
const rawUrl = subUrl + separator + 'flag=shadowrocket';
|
items.push({
|
||||||
const base64Url = btoa(rawUrl);
|
key: 'subscription',
|
||||||
const remark = encodeURIComponent(subTitle || sId || 'Subscription');
|
icon: <LinkOutlined />,
|
||||||
return `shadowrocket://add/sub://${base64Url}?remark=${remark}`;
|
label: t('subscription.tabLinks'),
|
||||||
}, []);
|
children: (
|
||||||
|
<SubLinksTab
|
||||||
const v2boxUrl = useMemo(
|
subUrl={subUrl}
|
||||||
() => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`,
|
subJsonUrl={subJsonUrl}
|
||||||
[],
|
subClashUrl={subClashUrl}
|
||||||
);
|
onCopy={copy}
|
||||||
const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []);
|
|
||||||
const happUrl = useMemo(() => `happ://add/${subUrl}`, []);
|
|
||||||
const incyUrl = useMemo(() => `incy://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 },
|
|
||||||
...(subEmail ? [{ key: 'email', label: t('subscription.email'), children: subEmail }] : []),
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
label: t('subscription.status'),
|
|
||||||
children: !enabled ? (
|
|
||||||
<Tag color="red">{t('subscription.inactive')}</Tag>
|
|
||||||
) : isUnlimited ? (
|
|
||||||
<Tag color="purple">{t('subscription.unlimited')}</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color={isActive ? 'green' : 'red'}>
|
|
||||||
{isActive ? t('subscription.active') : t('subscription.inactive')}
|
|
||||||
</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ 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, lang) : '-',
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
key: 'expiry',
|
|
||||||
label: t('subscription.expiry'),
|
|
||||||
children:
|
|
||||||
expireMs === 0
|
|
||||||
? t('subscription.noExpiry')
|
|
||||||
: IntlUtil.formatDate(expireMs, datepicker, lang),
|
|
||||||
});
|
|
||||||
return items;
|
|
||||||
}, [t, lang]);
|
|
||||||
|
|
||||||
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}`) },
|
|
||||||
{ key: 'android-incy', label: 'Incy', onClick: () => open(`incy://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) },
|
|
||||||
{ key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) },
|
|
||||||
],
|
|
||||||
[copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl],
|
|
||||||
);
|
|
||||||
|
|
||||||
const langMenuItems = useMemo(
|
|
||||||
() =>
|
|
||||||
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
|
|
||||||
(l) => ({
|
|
||||||
key: l.value,
|
|
||||||
label: (
|
|
||||||
<Space size={8}>
|
|
||||||
<span aria-hidden="true">{l.icon}</span>
|
|
||||||
<span>{l.name}</span>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
|
||||||
|
|
||||||
const cardTitle = (
|
|
||||||
<Space>
|
|
||||||
<span>{t('subscription.title')}</span>
|
|
||||||
<Tag>{sId}</Tag>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
|
|
||||||
const cardExtra = (
|
|
||||||
<Space size={8} align="center">
|
|
||||||
<Button
|
|
||||||
shape="circle"
|
|
||||||
size="large"
|
|
||||||
className="toolbar-btn"
|
|
||||||
aria-label={t('menu.theme')}
|
|
||||||
title={t('menu.theme')}
|
|
||||||
icon={themeIcon}
|
|
||||||
onClick={cycleTheme}
|
|
||||||
/>
|
|
||||||
<Popover
|
|
||||||
rootClassName={isDark ? 'dark' : 'light'}
|
|
||||||
placement="bottomRight"
|
|
||||||
trigger="click"
|
|
||||||
styles={{ content: { padding: 4 } }}
|
|
||||||
content={
|
|
||||||
<Menu
|
|
||||||
mode="vertical"
|
|
||||||
selectable
|
|
||||||
selectedKeys={[lang]}
|
|
||||||
items={langMenuItems}
|
|
||||||
onClick={({ key }) => onLangChange(key)}
|
|
||||||
style={{ border: 'none', minWidth: 160 }}
|
|
||||||
/>
|
/>
|
||||||
}
|
),
|
||||||
>
|
});
|
||||||
<Button
|
}
|
||||||
shape="circle"
|
if (subUrl) {
|
||||||
size="large"
|
items.push({
|
||||||
className="toolbar-btn"
|
key: 'apps',
|
||||||
aria-label={t('pages.settings.language')}
|
icon: <AppstoreOutlined />,
|
||||||
icon={<TranslationOutlined />}
|
label: t('subscription.tabApps'),
|
||||||
/>
|
children: <SubAppsTab apps={apps} initialPlatform={initialPlatform} onOpen={open} />,
|
||||||
</Popover>
|
});
|
||||||
</Space>
|
}
|
||||||
);
|
if (links.length > 0) {
|
||||||
|
items.push({
|
||||||
|
key: 'configs',
|
||||||
|
icon: <UnorderedListOutlined />,
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
{t('subscription.tabConfigs')}
|
||||||
|
<span className="sub-tab-count">{links.length}</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
children: <SubConfigsTab links={links} onCopy={copy} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [t, copy, open]);
|
||||||
|
|
||||||
|
const direction = RTL_LANGUAGES.has(lang) ? 'rtl' : 'ltr';
|
||||||
|
const pageClass = ['subscription-page', isDark && 'is-dark', isUltra && 'is-ultra']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const themeConfig = useMemo(() => {
|
||||||
|
const accent = isDark ? ACCENT.dark : ACCENT.light;
|
||||||
|
const primary = {
|
||||||
|
colorPrimary: accent.primary,
|
||||||
|
colorPrimaryHover: accent.hover,
|
||||||
|
colorPrimaryActive: accent.active,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...antdThemeConfig,
|
||||||
|
token: {
|
||||||
|
...antdThemeConfig.token,
|
||||||
|
...primary,
|
||||||
|
colorLink: accent.primary,
|
||||||
|
colorInfo: accent.primary,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
...antdThemeConfig.components,
|
||||||
|
Button: { ...antdThemeConfig.components?.Button, ...primary },
|
||||||
|
Progress: { ...antdThemeConfig.components?.Progress, remainingColor: accent.rail },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [antdThemeConfig, isDark]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={themeConfig} direction={direction}>
|
||||||
{messageContextHolder}
|
{messageContextHolder}
|
||||||
<Layout className={pageClass}>
|
<Layout className={pageClass} dir={direction}>
|
||||||
<Layout.Content className="content">
|
<div className="sub-aurora" aria-hidden="true">
|
||||||
<Row justify="center">
|
<span className="sub-aurora-grid" />
|
||||||
<Col xs={24} sm={22} md={18} lg={14} xl={12}>
|
</div>
|
||||||
<Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}>
|
<Layout.Content className="sub-content">
|
||||||
{announce && (
|
<Card className="sub-card">
|
||||||
<Alert type="info" showIcon title={announce} style={{ marginBottom: 16 }} />
|
<SubHeader
|
||||||
|
title={subTitle}
|
||||||
|
sId={sId}
|
||||||
|
email={clientEmail}
|
||||||
|
lang={lang}
|
||||||
|
onLangChange={onLangChange}
|
||||||
|
/>
|
||||||
|
{announce && <Alert type="info" showIcon title={announce} className="sub-announce" />}
|
||||||
|
<SubHero {...heroData} lang={lang} />
|
||||||
|
{tabs.length > 0 && <Tabs className="sub-tabs" tabBarGutter={24} items={tabs} />}
|
||||||
|
{(updateHours > 0 || subSupportUrl) && (
|
||||||
|
<footer className="sub-footer">
|
||||||
|
{updateHours > 0 && (
|
||||||
|
<span>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
{t('subscription.updateInterval', { hours: updateHours })}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<Descriptions
|
{subSupportUrl && (
|
||||||
bordered
|
<a href={subSupportUrl} target="_blank" rel="noopener noreferrer">
|
||||||
column={1}
|
<CustomerServiceOutlined />
|
||||||
size="small"
|
{t('subscription.support')}
|
||||||
className="info-table"
|
</a>
|
||||||
items={descriptionsItems}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SubUsageSummary
|
|
||||||
usedByte={
|
|
||||||
Number(subData.usedByte || 0) ||
|
|
||||||
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0)
|
|
||||||
}
|
|
||||||
totalByte={totalByte}
|
|
||||||
usedLabel={used}
|
|
||||||
totalLabel={total}
|
|
||||||
remainedLabel={remained}
|
|
||||||
expireMs={expireMs}
|
|
||||||
isActive={isActive}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(subUrl || subJsonUrl || subClashUrl) && (
|
|
||||||
<>
|
|
||||||
<Divider>{t('subscription.title')}</Divider>
|
|
||||||
<div className="links-section">
|
|
||||||
{subUrl && (
|
|
||||||
<div className="sub-link-row">
|
|
||||||
<Tag color="green" className="sub-link-tag">
|
|
||||||
SUB
|
|
||||||
</Tag>
|
|
||||||
<a
|
|
||||||
href={subUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="sub-link-title sub-link-anchor"
|
|
||||||
title={subUrl}
|
|
||||||
>
|
|
||||||
{sId}
|
|
||||||
</a>
|
|
||||||
<div className="sub-link-actions">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => copy(subUrl)}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
title={t('copy')}
|
|
||||||
/>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<div className="sub-link-qr-popover">
|
|
||||||
<Tag color="green" className="qr-tag">
|
|
||||||
{t('pages.settings.subSettings')}
|
|
||||||
</Tag>
|
|
||||||
<QRCode
|
|
||||||
value={subUrl}
|
|
||||||
size={QR_SIZE}
|
|
||||||
type="svg"
|
|
||||||
bordered={false}
|
|
||||||
color="#000000"
|
|
||||||
bgColor="#ffffff"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label="QR"
|
|
||||||
title="QR"
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subJsonUrl && (
|
|
||||||
<div className="sub-link-row">
|
|
||||||
<Tag color="purple" className="sub-link-tag">
|
|
||||||
JSON
|
|
||||||
</Tag>
|
|
||||||
<a
|
|
||||||
href={subJsonUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="sub-link-title sub-link-anchor"
|
|
||||||
title={subJsonUrl}
|
|
||||||
>
|
|
||||||
{sId}
|
|
||||||
</a>
|
|
||||||
<div className="sub-link-actions">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
href={appendRawView(subJsonUrl)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
aria-label={t('download')}
|
|
||||||
title={t('download')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => copy(subJsonUrl)}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
title={t('copy')}
|
|
||||||
/>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<div className="sub-link-qr-popover">
|
|
||||||
<Tag color="purple" className="qr-tag">
|
|
||||||
{t('pages.settings.subSettings')} JSON
|
|
||||||
</Tag>
|
|
||||||
<QRCode
|
|
||||||
value={subJsonUrl}
|
|
||||||
size={QR_SIZE}
|
|
||||||
type="svg"
|
|
||||||
bordered={false}
|
|
||||||
color="#000000"
|
|
||||||
bgColor="#ffffff"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label="QR"
|
|
||||||
title="QR"
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subClashUrl && (
|
|
||||||
<div className="sub-link-row">
|
|
||||||
<Tooltip title="Clash / Mihomo">
|
|
||||||
<Tag color="gold" className="sub-link-tag">
|
|
||||||
CLASH
|
|
||||||
</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
<a
|
|
||||||
href={subClashUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="sub-link-title sub-link-anchor"
|
|
||||||
title={subClashUrl}
|
|
||||||
>
|
|
||||||
{sId}
|
|
||||||
</a>
|
|
||||||
<div className="sub-link-actions">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
href={appendRawView(subClashUrl)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
aria-label={t('download')}
|
|
||||||
title={t('download')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => copy(subClashUrl)}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
title={t('copy')}
|
|
||||||
/>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<div className="sub-link-qr-popover">
|
|
||||||
<Tag color="gold" className="qr-tag">
|
|
||||||
Clash / Mihomo
|
|
||||||
</Tag>
|
|
||||||
<QRCode
|
|
||||||
value={subClashUrl}
|
|
||||||
size={QR_SIZE}
|
|
||||||
type="svg"
|
|
||||||
bordered={false}
|
|
||||||
color="#000000"
|
|
||||||
bgColor="#ffffff"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label="QR"
|
|
||||||
title="QR"
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</footer>
|
||||||
{links.length > 0 && (
|
)}
|
||||||
<>
|
</Card>
|
||||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
|
||||||
<div className="links-section">
|
|
||||||
<div className="sub-link-row">
|
|
||||||
<span className="sub-link-title">{t('subscription.copyAllConfigs')}</span>
|
|
||||||
<div className="sub-link-actions">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={copyAll}
|
|
||||||
aria-label={t('subscription.copyAllConfigs')}
|
|
||||||
title={t('subscription.copyAllConfigs')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{links.map((link, idx) => {
|
|
||||||
const parts = parseLinkParts(link);
|
|
||||||
const fallback = `Link ${idx + 1}`;
|
|
||||||
const rowTitle = parts?.remark || fallback;
|
|
||||||
const qrLabel = parts?.remark || rowTitle;
|
|
||||||
const canQr = !isPostQuantumLink(link);
|
|
||||||
const isWireguardLink =
|
|
||||||
link.startsWith('wireguard://') || link.startsWith('wg://');
|
|
||||||
const isAmneziawgLink = link.startsWith('vpn://');
|
|
||||||
return (
|
|
||||||
<Fragment key={link}>
|
|
||||||
<div className="sub-link-row">
|
|
||||||
{parts ? (
|
|
||||||
<LinkTags parts={parts} />
|
|
||||||
) : (
|
|
||||||
<Tag className="sub-link-tag">LINK</Tag>
|
|
||||||
)}
|
|
||||||
<span className="sub-link-title" title={rowTitle}>
|
|
||||||
{rowTitle}
|
|
||||||
</span>
|
|
||||||
<div className="sub-link-actions">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => copy(link)}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
title={t('copy')}
|
|
||||||
/>
|
|
||||||
{canQr && (
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<div className="sub-link-qr-popover">
|
|
||||||
<Tag className="qr-tag">{qrLabel}</Tag>
|
|
||||||
<QRCode
|
|
||||||
value={link}
|
|
||||||
size={220}
|
|
||||||
type="svg"
|
|
||||||
bordered={false}
|
|
||||||
color="#000000"
|
|
||||||
bgColor="#ffffff"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label="QR"
|
|
||||||
title="QR"
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isWireguardLink && (
|
|
||||||
<ConfigBlock
|
|
||||||
label={t('pages.clients.wireguardConfig')}
|
|
||||||
text={wireguardConfigFromLink(link, rowTitle)}
|
|
||||||
fileName={`${rowTitle || 'peer'}.conf`}
|
|
||||||
qrRemark={rowTitle}
|
|
||||||
tagColor="cyan"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isAmneziawgLink && (
|
|
||||||
<ConfigBlock
|
|
||||||
label={t('pages.clients.amneziaWgConfig')}
|
|
||||||
text={amneziawgConfigFromLink(link)}
|
|
||||||
fileName={`${rowTitle || 'peer'}.conf`}
|
|
||||||
qrRemark={rowTitle}
|
|
||||||
tagColor="purple"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Row gutter={[8, 8]} justify="center" className="apps-row">
|
|
||||||
<Col xs={24} sm={12} className="app-col">
|
|
||||||
<Dropdown trigger={['click']} menu={{ items: androidMenuItems }}>
|
|
||||||
<Button block={isMobile} size="large" type="primary">
|
|
||||||
<AndroidOutlined /> Android <DownOutlined />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={12} className="app-col">
|
|
||||||
<Dropdown trigger={['click']} menu={{ items: iosMenuItems }}>
|
|
||||||
<Button block={isMobile} size="large" type="primary">
|
|
||||||
<AppleOutlined /> iOS <DownOutlined />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Input, Modal, QRCode } from 'antd';
|
||||||
|
import { CopyOutlined, DownloadOutlined, QrcodeOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface SubQrButtonProps {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
onCopy: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubQrButton({ value, label, onCopy }: SubQrButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const saveQr = () => {
|
||||||
|
const canvas = qrRef.current?.querySelector('canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = canvas.toDataURL('image/png');
|
||||||
|
link.download = `${label || 'qrcode'}.png`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button icon={<QrcodeOutlined />} aria-label="QR" title="QR" onClick={() => setOpen(true)} />
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={440}
|
||||||
|
centered
|
||||||
|
destroyOnHidden
|
||||||
|
rootClassName="sub-qr-modal"
|
||||||
|
title={t('subscription.qrTitle')}
|
||||||
|
>
|
||||||
|
<p className="sub-muted sub-qr-modal-hint">{t('subscription.qrHint')}</p>
|
||||||
|
<div ref={qrRef} className="sub-qr-modal-code">
|
||||||
|
<QRCode
|
||||||
|
value={value}
|
||||||
|
size={240}
|
||||||
|
type="canvas"
|
||||||
|
marginSize={2}
|
||||||
|
bordered={false}
|
||||||
|
color="#000000"
|
||||||
|
bgColor="#ffffff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input.TextArea
|
||||||
|
className="sub-qr-modal-link"
|
||||||
|
value={value}
|
||||||
|
readOnly
|
||||||
|
dir="ltr"
|
||||||
|
autoSize={{ minRows: 2, maxRows: 5 }}
|
||||||
|
/>
|
||||||
|
<div className="sub-qr-modal-actions">
|
||||||
|
<Button type="primary" size="large" icon={<CopyOutlined />} onClick={() => onCopy(value)}>
|
||||||
|
{t('copy')}
|
||||||
|
</Button>
|
||||||
|
<Button size="large" icon={<DownloadOutlined />} onClick={saveQr}>
|
||||||
|
{t('subscription.saveQr')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
.usage-summary {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: var(--ant-color-fill-alter);
|
|
||||||
border: 1px solid var(--ant-color-border-secondary);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary.is-inactive {
|
|
||||||
opacity: 0.7;
|
|
||||||
border-color: var(--ant-color-error-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-labels {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 6px;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-used {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--ant-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-sep {
|
|
||||||
color: var(--ant-color-text-quaternary);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-total {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--ant-color-text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-chips {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-chips .ant-tag {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-bar.ant-progress {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-bar .ant-progress-outer {
|
|
||||||
padding-inline-end: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-bar .ant-progress-inner {
|
|
||||||
background: var(--ant-color-fill-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-foot {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ant-color-text-tertiary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
min-height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-remained::before {
|
|
||||||
content: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-pct {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ant-color-text-secondary);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Progress, Tag } from 'antd';
|
|
||||||
import { ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
|
||||||
|
|
||||||
import './SubUsageSummary.css';
|
|
||||||
|
|
||||||
interface SubUsageSummaryProps {
|
|
||||||
usedByte: number;
|
|
||||||
totalByte: number;
|
|
||||||
usedLabel: string;
|
|
||||||
totalLabel: string;
|
|
||||||
remainedLabel: string;
|
|
||||||
expireMs: number;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickStrokeColor(pct: number): { from: string; to: string } {
|
|
||||||
if (pct >= 90) return { from: '#ff7875', to: '#ff4d4f' };
|
|
||||||
if (pct >= 75) return { from: '#ffc53d', to: '#fa8c16' };
|
|
||||||
return { from: '#5fc983', to: '#36b37e' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatExpiryChip(expireMs: number): { label: string; color: string } | null {
|
|
||||||
if (expireMs <= 0) return null;
|
|
||||||
const diff = expireMs - Date.now();
|
|
||||||
if (diff <= 0) return { label: 'Expired', color: 'red' };
|
|
||||||
const days = Math.floor(diff / 86400000);
|
|
||||||
if (days >= 1) return { label: `${days}d`, color: days <= 3 ? 'orange' : 'blue' };
|
|
||||||
const hours = Math.max(1, Math.floor(diff / 3600000));
|
|
||||||
return { label: `${hours}h`, color: 'orange' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubUsageSummary({
|
|
||||||
usedByte,
|
|
||||||
totalByte,
|
|
||||||
usedLabel,
|
|
||||||
totalLabel,
|
|
||||||
remainedLabel,
|
|
||||||
expireMs,
|
|
||||||
isActive,
|
|
||||||
}: SubUsageSummaryProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const pct = useMemo(() => {
|
|
||||||
if (totalByte <= 0) return 0;
|
|
||||||
const v = (usedByte / totalByte) * 100;
|
|
||||||
if (!Number.isFinite(v)) return 0;
|
|
||||||
return Math.max(0, Math.min(100, v));
|
|
||||||
}, [usedByte, totalByte]);
|
|
||||||
|
|
||||||
const expiry = formatExpiryChip(expireMs);
|
|
||||||
const isUnlimited = totalByte <= 0;
|
|
||||||
const stroke = pickStrokeColor(pct);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`usage-summary ${!isActive ? 'is-inactive' : ''}`}>
|
|
||||||
<div className="usage-summary-head">
|
|
||||||
<div className="usage-summary-labels">
|
|
||||||
<span className="usage-summary-used">{usedLabel}</span>
|
|
||||||
<span className="usage-summary-sep">/</span>
|
|
||||||
<span className="usage-summary-total">{isUnlimited ? '∞' : totalLabel}</span>
|
|
||||||
</div>
|
|
||||||
<div className="usage-summary-chips">
|
|
||||||
{isUnlimited && (
|
|
||||||
<Tag color="purple" icon={<ThunderboltOutlined />}>
|
|
||||||
{t('subscription.unlimited')}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
{expiry && (
|
|
||||||
<Tag color={expiry.color} icon={<ClockCircleOutlined />}>
|
|
||||||
{expiry.label}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!isUnlimited && (
|
|
||||||
<Progress
|
|
||||||
percent={pct}
|
|
||||||
showInfo={false}
|
|
||||||
strokeColor={{ '0%': stroke.from, '100%': stroke.to }}
|
|
||||||
railColor="var(--ant-color-fill-secondary)"
|
|
||||||
strokeWidth={10}
|
|
||||||
className="usage-summary-bar"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="usage-summary-foot">
|
|
||||||
{!isUnlimited && (
|
|
||||||
<>
|
|
||||||
<span className="usage-summary-remained">{remainedLabel}</span>
|
|
||||||
<span className="usage-summary-pct">{pct.toFixed(1)}%</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Subscription page app icons
|
||||||
|
|
||||||
|
## Line icons — Arcticons (CC BY-SA 4.0)
|
||||||
|
|
||||||
|
`happ.svg`, `sing-box.svg`, `v2rayng.svg` and `v2raytun.svg` come from
|
||||||
|
[Arcticons](https://github.com/Arcticons-Team/Arcticons) by Donnnno and the
|
||||||
|
Arcticons contributors (as listed on svgicons.com), licensed under
|
||||||
|
[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||||
|
|
||||||
|
Changes from the originals: the stroke colour is `currentColor` so the page can
|
||||||
|
tint them to its theme, the stroke width is raised from 1 to 2 so they stay
|
||||||
|
legible at tile size, and the style classes and ids were removed. These modified
|
||||||
|
files are distributed under CC BY-SA 4.0 as well.
|
||||||
|
|
||||||
|
## App icons
|
||||||
|
|
||||||
|
The remaining files are each app's own icon, downscaled to 96 px, used only to
|
||||||
|
identify the app a button opens. They remain the trademarks of their owners.
|
||||||
|
|
||||||
|
| File | Source |
|
||||||
|
| ------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| `shadowrocket.webp` | App Store listing [id932747118](https://apps.apple.com/app/id932747118) |
|
||||||
|
| `streisand.webp` | App Store listing [id6450534064](https://apps.apple.com/app/id6450534064) |
|
||||||
|
| `v2box.webp` | App Store listing [id6446814690](https://apps.apple.com/app/id6446814690) |
|
||||||
|
| `incy.webp` | App Store listing [id6756943388](https://apps.apple.com/app/id6756943388) |
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="11.9387 18.636 11.2805 19.2322 13.2164 6.3148 21.6618 6.3148 21.082 10.1846"/><polyline points="20.2585 28.1999 18.113 42.5 9.6674 42.5 10.3198 38.1088"/><polyline points="28.1728 36.5585 27.2773 42.5 35.7228 42.5 37.9603 27.5876 36.0158 29.2999"/><polyline points="21.0144 27.3598 27.8555 27.3598 26.2122 38.3731 36.0158 29.2999 39.1998 8.0038 29.2593 17.99 28.8029 21.0714 27.2773 21.0714 26.4784 21.8658"/><polygon points="26.4784 21.8658 20.2243 28.1543 18.9689 28.1543 18.752 29.6379 8.8002 39.6355 11.9387 18.636 21.7423 9.5743 19.9047 21.8658 26.4784 21.8658"/><polyline points="38.0794 9.1294 38.6261 5.5 30.1808 5.5 28.1701 18.9112 29.2593 17.99"/></svg>
|
||||||
|
After Width: | Height: | Size: 840 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M40.7241,15.6976l-15.4896-10.8095c-.7416-.5175-1.7273-.5175-2.4689,0L7.2759,15.6976c-.9141.6379-.9141,1.9909,0,2.6288l15.4896,10.8095c.7416.5175,1.7273.5175,2.4689,0l15.4896-10.8095c.9141-.6379.9141-1.9909,0-2.6288Z"/><path d="M41.4096,17.012v13.976c0,.4977-.2285.9954-.6855,1.3144l-15.4896,10.8095c-.7416.5175-1.7273.5175-2.4689,0l-15.4896-10.8095c-.457-.3189-.6855-.8167-.6855-1.3144h0s0-13.976,0-13.976"/><line x1="24" y1="29.524" x2="24" y2="43.5"/><path d="M11.8734,12.4893l18.3951,13.1335v3.8047c0,.6869.7673,1.0951,1.3369.7111l4.3991-2.9651c.2961-.1996.4735-.5332.4735-.8903v-4.9939l-18.3951-13.1335"/></svg>
|
||||||
|
After Width: | Height: | Size: 783 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m9.6185,41.4866V10.6142h-4.1185v-4.0722h10.2641v14.3936c.6957-.6404,1.2872-1.1794,1.8728-1.7248,3.5152-3.2745,7.0291-6.5504,10.5432-9.826.9416-.8777,1.8767-1.7624,2.8301-2.6271.1437-.1303.3712-.2375.5603-.2382,3.5668-.0136,10.9295,0,10.9295,0-10.9555,11.6366-21.8724,23.2736-32.8815,34.9672Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 468 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect x="5.5" y="5.5" width="37" height="37" rx="4" ry="4"/><polyline points="28.9436 11.2023 20.4652 36.7977 11.9867 11.2023"/><path d="M28.3091,29.0207c0-2.3774,2.1537-4.2518,4.6165-3.7785,1.6155.3105,2.9055,1.7076,3.0663,3.3447.1195,1.2178-.2658,2.4194-1.1069,3.1576-1.5583,1.3675-6.5759,5.053-6.5759,5.053h7.7042"/></svg>
|
||||||
|
After Width: | Height: | Size: 484 B |
@@ -0,0 +1,20 @@
|
|||||||
|
import happ from './app-icons/happ.svg';
|
||||||
|
import incy from './app-icons/incy.webp';
|
||||||
|
import shadowrocket from './app-icons/shadowrocket.webp';
|
||||||
|
import singBox from './app-icons/sing-box.svg';
|
||||||
|
import streisand from './app-icons/streisand.webp';
|
||||||
|
import v2box from './app-icons/v2box.webp';
|
||||||
|
import v2rayng from './app-icons/v2rayng.svg';
|
||||||
|
import v2raytun from './app-icons/v2raytun.svg';
|
||||||
|
|
||||||
|
// Tinted entries are Arcticons line art drawn in the theme colour; the rest are full-colour app icons.
|
||||||
|
export const APP_ICONS: Record<string, { src: string; tinted: boolean }> = {
|
||||||
|
V2Box: { src: v2box, tinted: false },
|
||||||
|
V2RayNG: { src: v2rayng, tinted: true },
|
||||||
|
'Sing-box': { src: singBox, tinted: true },
|
||||||
|
V2RayTun: { src: v2raytun, tinted: true },
|
||||||
|
Happ: { src: happ, tinted: true },
|
||||||
|
Incy: { src: incy, tinted: false },
|
||||||
|
Shadowrocket: { src: shadowrocket, tinted: false },
|
||||||
|
Streisand: { src: streisand, tinted: false },
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
export type SubStatus = 'active' | 'unlimited' | 'expired' | 'depleted' | 'disabled';
|
||||||
|
|
||||||
|
export interface SubUsage {
|
||||||
|
enabled: boolean;
|
||||||
|
usedByte: number;
|
||||||
|
totalByte: number;
|
||||||
|
expireMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSubStatus(sub: SubUsage, now: number): SubStatus {
|
||||||
|
if (!sub.enabled) return 'disabled';
|
||||||
|
if (sub.expireMs > 0 && now >= sub.expireMs) return 'expired';
|
||||||
|
if (sub.totalByte > 0 && sub.usedByte >= sub.totalByte) return 'depleted';
|
||||||
|
if (sub.totalByte <= 0 && sub.expireMs === 0) return 'unlimited';
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysUntil(expireMs: number, now: number): number | null {
|
||||||
|
if (expireMs <= 0) return null;
|
||||||
|
return Math.max(0, Math.ceil((expireMs - now) / DAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usagePercent(usedByte: number, totalByte: number): number {
|
||||||
|
if (totalByte <= 0) return 0;
|
||||||
|
const pct = (usedByte / totalByte) * 100;
|
||||||
|
return Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppPlatform = 'android' | 'ios';
|
||||||
|
|
||||||
|
export function detectPlatform(userAgent: string): AppPlatform {
|
||||||
|
// iPadOS sends a Macintosh UA, and App Store clients also run on Apple-silicon Macs.
|
||||||
|
if (/iphone|ipad|ipod|macintosh/i.test(userAgent)) return 'ios';
|
||||||
|
return 'android';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubApp {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubAppSource {
|
||||||
|
subUrl: string;
|
||||||
|
sId: string;
|
||||||
|
subTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSubApps({
|
||||||
|
subUrl,
|
||||||
|
sId,
|
||||||
|
subTitle,
|
||||||
|
}: SubAppSource): Record<AppPlatform, SubApp[]> {
|
||||||
|
const encSub = encodeURIComponent(subUrl);
|
||||||
|
const profileName = encodeURIComponent(subTitle || sId);
|
||||||
|
|
||||||
|
const v2box = {
|
||||||
|
name: 'V2Box',
|
||||||
|
url: `v2box://install-sub?url=${encSub}&name=${encodeURIComponent(sId)}`,
|
||||||
|
};
|
||||||
|
const singBox = {
|
||||||
|
name: 'Sing-box',
|
||||||
|
url: `sing-box://import-remote-profile?url=${encSub}#${profileName}`,
|
||||||
|
};
|
||||||
|
const v2raytun = { name: 'V2RayTun', url: `v2raytun://import/${subUrl}` };
|
||||||
|
const happ = { name: 'Happ', url: `happ://add/${subUrl}` };
|
||||||
|
const incy = { name: 'Incy', url: `incy://add/${subUrl}` };
|
||||||
|
const rocketSource = `${subUrl}${subUrl.includes('?') ? '&' : '?'}flag=shadowrocket`;
|
||||||
|
const rocketRemark = encodeURIComponent(subTitle || sId || 'Subscription');
|
||||||
|
|
||||||
|
return {
|
||||||
|
android: [
|
||||||
|
v2box,
|
||||||
|
{ name: 'V2RayNG', url: `v2rayng://install-config?url=${encSub}` },
|
||||||
|
singBox,
|
||||||
|
v2raytun,
|
||||||
|
happ,
|
||||||
|
incy,
|
||||||
|
],
|
||||||
|
ios: [
|
||||||
|
{
|
||||||
|
name: 'Shadowrocket',
|
||||||
|
url: `shadowrocket://add/sub://${btoa(rocketSource)}?remark=${rocketRemark}`,
|
||||||
|
},
|
||||||
|
v2box,
|
||||||
|
{ name: 'Streisand', url: `streisand://import/${encSub}` },
|
||||||
|
v2raytun,
|
||||||
|
happ,
|
||||||
|
incy,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -81,9 +81,9 @@ describe('QrPanel dense AmneziaWG config', () => {
|
|||||||
const completeQr = qrGeometry(complete);
|
const completeQr = qrGeometry(complete);
|
||||||
const shorterQr = qrGeometry(withoutDisableCookies);
|
const shorterQr = qrGeometry(withoutDisableCookies);
|
||||||
|
|
||||||
expect(completeQr.viewBox).toBe('0 0 105 105');
|
expect(completeQr.viewBox).toBe('0 0 101 101');
|
||||||
expect(shorterQr.viewBox).toBe('0 0 101 101');
|
expect(shorterQr.viewBox).toBe('0 0 97 97');
|
||||||
expect(completeQr.foreground).toMatch(/^M4 4h7/);
|
expect(completeQr.foreground).toMatch(/^M2 2h7/);
|
||||||
expect(shorterQr.foreground).toMatch(/^M4 4h7/);
|
expect(shorterQr.foreground).toMatch(/^M2 2h7/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { APP_ICONS } from '@/pages/sub/appIcons';
|
||||||
|
import { buildSubApps } from '@/pages/sub/subPageModel';
|
||||||
|
|
||||||
|
describe('APP_ICONS', () => {
|
||||||
|
it('has an icon for every app the subscription page offers on every platform', () => {
|
||||||
|
const apps = buildSubApps({
|
||||||
|
subUrl: 'https://sub.example.com/sub/abc',
|
||||||
|
sId: 'abc',
|
||||||
|
subTitle: '',
|
||||||
|
});
|
||||||
|
const names = [...new Set(Object.values(apps).flatMap((list) => list.map((app) => app.name)))];
|
||||||
|
|
||||||
|
expect(names.filter((name) => !APP_ICONS[name]?.src)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries no icon for an app the page no longer offers', () => {
|
||||||
|
const apps = buildSubApps({
|
||||||
|
subUrl: 'https://sub.example.com/sub/abc',
|
||||||
|
sId: 'abc',
|
||||||
|
subTitle: '',
|
||||||
|
});
|
||||||
|
const offered = new Set(Object.values(apps).flatMap((list) => list.map((app) => app.name)));
|
||||||
|
|
||||||
|
expect(Object.keys(APP_ICONS).filter((name) => !offered.has(name))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildSubApps,
|
||||||
|
daysUntil,
|
||||||
|
detectPlatform,
|
||||||
|
resolveSubStatus,
|
||||||
|
usagePercent,
|
||||||
|
} from '@/pages/sub/subPageModel';
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
const NOW = Date.UTC(2026, 8, 15, 12, 0, 0);
|
||||||
|
|
||||||
|
describe('resolveSubStatus', () => {
|
||||||
|
const base = { enabled: true, usedByte: 10, totalByte: 100, expireMs: NOW + DAY };
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'disabled wins over expiry and quota',
|
||||||
|
{ ...base, enabled: false, expireMs: NOW - DAY },
|
||||||
|
'disabled',
|
||||||
|
],
|
||||||
|
['expired at the expiry instant', { ...base, expireMs: NOW }, 'expired'],
|
||||||
|
['expired beats depleted', { ...base, usedByte: 100, expireMs: NOW - DAY }, 'expired'],
|
||||||
|
['depleted once usage reaches the quota', { ...base, usedByte: 100 }, 'depleted'],
|
||||||
|
[
|
||||||
|
'unlimited with neither quota nor expiry',
|
||||||
|
{ ...base, totalByte: 0, expireMs: 0 },
|
||||||
|
'unlimited',
|
||||||
|
],
|
||||||
|
['active without quota but with a future expiry', { ...base, totalByte: 0 }, 'active'],
|
||||||
|
['active inside quota and before expiry', base, 'active'],
|
||||||
|
] as const)('%s', (_name, input, want) => {
|
||||||
|
expect(resolveSubStatus(input, NOW)).toBe(want);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('daysUntil', () => {
|
||||||
|
it('is null for a subscription that never expires', () => {
|
||||||
|
expect(daysUntil(0, NOW)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds the last partial day up to 1', () => {
|
||||||
|
expect(daysUntil(NOW + 3 * 3_600_000, NOW)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts whole days', () => {
|
||||||
|
expect(daysUntil(NOW + 23 * DAY, NOW)).toBe(23);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays at 0 after expiry', () => {
|
||||||
|
expect(daysUntil(NOW - DAY, NOW)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('usagePercent', () => {
|
||||||
|
it('is 0 without a quota instead of NaN or Infinity', () => {
|
||||||
|
expect(usagePercent(5_000, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps an over-quota client to 100', () => {
|
||||||
|
expect(usagePercent(150, 100)).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectPlatform', () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/128.0 Mobile Safari/537.36',
|
||||||
|
'android',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
|
||||||
|
'ios',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.5 Safari/605.1.15',
|
||||||
|
'ios',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36',
|
||||||
|
'android',
|
||||||
|
],
|
||||||
|
['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36', 'android'],
|
||||||
|
])('%s -> %s', (ua, want) => {
|
||||||
|
expect(detectPlatform(ua)).toBe(want);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSubApps', () => {
|
||||||
|
const subUrl = 'https://sub.example.com/sub/abc';
|
||||||
|
const encSub = encodeURIComponent(subUrl);
|
||||||
|
const sub = { subUrl, sId: 'abc', subTitle: 'Nova Net' };
|
||||||
|
|
||||||
|
it('offers Android and iOS app lists only', () => {
|
||||||
|
expect(Object.keys(buildSubApps(sub))).toEqual(['android', 'ios']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every Android app a one-tap import link', () => {
|
||||||
|
expect(buildSubApps(sub).android).toEqual([
|
||||||
|
{ name: 'V2Box', url: `v2box://install-sub?url=${encSub}&name=abc` },
|
||||||
|
{ name: 'V2RayNG', url: `v2rayng://install-config?url=${encSub}` },
|
||||||
|
{ name: 'Sing-box', url: `sing-box://import-remote-profile?url=${encSub}#Nova%20Net` },
|
||||||
|
{ name: 'V2RayTun', url: `v2raytun://import/${subUrl}` },
|
||||||
|
{ name: 'Happ', url: `happ://add/${subUrl}` },
|
||||||
|
{ name: 'Incy', url: `incy://add/${subUrl}` },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every iOS app a one-tap import link', () => {
|
||||||
|
const rocket = Buffer.from(`${subUrl}?flag=shadowrocket`).toString('base64');
|
||||||
|
expect(buildSubApps(sub).ios).toEqual([
|
||||||
|
{ name: 'Shadowrocket', url: `shadowrocket://add/sub://${rocket}?remark=Nova%20Net` },
|
||||||
|
{ name: 'V2Box', url: `v2box://install-sub?url=${encSub}&name=abc` },
|
||||||
|
{ name: 'Streisand', url: `streisand://import/${encSub}` },
|
||||||
|
{ name: 'V2RayTun', url: `v2raytun://import/${subUrl}` },
|
||||||
|
{ name: 'Happ', url: `happ://add/${subUrl}` },
|
||||||
|
{ name: 'Incy', url: `incy://add/${subUrl}` },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the sing-box profile after the subscription id when there is no title', () => {
|
||||||
|
expect(buildSubApps({ ...sub, subTitle: '' }).android[2].url).toBe(
|
||||||
|
`sing-box://import-remote-profile?url=${encSub}#abc`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends flag=shadowrocket with & when the subscription URL already has a query', () => {
|
||||||
|
const withQuery = { ...sub, subUrl: `${subUrl}?token=1` };
|
||||||
|
const rocket = Buffer.from(`${subUrl}?token=1&flag=shadowrocket`).toString('base64');
|
||||||
|
expect(buildSubApps(withQuery).ios[0].url).toBe(
|
||||||
|
`shadowrocket://add/sub://${rocket}?remark=Nova%20Net`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -660,6 +661,8 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
|
|||||||
if datepicker == "" {
|
if datepicker == "" {
|
||||||
datepicker = "gregorian"
|
datepicker = "gregorian"
|
||||||
}
|
}
|
||||||
|
subUpdates, _ := a.settingService.GetSubUpdates()
|
||||||
|
updateHours, _ := strconv.Atoi(subUpdates)
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"sId": page.SId,
|
"sId": page.SId,
|
||||||
@@ -680,6 +683,7 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
|
|||||||
"subClashUrl": page.SubClashUrl,
|
"subClashUrl": page.SubClashUrl,
|
||||||
"subTitle": page.SubTitle,
|
"subTitle": page.SubTitle,
|
||||||
"subSupportUrl": page.SubSupportUrl,
|
"subSupportUrl": page.SubSupportUrl,
|
||||||
|
"subUpdates": updateHours,
|
||||||
"links": page.Result,
|
"links": page.Result,
|
||||||
"emails": page.Emails,
|
"emails": page.Emails,
|
||||||
"datepicker": datepicker,
|
"datepicker": datepicker,
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ func TestSubInfoEndpoint_ServesStatusJSONEvenForBrowsers(t *testing.T) {
|
|||||||
t.Fatalf("info payload missing %q; body=%s", key, w.Body.String())
|
t.Fatalf("info payload missing %q; body=%s", key, w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if info["subUpdates"] != float64(12) {
|
||||||
|
t.Fatalf("subUpdates = %v, want the default 12-hour interval", info["subUpdates"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubInfoEndpoint_UnknownSubIs404(t *testing.T) {
|
func TestSubInfoEndpoint_UnknownSubIs404(t *testing.T) {
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
"somethingWentWrong": "حدث خطأ ما",
|
"somethingWentWrong": "حدث خطأ ما",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "معلومات الاشتراك",
|
"title": "معلومات الاشتراك",
|
||||||
"subId": "معرّف الاشتراك",
|
|
||||||
"status": "الحالة",
|
"status": "الحالة",
|
||||||
"downloaded": "التنزيل",
|
"downloaded": "التنزيل",
|
||||||
"uploaded": "الرفع",
|
"uploaded": "الرفع",
|
||||||
@@ -93,7 +92,20 @@
|
|||||||
"noExpiry": "بدون انتهاء",
|
"noExpiry": "بدون انتهاء",
|
||||||
"copyAllConfigs": "نسخ جميع الإعدادات",
|
"copyAllConfigs": "نسخ جميع الإعدادات",
|
||||||
"copyAllConfigsCopied": "تم نسخ جميع الإعدادات",
|
"copyAllConfigsCopied": "تم نسخ جميع الإعدادات",
|
||||||
"email": "البريد"
|
"daysLeft": "الأيام المتبقية",
|
||||||
|
"expired": "منتهي الصلاحية",
|
||||||
|
"depleted": "نفدت البيانات",
|
||||||
|
"ofTotal": "من {total}",
|
||||||
|
"tabLinks": "الاشتراك",
|
||||||
|
"tabApps": "التطبيقات",
|
||||||
|
"tabConfigs": "الكونفيجات",
|
||||||
|
"scanTitle": "امسح بهاتفك",
|
||||||
|
"scanHint": "امسح هذا الرمز في تطبيق VPN لإضافة الاشتراك دون نسخ الرابط.",
|
||||||
|
"updateInterval": "تحديث تلقائي كل {hours} ساعة",
|
||||||
|
"support": "الدعم",
|
||||||
|
"qrTitle": "امسح. استورد. اتصل.",
|
||||||
|
"qrHint": "امسح الرمز بتطبيق العميل. لا تشارك هذا الرمز مع أحد.",
|
||||||
|
"saveQr": "حفظ QR"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "الثيم",
|
"theme": "الثيم",
|
||||||
|
|||||||
@@ -80,8 +80,6 @@
|
|||||||
"somethingWentWrong": "Something went wrong",
|
"somethingWentWrong": "Something went wrong",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Subscription info",
|
"title": "Subscription info",
|
||||||
"subId": "Subscription ID",
|
|
||||||
"email": "Email",
|
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"downloaded": "Downloaded",
|
"downloaded": "Downloaded",
|
||||||
"uploaded": "Uploaded",
|
"uploaded": "Uploaded",
|
||||||
@@ -93,7 +91,21 @@
|
|||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"noExpiry": "No expiry",
|
"noExpiry": "No expiry",
|
||||||
"copyAllConfigs": "Copy All Configs",
|
"copyAllConfigs": "Copy All Configs",
|
||||||
"copyAllConfigsCopied": "All configs copied"
|
"copyAllConfigsCopied": "All configs copied",
|
||||||
|
"daysLeft": "Days left",
|
||||||
|
"expired": "Expired",
|
||||||
|
"depleted": "Data used up",
|
||||||
|
"ofTotal": "of {total}",
|
||||||
|
"tabLinks": "Subscription",
|
||||||
|
"tabApps": "Apps",
|
||||||
|
"tabConfigs": "Configs",
|
||||||
|
"scanTitle": "Scan with your phone",
|
||||||
|
"scanHint": "Scan this code in your VPN app to add the subscription without copying the link.",
|
||||||
|
"updateInterval": "Auto-updates every {hours} hours",
|
||||||
|
"support": "Support",
|
||||||
|
"qrTitle": "Scan. Import. Connect.",
|
||||||
|
"qrHint": "Scan with your client app. Keep this code private.",
|
||||||
|
"saveQr": "Save QR"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
"somethingWentWrong": "Algo salió mal",
|
"somethingWentWrong": "Algo salió mal",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Información de suscripción",
|
"title": "Información de suscripción",
|
||||||
"subId": "ID de suscripción",
|
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
"downloaded": "Descargado",
|
"downloaded": "Descargado",
|
||||||
"uploaded": "Subido",
|
"uploaded": "Subido",
|
||||||
@@ -93,7 +92,20 @@
|
|||||||
"noExpiry": "Sin caducidad",
|
"noExpiry": "Sin caducidad",
|
||||||
"copyAllConfigs": "Copiar Todas las Configuraciones",
|
"copyAllConfigs": "Copiar Todas las Configuraciones",
|
||||||
"copyAllConfigsCopied": "Todas las configuraciones copiadas",
|
"copyAllConfigsCopied": "Todas las configuraciones copiadas",
|
||||||
"email": "Email"
|
"daysLeft": "Días restantes",
|
||||||
|
"expired": "Caducada",
|
||||||
|
"depleted": "Datos agotados",
|
||||||
|
"ofTotal": "de {total}",
|
||||||
|
"tabLinks": "Suscripción",
|
||||||
|
"tabApps": "Aplicaciones",
|
||||||
|
"tabConfigs": "Configuraciones",
|
||||||
|
"scanTitle": "Escanea con tu teléfono",
|
||||||
|
"scanHint": "Escanea este código en tu aplicación VPN para añadir la suscripción sin copiar el enlace.",
|
||||||
|
"updateInterval": "Se actualiza automáticamente cada {hours} horas",
|
||||||
|
"support": "Soporte",
|
||||||
|
"qrTitle": "Escanea. Importa. Conéctate.",
|
||||||
|
"qrHint": "Escanéalo con tu aplicación cliente. Mantén este código en privado.",
|
||||||
|
"saveQr": "Guardar QR"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
|
|||||||
@@ -80,8 +80,6 @@
|
|||||||
"somethingWentWrong": "مشکلی پیش آمد",
|
"somethingWentWrong": "مشکلی پیش آمد",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "اطلاعات سابسکریپشن",
|
"title": "اطلاعات سابسکریپشن",
|
||||||
"subId": "شناسه اشتراک",
|
|
||||||
"email": "ایمیل",
|
|
||||||
"status": "وضعیت",
|
"status": "وضعیت",
|
||||||
"downloaded": "دانلود",
|
"downloaded": "دانلود",
|
||||||
"uploaded": "آپلود",
|
"uploaded": "آپلود",
|
||||||
@@ -93,7 +91,21 @@
|
|||||||
"unlimited": "نامحدود",
|
"unlimited": "نامحدود",
|
||||||
"noExpiry": "بدون انقضا",
|
"noExpiry": "بدون انقضا",
|
||||||
"copyAllConfigs": "کپی همه کانفیگها",
|
"copyAllConfigs": "کپی همه کانفیگها",
|
||||||
"copyAllConfigsCopied": "همه کانفیگها کپی شدند"
|
"copyAllConfigsCopied": "همه کانفیگها کپی شدند",
|
||||||
|
"daysLeft": "روز باقیمانده",
|
||||||
|
"expired": "منقضی شده",
|
||||||
|
"depleted": "حجم تمام شده",
|
||||||
|
"ofTotal": "از {total}",
|
||||||
|
"tabLinks": "اشتراک",
|
||||||
|
"tabApps": "اپلیکیشنها",
|
||||||
|
"tabConfigs": "کانفیگها",
|
||||||
|
"scanTitle": "اسکن با گوشی",
|
||||||
|
"scanHint": "این کد را در اپلیکیشن VPN اسکن کنید تا اشتراک بدون کپیکردن لینک اضافه شود.",
|
||||||
|
"updateInterval": "بهروزرسانی خودکار هر {hours} ساعت",
|
||||||
|
"support": "پشتیبانی",
|
||||||
|
"qrTitle": "اسکن. افزودن. اتصال.",
|
||||||
|
"qrHint": "با اپلیکیشن کلاینت اسکن کنید. این کد را به کسی ندهید.",
|
||||||
|
"saveQr": "ذخیره QR"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "تم",
|
"theme": "تم",
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
"somethingWentWrong": "Terjadi kesalahan",
|
"somethingWentWrong": "Terjadi kesalahan",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Info langganan",
|
"title": "Info langganan",
|
||||||
"subId": "ID langganan",
|
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"downloaded": "Diunduh",
|
"downloaded": "Diunduh",
|
||||||
"uploaded": "Diunggah",
|
"uploaded": "Diunggah",
|
||||||
@@ -93,7 +92,20 @@
|
|||||||
"noExpiry": "Tanpa kedaluwarsa",
|
"noExpiry": "Tanpa kedaluwarsa",
|
||||||
"copyAllConfigs": "Salin Semua Konfigurasi",
|
"copyAllConfigs": "Salin Semua Konfigurasi",
|
||||||
"copyAllConfigsCopied": "Semua konfigurasi tersalin",
|
"copyAllConfigsCopied": "Semua konfigurasi tersalin",
|
||||||
"email": "Email"
|
"daysLeft": "Sisa hari",
|
||||||
|
"expired": "Kedaluwarsa",
|
||||||
|
"depleted": "Kuota habis",
|
||||||
|
"ofTotal": "dari {total}",
|
||||||
|
"tabLinks": "Langganan",
|
||||||
|
"tabApps": "Aplikasi",
|
||||||
|
"tabConfigs": "Konfigurasi",
|
||||||
|
"scanTitle": "Pindai dengan ponsel",
|
||||||
|
"scanHint": "Pindai kode ini di aplikasi VPN untuk menambahkan langganan tanpa menyalin tautan.",
|
||||||
|
"updateInterval": "Diperbarui otomatis setiap {hours} jam",
|
||||||
|
"support": "Dukungan",
|
||||||
|
"qrTitle": "Pindai. Impor. Terhubung.",
|
||||||
|
"qrHint": "Pindai dengan aplikasi klien Anda. Jaga kerahasiaan kode ini.",
|
||||||
|
"saveQr": "Simpan QR"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
"somethingWentWrong": "エラーが発生しました",
|
"somethingWentWrong": "エラーが発生しました",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "サブスクリプション情報",
|
"title": "サブスクリプション情報",
|
||||||
"subId": "サブスクリプションID",
|
|
||||||
"status": "ステータス",
|
"status": "ステータス",
|
||||||
"downloaded": "ダウンロード",
|
"downloaded": "ダウンロード",
|
||||||
"uploaded": "アップロード",
|
"uploaded": "アップロード",
|
||||||
@@ -93,7 +92,20 @@
|
|||||||
"noExpiry": "期限なし",
|
"noExpiry": "期限なし",
|
||||||
"copyAllConfigs": "すべての設定をコピー",
|
"copyAllConfigs": "すべての設定をコピー",
|
||||||
"copyAllConfigsCopied": "すべての設定をコピーしました",
|
"copyAllConfigsCopied": "すべての設定をコピーしました",
|
||||||
"email": "メール"
|
"daysLeft": "残り日数",
|
||||||
|
"expired": "期限切れ",
|
||||||
|
"depleted": "データ上限到達",
|
||||||
|
"ofTotal": "/ {total}",
|
||||||
|
"tabLinks": "サブスクリプション",
|
||||||
|
"tabApps": "アプリ",
|
||||||
|
"tabConfigs": "設定",
|
||||||
|
"scanTitle": "スマートフォンでスキャン",
|
||||||
|
"scanHint": "VPN アプリでこのコードをスキャンすると、リンクをコピーせずにサブスクリプションを追加できます。",
|
||||||
|
"updateInterval": "{hours} 時間ごとに自動更新",
|
||||||
|
"support": "サポート",
|
||||||
|
"qrTitle": "スキャン。インポート。接続。",
|
||||||
|
"qrHint": "クライアントアプリでスキャンしてください。このコードは他人に見せないでください。",
|
||||||
|
"saveQr": "QR を保存"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "テーマ",
|
"theme": "テーマ",
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
"somethingWentWrong": "Algo deu errado",
|
"somethingWentWrong": "Algo deu errado",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Informações da assinatura",
|
"title": "Informações da assinatura",
|
||||||
"subId": "ID da assinatura",
|
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"downloaded": "Baixado",
|
"downloaded": "Baixado",
|
||||||
"uploaded": "Enviado",
|
"uploaded": "Enviado",
|
||||||
@@ -93,7 +92,20 @@
|
|||||||
"noExpiry": "Sem validade",
|
"noExpiry": "Sem validade",
|
||||||
"copyAllConfigs": "Copiar Todas as Configurações",
|
"copyAllConfigs": "Copiar Todas as Configurações",
|
||||||
"copyAllConfigsCopied": "Todas as configurações copiadas",
|
"copyAllConfigsCopied": "Todas as configurações copiadas",
|
||||||
"email": "Email"
|
"daysLeft": "Dias restantes",
|
||||||
|
"expired": "Expirada",
|
||||||
|
"depleted": "Dados esgotados",
|
||||||
|
"ofTotal": "de {total}",
|
||||||
|
"tabLinks": "Assinatura",
|
||||||
|
"tabApps": "Aplicativos",
|
||||||
|
"tabConfigs": "Configurações",
|
||||||
|
"scanTitle": "Escaneie com o celular",
|
||||||
|
"scanHint": "Escaneie este código no seu aplicativo de VPN para adicionar a assinatura sem copiar o link.",
|
||||||
|
"updateInterval": "Atualiza automaticamente a cada {hours} horas",
|
||||||
|
"support": "Suporte",
|
||||||
|
"qrTitle": "Escaneie. Importe. Conecte.",
|
||||||
|
"qrHint": "Escaneie com seu aplicativo cliente. Mantenha este código em sigilo.",
|
||||||
|
"saveQr": "Salvar QR"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
"somethingWentWrong": "Что-то пошло не так",
|
"somethingWentWrong": "Что-то пошло не так",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Информация о подписке",
|
"title": "Информация о подписке",
|
||||||
"subId": "ID подписки",
|
|
||||||
"status": "Статус",
|
"status": "Статус",
|
||||||
"downloaded": "Загружено",
|
"downloaded": "Загружено",
|
||||||
"uploaded": "Отправлено",
|
"uploaded": "Отправлено",
|
||||||
@@ -93,7 +92,20 @@
|
|||||||
"noExpiry": "Бессрочно",
|
"noExpiry": "Бессрочно",
|
||||||
"copyAllConfigs": "Копировать все конфигурации",
|
"copyAllConfigs": "Копировать все конфигурации",
|
||||||
"copyAllConfigsCopied": "Все конфигурации скопированы",
|
"copyAllConfigsCopied": "Все конфигурации скопированы",
|
||||||
"email": "Email"
|
"daysLeft": "Осталось дней",
|
||||||
|
"expired": "Истёк",
|
||||||
|
"depleted": "Трафик исчерпан",
|
||||||
|
"ofTotal": "из {total}",
|
||||||
|
"tabLinks": "Подписка",
|
||||||
|
"tabApps": "Приложения",
|
||||||
|
"tabConfigs": "Конфиги",
|
||||||
|
"scanTitle": "Сканируйте телефоном",
|
||||||
|
"scanHint": "Отсканируйте этот код в VPN-приложении, чтобы добавить подписку без копирования ссылки.",
|
||||||
|
"updateInterval": "Автообновление каждые {hours} ч",
|
||||||
|
"support": "Поддержка",
|
||||||
|
"qrTitle": "Сканируйте. Импортируйте. Подключайтесь.",
|
||||||
|
"qrHint": "Отсканируйте в клиентском приложении. Никому не показывайте этот код.",
|
||||||
|
"saveQr": "Сохранить QR"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "Тема",
|
"theme": "Тема",
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
"somethingWentWrong": "Bir hata oluştu",
|
"somethingWentWrong": "Bir hata oluştu",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Abonelik Bilgisi",
|
"title": "Abonelik Bilgisi",
|
||||||
"subId": "Abonelik Kimliği",
|
|
||||||
"status": "Durum",
|
"status": "Durum",
|
||||||
"downloaded": "İndirilen",
|
"downloaded": "İndirilen",
|
||||||
"uploaded": "Yüklenen",
|
"uploaded": "Yüklenen",
|
||||||
@@ -93,7 +92,20 @@
|
|||||||
"noExpiry": "Süresiz",
|
"noExpiry": "Süresiz",
|
||||||
"copyAllConfigs": "Tüm Yapılandırmaları Kopyala",
|
"copyAllConfigs": "Tüm Yapılandırmaları Kopyala",
|
||||||
"copyAllConfigsCopied": "Tüm yapılandırmalar kopyalandı",
|
"copyAllConfigsCopied": "Tüm yapılandırmalar kopyalandı",
|
||||||
"email": "E-posta"
|
"daysLeft": "Kalan gün",
|
||||||
|
"expired": "Süresi doldu",
|
||||||
|
"depleted": "Kota doldu",
|
||||||
|
"ofTotal": "/ {total}",
|
||||||
|
"tabLinks": "Abonelik",
|
||||||
|
"tabApps": "Uygulamalar",
|
||||||
|
"tabConfigs": "Yapılandırmalar",
|
||||||
|
"scanTitle": "Telefonunuzla tarayın",
|
||||||
|
"scanHint": "Bağlantıyı kopyalamadan aboneliği eklemek için bu kodu VPN uygulamanızda tarayın.",
|
||||||
|
"updateInterval": "Her {hours} saatte bir otomatik güncellenir",
|
||||||
|
"support": "Destek",
|
||||||
|
"qrTitle": "Tara. İçe aktar. Bağlan.",
|
||||||
|
"qrHint": "İstemci uygulamanızla tarayın. Bu kodu kimseyle paylaşmayın.",
|
||||||
|
"saveQr": "QR'ı kaydet"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
"somethingWentWrong": "Щось пішло не так",
|
"somethingWentWrong": "Щось пішло не так",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Інформація про підписку",
|
"title": "Інформація про підписку",
|
||||||
"subId": "ID підписки",
|
|
||||||
"status": "Статус",
|
"status": "Статус",
|
||||||
"downloaded": "Завантажено",
|
"downloaded": "Завантажено",
|
||||||
"uploaded": "Відвантажено",
|
"uploaded": "Відвантажено",
|
||||||
@@ -93,7 +92,20 @@
|
|||||||
"noExpiry": "Без строку",
|
"noExpiry": "Без строку",
|
||||||
"copyAllConfigs": "Копіювати всі конфігурації",
|
"copyAllConfigs": "Копіювати всі конфігурації",
|
||||||
"copyAllConfigsCopied": "Всі конфігурації скопійовано",
|
"copyAllConfigsCopied": "Всі конфігурації скопійовано",
|
||||||
"email": "Email"
|
"daysLeft": "Залишилось днів",
|
||||||
|
"expired": "Термін дії минув",
|
||||||
|
"depleted": "Трафік вичерпано",
|
||||||
|
"ofTotal": "з {total}",
|
||||||
|
"tabLinks": "Підписка",
|
||||||
|
"tabApps": "Застосунки",
|
||||||
|
"tabConfigs": "Конфіги",
|
||||||
|
"scanTitle": "Скануйте телефоном",
|
||||||
|
"scanHint": "Відскануйте цей код у VPN-застосунку, щоб додати підписку без копіювання посилання.",
|
||||||
|
"updateInterval": "Автооновлення кожні {hours} год",
|
||||||
|
"support": "Підтримка",
|
||||||
|
"qrTitle": "Скануйте. Імпортуйте. Підключайтеся.",
|
||||||
|
"qrHint": "Відскануйте в клієнтському застосунку. Нікому не показуйте цей код.",
|
||||||
|
"saveQr": "Зберегти QR"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "Тема",
|
"theme": "Тема",
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
"somethingWentWrong": "Đã xảy ra lỗi",
|
"somethingWentWrong": "Đã xảy ra lỗi",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Thông tin đăng ký",
|
"title": "Thông tin đăng ký",
|
||||||
"subId": "ID đăng ký",
|
|
||||||
"status": "Trạng thái",
|
"status": "Trạng thái",
|
||||||
"downloaded": "Đã tải xuống",
|
"downloaded": "Đã tải xuống",
|
||||||
"uploaded": "Đã tải lên",
|
"uploaded": "Đã tải lên",
|
||||||
@@ -93,7 +92,20 @@
|
|||||||
"noExpiry": "Không hết hạn",
|
"noExpiry": "Không hết hạn",
|
||||||
"copyAllConfigs": "Sao chép tất cả cấu hình",
|
"copyAllConfigs": "Sao chép tất cả cấu hình",
|
||||||
"copyAllConfigsCopied": "Đã sao chép tất cả cấu hình",
|
"copyAllConfigsCopied": "Đã sao chép tất cả cấu hình",
|
||||||
"email": "Email"
|
"daysLeft": "Số ngày còn lại",
|
||||||
|
"expired": "Đã hết hạn",
|
||||||
|
"depleted": "Hết dung lượng",
|
||||||
|
"ofTotal": "trên {total}",
|
||||||
|
"tabLinks": "Gói đăng ký",
|
||||||
|
"tabApps": "Ứng dụng",
|
||||||
|
"tabConfigs": "Cấu hình",
|
||||||
|
"scanTitle": "Quét bằng điện thoại",
|
||||||
|
"scanHint": "Quét mã này trong ứng dụng VPN để thêm gói đăng ký mà không cần sao chép liên kết.",
|
||||||
|
"updateInterval": "Tự động cập nhật mỗi {hours} giờ",
|
||||||
|
"support": "Hỗ trợ",
|
||||||
|
"qrTitle": "Quét. Nhập. Kết nối.",
|
||||||
|
"qrHint": "Quét bằng ứng dụng khách. Không chia sẻ mã này cho người khác.",
|
||||||
|
"saveQr": "Lưu QR"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "Chủ đề",
|
"theme": "Chủ đề",
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
"somethingWentWrong": "出了点问题",
|
"somethingWentWrong": "出了点问题",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "订阅信息",
|
"title": "订阅信息",
|
||||||
"subId": "订阅 ID",
|
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"downloaded": "已下载",
|
"downloaded": "已下载",
|
||||||
"uploaded": "已上传",
|
"uploaded": "已上传",
|
||||||
@@ -93,7 +92,20 @@
|
|||||||
"noExpiry": "无到期",
|
"noExpiry": "无到期",
|
||||||
"copyAllConfigs": "复制全部配置",
|
"copyAllConfigs": "复制全部配置",
|
||||||
"copyAllConfigsCopied": "已复制全部配置",
|
"copyAllConfigsCopied": "已复制全部配置",
|
||||||
"email": "邮箱"
|
"daysLeft": "剩余天数",
|
||||||
|
"expired": "已过期",
|
||||||
|
"depleted": "流量已用完",
|
||||||
|
"ofTotal": "共 {total}",
|
||||||
|
"tabLinks": "订阅",
|
||||||
|
"tabApps": "应用",
|
||||||
|
"tabConfigs": "配置",
|
||||||
|
"scanTitle": "用手机扫码",
|
||||||
|
"scanHint": "在 VPN 应用中扫描此二维码,无需复制链接即可添加订阅。",
|
||||||
|
"updateInterval": "每 {hours} 小时自动更新",
|
||||||
|
"support": "支持",
|
||||||
|
"qrTitle": "扫码。导入。连接。",
|
||||||
|
"qrHint": "使用客户端应用扫描。请勿泄露此二维码。",
|
||||||
|
"saveQr": "保存二维码"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
"somethingWentWrong": "發生錯誤",
|
"somethingWentWrong": "發生錯誤",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "訂閱資訊",
|
"title": "訂閱資訊",
|
||||||
"subId": "訂閱 ID",
|
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"downloaded": "已下載",
|
"downloaded": "已下載",
|
||||||
"uploaded": "已上傳",
|
"uploaded": "已上傳",
|
||||||
@@ -93,7 +92,20 @@
|
|||||||
"noExpiry": "無到期",
|
"noExpiry": "無到期",
|
||||||
"copyAllConfigs": "複製全部配置",
|
"copyAllConfigs": "複製全部配置",
|
||||||
"copyAllConfigsCopied": "已複製全部配置",
|
"copyAllConfigsCopied": "已複製全部配置",
|
||||||
"email": "電子郵件"
|
"daysLeft": "剩餘天數",
|
||||||
|
"expired": "已過期",
|
||||||
|
"depleted": "流量已用完",
|
||||||
|
"ofTotal": "共 {total}",
|
||||||
|
"tabLinks": "訂閱",
|
||||||
|
"tabApps": "應用程式",
|
||||||
|
"tabConfigs": "設定檔",
|
||||||
|
"scanTitle": "用手機掃描",
|
||||||
|
"scanHint": "在 VPN 應用程式中掃描此 QR 碼,無需複製連結即可新增訂閱。",
|
||||||
|
"updateInterval": "每 {hours} 小時自動更新",
|
||||||
|
"support": "支援",
|
||||||
|
"qrTitle": "掃描。匯入。連線。",
|
||||||
|
"qrHint": "使用用戶端應用程式掃描。請勿外洩此 QR 碼。",
|
||||||
|
"saveQr": "儲存 QR 碼"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"theme": "主題",
|
"theme": "主題",
|
||||||
|
|||||||