axyenniu daily reward page
This commit is contained in:
@ -22,6 +22,7 @@ import { FullScreenLoader } from './components/FullScreenLoader';
|
||||
import { News } from './pages/News';
|
||||
import PageHeader from './components/PageHeader';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import DailyReward from './pages/DailyReward';
|
||||
|
||||
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
@ -173,6 +174,14 @@ const AppLayout = () => {
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/daily"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<DailyReward />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/shop"
|
||||
element={
|
||||
|
||||
@ -499,6 +499,36 @@ export interface DailyClaimResponse {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DailyDaysResponse {
|
||||
ok: boolean;
|
||||
days: string[]; // YYYY-MM-DD (по ЕКБ)
|
||||
count: number;
|
||||
}
|
||||
|
||||
export async function fetchDailyClaimDays(limit = 120): Promise<DailyDaysResponse> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
if (!accessToken || !clientToken) {
|
||||
throw new Error('Нет токенов авторизации');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
accessToken,
|
||||
clientToken,
|
||||
limit: String(limit),
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/users/daily/days?${params.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось получить дни ежедневных наград');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function fetchDailyStatus(): Promise<DailyStatusResponse> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
Box,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import CustomTooltip from './CustomTooltip';
|
||||
import CustomTooltip from './Notifications/CustomTooltip';
|
||||
// Тип для плаща с необязательными полями для обоих вариантов использования
|
||||
export interface CapeCardProps {
|
||||
cape: {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// CoinsDisplay.tsx
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import CustomTooltip from './CustomTooltip';
|
||||
import CustomTooltip from './Notifications/CustomTooltip';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchCoins } from '../api';
|
||||
|
||||
@ -207,14 +207,14 @@ export default function CoinsDisplay({
|
||||
<CoinsDisplay value={1500} />
|
||||
|
||||
// Пример 2: Получение данных по username с автообновлением
|
||||
<CoinsDisplay
|
||||
username="player123"
|
||||
<CoinsDisplay
|
||||
username="player123"
|
||||
autoUpdate={true}
|
||||
updateInterval={30000} // обновлять каждые 30 секунд
|
||||
/>
|
||||
|
||||
// Пример 3: Кастомная стилизация без иконки
|
||||
<CoinsDisplay
|
||||
<CoinsDisplay
|
||||
value={9999}
|
||||
size="small"
|
||||
showIcon={false}
|
||||
@ -224,7 +224,7 @@ export default function CoinsDisplay({
|
||||
/>
|
||||
|
||||
// Пример 4: Большой отображение для профиля
|
||||
<CoinsDisplay
|
||||
<CoinsDisplay
|
||||
username="player123"
|
||||
size="large"
|
||||
tooltipText="Ваш текущий баланс"
|
||||
|
||||
180
src/renderer/components/Notifications/CustomNotification.tsx
Normal file
180
src/renderer/components/Notifications/CustomNotification.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
import * as React from 'react';
|
||||
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
|
||||
export type NotificationVertical = 'top' | 'bottom';
|
||||
export type NotificationHorizontal = 'left' | 'center' | 'right';
|
||||
|
||||
export type NotificationPosition = {
|
||||
vertical: NotificationVertical;
|
||||
horizontal: NotificationHorizontal;
|
||||
};
|
||||
|
||||
export type NotificationSeverity = 'success' | 'info' | 'warning' | 'error';
|
||||
|
||||
export interface CustomNotificationProps {
|
||||
open: boolean;
|
||||
message: React.ReactNode;
|
||||
onClose: () => void;
|
||||
|
||||
severity?: NotificationSeverity;
|
||||
position?: NotificationPosition;
|
||||
|
||||
autoHideDuration?: number;
|
||||
variant?: 'filled' | 'outlined' | 'standard';
|
||||
}
|
||||
|
||||
const getAccent = (severity: NotificationSeverity) => {
|
||||
switch (severity) {
|
||||
case 'success':
|
||||
return {
|
||||
// glow: 'rgba(43, 255, 0, 0.45)',
|
||||
// a1: 'rgba(43, 255, 0, 0.90)',
|
||||
// a2: 'rgba(0, 255, 170, 0.55)',
|
||||
// a3: 'rgba(0, 200, 120, 0.35)',
|
||||
glow: 'rgba(138, 35, 135, 0.45)',
|
||||
a1: 'rgba(242, 113, 33, 0.90)',
|
||||
a2: 'rgba(138, 35, 135, 0.90)',
|
||||
a3: 'rgba(233, 64, 205, 0.90)',
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
glow: 'rgba(255, 193, 7, 0.45)',
|
||||
a1: 'rgba(255, 193, 7, 0.90)',
|
||||
a2: 'rgba(255, 120, 0, 0.55)',
|
||||
a3: 'rgba(255, 80, 0, 0.35)',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
glow: 'rgba(255, 77, 77, 0.50)',
|
||||
a1: 'rgba(255, 77, 77, 0.90)',
|
||||
a2: 'rgba(233, 64, 87, 0.65)',
|
||||
a3: 'rgba(138, 35, 135, 0.45)',
|
||||
};
|
||||
case 'info':
|
||||
default:
|
||||
return {
|
||||
glow: 'rgba(33, 150, 243, 0.45)',
|
||||
a1: 'rgba(33, 150, 243, 0.90)',
|
||||
a2: 'rgba(0, 255, 255, 0.45)',
|
||||
a3: 'rgba(120, 60, 255, 0.35)',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default function CustomNotification({
|
||||
open,
|
||||
message,
|
||||
onClose,
|
||||
severity = 'info',
|
||||
position = { vertical: 'bottom', horizontal: 'center' },
|
||||
autoHideDuration = 3000,
|
||||
variant = 'filled',
|
||||
}: CustomNotificationProps) {
|
||||
const accent = getAccent(severity);
|
||||
|
||||
const handleClose = (
|
||||
_event?: React.SyntheticEvent | Event,
|
||||
reason?: SnackbarCloseReason
|
||||
) => {
|
||||
if (reason === 'clickaway') return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
autoHideDuration={autoHideDuration}
|
||||
anchorOrigin={position}
|
||||
sx={{
|
||||
'& .MuiSnackbarContent-root': {
|
||||
background: 'transparent',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleClose}
|
||||
severity={severity}
|
||||
variant={variant}
|
||||
icon={false}
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderRadius: '1vw',
|
||||
px: '2vw',
|
||||
py: '1vw',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
// базовый фон как в тултипе
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.88)',
|
||||
color: '#fff',
|
||||
fontFamily: 'Benzin-Bold, sans-serif',
|
||||
|
||||
// рамка + неоновая подсветка
|
||||
border: `1px solid ${accent.a2}`,
|
||||
boxShadow: `
|
||||
0 0 1.6vw ${accent.glow},
|
||||
0 0 0.6vw rgba(0, 0, 0, 0.35),
|
||||
inset 0 0 0.6vw rgba(0, 0, 0, 0.45)
|
||||
`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
|
||||
// внутренний градиентный бордер как у CustomTooltip
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: '1vw',
|
||||
padding: '2px',
|
||||
background: `
|
||||
linear-gradient(
|
||||
135deg,
|
||||
${accent.a1} 0%,
|
||||
${accent.a2} 50%,
|
||||
${accent.a3} 100%
|
||||
)
|
||||
`,
|
||||
WebkitMask:
|
||||
'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
||||
WebkitMaskComposite: 'xor',
|
||||
maskComposite: 'exclude',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
},
|
||||
|
||||
// контент поверх ::before
|
||||
'& .MuiAlert-message': {
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
padding: 0,
|
||||
fontSize: '1.5vw',
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
|
||||
// кнопка закрытия
|
||||
'& .MuiAlert-action': {
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
alignItems: 'center',
|
||||
padding: 0,
|
||||
marginLeft: '1vw',
|
||||
},
|
||||
'& .MuiIconButton-root': {
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
color: accent.a1,
|
||||
transform: 'scale(1.08)',
|
||||
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@ -39,6 +39,13 @@ export default function PageHeader() {
|
||||
};
|
||||
}
|
||||
|
||||
if (path.startsWith('/daily')) {
|
||||
return {
|
||||
title: 'Ежедневные награды',
|
||||
subtitle: 'Ежедневный вход на сервер приносит бонусы и полезные награды!',
|
||||
};
|
||||
}
|
||||
|
||||
if (path.startsWith('/profile')) {
|
||||
return {
|
||||
title: 'Профиль пользователя',
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import { claimDaily, fetchDailyStatus, DailyStatusResponse } from '../../api';
|
||||
import CoinsDisplay from '../CoinsDisplay';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function formatHHMMSS(totalSeconds: number) {
|
||||
const s = Math.max(0, Math.floor(totalSeconds));
|
||||
@ -37,6 +38,7 @@ type DailyStatusCompat = DailyStatusResponse & {
|
||||
};
|
||||
|
||||
export default function DailyRewards({ onClaimed, onOpenGame }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<DailyStatusCompat | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tick, setTick] = useState(0);
|
||||
@ -129,11 +131,14 @@ export default function DailyRewards({ onClaimed, onOpenGame }: Props) {
|
||||
return `До следующей награды: ${formatHHMMSS(clientSecondsLeft)}`;
|
||||
}, [status, wasOnlineToday, canClaim, clientSecondsLeft]);
|
||||
|
||||
const navigateDaily = () => {
|
||||
navigate('/daily');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
width: '90%',
|
||||
maxWidth: 520,
|
||||
width: '100%',
|
||||
background: 'rgba(20,20,20,0.9)',
|
||||
borderRadius: '2vw',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
@ -227,6 +232,22 @@ export default function DailyRewards({ onClaimed, onOpenGame }: Props) {
|
||||
>
|
||||
{loading ? 'Забираем...' : 'Забрать награду'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={navigateDaily}
|
||||
sx={{
|
||||
mt: 1,
|
||||
transition: 'transform 0.3s ease',
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderRadius: '2.5vw',
|
||||
'&:hover': { transform: 'scale(1.03)' },
|
||||
}}
|
||||
>
|
||||
Ежедневные награды
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@ -5,7 +5,7 @@ import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import { fetchCoins } from '../api';
|
||||
import CustomTooltip from './CustomTooltip';
|
||||
import CustomTooltip from './Notifications/CustomTooltip';
|
||||
import CoinsDisplay from './CoinsDisplay';
|
||||
declare global {
|
||||
interface Window {
|
||||
|
||||
483
src/renderer/pages/DailyReward.tsx
Normal file
483
src/renderer/pages/DailyReward.tsx
Normal file
@ -0,0 +1,483 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Stack,
|
||||
Paper,
|
||||
ButtonBase,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
|
||||
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
|
||||
import TodayRoundedIcon from '@mui/icons-material/TodayRounded';
|
||||
import CustomTooltip from '../components/Notifications/CustomTooltip';
|
||||
import CoinsDisplay from '../components/CoinsDisplay';
|
||||
import { claimDaily, fetchDailyStatus, DailyStatusResponse, fetchDailyClaimDays } from '../api';
|
||||
|
||||
const RU_MONTHS = [
|
||||
'Январь','Февраль','Март','Апрель','Май','Июнь',
|
||||
'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь',
|
||||
];
|
||||
const RU_WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
const keyOf = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const isSameDay = (a: Date, b: Date) =>
|
||||
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
const weekdayMonFirst = (date: Date) => (date.getDay() + 6) % 7;
|
||||
|
||||
const EKATERINBURG_TZ = 'Asia/Yekaterinburg';
|
||||
|
||||
function keyOfInTZ(date: Date, timeZone: string) {
|
||||
// en-CA даёт ровно YYYY-MM-DD
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
type Cell = { date: Date; inCurrentMonth: boolean };
|
||||
|
||||
function buildCalendarGrid(viewYear: number, viewMonth: number): Cell[] {
|
||||
const first = new Date(viewYear, viewMonth, 1);
|
||||
const lead = weekdayMonFirst(first);
|
||||
const total = 42;
|
||||
const cells: Cell[] = [];
|
||||
for (let i = 0; i < total; i++) {
|
||||
const d = new Date(viewYear, viewMonth, 1 - lead + i);
|
||||
cells.push({ date: d, inCurrentMonth: d.getMonth() === viewMonth });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function formatHHMMSS(totalSeconds: number) {
|
||||
const s = Math.max(0, Math.floor(totalSeconds));
|
||||
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
|
||||
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
|
||||
const ss = String(s % 60).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function calcRewardByStreak(streak: number) {
|
||||
return Math.min(10 + Math.max(0, streak - 1) * 10, 50);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onClaimed?: (coinsAdded: number) => void;
|
||||
onOpenGame?: () => void;
|
||||
};
|
||||
|
||||
type DailyStatusCompat = DailyStatusResponse & {
|
||||
was_online_today?: boolean;
|
||||
next_claim_at_utc?: string;
|
||||
next_claim_at_local?: string;
|
||||
};
|
||||
|
||||
export default function DailyReward({ onClaimed }: Props) {
|
||||
const today = useMemo(() => startOfDay(new Date()), []);
|
||||
const [view, setView] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
const [selected, setSelected] = useState<Date>(today);
|
||||
|
||||
// перенесённая логика статуса/клейма
|
||||
const [status, setStatus] = useState<DailyStatusCompat | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [success, setSuccess] = useState<string>('');
|
||||
|
||||
const [claimDays, setClaimDays] = useState<Set<string>>(new Set());
|
||||
|
||||
const viewYear = view.getFullYear();
|
||||
const viewMonth = view.getMonth();
|
||||
const grid = useMemo(() => buildCalendarGrid(viewYear, viewMonth), [viewYear, viewMonth]);
|
||||
|
||||
const streak = status?.streak ?? 0;
|
||||
const wasOnlineToday = status?.was_online_today ?? false;
|
||||
const canClaim = (status?.can_claim ?? false) && wasOnlineToday;
|
||||
|
||||
const goPrev = () => setView((v) => new Date(v.getFullYear(), v.getMonth() - 1, 1));
|
||||
const goNext = () => setView((v) => new Date(v.getFullYear(), v.getMonth() + 1, 1));
|
||||
const goToday = () => {
|
||||
const t = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
setView(t);
|
||||
setSelected(today);
|
||||
};
|
||||
|
||||
const selectedKey = keyOf(selected);
|
||||
|
||||
const loadStatus = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const s = (await fetchDailyStatus()) as DailyStatusCompat;
|
||||
setStatus(s);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка загрузки статуса');
|
||||
}
|
||||
};
|
||||
|
||||
const loadClaimDays = async () => {
|
||||
try {
|
||||
const r = await fetchDailyClaimDays(180);
|
||||
if (r.ok) setClaimDays(new Set(r.days));
|
||||
} catch (e) {
|
||||
console.error('Ошибка загрузки дней наград:', e);
|
||||
// можно setError(...) если хочешь показывать
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
loadClaimDays();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((x) => x + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const clientSecondsLeft = useMemo(() => {
|
||||
if (!status) return 0;
|
||||
if (canClaim) return 0;
|
||||
return Math.max(0, (status.seconds_to_next ?? 0) - tick);
|
||||
}, [status, tick, canClaim]);
|
||||
|
||||
// ✅ фикс прогресса: считаем от clientSecondsLeft, а не от status.seconds_to_next (который не меняется)
|
||||
const progressValue = useMemo(() => {
|
||||
const day = 24 * 3600;
|
||||
const remaining = Math.min(day, Math.max(0, clientSecondsLeft));
|
||||
return ((day - remaining) / day) * 100;
|
||||
}, [clientSecondsLeft]);
|
||||
|
||||
const todaysReward = useMemo(() => {
|
||||
const effectiveStreak = canClaim ? Math.max(1, streak === 0 ? 1 : streak) : streak;
|
||||
return calcRewardByStreak(effectiveStreak);
|
||||
}, [streak, canClaim]);
|
||||
|
||||
const subtitle = useMemo(() => {
|
||||
if (!status) return '';
|
||||
if (!wasOnlineToday) return 'Награда откроется после входа на сервер сегодня.';
|
||||
if (canClaim) return 'Можно забрать прямо сейчас 🎁';
|
||||
return `До следующей награды: ${formatHHMMSS(clientSecondsLeft)}`;
|
||||
}, [status, wasOnlineToday, canClaim, clientSecondsLeft]);
|
||||
|
||||
const handleClaim = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const res = await claimDaily();
|
||||
|
||||
if (res.claimed) {
|
||||
const added = res.coins_added ?? 0;
|
||||
setSuccess(`Вы получили ${added} монет!`);
|
||||
onClaimed?.(added);
|
||||
} else {
|
||||
if (res.reason === 'not_online_today') {
|
||||
setError('Чтобы забрать награду — зайдите на сервер сегодня хотя бы на минуту.');
|
||||
} else {
|
||||
setError(res.reason || 'Награда недоступна');
|
||||
}
|
||||
}
|
||||
|
||||
await loadStatus();
|
||||
await loadClaimDays();
|
||||
setTick(0);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка при получении награды');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ px: { xs: 2, md: 4 }, py: { xs: 2, md: 3 }, mt: '-3vh', width: '85%' }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
background:
|
||||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.18), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.14), transparent 55%), rgba(10,10,20,0.94)',
|
||||
boxShadow: '0 18px 60px rgba(0,0,0,0.55)',
|
||||
}}
|
||||
>
|
||||
{/* alerts */}
|
||||
<Box sx={{ px: { xs: 2, md: 3 }, pt: 2 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 1.5 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 1.5 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
px: { xs: 2, md: 3 },
|
||||
pb: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 220, display: 'flex', gap: '1vw', alignItems: 'center' }}>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.75)', display: 'flex', gap: 1 }}>
|
||||
<CoinsDisplay value={todaysReward} size="small" />
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold, system-ui, -apple-system, Segoe UI, Roboto, Arial',
|
||||
fontWeight: 800,
|
||||
fontSize: '1rem',
|
||||
color: '#fff',
|
||||
lineHeight: 1.15,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Серия дней: <b>{streak}</b>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<CustomTooltip title="К текущему месяцу">
|
||||
<IconButton
|
||||
onClick={goToday}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
bgcolor: 'rgba(0,0,0,0.22)',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)' },
|
||||
}}
|
||||
>
|
||||
<TodayRoundedIcon sx={{ fontSize: '2vw', p: '0.2vw' }} />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
|
||||
<IconButton
|
||||
onClick={goPrev}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
bgcolor: 'rgba(0,0,0,0.22)',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)' },
|
||||
}}
|
||||
>
|
||||
<ChevronLeftRoundedIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ minWidth: 160, textAlign: 'center', maxWidth: '10vw' }}>
|
||||
<Typography sx={{ color: '#fff', fontWeight: 800, letterSpacing: 0.2, fontSize: { xs: 14.5, md: 16 } }}>
|
||||
{RU_MONTHS[viewMonth]} {viewYear}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
onClick={goNext}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
bgcolor: 'rgba(0,0,0,0.22)',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)' },
|
||||
}}
|
||||
>
|
||||
<ChevronRightRoundedIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
|
||||
|
||||
{/* Calendar */}
|
||||
<Box sx={{ px: { xs: 2, md: 3 }, py: 2.5 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 1, mb: 1.2 }}>
|
||||
{RU_WEEKDAYS.map((w, i) => (
|
||||
<Typography
|
||||
key={w}
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: i >= 5 ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.6)',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{w}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 1 }}>
|
||||
{grid.map(({ date, inCurrentMonth }) => {
|
||||
const d = startOfDay(date);
|
||||
const isToday = isSameDay(d, today);
|
||||
const isSelected = isSameDay(d, selected);
|
||||
|
||||
const dayKeyEkb = keyOfInTZ(d, EKATERINBURG_TZ);
|
||||
const claimed = claimDays.has(dayKeyEkb);
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
key={dayKeyEkb}
|
||||
onClick={() => setSelected(d)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '1 / 1',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
border: isSelected ? '1px solid rgba(242,113,33,0.85)' : 'none',
|
||||
bgcolor: inCurrentMonth ? 'rgba(0,0,0,0.24)' : 'rgba(0,0,0,0.12)',
|
||||
transition: 'transform 0.18s ease, background-color 0.18s ease, border-color 0.18s ease',
|
||||
transform: isSelected ? 'scale(1.02)' : 'scale(1)',
|
||||
'&:hover': {
|
||||
bgcolor: inCurrentMonth ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.04)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isToday && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: -20,
|
||||
background: 'radial-gradient(circle at 50% 50%, rgba(233,64,205,0.22), transparent 55%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: 0.3,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
color: inCurrentMonth ? '#fff' : 'rgba(255,255,255,0.35)',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{d.getDate()}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 10.5,
|
||||
color: claimed
|
||||
? 'rgba(156, 255, 198, 0.9)'
|
||||
: isToday
|
||||
? 'rgba(242,113,33,0.95)'
|
||||
: 'rgba(255,255,255,0.45)',
|
||||
fontWeight: 700,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{claimed ? 'получено' : isToday ? 'сегодня' : ''}
|
||||
</Typography>
|
||||
|
||||
{claimed && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
bgcolor: 'rgba(156, 255, 198, 0.95)',
|
||||
boxShadow: '0 0 12px rgba(156, 255, 198, 0.35)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Footer actions */}
|
||||
<Box sx={{ mt: 2.2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Box>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.65)', fontSize: 12 }}>
|
||||
Выбрано: <span style={{ color: '#fff', fontWeight: 800 }}>{selectedKey}</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: '1.2vw', alignItems: 'center' }}>
|
||||
<CustomTooltip title={subtitle} disableInteractive>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
cursor: loading || !status?.ok || !canClaim ? 'help' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={loading || !status?.ok || !canClaim}
|
||||
onClick={handleClaim}
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 1.2,
|
||||
borderRadius: '2vw',
|
||||
textTransform: 'uppercase',
|
||||
fontFamily:
|
||||
'Benzin-Bold, system-ui, -apple-system, Segoe UI, Roboto, Arial',
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
transition:
|
||||
'transform 0.25s ease, box-shadow 0.25s ease, filter 0.25s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(0.98)',
|
||||
filter: 'brightness(0.92)',
|
||||
boxShadow: '0 0.5vw 1vw rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
background: 'rgba(255,255,255,0.10)',
|
||||
color: 'rgba(255,255,255,0.45)',
|
||||
pointerEvents: 'none', // важно оставить
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? 'Забираем...' : 'Забрать'}
|
||||
</Button>
|
||||
</Box>
|
||||
</CustomTooltip>
|
||||
|
||||
<CustomTooltip title="Сбросить выбор на сегодня">
|
||||
<IconButton
|
||||
onClick={() => setSelected(today)}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
bgcolor: 'rgba(0,0,0,0.22)',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)' },
|
||||
}}
|
||||
>
|
||||
<TodayRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -169,7 +169,7 @@ export const News = () => {
|
||||
borderRadius: '1.5vw',
|
||||
background:
|
||||
'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
// border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
}}
|
||||
@ -350,7 +350,7 @@ export const News = () => {
|
||||
borderRadius: '1.5vw',
|
||||
background:
|
||||
'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
border: '1px solid rgba(255, 255, 255, 0)',
|
||||
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
width: '80vw',
|
||||
@ -415,7 +415,7 @@ export const News = () => {
|
||||
fontSize: '0.7vw',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid rgba(242,113,33,0.6)',
|
||||
// border: '1px solid rgba(242,113,33,0.6)',
|
||||
background:
|
||||
'linear-gradient(120deg, rgba(242,113,33,0.18), rgba(233,64,87,0.12), rgba(138,35,135,0.16))',
|
||||
backdropFilter: 'blur(12px)',
|
||||
|
||||
@ -25,8 +25,12 @@ import CapeCard from '../components/CapeCard';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
import { OnlinePlayersPanel } from '../components/OnlinePlayersPanel';
|
||||
import DailyRewards from '../components/Profile/DailyRewards';
|
||||
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function Profile() {
|
||||
const navigate = useNavigate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [walkingSpeed, setWalkingSpeed] = useState<number>(0.5);
|
||||
const [skin, setSkin] = useState<string>('');
|
||||
@ -46,6 +50,22 @@ export default function Profile() {
|
||||
const [viewerWidth, setViewerWidth] = useState(500);
|
||||
const [viewerHeight, setViewerHeight] = useState(600);
|
||||
|
||||
// notification
|
||||
const [notifOpen, setNotifOpen] = useState(false);
|
||||
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||||
const [notifSeverity, setNotifSeverity] = useState<
|
||||
'success' | 'info' | 'warning' | 'error'
|
||||
>('success');
|
||||
|
||||
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
});
|
||||
|
||||
const navigateDaily = () => {
|
||||
navigate('/daily');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
if (savedConfig) {
|
||||
@ -154,8 +174,16 @@ export default function Profile() {
|
||||
const handleUploadSkin = async () => {
|
||||
setLoading(true);
|
||||
if (!skinFile || !username) {
|
||||
setStatusMessage('Необходимо выбрать файл и указать имя пользователя');
|
||||
const msg = 'Необходимо выбрать файл и указать имя пользователя';
|
||||
setStatusMessage(msg);
|
||||
setUploadStatus('error');
|
||||
|
||||
// notification
|
||||
setNotifMsg(msg);
|
||||
setNotifSeverity('error');
|
||||
setNotifOpen(true);
|
||||
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -163,10 +191,15 @@ export default function Profile() {
|
||||
|
||||
try {
|
||||
await uploadSkin(username, skinFile, skinModel);
|
||||
|
||||
setStatusMessage('Скин успешно загружен!');
|
||||
setUploadStatus('success');
|
||||
|
||||
// notification
|
||||
setNotifMsg('Скин успешно загружен!');
|
||||
setNotifSeverity('success');
|
||||
setNotifPos({ vertical: 'bottom', horizontal: 'left' });
|
||||
setNotifOpen(true);
|
||||
|
||||
// Обновляем информацию о игроке, чтобы увидеть новый скин
|
||||
const config = JSON.parse(
|
||||
localStorage.getItem('launcher_config') || '{}',
|
||||
@ -175,10 +208,18 @@ export default function Profile() {
|
||||
loadPlayerData(config.uuid);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
`Ошибка: ${error instanceof Error ? error.message : 'Не удалось загрузить скин'}`,
|
||||
);
|
||||
const msg = `Ошибка: ${
|
||||
error instanceof Error ? error.message : 'Не удалось загрузить скин'
|
||||
}`;
|
||||
|
||||
setStatusMessage(msg);
|
||||
setUploadStatus('error');
|
||||
|
||||
// notification
|
||||
setNotifMsg(msg);
|
||||
setNotifSeverity('error');
|
||||
setNotifPos({ vertical: 'bottom', horizontal: 'left' });
|
||||
setNotifOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -187,16 +228,25 @@ export default function Profile() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
my: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '100px',
|
||||
mt: '10vh',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
|
||||
gap: '3vw',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'start',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<CustomNotification
|
||||
open={notifOpen}
|
||||
message={notifMsg}
|
||||
severity={notifSeverity}
|
||||
position={notifPos}
|
||||
onClose={() => setNotifOpen(false)}
|
||||
autoHideDuration={2500}
|
||||
/>
|
||||
{loading ? (
|
||||
<FullScreenLoader message="Загрузка вашего профиля" />
|
||||
) : (
|
||||
@ -208,6 +258,9 @@ export default function Profile() {
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Используем переработанный компонент SkinViewer */}
|
||||
@ -243,16 +296,20 @@ export default function Profile() {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1vw',
|
||||
width: '100%',
|
||||
maxWidth: '44vw',
|
||||
justifySelf: 'start'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '40vw',
|
||||
maxWidth: '40vw',
|
||||
width: '100%',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
padding: '3vw',
|
||||
borderRadius: '1vw',
|
||||
flexShrink: 0,
|
||||
boxSizing: 'border-box',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@ -379,19 +436,6 @@ export default function Profile() {
|
||||
<MenuItem value="classic">Классическая (Steve)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{uploadStatus === 'error' && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{statusMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadStatus === 'success' && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{statusMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
sx={{
|
||||
color: 'white',
|
||||
@ -453,7 +497,22 @@ export default function Profile() {
|
||||
</Box>
|
||||
</Box>
|
||||
<OnlinePlayersPanel currentUsername={username} />
|
||||
<DailyRewards />
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={navigateDaily}
|
||||
sx={{
|
||||
mt: 1,
|
||||
transition: 'transform 0.3s ease',
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderRadius: '2.5vw',
|
||||
'&:hover': { transform: 'scale(1.03)' },
|
||||
}}
|
||||
>
|
||||
Ежедневные награды
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -52,7 +52,6 @@ export const VersionCard: React.FC<VersionCardProps> = ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '2.5vw',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
boxShadow: isHovered
|
||||
? '0 0 10px rgba(233,64,205,0.55)'
|
||||
: '0 14px 40px rgba(0, 0, 0, 0.6)',
|
||||
@ -249,23 +248,22 @@ export const VersionsExplorer = () => {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '2.5vw',
|
||||
border: '1px dashed rgba(255,255,255,0.3)',
|
||||
position: 'relative',
|
||||
border: 'none',
|
||||
boxShadow: '0 14px 40px rgba(0, 0, 0, 0.6)',
|
||||
transition: 'transform 0.35s ease, box-shadow 0.35s ease',
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
transform: hoveredCardId === 'add' ? 'scale(1.04)' : 'scale(1)',
|
||||
zIndex: hoveredCardId === 'add' ? 10 : 1,
|
||||
transition: 'transform 0.35s ease, box-shadow 0.35s ease',
|
||||
willChange: 'transform, box-shadow',
|
||||
'&:hover': {
|
||||
boxShadow: '0 0 40px rgba(242,113,33,0.7)',
|
||||
borderStyle: 'solid',
|
||||
transform: 'scale(1.02)',
|
||||
zIndex: 10,
|
||||
},
|
||||
}}
|
||||
onClick={handleAddVersion}
|
||||
onMouseEnter={() => setHoveredCardId('add')}
|
||||
onMouseLeave={() => setHoveredCardId(null)}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: '4vw', color: '#fff' }} />
|
||||
<Typography
|
||||
@ -285,7 +283,7 @@ export const VersionsExplorer = () => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
px: '7vw',
|
||||
px: '5vw',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@ -391,7 +389,6 @@ export const VersionsExplorer = () => {
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
background: 'linear-gradient(145deg, #000000 10%, #8A2387 100%)',
|
||||
border: '1px solid rgba(255,255,255,0.16)',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.85)',
|
||||
p: 4,
|
||||
borderRadius: '2.5vw',
|
||||
@ -426,7 +423,7 @@ export const VersionsExplorer = () => {
|
||||
borderRadius: '1vw',
|
||||
mb: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.35)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
border: '1px solid rgba(20,20,20,0.2)',
|
||||
cursor: 'pointer',
|
||||
transition:
|
||||
'background-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease',
|
||||
|
||||
Reference in New Issue
Block a user