From 0d9fd58bc7e0d88d0f0c66c7d922313be885bcea Mon Sep 17 00:00:00 2001 From: 12975 <1297598740@qq.com> Date: Tue, 27 Jan 2026 00:29:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=9A=90=E7=A7=81?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E3=80=81=E5=85=A8=E9=9D=A2=E7=9A=84=E4=BA=A4?= =?UTF-8?q?=E6=98=93=E7=AE=A1=E7=90=86=E3=80=81=E9=A2=84=E7=AE=97=E4=B8=8E?= =?UTF-8?q?=E5=82=A8=E8=93=84=E7=BD=90=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86=E6=95=B4=E4=BD=93=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E5=92=8C=E9=A1=B5=E9=9D=A2=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../budget/BudgetCard/BudgetCard.tsx | 6 +- .../budget/BudgetCard/BudgetCardSkeleton.css | 23 + .../budget/BudgetCard/BudgetCardSkeleton.tsx | 45 + src/components/budget/BudgetCard/index.ts | 3 +- .../budget/PiggyBankCard/PiggyBankCard.tsx | 8 +- .../PiggyBankCard/PiggyBankCardSkeleton.css | 23 + .../PiggyBankCard/PiggyBankCardSkeleton.tsx | 39 + src/components/budget/PiggyBankCard/index.ts | 3 +- .../PiggyBankTransactionModal.tsx | 22 +- src/components/budget/index.ts | 3 +- src/components/charts/SpendingTrendChart.tsx | 39 +- src/components/common/AppHeader/AppHeader.tsx | 140 ++++ src/components/common/AppHeader/index.ts | 1 + .../DataSyncIndicator/DataSyncIndicator.css | 74 ++ .../DataSyncIndicator/DataSyncIndicator.tsx | 71 ++ src/components/common/Layout/Layout.css | 65 ++ src/components/common/Layout/Layout.tsx | 78 +- .../common/MicroInteraction/LikeButton.css | 82 ++ .../common/MicroInteraction/LikeButton.tsx | 93 +++ .../common/SpotlightGuide/SpotlightGuide.css | 139 ++++ .../common/SpotlightGuide/SpotlightGuide.tsx | 221 +++++ src/components/common/SpotlightGuide/index.ts | 1 + .../common/ThemePicker/ThemePicker.css | 153 ++++ .../common/ThemePicker/ThemePicker.tsx | 71 ++ .../CurrencyConverter/CurrencyConverter.css | 449 +++++----- .../CategoryPieChart/CategoryPieChart.tsx | 27 +- .../report/ExportButton/ExportButton.tsx | 32 +- .../ExportPreviewModal/ExportPreviewModal.css | 179 ++++ .../ExportPreviewModal/ExportPreviewModal.tsx | 142 ++++ .../report/ExportPreviewModal/index.ts | 1 + .../report/SummaryCard/SummaryCard.tsx | 67 +- .../report/TrendLineChart/TrendLineChart.tsx | 67 +- .../settings/ThemePicker/ThemePicker.css | 98 +++ .../settings/ThemePicker/ThemePicker.tsx | 40 + .../TransactionCalendar.css | 194 +++++ .../TransactionCalendar.tsx | 225 +++++ .../TransactionItem/TransactionItem.css | 91 +- .../TransactionItem/TransactionItem.tsx | 227 +++-- .../TransactionItemSkeleton.css | 28 + .../TransactionItemSkeleton.tsx | 29 + .../transaction/TransactionItem/index.ts | 4 +- .../TransactionList/TransactionList.tsx | 66 +- .../TransactionReceiptModal.css | 273 ++++++ .../TransactionReceiptModal.tsx | 130 +++ .../TransactionReceiptModal/index.ts | 1 + src/components/transaction/index.ts | 2 + src/hooks/index.ts | 4 + src/hooks/useGuide.tsx | 130 +++ src/hooks/useNotifications.tsx | 142 ++++ src/hooks/usePrivacy.tsx | 60 ++ src/hooks/useTheme.tsx | 80 +- src/index.css | 1 + src/pages/Budget/Budget.tsx | 19 +- src/pages/Home/Home.tsx | 774 +++++++++--------- src/pages/Notifications/Notifications.css | 257 ++++++ src/pages/Notifications/Notifications.tsx | 167 ++++ src/pages/Notifications/index.ts | 2 + src/pages/Reports/Reports.tsx | 19 +- src/pages/Settings/Settings.tsx | 37 +- src/pages/Transactions/Transactions.tsx | 162 +++- src/pages/ZenMode/ZenMode.css | 292 +++++++ src/pages/ZenMode/ZenMode.tsx | 228 ++++++ src/router/index.tsx | 5 + src/services/reportService.ts | 6 +- src/styles/privacy.css | 28 + src/styles/themes.css | 7 + src/utils/themeUtils.ts | 98 +++ 67 files changed, 5418 insertions(+), 875 deletions(-) create mode 100644 src/components/budget/BudgetCard/BudgetCardSkeleton.css create mode 100644 src/components/budget/BudgetCard/BudgetCardSkeleton.tsx create mode 100644 src/components/budget/PiggyBankCard/PiggyBankCardSkeleton.css create mode 100644 src/components/budget/PiggyBankCard/PiggyBankCardSkeleton.tsx create mode 100644 src/components/common/AppHeader/AppHeader.tsx create mode 100644 src/components/common/AppHeader/index.ts create mode 100644 src/components/common/DataSyncIndicator/DataSyncIndicator.css create mode 100644 src/components/common/DataSyncIndicator/DataSyncIndicator.tsx create mode 100644 src/components/common/MicroInteraction/LikeButton.css create mode 100644 src/components/common/MicroInteraction/LikeButton.tsx create mode 100644 src/components/common/SpotlightGuide/SpotlightGuide.css create mode 100644 src/components/common/SpotlightGuide/SpotlightGuide.tsx create mode 100644 src/components/common/SpotlightGuide/index.ts create mode 100644 src/components/common/ThemePicker/ThemePicker.css create mode 100644 src/components/common/ThemePicker/ThemePicker.tsx create mode 100644 src/components/report/ExportPreviewModal/ExportPreviewModal.css create mode 100644 src/components/report/ExportPreviewModal/ExportPreviewModal.tsx create mode 100644 src/components/report/ExportPreviewModal/index.ts create mode 100644 src/components/settings/ThemePicker/ThemePicker.css create mode 100644 src/components/settings/ThemePicker/ThemePicker.tsx create mode 100644 src/components/transaction/TransactionCalendar/TransactionCalendar.css create mode 100644 src/components/transaction/TransactionCalendar/TransactionCalendar.tsx create mode 100644 src/components/transaction/TransactionItem/TransactionItemSkeleton.css create mode 100644 src/components/transaction/TransactionItem/TransactionItemSkeleton.tsx create mode 100644 src/components/transaction/TransactionReceiptModal/TransactionReceiptModal.css create mode 100644 src/components/transaction/TransactionReceiptModal/TransactionReceiptModal.tsx create mode 100644 src/components/transaction/TransactionReceiptModal/index.ts create mode 100644 src/hooks/useGuide.tsx create mode 100644 src/hooks/useNotifications.tsx create mode 100644 src/hooks/usePrivacy.tsx create mode 100644 src/pages/Notifications/Notifications.css create mode 100644 src/pages/Notifications/Notifications.tsx create mode 100644 src/pages/Notifications/index.ts create mode 100644 src/pages/ZenMode/ZenMode.css create mode 100644 src/pages/ZenMode/ZenMode.tsx create mode 100644 src/styles/privacy.css create mode 100644 src/utils/themeUtils.ts diff --git a/src/components/budget/BudgetCard/BudgetCard.tsx b/src/components/budget/BudgetCard/BudgetCard.tsx index 1b3216b..573cad1 100644 --- a/src/components/budget/BudgetCard/BudgetCard.tsx +++ b/src/components/budget/BudgetCard/BudgetCard.tsx @@ -93,7 +93,7 @@ export const BudgetCard: React.FC = ({
-
+
{formatCurrency(amount, 'CNY')}
= ({
已用 - + {formatCurrency(spent, 'CNY')}
@@ -132,7 +132,7 @@ export const BudgetCard: React.FC = ({ {formatCurrency(Math.abs(remaining), 'CNY')} diff --git a/src/components/budget/BudgetCard/BudgetCardSkeleton.css b/src/components/budget/BudgetCard/BudgetCardSkeleton.css new file mode 100644 index 0000000..2d00ee9 --- /dev/null +++ b/src/components/budget/BudgetCard/BudgetCardSkeleton.css @@ -0,0 +1,23 @@ +/* BudgetCardSkeleton.css */ +.budget-card-skeleton { + background: var(--glass-panel-bg); + border-radius: var(--radius-xl); + border: 1px solid var(--glass-border); + padding: var(--space-4); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.budget-card-skeleton-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.budget-card-skeleton-amount { + display: flex; + flex-direction: column; + align-items: flex-end; +} \ No newline at end of file diff --git a/src/components/budget/BudgetCard/BudgetCardSkeleton.tsx b/src/components/budget/BudgetCard/BudgetCardSkeleton.tsx new file mode 100644 index 0000000..22c1031 --- /dev/null +++ b/src/components/budget/BudgetCard/BudgetCardSkeleton.tsx @@ -0,0 +1,45 @@ +/** + * Budget Card Skeleton + * Loading placeholder for budget cards + */ + +import React from 'react'; +import { Skeleton } from '../../../common/Skeleton/Skeleton'; +import './BudgetCardSkeleton.css'; + +export const BudgetCardSkeleton: React.FC = () => { + return ( +
+
+
+ +
+ + +
+
+
+ + +
+
+ +
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+
+ ); +}; diff --git a/src/components/budget/BudgetCard/index.ts b/src/components/budget/BudgetCard/index.ts index d6c8a3f..1513227 100644 --- a/src/components/budget/BudgetCard/index.ts +++ b/src/components/budget/BudgetCard/index.ts @@ -1 +1,2 @@ -export { BudgetCard, default } from './BudgetCard'; +export * from './BudgetCard'; +export * from './BudgetCardSkeleton'; diff --git a/src/components/budget/PiggyBankCard/PiggyBankCard.tsx b/src/components/budget/PiggyBankCard/PiggyBankCard.tsx index 74d4aa5..f6af27e 100644 --- a/src/components/budget/PiggyBankCard/PiggyBankCard.tsx +++ b/src/components/budget/PiggyBankCard/PiggyBankCard.tsx @@ -122,11 +122,11 @@ export const PiggyBankCard: React.FC = ({
- + {formatCurrency(piggyBank.currentAmount, 'CNY')} / - + {formatCurrency(piggyBank.targetAmount, 'CNY')}
@@ -165,7 +165,7 @@ export const PiggyBankCard: React.FC = ({
已存入 - + {formatCurrency(piggyBank.currentAmount, 'CNY')}
@@ -177,7 +177,7 @@ export const PiggyBankCard: React.FC = ({
{isCompleted ? '已超额' : '还需存'} - + {formatCurrency(Math.abs(remaining), 'CNY')}
diff --git a/src/components/budget/PiggyBankCard/PiggyBankCardSkeleton.css b/src/components/budget/PiggyBankCard/PiggyBankCardSkeleton.css new file mode 100644 index 0000000..22ed32d --- /dev/null +++ b/src/components/budget/PiggyBankCard/PiggyBankCardSkeleton.css @@ -0,0 +1,23 @@ +/* PiggyBankCardSkeleton.css */ +.piggy-bank-card-skeleton { + background: var(--glass-panel-bg); + border-radius: var(--radius-xl); + border: 1px solid var(--glass-border); + padding: var(--space-4); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.piggy-bank-card-skeleton-header { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.piggy-bank-card-skeleton-stats { + display: flex; + justify-content: space-between; + gap: var(--space-2); +} \ No newline at end of file diff --git a/src/components/budget/PiggyBankCard/PiggyBankCardSkeleton.tsx b/src/components/budget/PiggyBankCard/PiggyBankCardSkeleton.tsx new file mode 100644 index 0000000..56d0e35 --- /dev/null +++ b/src/components/budget/PiggyBankCard/PiggyBankCardSkeleton.tsx @@ -0,0 +1,39 @@ +/** + * PiggyBank Card Skeleton + * Loading placeholder for piggy bank cards + */ + +import React from 'react'; +import { Skeleton } from '../../../common/Skeleton/Skeleton'; +import './PiggyBankCardSkeleton.css'; + +export const PiggyBankCardSkeleton: React.FC = () => { + return ( +
+
+ +
+ +
+ + +
+
+
+ +
+
+ + +
+ +
+ +
+ + + +
+
+ ); +}; diff --git a/src/components/budget/PiggyBankCard/index.ts b/src/components/budget/PiggyBankCard/index.ts index cbf6a1b..1150dcd 100644 --- a/src/components/budget/PiggyBankCard/index.ts +++ b/src/components/budget/PiggyBankCard/index.ts @@ -1 +1,2 @@ -export { PiggyBankCard } from './PiggyBankCard'; +export * from './PiggyBankCard'; +export * from './PiggyBankCardSkeleton'; diff --git a/src/components/budget/PiggyBankTransactionModal/PiggyBankTransactionModal.tsx b/src/components/budget/PiggyBankTransactionModal/PiggyBankTransactionModal.tsx index 4b0f769..f3743d6 100644 --- a/src/components/budget/PiggyBankTransactionModal/PiggyBankTransactionModal.tsx +++ b/src/components/budget/PiggyBankTransactionModal/PiggyBankTransactionModal.tsx @@ -66,11 +66,11 @@ export const PiggyBankTransactionModal: React.FC const quickAmounts = isDeposit ? [100, 500, 1000, 5000] : [ - Math.min(100, piggyBank.currentAmount), - Math.min(500, piggyBank.currentAmount), - Math.min(1000, piggyBank.currentAmount), - piggyBank.currentAmount, - ].filter((v) => v > 0); + Math.min(100, piggyBank.currentAmount), + Math.min(500, piggyBank.currentAmount), + Math.min(1000, piggyBank.currentAmount), + piggyBank.currentAmount, + ].filter((v) => v > 0); return (
@@ -96,7 +96,7 @@ export const PiggyBankTransactionModal: React.FC {piggyBank.name} - + 当前: {formatCurrency(piggyBank.currentAmount, 'CNY')}
@@ -112,9 +112,8 @@ export const PiggyBankTransactionModal: React.FC id="amount" value={amount} onChange={handleAmountChange} - className={`piggy-bank-transaction-modal__input ${ - error ? 'piggy-bank-transaction-modal__input--error' : '' - }`} + className={`piggy-bank-transaction-modal__input ${error ? 'piggy-bank-transaction-modal__input--error' : '' + }`} placeholder="0.00" step="0.01" min="0" @@ -169,9 +168,8 @@ export const PiggyBankTransactionModal: React.FC + + + +
+ +
+ + + + +
+ + ); +}); diff --git a/src/components/common/AppHeader/index.ts b/src/components/common/AppHeader/index.ts new file mode 100644 index 0000000..139cb8b --- /dev/null +++ b/src/components/common/AppHeader/index.ts @@ -0,0 +1 @@ +export * from './AppHeader'; diff --git a/src/components/common/DataSyncIndicator/DataSyncIndicator.css b/src/components/common/DataSyncIndicator/DataSyncIndicator.css new file mode 100644 index 0000000..7617274 --- /dev/null +++ b/src/components/common/DataSyncIndicator/DataSyncIndicator.css @@ -0,0 +1,74 @@ +.data-sync-indicator { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 99px; + /* Capsule shape */ + font-size: 0.8rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s ease; +} + +.data-sync-indicator:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.data-sync-indicator.syncing { + color: var(--accent-primary); + border-color: var(--accent-primary); + background: rgba(var(--accent-rgb), 0.1); + cursor: default; +} + +.data-sync-indicator.synced { + color: var(--accent-success, #10b981); + border-color: transparent; + background: transparent; +} + +.data-sync-indicator.synced:hover { + background: rgba(16, 185, 129, 0.1); +} + +.data-sync-indicator.error { + color: var(--accent-error, #ef4444); + border-color: var(--accent-error, #ef4444); + background: rgba(239, 68, 68, 0.1); +} + +.data-sync-indicator.offline { + background: var(--bg-elevated); + border-style: dashed; + cursor: not-allowed; + opacity: 0.7; +} + +.data-sync-indicator .spin { + animation: spin 1s linear infinite; +} + +.sync-text { + display: none; +} + +@media (min-width: 1024px) { + .sync-text { + display: inline; + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/src/components/common/DataSyncIndicator/DataSyncIndicator.tsx b/src/components/common/DataSyncIndicator/DataSyncIndicator.tsx new file mode 100644 index 0000000..aa03e49 --- /dev/null +++ b/src/components/common/DataSyncIndicator/DataSyncIndicator.tsx @@ -0,0 +1,71 @@ +import React, { useState, useEffect } from 'react'; +import { Icon } from '@iconify/react'; +import './DataSyncIndicator.css'; + +export const DataSyncIndicator: React.FC = () => { + const [isOnline, setIsOnline] = useState(navigator.onLine); + const [syncStatus, setSyncStatus] = useState<'synced' | 'syncing' | 'error'>('synced'); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + // Simulate periodic sync + useEffect(() => { + if (!isOnline) return; + + const interval = setInterval(() => { + setSyncStatus('syncing'); + setTimeout(() => { + setSyncStatus(Math.random() > 0.05 ? 'synced' : 'error'); + }, 2000); + }, 30000); // Sync every 30s + + return () => clearInterval(interval); + }, [isOnline]); + + const handleManualSync = () => { + if (!isOnline) return; + setSyncStatus('syncing'); + setTimeout(() => { + setSyncStatus('synced'); + }, 1500); + }; + + if (!isOnline) { + return ( +
+ + 离线 +
+ ); + } + + return ( + + ); +}; diff --git a/src/components/common/Layout/Layout.css b/src/components/common/Layout/Layout.css index e93a0a1..409a45b 100644 --- a/src/components/common/Layout/Layout.css +++ b/src/components/common/Layout/Layout.css @@ -171,6 +171,29 @@ transform: translateY(0) scale(0.95); } +.notification-btn { + position: relative; +} + +.notification-badge { + position: absolute; + top: -2px; + right: -2px; + background: #ef4444; + color: white; + font-size: 0.65rem; + font-weight: 700; + min-width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 99px; + padding: 0 4px; + box-shadow: 0 2px 4px rgba(239, 68, 68, 0.4); + border: 1.5px solid var(--bg-primary); +} + /* Avatar Styles */ .header-avatar-img { width: 38px; @@ -257,6 +280,11 @@ /* Prevent body scroll, force main to scroll */ } + /* Zen Mode Overrides */ + .app-layout.zen-mode .app-body { + height: 100vh; + } + .app-main { flex: 1; padding: var(--spacing-lg); @@ -296,4 +324,41 @@ .app-header { transition: none; } +} + +/* Zen Mode Specific Styles */ +.app-layout.zen-mode .app-main { + max-width: 900px; + margin: 0 auto; + padding-top: 4rem; + /* Give some breathing room */ +} + +.zen-exit-btn { + position: fixed; + top: 1rem; + right: 1.5rem; + z-index: 200; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--glass-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--glass-border); + border-radius: 99px; + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: var(--glass-shadow); +} + +.zen-exit-btn:hover { + background: var(--bg-elevated); + transform: translateY(-1px); + color: var(--text-primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } \ No newline at end of file diff --git a/src/components/common/Layout/Layout.tsx b/src/components/common/Layout/Layout.tsx index 2f93210..5cba809 100644 --- a/src/components/common/Layout/Layout.tsx +++ b/src/components/common/Layout/Layout.tsx @@ -1,81 +1,39 @@ -import { useState, useEffect } from 'react'; -import { Outlet, useNavigate } from 'react-router-dom'; + +import { Outlet } from 'react-router-dom'; import { useTheme } from '../../../hooks'; import { Icon } from '@iconify/react'; import Navigation from '../Navigation'; import { CommandPalette } from '../CommandPalette'; -import authService from '../../../services/authService'; -import type { User } from '../../../services/authService'; +import { SpotlightGuide } from '../SpotlightGuide'; +import { AppHeader } from '../AppHeader'; +import { useCallback, useState } from 'react'; import './Layout.css'; /** - * Layout Component - * Main layout wrapper with theme toggle and navigation - * Uses Outlet to render child routes + * Main Layout + * Optimized to prevent re-renders of the main content when header state changes. */ function Layout() { - const { theme, toggleTheme, isDark } = useTheme(); - const navigate = useNavigate(); - const [user, setUser] = useState(null); - - // Load user info for the header avatar - useEffect(() => { - const fetchUser = async () => { - try { - const userData = await authService.getCurrentUser(); - setUser(userData); - } catch (error) { - // Silent error, just show default icon - console.debug('Failed to load user for layout header', error); - } - }; - fetchUser(); - }, []); + const { isZenMode, setZenMode } = useTheme(); return ( -
-
-
-
-
-
-
-

Novault

-

财富不应被死锁在黑暗的保险库中,而应像新星一样流动、闪耀

-
-
-
- - - -
-
+
+ {isZenMode && ( + + )} + {!isZenMode && }
- + {!isZenMode && }
+
); } diff --git a/src/components/common/MicroInteraction/LikeButton.css b/src/components/common/MicroInteraction/LikeButton.css new file mode 100644 index 0000000..6b2f612 --- /dev/null +++ b/src/components/common/MicroInteraction/LikeButton.css @@ -0,0 +1,82 @@ +.like-button { + display: flex; + align-items: center; + gap: 0.5rem; + background: transparent; + border: none; + cursor: pointer; + padding: 4px 8px; + border-radius: 99px; + transition: background 0.2s ease; + user-select: none; +} + +.like-button:hover { + background: rgba(var(--accent-rgb), 0.1); +} + +.like-button.active { + background: rgba(var(--accent-rgb), 0.15); +} + +.icon-wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.heart-filled { + color: #ef4444; + /* Standard Red for heart */ + filter: drop-shadow(0 2px 4px rgba(239, 68, 68, 0.3)); +} + +.heart-outline { + color: var(--text-tertiary); + transition: color 0.2s ease; +} + +.like-button:hover .heart-outline { + color: #ef4444; +} + +.like-count { + font-weight: 600; + color: var(--text-secondary); +} + +.like-button.active .like-count { + color: #ef4444; +} + +/* Particles */ +.particles { + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + pointer-events: none; +} + +.particle { + position: absolute; + width: 4px; + height: 4px; + border-radius: 50%; + background-color: #ef4444; +} + +/* Sizes */ +.size-sm { + padding: 2px 6px; +} + +.size-md { + padding: 4px 8px; +} + +.size-lg { + padding: 6px 12px; +} \ No newline at end of file diff --git a/src/components/common/MicroInteraction/LikeButton.tsx b/src/components/common/MicroInteraction/LikeButton.tsx new file mode 100644 index 0000000..b0eb1c6 --- /dev/null +++ b/src/components/common/MicroInteraction/LikeButton.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Icon } from '@iconify/react'; +import './LikeButton.css'; + +interface LikeButtonProps { + initialCount?: number; + active?: boolean; + onToggle?: (active: boolean) => void; + size?: 'sm' | 'md' | 'lg'; +} + +export const LikeButton: React.FC = ({ + initialCount = 0, + active = false, + onToggle, + size = 'md' +}) => { + const [isActive, setIsActive] = useState(active); + const [count, setCount] = useState(initialCount); + const [isExploding, setIsExploding] = useState(false); + + // Size mapping + const sizeMap = { + sm: { width: 16, fontSize: '0.75rem' }, + md: { width: 20, fontSize: '0.875rem' }, + lg: { width: 24, fontSize: '1rem' } + }; + + const iconSize = sizeMap[size].width; + + const handleClick = () => { + const newState = !isActive; + setIsActive(newState); + setCount(prev => newState ? prev + 1 : prev - 1); + + if (newState) { + setIsExploding(true); + setTimeout(() => setIsExploding(false), 1000); + } + + onToggle?.(newState); + }; + + return ( + + ); +}; diff --git a/src/components/common/SpotlightGuide/SpotlightGuide.css b/src/components/common/SpotlightGuide/SpotlightGuide.css new file mode 100644 index 0000000..55f0352 --- /dev/null +++ b/src/components/common/SpotlightGuide/SpotlightGuide.css @@ -0,0 +1,139 @@ +.guide-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 9999; + pointer-events: none; + /* Let events pass for mask, but catch in tooltip */ +} + +.guide-mask { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: auto; + /* Catch clicks on overlay to prevent interaction with app */ +} + +.guide-highlight-box { + position: absolute; + border: 2px solid var(--color-primary); + border-radius: 12px; + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.2), 0 0 20px rgba(99, 102, 241, 0.4); + pointer-events: none; + z-index: 10000; +} + +.guide-tooltip { + position: absolute; + width: 320px; + background: var(--bg-card); + border: 1px solid var(--glass-border); + border-radius: 16px; + padding: 20px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); + z-index: 10001; + pointer-events: auto; + overflow: hidden; +} + +/* Glassmorphism for tooltip */ +.guide-tooltip { + background: var(--glass-panel-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.tooltip-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.tooltip-header h4 { + margin: 0; + font-size: 1.1rem; + font-weight: 700; + background: var(--gradient-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.close-btn { + background: transparent; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + border-radius: 50%; + display: flex; + transition: all 0.2s; +} + +.close-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.tooltip-content { + margin: 0 0 20px 0; + color: var(--text-secondary); + font-size: 0.95rem; + line-height: 1.6; +} + +.tooltip-footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.step-count { + font-size: 0.8rem; + color: var(--text-tertiary); + font-weight: 600; +} + +.tooltip-actions { + display: flex; + gap: 8px; +} + +.back-btn { + padding: 6px 14px; + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 20px; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s; +} + +.back-btn:hover { + background: var(--bg-hover); +} + +.next-btn { + padding: 6px 16px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); + transition: all 0.2s; +} + +.next-btn:hover { + background: var(--color-primary-dark); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); +} \ No newline at end of file diff --git a/src/components/common/SpotlightGuide/SpotlightGuide.tsx b/src/components/common/SpotlightGuide/SpotlightGuide.tsx new file mode 100644 index 0000000..31ea8cc --- /dev/null +++ b/src/components/common/SpotlightGuide/SpotlightGuide.tsx @@ -0,0 +1,221 @@ + +import React, { useEffect, useState, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Icon } from '@iconify/react'; +import { useGuide } from '../../../hooks'; +import './SpotlightGuide.css'; + +export const SpotlightGuide: React.FC = () => { + const { isActive, currentStep, nextStep, prevStep, endGuide, currentStepIndex } = useGuide(); + const [targetRect, setTargetRect] = useState(null); + + // Update target rect when step changes or window resizes + useEffect(() => { + if (!isActive || !currentStep) return; + + const updateRect = () => { + const element = document.getElementById(currentStep.targetId); + if (element) { + // Add some padding + const rect = element.getBoundingClientRect(); + setTargetRect(rect); + + // Scroll target into view if needed + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } else { + // Warning if ID not found, potentially skip? + console.warn(`Guide target ID "${currentStep.targetId}" not found.`); + // For now, center? Or just null + setTargetRect(null); + } + }; + + updateRect(); + window.addEventListener('resize', updateRect); + window.addEventListener('scroll', updateRect, true); // Catch scroll events + + // Small delay to ensure layout is stable + const cleanup = setTimeout(updateRect, 100); + + return () => { + window.removeEventListener('resize', updateRect); + window.removeEventListener('scroll', updateRect, true); + clearTimeout(cleanup); + }; + }, [isActive, currentStep]); + + if (!isActive || !currentStep) return null; + + const PADDING = 8; + const highlightStyle = targetRect ? { + top: targetRect.top - PADDING, + left: targetRect.left - PADDING, + width: targetRect.width + (PADDING * 2), + height: targetRect.height + (PADDING * 2), + } : { + top: '50%', + left: '50%', + width: 0, + height: 0, + }; + + // Determine tooltip position + const getTooltipPosition = () => { + if (!targetRect) return { top: '50%', left: '50%', x: '-50%', y: '-50%' }; + + // Default 24px gap + const gap = 24; + + // Simple logic (can be improved) + // If not specified, default to bottom unless space is tight + const placement = currentStep.placement || 'bottom'; + + switch (placement) { + case 'top': + return { + top: highlightStyle.top - gap, + left: highlightStyle.left + (highlightStyle.width as number) / 2, + x: '-50%', + y: '-100%', + }; + case 'bottom': + default: + // Check if hitting bottom + if (window.innerHeight - (targetRect.bottom + gap + 200) < 0) { + // Flip to top if close to bottom + return { + top: highlightStyle.top - gap, + left: highlightStyle.left + (highlightStyle.width as number) / 2, + x: '-50%', + y: '-100%', + }; + } + return { + top: (highlightStyle.top as number) + (highlightStyle.height as number) + gap, + left: highlightStyle.left + (highlightStyle.width as number) / 2, + x: '-50%', + y: '0%', + }; + // Add left/right logic if needed + } + }; + + const tooltipPos = getTooltipPosition(); + + return createPortal( + + {/* Dark Backdrop with "hole" is hard to animate smoothly with complex shapes. + Instead, we use a full dark overlay and a "Spotlight" that sits on top + or we can use SVG masking. + For "Premium" feel, let's use a "Focus Ring" approach + Darken everything else. + */} + + {/* SVG Mask for the "Hole" effect */} + + + + + {/* The Hole */} + {targetRect && ( + + )} + + + + + + {/* Glowing Border / Focus Ring */} + {targetRect && ( + + )} + + {/* Tooltip Card */} + {targetRect && ( + +
+

{currentStep.title}

+ +
+

{currentStep.content}

+
+ + {currentStepIndex + 1} / Total + +
+ {currentStepIndex > 0 && ( + + )} + +
+
+
+ )} +
, + document.body + ); +}; diff --git a/src/components/common/SpotlightGuide/index.ts b/src/components/common/SpotlightGuide/index.ts new file mode 100644 index 0000000..72a8cfa --- /dev/null +++ b/src/components/common/SpotlightGuide/index.ts @@ -0,0 +1 @@ +export * from './SpotlightGuide'; diff --git a/src/components/common/ThemePicker/ThemePicker.css b/src/components/common/ThemePicker/ThemePicker.css new file mode 100644 index 0000000..e5dc571 --- /dev/null +++ b/src/components/common/ThemePicker/ThemePicker.css @@ -0,0 +1,153 @@ +/* ThemePicker.css */ +.theme-picker { + position: relative; + display: inline-block; +} + +.theme-picker-trigger { + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + color: var(--text-secondary); + transition: all var(--duration-fast) var(--ease-in-out); + position: relative; +} + +.theme-picker-trigger:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.theme-picker-trigger.active { + background: var(--bg-active); + color: var(--accent-primary); +} + +.color-preview { + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + bottom: 8px; + right: 8px; + border: 1px solid var(--bg-card); + box-shadow: 0 0 0 1px var(--border-color); +} + +.theme-picker-popover { + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + background: var(--bg-card); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + box-shadow: var(--glass-shadow); + width: 240px; + padding: var(--space-4); + z-index: 1000; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + animation: fadeScaleIn var(--duration-fast) var(--ease-out); +} + +@keyframes fadeScaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +.theme-picker-header { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--space-3); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border-color); +} + +.theme-presets { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 8px; + margin-bottom: var(--space-4); +} + +.theme-preset-btn { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: transform var(--duration-fast); +} + +.theme-preset-btn:hover { + transform: scale(1.1); +} + +.theme-preset-btn.active { + border-color: var(--text-primary); + box-shadow: 0 0 0 2px var(--bg-card); +} + +.theme-custom { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.theme-custom-label { + font-size: var(--text-xs); + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 4px; +} + +.color-input-wrapper { + display: flex; + align-items: center; + gap: var(--space-2); + padding: 4px; + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.color-input { + width: 32px; + height: 32px; + border: none; + background: none; + cursor: pointer; + padding: 0; +} + +.color-input::-webkit-color-swatch-wrapper { + padding: 0; +} + +.color-input::-webkit-color-swatch { + border: none; + border-radius: 6px; +} + +.color-value { + font-size: var(--text-xs); + font-family: monospace; + color: var(--text-secondary); + flex: 1; +} \ No newline at end of file diff --git a/src/components/common/ThemePicker/ThemePicker.tsx b/src/components/common/ThemePicker/ThemePicker.tsx new file mode 100644 index 0000000..563f9ca --- /dev/null +++ b/src/components/common/ThemePicker/ThemePicker.tsx @@ -0,0 +1,71 @@ +/** + * Theme Picker Component + * Allows users to choose a primary color for the application + */ + +import React, { useState, useRef } from 'react'; +import { useTheme } from '../../../hooks/useTheme'; +import { PRESET_COLORS } from '../../../hooks/useTheme'; +import { Icon } from '@iconify/react'; +import { useClickAway } from 'react-use'; +import './ThemePicker.css'; + +export const ThemePicker: React.FC = () => { + const { primaryColor, setPrimaryColor } = useTheme(); + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + + useClickAway(ref, () => setIsOpen(false)); + + return ( +
+ + + {isOpen && ( +
+
+ Theme Color +
+ +
+ {PRESET_COLORS.map((color) => ( +
+ +
+ +
+ setPrimaryColor(e.target.value)} + className="color-input" + /> + {primaryColor.toUpperCase()} +
+
+
+ )} +
+ ); +}; diff --git a/src/components/exchangeRate/CurrencyConverter/CurrencyConverter.css b/src/components/exchangeRate/CurrencyConverter/CurrencyConverter.css index 5569678..04cc1da 100644 --- a/src/components/exchangeRate/CurrencyConverter/CurrencyConverter.css +++ b/src/components/exchangeRate/CurrencyConverter/CurrencyConverter.css @@ -1,40 +1,69 @@ +/* CurrencyConverter - Skeuomorphic & Glassmorphism 2.0 Design */ + .currency-converter { display: flex; flex-direction: column; - gap: 1rem; + gap: 1.5rem; + background: var(--glass-panel-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--glass-border); + border-radius: 20px; + padding: 1.5rem; + box-shadow: + 0 10px 40px -10px rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(255, 255, 255, 0.05) inset; + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.currency-converter:hover { + transform: translateY(-2px); + box-shadow: + 0 20px 50px -10px rgba(0, 0, 0, 0.15), + 0 0 0 1px rgba(255, 255, 255, 0.1) inset; } .currency-converter__header { margin-bottom: 0.5rem; + border-bottom: 1px solid var(--glass-border); + padding-bottom: 1rem; } .currency-converter__title { - font-size: 1.125rem; + font-family: 'Outfit', sans-serif; + font-size: 1.25rem; font-weight: 700; - color: var(--color-text, #1f2937); + background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; margin: 0 0 0.25rem 0; } .currency-converter__subtitle { font-size: 0.8125rem; - color: var(--color-text-secondary, #6b7280); + color: var(--text-tertiary); margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; } .currency-converter__body { display: flex; flex-direction: column; - gap: 1rem; + gap: 1.25rem; } .currency-converter__label { display: block; font-size: 0.75rem; font-weight: 600; - color: var(--color-text-secondary, #6b7280); - margin-bottom: 0.375rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; + opacity: 0.8; } .currency-converter__amount-section { @@ -42,48 +71,63 @@ flex-direction: column; } +/* Inset Input Style (Skeuomorphic Well) */ .currency-converter__amount-wrapper { display: flex; align-items: center; - background: var(--color-bg-secondary, #f9fafb); - border: 1px solid var(--color-border, #e5e7eb); - border-radius: var(--radius-md, 8px); - padding: 0.75rem 1rem; + background: rgba(0, 0, 0, 0.03); + /* Light mode well */ + border: 1px solid transparent; + /* Removed borders for cleaner look, handled by shadow */ + border-radius: 12px; + padding: 0.875rem 1rem; transition: all 0.2s ease; + box-shadow: inset 1px 1px 4px rgba(0, 0, 0, 0.1), inset -1px -1px 4px rgba(255, 255, 255, 0.5); +} + +[data-theme="dark"] .currency-converter__amount-wrapper { + background: rgba(0, 0, 0, 0.2); + box-shadow: inset 1px 1px 4px rgba(0, 0, 0, 0.3), inset -1px -1px 4px rgba(255, 255, 255, 0.05); } .currency-converter__amount-wrapper:focus-within { - border-color: var(--color-primary, #3b82f6); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: inset 1px 1px 4px rgba(0, 0, 0, 0.1), 0 0 0 2px var(--accent-primary); + background: var(--bg-primary); } .currency-converter__amount-symbol { - font-size: 1rem; + font-size: 1.125rem; font-weight: 600; - color: var(--color-text-secondary, #6b7280); - margin-right: 0.5rem; + color: var(--text-secondary); + margin-right: 0.75rem; flex-shrink: 0; + opacity: 0.7; } .currency-converter__amount-input { flex: 1; border: none; background: transparent; - font-size: 1.25rem; + font-family: 'Outfit', sans-serif; + font-size: 1.5rem; font-weight: 600; - color: var(--color-text, #1f2937); + color: var(--text-primary); outline: none; min-width: 0; + text-align: right; } .currency-converter__amount-input::placeholder { - color: var(--color-text-tertiary, #9ca3af); + color: var(--text-muted); + opacity: 0.5; } +/* Currency Selectors */ .currency-converter__currencies { display: flex; align-items: flex-end; gap: 0.75rem; + position: relative; } .currency-converter__currency-select { @@ -94,26 +138,31 @@ .currency-converter__select-wrapper { display: flex; align-items: center; - background: var(--color-bg-secondary, #f9fafb); - border: 1px solid var(--color-border, #e5e7eb); - border-radius: var(--radius-md, 8px); + background: var(--bg-elevated); + border: 1px solid var(--glass-border); + border-radius: 12px; padding: 0.625rem 0.75rem; transition: all 0.2s ease; gap: 0.5rem; + cursor: pointer; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05); } -.currency-converter__select-wrapper:focus-within { - border-color: var(--color-primary, #3b82f6); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +.currency-converter__select-wrapper:hover { + transform: translateY(-1px); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.08); + border-color: var(--text-muted); } .currency-converter__select-flag { - width: 24px; - height: 16px; - border-radius: 2px; + width: 28px; + height: 28px; + border-radius: 50%; overflow: hidden; flex-shrink: 0; - background: var(--color-bg-tertiary, #e5e7eb); + background: var(--bg-tertiary); + border: 2px solid var(--bg-primary); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } .currency-converter__flag-img { @@ -126,41 +175,54 @@ flex: 1; border: none; background: transparent; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text, #1f2937); + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); outline: none; cursor: pointer; min-width: 0; + appearance: none; + /* Hide default arrow to use custom styling logic if needed, but keeping simple here */ } .currency-converter__select-symbol { font-size: 0.875rem; - font-weight: 600; - color: var(--color-primary, #3b82f6); + font-weight: 700; + color: var(--accent-primary); flex-shrink: 0; + background: rgba(var(--accent-rgb), 0.1); + padding: 2px 6px; + border-radius: 6px; } +/* Swap Button - Floating Sphere */ .currency-converter__swap-btn { display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; + width: 44px; + height: 44px; border: none; border-radius: 50%; - background: var(--color-primary-light, #dbeafe); - color: var(--color-primary, #3b82f6); + background: linear-gradient(135deg, var(--bg-elevated) 0%, var(--bg-primary) 100%); + color: var(--text-secondary); cursor: pointer; - transition: all 0.2s ease; + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); flex-shrink: 0; margin-bottom: 0.125rem; + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.1), + 0 0 0 1px var(--glass-border); + z-index: 2; } .currency-converter__swap-btn:hover:not(:disabled) { - background: var(--color-primary, #3b82f6); + background: var(--accent-primary); color: white; - transform: rotate(180deg); + transform: rotate(180deg) scale(1.1); + box-shadow: + 0 8px 20px rgba(var(--accent-rgb), 0.3), + 0 0 0 1px rgba(255, 255, 255, 0.2) inset; } .currency-converter__swap-icon { @@ -168,37 +230,164 @@ height: 20px; } +/* Convert Button - 3D Pressed Style */ .currency-converter__convert-btn { display: flex; align-items: center; justify-content: center; gap: 0.5rem; width: 100%; - padding: 0.875rem 1.5rem; + padding: 1rem 1.5rem; border: none; - border-radius: var(--radius-md, 8px); - background: var(--color-primary, #3b82f6); + border-radius: 12px; + background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%); color: white; - font-size: 1rem; - font-weight: 600; + /* Always white text on primary button */ + font-size: 1.125rem; + font-weight: 700; cursor: pointer; - transition: all 0.2s ease; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 4px 15px rgba(var(--accent-rgb), 0.4), + 0 1px 0 rgba(255, 255, 255, 0.2) inset; + position: relative; + overflow: hidden; } .currency-converter__convert-btn:hover:not(:disabled) { - background: var(--color-primary-hover, #2563eb); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + transform: translateY(-2px); + box-shadow: + 0 8px 25px rgba(var(--accent-rgb), 0.5), + 0 1px 0 rgba(255, 255, 255, 0.3) inset; } -.currency-converter__convert-btn:disabled { - opacity: 0.6; - cursor: not-allowed; +.currency-converter__convert-btn:active:not(:disabled) { + transform: translateY(1px); + box-shadow: + 0 2px 5px rgba(var(--accent-rgb), 0.3), + inset 0 2px 5px rgba(0, 0, 0, 0.2); +} + +/* Result Screen - LCD / Digital look */ +.currency-converter__result { + display: flex; + flex-direction: column; + padding: 1.25rem; + background: var(--bg-primary); + /* "Screen" background */ + border: 1px solid var(--glass-border); + border-radius: 16px; + animation: result-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.05); + /* Inner shadow for depth */ + position: relative; +} + +/* Green glow for result container if success? Optional */ + +@keyframes result-pop { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +.currency-converter__result-header { + margin-bottom: 1rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.currency-converter__result-label { + font-size: 0.75rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.currency-converter__result-body { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.currency-converter__result-from, +.currency-converter__result-to { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.currency-converter__result-from { + align-items: flex-start; +} + +.currency-converter__result-to { + align-items: flex-end; +} + +.currency-converter__result-amount { + font-family: 'Outfit', sans-serif; + /* Or Monospace for digital look */ + font-size: 1.25rem; + font-weight: 600; + color: var(--text-secondary); + white-space: nowrap; +} + +.currency-converter__result-amount--highlight { + font-size: 1.75rem; + font-weight: 800; + color: var(--accent-primary); + text-shadow: 0 0 20px rgba(var(--accent-rgb), 0.2); +} + +.currency-converter__result-currency { + font-size: 0.75rem; + font-weight: 700; + color: var(--text-tertiary); + background: var(--bg-tertiary); + padding: 2px 6px; + border-radius: 4px; +} + +.currency-converter__result-arrow { + flex-shrink: 0; + width: 24px; + height: 24px; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; +} + +.currency-converter__result-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 1rem; + border-top: 1px dashed var(--glass-border); +} + +.currency-converter__result-rate { + font-size: 0.8rem; + color: var(--text-secondary); + font-weight: 500; } .currency-converter__spinner { - width: 18px; - height: 18px; + width: 20px; + height: 20px; border: 2px solid rgba(255, 255, 255, 0.3); border-top-color: white; border-radius: 50%; @@ -206,124 +395,9 @@ } @keyframes converter-spin { - to { transform: rotate(360deg); } -} - -.currency-converter__error { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1rem; - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.2); - border-radius: var(--radius-md, 8px); - color: var(--color-error, #ef4444); - font-size: 0.875rem; -} - -.currency-converter__error-icon { - width: 18px; - height: 18px; - flex-shrink: 0; -} - -.currency-converter__result { - display: flex; - flex-direction: column; - padding: 1rem; - background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(16, 185, 129, 0.08) 100%); - border: 1px solid rgba(59, 130, 246, 0.15); - border-radius: var(--radius-lg, 12px); - animation: result-fade-in 0.3s ease; -} - -@keyframes result-fade-in { - from { opacity: 0; transform: translateY(-8px); } - to { opacity: 1; transform: translateY(0); } -} - -.currency-converter__result-header { - margin-bottom: 0.75rem; -} - -.currency-converter__result-label { - font-size: 0.75rem; - font-weight: 600; - color: var(--color-text-secondary, #6b7280); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.currency-converter__result-body { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - margin-bottom: 0.75rem; -} - -.currency-converter__result-from, -.currency-converter__result-to { - display: flex; - flex-direction: column; - gap: 0.125rem; - min-width: 0; -} - -.currency-converter__result-from { align-items: flex-start; } -.currency-converter__result-to { align-items: flex-end; } - -.currency-converter__result-amount { - font-size: 1.25rem; - font-weight: 700; - color: var(--color-text, #1f2937); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.currency-converter__result-amount--highlight { - font-size: 1.5rem; - color: var(--color-primary, #3b82f6); -} - -.currency-converter__result-currency { - font-size: 0.75rem; - font-weight: 500; - color: var(--color-text-secondary, #6b7280); -} - -.currency-converter__result-arrow { - flex-shrink: 0; - width: 24px; - height: 24px; - color: var(--color-text-tertiary, #9ca3af); -} - -.currency-converter__result-arrow svg { - width: 100%; - height: 100%; -} - -.currency-converter__result-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding-top: 0.75rem; - border-top: 1px solid rgba(0, 0, 0, 0.06); -} - -.currency-converter__result-rate { - font-size: 0.75rem; - color: var(--color-text-secondary, #6b7280); -} - -.currency-converter__result-time { - font-size: 0.6875rem; - color: var(--color-text-tertiary, #9ca3af); - background: rgba(0, 0, 0, 0.04); - padding: 2px 6px; - border-radius: 4px; + to { + transform: rotate(360deg); + } } @media (max-width: 640px) { @@ -335,31 +409,6 @@ .currency-converter__swap-btn { align-self: center; transform: rotate(90deg); - margin: 0.25rem 0; + margin: 0.5rem 0; } - - .currency-converter__swap-btn:hover:not(:disabled) { - transform: rotate(270deg); - } - - .currency-converter__result-body { - flex-direction: column; - gap: 0.5rem; - } - - .currency-converter__result-from, - .currency-converter__result-to { - align-items: center; - width: 100%; - } - - .currency-converter__result-arrow { - transform: rotate(90deg); - } - - .currency-converter__result-footer { - flex-direction: column; - gap: 0.5rem; - align-items: center; - } -} +} \ No newline at end of file diff --git a/src/components/report/CategoryPieChart/CategoryPieChart.tsx b/src/components/report/CategoryPieChart/CategoryPieChart.tsx index b262da4..0ece9d4 100644 --- a/src/components/report/CategoryPieChart/CategoryPieChart.tsx +++ b/src/components/report/CategoryPieChart/CategoryPieChart.tsx @@ -33,10 +33,33 @@ function CategoryPieChart({ data, title = '分类占比', loading = false, onCat tooltip: { trigger: 'item', formatter: (params: any) => { - return `${params.name}
金额: ¥${params.value.toLocaleString('zh-CN', { + let isTop = false; + // Find if this is the max value + if (data && data.length > 0) { + const maxVal = Math.max(...data.map(d => d.value)); + if (params.value === maxVal) isTop = true; + } + + let content = `
${params.name}
`; + content += `
+ 金额: + ¥${params.value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2, - })}
占比: ${params.percent}%`; + })}
+
`; + content += `
+ 占比: + ${params.percent}% +
`; + + if (isTop) { + content += `
+ 🏆 最大支出项 +
`; + } + + return content; }, }, legend: { diff --git a/src/components/report/ExportButton/ExportButton.tsx b/src/components/report/ExportButton/ExportButton.tsx index 58c3c2e..2abebb5 100644 --- a/src/components/report/ExportButton/ExportButton.tsx +++ b/src/components/report/ExportButton/ExportButton.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import './ExportButton.css'; import { exportReport, type ExportParams } from '../../../services/reportService'; import type { CurrencyCode } from '../../../types'; +import { ExportPreviewModal } from '../ExportPreviewModal/ExportPreviewModal'; interface ExportButtonProps { startDate: string; @@ -17,8 +18,16 @@ interface ExportButtonProps { function ExportButton({ startDate, endDate, targetCurrency }: ExportButtonProps) { const [exporting, setExporting] = useState<'pdf' | 'excel' | null>(null); const [error, setError] = useState(null); + const [showPreview, setShowPreview] = useState(false); const handleExport = async (format: 'pdf' | 'excel') => { + // PDF now opens preview first + if (format === 'pdf') { + setShowPreview(true); + return; + } + + // Excel direct export logic try { setError(null); setExporting(format); @@ -35,7 +44,7 @@ function ExportButton({ startDate, endDate, targetCurrency }: ExportButtonProps) } await exportReport(params); - + // Show success message briefly setTimeout(() => { setExporting(null); @@ -55,17 +64,8 @@ function ExportButton({ startDate, endDate, targetCurrency }: ExportButtonProps) onClick={() => handleExport('pdf')} disabled={exporting !== null} > - {exporting === 'pdf' ? ( - <> - - 导出中... - - ) : ( - <> - 📄 - 导出PDF - - )} + 📄 + 预览/导出PDF
); } diff --git a/src/components/report/ExportPreviewModal/ExportPreviewModal.css b/src/components/report/ExportPreviewModal/ExportPreviewModal.css new file mode 100644 index 0000000..515c7fa --- /dev/null +++ b/src/components/report/ExportPreviewModal/ExportPreviewModal.css @@ -0,0 +1,179 @@ +.preview-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 24px; +} + +.preview-modal-content { + background: var(--bg-card); + width: 100%; + max-width: 900px; + height: 85vh; + border-radius: var(--radius-xl); + box-shadow: var(--shadow-2xl); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--glass-border); +} + +.preview-modal-header { + padding: 16px 24px; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--glass-panel-bg); +} + +.preview-modal-header h2 { + margin: 0; + font-size: 1.25rem; + color: var(--text-primary); +} + +.preview-close-btn { + background: transparent; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + border-radius: 50%; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.preview-close-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.preview-modal-body { + flex: 1; + background: #525659; + /* Standard PDF viewer background */ + position: relative; + overflow: hidden; +} + +.preview-iframe { + width: 100%; + height: 100%; + border: none; + display: block; +} + +.preview-loading, +.preview-error { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: white; + background: var(--bg-card); + gap: 16px; +} + +.preview-error p { + color: var(--text-secondary); +} + +.preview-error .retry-btn { + padding: 8px 16px; + background: var(--color-primary); + color: white; + border: none; + border-radius: var(--radius-md); + cursor: pointer; +} + +.preview-modal-footer { + padding: 16px 24px; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--glass-panel-bg); +} + +.preview-info { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.footer-buttons { + display: flex; + gap: 12px; +} + +.cancel-btn { + padding: 8px 16px; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-primary); + border-radius: var(--radius-md); + cursor: pointer; + font-weight: 500; + transition: all 0.2s; +} + +.cancel-btn:hover { + background: var(--bg-hover); +} + +.confirm-download-btn { + padding: 8px 20px; + background: var(--color-primary); + color: white; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + box-shadow: var(--shadow-sm); + transition: all 0.2s; +} + +.confirm-download-btn:hover:not(:disabled) { + background: var(--color-primary-dark); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.confirm-download-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/src/components/report/ExportPreviewModal/ExportPreviewModal.tsx b/src/components/report/ExportPreviewModal/ExportPreviewModal.tsx new file mode 100644 index 0000000..dd3f0b5 --- /dev/null +++ b/src/components/report/ExportPreviewModal/ExportPreviewModal.tsx @@ -0,0 +1,142 @@ + +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Icon } from '@iconify/react'; +import { exportReport } from '../../../services/reportService'; +import type { CurrencyCode } from '../../../types'; +import './ExportPreviewModal.css'; + +interface ExportPreviewModalProps { + isOpen: boolean; + onClose: () => void; + startDate: string; + endDate: string; + targetCurrency?: CurrencyCode; +} + +export const ExportPreviewModal: React.FC = ({ + isOpen, + onClose, + startDate, + endDate, + targetCurrency +}) => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [pdfUrl, setPdfUrl] = useState(null); + + useEffect(() => { + if (isOpen) { + loadPreview(); + } else { + // Cleanup URL when closed + if (pdfUrl) { + URL.revokeObjectURL(pdfUrl); + setPdfUrl(null); + } + } + }, [isOpen, startDate, endDate, targetCurrency]); + + const loadPreview = async () => { + try { + setLoading(true); + setError(null); + const blob = await exportReport({ + start_date: startDate, + end_date: endDate, + format: 'pdf', + target_currency: targetCurrency + }, true); // Request blob + + if (blob instanceof Blob) { + const url = URL.createObjectURL(blob); + setPdfUrl(url); + } else { + throw new Error('Failed to retrieve PDF data'); + } + } catch (err) { + console.error('Preview failed:', err); + setError('预览生成失败,请稍后重试'); + } finally { + setLoading(false); + } + }; + + const handleDownload = () => { + if (!pdfUrl) return; + const link = document.createElement('a'); + link.href = pdfUrl; + link.download = `report_${startDate}_${endDate}.pdf`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + if (!isOpen) return null; + + return ( + +
+ e.stopPropagation()} + initial={{ opacity: 0, scale: 0.95, y: 20 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.95, y: 20 }} + transition={{ duration: 0.2 }} + > +
+

导出预览

+
+ +
+
+ +
+ {loading && ( +
+
+

正在生成报表预览...

+
+ )} + + {error && ( +
+ +

{error}

+ +
+ )} + + {!loading && !error && pdfUrl && ( +