feat: 移动端UI精致化优化
- 添加页面过渡动画(淡入+滑动) - 优化统计卡片视觉,添加渐变色和微动效 - 实现列表项左滑删除手势(编辑+删除按钮) - 优化底部导航,缩小高度并添加点击波纹效果 - 改进空状态,添加插画和引导按钮 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
import { Icon } from './icons';
|
||||||
|
|
||||||
|
export default function EmptyState({
|
||||||
|
icon = 'info',
|
||||||
|
title = '暂无数据',
|
||||||
|
description = '这里还没有内容',
|
||||||
|
action,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state-enhanced">
|
||||||
|
<div className="empty-state-illustration">
|
||||||
|
<div className="empty-state-bg">
|
||||||
|
<Icon name={icon} size={32} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="empty-state-title">{title}</h3>
|
||||||
|
{description && <p className="empty-state-description">{description}</p>}
|
||||||
|
{action && (
|
||||||
|
<button type="button" className="btn btn-primary empty-state-action" onClick={onAction}>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
export default function PageTransition({ children }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
const [displayChildren, setDisplayChildren] = useState(children);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRouteChangeStart = () => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRouteChangeComplete = () => {
|
||||||
|
setDisplayChildren(children);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
router.events.on('routeChangeStart', handleRouteChangeStart);
|
||||||
|
router.events.on('routeChangeComplete', handleRouteChangeComplete);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
router.events.off('routeChangeStart', handleRouteChangeStart);
|
||||||
|
router.events.off('routeChangeComplete', handleRouteChangeComplete);
|
||||||
|
};
|
||||||
|
}, [router, children]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAnimating) {
|
||||||
|
setDisplayChildren(children);
|
||||||
|
}
|
||||||
|
}, [children, isAnimating]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`page-transition ${isAnimating ? 'page-transition--entering' : 'page-transition--active'}`}>
|
||||||
|
{displayChildren}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
export default function SwipeableItem({
|
||||||
|
children,
|
||||||
|
onSwipeLeft,
|
||||||
|
renderRightActions,
|
||||||
|
swipeThreshold = 80,
|
||||||
|
}) {
|
||||||
|
const [isSwiping, setIsSwiping] = useState(false);
|
||||||
|
const [translateX, setTranslateX] = useState(0);
|
||||||
|
const [isRevealed, setIsRevealed] = useState(false);
|
||||||
|
const startXRef = useRef(0);
|
||||||
|
const currentXRef = useRef(0);
|
||||||
|
const itemRef = useRef(null);
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback((e) => {
|
||||||
|
startXRef.current = e.touches[0].clientX;
|
||||||
|
currentXRef.current = startXRef.current;
|
||||||
|
setIsSwiping(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback((e) => {
|
||||||
|
if (!isSwiping) return;
|
||||||
|
|
||||||
|
currentXRef.current = e.touches[0].clientX;
|
||||||
|
const diff = currentXRef.current - startXRef.current;
|
||||||
|
|
||||||
|
// Only allow swiping left (negative diff)
|
||||||
|
if (diff < 0) {
|
||||||
|
// Limit the swipe distance
|
||||||
|
const newTranslateX = Math.max(diff, -swipeThreshold - 20);
|
||||||
|
setTranslateX(newTranslateX);
|
||||||
|
} else if (isRevealed) {
|
||||||
|
// If already revealed, allow swiping right to close
|
||||||
|
const newTranslateX = Math.min(0, -swipeThreshold + diff);
|
||||||
|
setTranslateX(newTranslateX);
|
||||||
|
}
|
||||||
|
}, [isSwiping, isRevealed, swipeThreshold]);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
setIsSwiping(false);
|
||||||
|
const diff = currentXRef.current - startXRef.current;
|
||||||
|
|
||||||
|
if (diff < -swipeThreshold / 2 || (isRevealed && diff > -swipeThreshold / 2)) {
|
||||||
|
// Reveal actions
|
||||||
|
setTranslateX(-swipeThreshold);
|
||||||
|
setIsRevealed(true);
|
||||||
|
} else {
|
||||||
|
// Snap back
|
||||||
|
setTranslateX(0);
|
||||||
|
setIsRevealed(false);
|
||||||
|
}
|
||||||
|
}, [isRevealed, swipeThreshold]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setTranslateX(0);
|
||||||
|
setIsRevealed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleActionClick = useCallback((callback) => {
|
||||||
|
callback?.();
|
||||||
|
handleClose();
|
||||||
|
}, [handleClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="swipeable-item" ref={itemRef}>
|
||||||
|
<div
|
||||||
|
className={`swipeable-content ${isSwiping ? 'is-swiping' : ''}`}
|
||||||
|
style={{
|
||||||
|
transform: `translateX(${translateX}px)`,
|
||||||
|
transition: isSwiping ? 'none' : 'transform 0.2s ease-out',
|
||||||
|
}}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onTouchCancel={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{renderRightActions && (
|
||||||
|
<div
|
||||||
|
className="swipeable-actions"
|
||||||
|
style={{
|
||||||
|
opacity: isRevealed || translateX < 0 ? 1 : 0,
|
||||||
|
transition: 'opacity 0.2s ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderRightActions(handleActionClick)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { SWRConfig } from 'swr';
|
|||||||
import ErrorBoundary from '../components/ErrorBoundary';
|
import ErrorBoundary from '../components/ErrorBoundary';
|
||||||
import { ToastProvider } from '../components/ToastProvider';
|
import { ToastProvider } from '../components/ToastProvider';
|
||||||
import { ConfirmProvider } from '../components/ConfirmProvider';
|
import { ConfirmProvider } from '../components/ConfirmProvider';
|
||||||
|
import PageTransition from '../components/PageTransition';
|
||||||
import { swrConfig } from '../lib/swr-config';
|
import { swrConfig } from '../lib/swr-config';
|
||||||
import '../styles/globals.css';
|
import '../styles/globals.css';
|
||||||
|
|
||||||
@@ -11,7 +12,9 @@ export default function App({ Component, pageProps }) {
|
|||||||
<SWRConfig value={swrConfig}>
|
<SWRConfig value={swrConfig}>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<PageTransition>
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
|
</PageTransition>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ConfirmProvider>
|
</ConfirmProvider>
|
||||||
</SWRConfig>
|
</SWRConfig>
|
||||||
|
|||||||
+54
-6
@@ -4,10 +4,12 @@ import { isLoggedIn, getRecords, getStats, createRecord, updateRecord, deleteRec
|
|||||||
import AppShell from '../components/AppShell';
|
import AppShell from '../components/AppShell';
|
||||||
import { Icon } from '../components/icons';
|
import { Icon } from '../components/icons';
|
||||||
import RecordModal from '../components/RecordModal';
|
import RecordModal from '../components/RecordModal';
|
||||||
|
import SwipeableItem from '../components/SwipeableItem';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
import { useToast } from '../components/ToastProvider';
|
import { useToast } from '../components/ToastProvider';
|
||||||
import { useConfirm } from '../components/ConfirmProvider';
|
import { useConfirm } from '../components/ConfirmProvider';
|
||||||
import { useCategories } from '../hooks/useCategories';
|
import { useCategories } from '../hooks/useCategories';
|
||||||
import { getMonthStr, formatMoney, formatMonth, parseDateTime, getDatePart } from '../lib/utils';
|
import { getMonthStr, formatMoney, formatMonth, parseDateTime } from '../lib/utils';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -191,6 +193,23 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleQuickDelete = async (record) => {
|
||||||
|
const shouldDelete = await confirm({
|
||||||
|
title: '删除这条记录?',
|
||||||
|
description: `${record.category} ${record.type === 'income' ? '+' : '-'}${formatMoney(record.amount)}`,
|
||||||
|
confirmText: '删除',
|
||||||
|
});
|
||||||
|
if (!shouldDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteRecord(record.id);
|
||||||
|
setRecords(prev => prev.filter(r => r.id !== record.id));
|
||||||
|
showToast('记录已删除', { tone: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, { tone: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const groupedRecords = useMemo(() => {
|
const groupedRecords = useMemo(() => {
|
||||||
return records.reduce((groups, record) => {
|
return records.reduce((groups, record) => {
|
||||||
const date = getDatePart(record.date);
|
const date = getDatePart(record.date);
|
||||||
@@ -286,10 +305,14 @@ export default function Home() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading-state">加载中...</div>
|
<div className="loading-state">加载中...</div>
|
||||||
) : records.length === 0 ? (
|
) : records.length === 0 ? (
|
||||||
<div className="empty-state empty-state--card">
|
<EmptyState
|
||||||
<div className="empty-state__icon"><Icon name="info" size={24} /></div>
|
icon="info"
|
||||||
<p>暂无记录,点击上方按钮添加。</p>
|
title="暂无记录"
|
||||||
</div>
|
description={typeFilter === 'all' ? '本月还没有记账,点击下方按钮添加第一笔记录' : '该类型下暂无记录'}
|
||||||
|
action
|
||||||
|
actionLabel="添加记录"
|
||||||
|
onAction={openAddModal}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="records-list records-list--flush">
|
<div className="records-list records-list--flush">
|
||||||
{sortedDates.map((date) => (
|
{sortedDates.map((date) => (
|
||||||
@@ -301,7 +324,31 @@ export default function Home() {
|
|||||||
{groupedRecords[date].map((record) => {
|
{groupedRecords[date].map((record) => {
|
||||||
const icon = categoryIconMap[`${record.type}:${record.category}`] || '📝';
|
const icon = categoryIconMap[`${record.type}:${record.category}`] || '📝';
|
||||||
return (
|
return (
|
||||||
<button key={record.id} type="button" className="record-item" onClick={() => openEditModal(record)}>
|
<SwipeableItem
|
||||||
|
key={record.id}
|
||||||
|
onSwipeLeft={() => openEditModal(record)}
|
||||||
|
renderRightActions={(handleAction) => (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="swipeable-action swipeable-action--edit"
|
||||||
|
onClick={() => handleAction(() => openEditModal(record))}
|
||||||
|
aria-label="编辑"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="swipeable-action swipeable-action--delete"
|
||||||
|
onClick={() => handleAction(() => handleQuickDelete(record))}
|
||||||
|
aria-label="删除"
|
||||||
|
>
|
||||||
|
<Icon name="trash" size={20} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button type="button" className="record-item" onClick={() => openEditModal(record)}>
|
||||||
<div className="record-left">
|
<div className="record-left">
|
||||||
<div className="record-icon">{icon}</div>
|
<div className="record-icon">{icon}</div>
|
||||||
<div className="record-info">
|
<div className="record-info">
|
||||||
@@ -314,6 +361,7 @@ export default function Home() {
|
|||||||
{formatMoney(record.amount)}
|
{formatMoney(record.amount)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</SwipeableItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+236
-22
@@ -564,24 +564,77 @@ textarea:focus-visible {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.82);
|
background: rgba(255, 255, 255, 0.82);
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45);
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45), 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition), background var(--transition);
|
transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition), background var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-2px);
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:active {
|
||||||
|
transform: translateY(0) scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.active {
|
.stat-card.active {
|
||||||
border-color: rgba(37, 99, 235, 0.3);
|
border-color: rgba(37, 99, 235, 0.3);
|
||||||
background: rgba(240, 246, 255, 0.96);
|
background: rgba(240, 246, 255, 0.96);
|
||||||
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.08);
|
box-shadow: 0 10px 28px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Income stat card with gradient */
|
||||||
|
.stat-card:has(.income) {
|
||||||
|
background: linear-gradient(135deg, rgba(5, 150, 105, 0.08) 0%, rgba(5, 150, 105, 0.02) 100%);
|
||||||
|
border-color: rgba(5, 150, 105, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:has(.income):hover {
|
||||||
|
background: linear-gradient(135deg, rgba(5, 150, 105, 0.12) 0%, rgba(5, 150, 105, 0.04) 100%);
|
||||||
|
border-color: rgba(5, 150, 105, 0.22);
|
||||||
|
box-shadow: 0 8px 24px rgba(5, 150, 105, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:has(.income).active {
|
||||||
|
background: linear-gradient(135deg, rgba(5, 150, 105, 0.16) 0%, rgba(5, 150, 105, 0.06) 100%);
|
||||||
|
border-color: rgba(5, 150, 105, 0.3);
|
||||||
|
box-shadow: 0 10px 28px rgba(5, 150, 105, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expense stat card with gradient */
|
||||||
|
.stat-card:has(.expense) {
|
||||||
|
background: linear-gradient(135deg, rgba(220, 38, 38, 0.08) 0%, rgba(220, 38, 38, 0.02) 100%);
|
||||||
|
border-color: rgba(220, 38, 38, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:has(.expense):hover {
|
||||||
|
background: linear-gradient(135deg, rgba(220, 38, 38, 0.12) 0%, rgba(220, 38, 38, 0.04) 100%);
|
||||||
|
border-color: rgba(220, 38, 38, 0.22);
|
||||||
|
box-shadow: 0 8px 24px rgba(220, 38, 38, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:has(.expense).active {
|
||||||
|
background: linear-gradient(135deg, rgba(220, 38, 38, 0.16) 0%, rgba(220, 38, 38, 0.06) 100%);
|
||||||
|
border-color: rgba(220, 38, 38, 0.3);
|
||||||
|
box-shadow: 0 10px 28px rgba(220, 38, 38, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card--balance {
|
.stat-card--balance {
|
||||||
background: rgba(255, 255, 255, 0.82);
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.08) 0%, rgba(37, 99, 235, 0.02) 100%);
|
||||||
|
border-color: rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card--balance:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.12) 0%, rgba(37, 99, 235, 0.04) 100%);
|
||||||
|
border-color: rgba(37, 99, 235, 0.22);
|
||||||
|
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card--balance.active {
|
||||||
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.16) 0%, rgba(37, 99, 235, 0.06) 100%);
|
||||||
|
border-color: rgba(37, 99, 235, 0.3);
|
||||||
|
box-shadow: 0 10px 28px rgba(37, 99, 235, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
@@ -1817,31 +1870,53 @@ textarea:focus-visible {
|
|||||||
.bottom-nav__inner {
|
.bottom-nav__inner {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
padding: 10px 10px 11px;
|
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
|
||||||
border-radius: 22px 22px 0 0;
|
border-radius: 20px 20px 0 0;
|
||||||
background: rgba(255, 255, 255, 0.84);
|
background: rgba(255, 255, 255, 0.88);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.66);
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||||
box-shadow: 0 -12px 30px rgba(15, 23, 42, 0.06);
|
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.05);
|
||||||
backdrop-filter: blur(24px);
|
backdrop-filter: blur(24px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav__item {
|
.bottom-nav__item {
|
||||||
min-height: 54px;
|
min-height: 48px;
|
||||||
display: grid;
|
display: flex;
|
||||||
justify-items: center;
|
flex-direction: column;
|
||||||
align-content: center;
|
align-items: center;
|
||||||
gap: 4px;
|
justify-content: center;
|
||||||
padding: 7px 4px;
|
gap: 3px;
|
||||||
border-radius: 16px;
|
padding: 5px 4px;
|
||||||
|
border-radius: 14px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition), color var(--transition), transform var(--transition);
|
transition: background var(--transition), color var(--transition), transform var(--transition);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
background: rgba(37, 99, 235, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: width 0.3s ease, height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__item:active::before {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav__item span {
|
.bottom-nav__item span {
|
||||||
font-size: 0.74rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav__item.is-active {
|
.bottom-nav__item.is-active {
|
||||||
@@ -1849,6 +1924,18 @@ textarea:focus-visible {
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-nav__item.is-active::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__item .icon {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__item:active .icon {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-label {
|
.checkbox-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2191,6 +2278,133 @@ textarea:focus-visible {
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-spinning {
|
/* Swipeable Item */
|
||||||
animation: spin 1s linear infinite;
|
.swipeable-item {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-content.is-swiping {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: transparent;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-action {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-action:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-action--edit {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-action--delete {
|
||||||
|
background: var(--expense-soft);
|
||||||
|
color: var(--expense);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State Enhanced */
|
||||||
|
.empty-state-enhanced {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-illustration {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-bg {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.1) 0%, rgba(37, 99, 235, 0.04) 100%);
|
||||||
|
color: var(--primary);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-bg::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -8px;
|
||||||
|
background: radial-gradient(circle, rgba(37, 99, 235, 0.08) 0%, transparent 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-description {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-action {
|
||||||
|
min-width: 140px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-action .icon {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.page-transition {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: opacity 250ms ease-out, transform 250ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-transition--entering {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-transition--active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user