v0.0.01b
This commit is contained in:
215
src/pages/AccountPage.tsx
Normal file
215
src/pages/AccountPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useDevices } from '../hooks/useDevices';
|
||||
import { useToastStore } from '../store/useToast';
|
||||
import { Shell } from '../components/Shell';
|
||||
import { Reveal } from '../components/Reveal';
|
||||
import { Skeleton } from '../components/Skeleton';
|
||||
import { StatStrip } from '../components/StatStrip';
|
||||
import { DeviceCard } from '../components/DeviceCard';
|
||||
import { Modal } from '../components/Modal';
|
||||
import type { Device, SubscriptionData, UserInfo } from '../types';
|
||||
|
||||
export function AccountPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user, isAuthenticated, clear } = useAuth();
|
||||
const showToast = useToastStore((s) => s.show);
|
||||
const { data: devices, loading: devicesLoading, remove } = useDevices();
|
||||
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(user);
|
||||
const [subscription, setSubscription] = useState<SubscriptionData | null>(null);
|
||||
const [loadingMeta, setLoadingMeta] = useState(true);
|
||||
const [pending, setPending] = useState<Device | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigate('/login', { replace: true, state: { returnTo: '/account' } });
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const loadMeta = useCallback(async () => {
|
||||
setLoadingMeta(true);
|
||||
try {
|
||||
const [me, sub] = await Promise.all([api.getMe(), api.getSubscription()]);
|
||||
setUserInfo(me);
|
||||
setSubscription(sub);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof ApiError ? err.message : 'Не удалось загрузить аккаунт';
|
||||
showToast(message);
|
||||
if (err instanceof ApiError && err.status === 401) clear();
|
||||
} finally {
|
||||
setLoadingMeta(false);
|
||||
}
|
||||
}, [clear, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
void loadMeta();
|
||||
}, [isAuthenticated, loadMeta]);
|
||||
|
||||
const handleDelete = (device: Device) => setPending(device);
|
||||
|
||||
const copySubscriptionLink = async () => {
|
||||
const link = subscription?.subscription_link;
|
||||
if (!link) return;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(link);
|
||||
} else {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = link;
|
||||
input.style.position = 'fixed';
|
||||
input.style.opacity = '0';
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
input.remove();
|
||||
}
|
||||
showToast('Ссылка подписки скопирована');
|
||||
} catch {
|
||||
showToast('Не удалось скопировать ссылку');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pending) return;
|
||||
const ok = await remove(pending.hwid);
|
||||
setPending(null);
|
||||
if (ok) {
|
||||
showToast(`${pending.model ?? 'Устройство'} отключено`);
|
||||
} else {
|
||||
showToast('Не удалось удалить устройство');
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = () => {
|
||||
clear();
|
||||
showToast('Сессия завершена');
|
||||
navigate('/', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell active="account">
|
||||
<section className="account-hero">
|
||||
<Reveal>
|
||||
<div className="kicker">Ваш аккаунт</div>
|
||||
<h1 className="hero-title">{userInfo?.username ?? 'Аккаунт'}</h1>
|
||||
{!loadingMeta ? (
|
||||
<p className="hero-sub">
|
||||
{subscription?.has_subscription && subscription.expires_at
|
||||
? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
|
||||
: 'Активная подписка отсутствует'}
|
||||
</p>
|
||||
) : null}
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{loadingMeta ? (
|
||||
<div className="profile">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div className="stat" key={i}>
|
||||
<Skeleton width="34px" height="34px" radius="50%" />
|
||||
<Skeleton width="80px" height="10px" />
|
||||
<Skeleton width="120px" height="26px" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Reveal>
|
||||
<StatStrip user={userInfo} subscription={subscription} deviceCount={devices.length} />
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{!loadingMeta && subscription?.subscription_link ? (
|
||||
<Reveal>
|
||||
<section className="subscription-link" aria-label="Ссылка подписки">
|
||||
<div className="subscription-emblem" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1L11 5.1" />
|
||||
<path d="M14 11a5 5 0 0 0-7.1-.1l-2 2A5 5 0 0 0 12 20l1.1-1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="subscription-copy">
|
||||
<div className="subscription-label">Ссылка подписки</div>
|
||||
<span className="subscription-url" title={subscription.subscription_link}>
|
||||
{subscription.subscription_link}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="copy-link" onClick={() => void copySubscriptionLink()}>
|
||||
Копировать
|
||||
</button>
|
||||
</section>
|
||||
</Reveal>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<Reveal>
|
||||
<div className="devices-head">
|
||||
<h2 className="devices-title">Подключённые устройства</h2>
|
||||
<div className="devices-count">
|
||||
<strong>{devices.length}</strong>активно
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{devicesLoading ? (
|
||||
<div className="device-grid">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div className="device" key={i}>
|
||||
<Skeleton width="48px" height="48px" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton width="70%" height="20px" />
|
||||
<Skeleton width="50%" height="12px" style={{ marginTop: 8 }} />
|
||||
</div>
|
||||
<Skeleton width="36px" height="36px" radius="50%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="devices-empty">
|
||||
<div className="devices-empty-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="4" width="20" height="13" rx="1.5" />
|
||||
<path d="M1 20h22M9 20l1-3h4l1 3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="devices-empty-title">Нет подключённых устройств</div>
|
||||
<div className="devices-empty-sub">
|
||||
Установите Malenia на устройство и подключите его к этому аккаунту.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="device-grid">
|
||||
{devices.map((d) => (
|
||||
<DeviceCard key={d.hwid} device={d} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="account-foot">
|
||||
<span>MaleniaVPN</span>
|
||||
<button type="button" onClick={signOut}>
|
||||
Выйти
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<Modal
|
||||
open={pending !== null}
|
||||
onClose={() => setPending(null)}
|
||||
kicker="Подтверждение"
|
||||
title="Удалить это устройство?"
|
||||
body={
|
||||
<>
|
||||
Отключение <strong>{pending?.model ?? 'устройства'}</strong> немедленно завершит его
|
||||
VPN-сессию. Вы сможете подключить его снова в любой момент.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user