@@ -162,7 +181,7 @@ export default function MarkdownToolbar({ textareaRef }) {
diff --git a/src/components/admin/MarkdownToolbar.module.css b/src/components/admin/MarkdownToolbar.module.css
index 25bbfcd..1037ea1 100644
--- a/src/components/admin/MarkdownToolbar.module.css
+++ b/src/components/admin/MarkdownToolbar.module.css
@@ -32,6 +32,12 @@
border-color: var(--color-muted);
}
+.toolActive {
+ background: var(--color-surface-2);
+ color: var(--color-ink);
+ border-color: var(--color-muted);
+}
+
.separator {
width: 1px;
height: 20px;
@@ -51,7 +57,17 @@
display: flex;
flex-direction: column;
gap: var(--space-4);
- animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
+ animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
+ transform-origin: top left;
+}
+
+.popoverOut {
+ animation: popoverCollapse 160ms cubic-bezier(0.4, 0, 1, 1) both;
+}
+
+@keyframes popoverCollapse {
+ from { opacity: 1; transform: translateY(0) scale(1); }
+ to { opacity: 0; transform: translateY(-8px) scale(0.96); }
}
.popoverTitle {
diff --git a/src/components/layout/AppShell.jsx b/src/components/layout/AppShell.jsx
index 10a1671..0f67a3f 100644
--- a/src/components/layout/AppShell.jsx
+++ b/src/components/layout/AppShell.jsx
@@ -3,26 +3,46 @@ import { useState, useEffect, useRef } from 'react';
import Header from './Header';
import Footer from './Footer';
import PetalCanvas from '../ui/PetalCanvas';
+import styles from './AppShell.module.css';
export default function AppShell() {
const location = useLocation();
const prevPath = useRef(location.pathname);
const [petalTrigger, setPetalTrigger] = useState(0);
+ // Track location key to re-trigger enter animation on every navigation
+ const [displayKey, setDisplayKey] = useState(location.key);
+ const [exiting, setExiting] = useState(false);
+ const exitTimer = useRef(null);
+
useEffect(() => {
- if (prevPath.current !== location.pathname) {
- prevPath.current = location.pathname;
- setPetalTrigger(t => t + 1);
+ if (prevPath.current === location.pathname) return;
+ prevPath.current = location.pathname;
+ setPetalTrigger(t => t + 1);
+
+ // Play exit on old content, then swap to new
+ setExiting(true);
+ clearTimeout(exitTimer.current);
+ exitTimer.current = setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'instant' });
- }
- }, [location.pathname]);
+ setDisplayKey(location.key);
+ setExiting(false);
+ }, 160);
+
+ return () => clearTimeout(exitTimer.current);
+ }, [location.pathname, location.key]);
return (
<>
-
+
+
+
>
diff --git a/src/components/layout/AppShell.module.css b/src/components/layout/AppShell.module.css
new file mode 100644
index 0000000..7678a6e
--- /dev/null
+++ b/src/components/layout/AppShell.module.css
@@ -0,0 +1,22 @@
+.pageContent {
+ will-change: opacity, transform;
+}
+
+.enter {
+ animation: pageEnter 300ms cubic-bezier(0.4, 0, 0.2, 1) both;
+}
+
+.exit {
+ animation: pageExit 160ms cubic-bezier(0.4, 0, 1, 1) both;
+ pointer-events: none;
+}
+
+@keyframes pageEnter {
+ from { opacity: 0; transform: translateY(14px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes pageExit {
+ from { opacity: 1; transform: translateY(0); }
+ to { opacity: 0; transform: translateY(-8px); }
+}
diff --git a/src/components/ui/ConfirmModal.jsx b/src/components/ui/ConfirmModal.jsx
new file mode 100644
index 0000000..15eae6f
--- /dev/null
+++ b/src/components/ui/ConfirmModal.jsx
@@ -0,0 +1,57 @@
+import { useState, useEffect, useCallback } from 'react';
+import styles from './ConfirmModal.module.css';
+
+const CLOSE_DURATION = 180;
+
+export default function ConfirmModal({ open, title, message, confirmLabel = '确认', danger = false, onConfirm, onCancel }) {
+ const [visible, setVisible] = useState(false);
+ const [closing, setClosing] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ setClosing(false);
+ setVisible(true);
+ }
+ }, [open]);
+
+ const triggerClose = useCallback((cb) => {
+ setClosing(true);
+ setTimeout(() => {
+ setVisible(false);
+ setClosing(false);
+ cb?.();
+ }, CLOSE_DURATION);
+ }, []);
+
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') triggerClose(onCancel); };
+ if (visible) window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [visible, triggerClose, onCancel]);
+
+ if (!visible && !open) return null;
+
+ return (
+
triggerClose(onCancel)}
+ >
+
e.stopPropagation()}
+ >
+ {title &&
{title}
}
+ {message &&
{message}
}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/ConfirmModal.module.css b/src/components/ui/ConfirmModal.module.css
new file mode 100644
index 0000000..c2fc962
--- /dev/null
+++ b/src/components/ui/ConfirmModal.module.css
@@ -0,0 +1,77 @@
+.overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+ z-index: 400;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-5);
+ animation: fadeIn 180ms ease both;
+}
+
+.overlayOut {
+ animation: fadeOut 180ms ease both;
+}
+
+.modal {
+ width: 100%;
+ max-width: 360px;
+ padding: var(--space-6);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ animation: modalIn 220ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
+}
+
+.modalOut {
+ animation: modalOut 180ms cubic-bezier(0.4, 0, 1, 1) both;
+}
+
+.title {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--color-ink);
+ line-height: 1.4;
+}
+
+.message {
+ font-size: 0.875rem;
+ color: var(--color-ink-muted);
+ line-height: 1.65;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-3);
+ margin-top: var(--space-2);
+}
+
+.btnDanger {
+ background: #d94f4f;
+ color: #fff;
+ border-color: #d94f4f;
+}
+
+.btnDanger:hover {
+ background: #c03e3e;
+ border-color: #c03e3e;
+}
+
+@keyframes fadeOut {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+
+@keyframes modalIn {
+ from { opacity: 0; transform: scale(0.92) translateY(12px); }
+ to { opacity: 1; transform: scale(1) translateY(0); }
+}
+
+@keyframes modalOut {
+ from { opacity: 1; transform: scale(1) translateY(0); }
+ to { opacity: 0; transform: scale(0.94) translateY(8px); }
+}
diff --git a/src/components/ui/SearchBar.jsx b/src/components/ui/SearchBar.jsx
index 70141cd..2bf8e3c 100644
--- a/src/components/ui/SearchBar.jsx
+++ b/src/components/ui/SearchBar.jsx
@@ -5,21 +5,36 @@ import { search } from '../../utils/searchIndex';
import { formatDateShort } from '../../utils/dateFormat';
import styles from './SearchBar.module.css';
+const CLOSE_DURATION = 180;
+
export default function SearchBar({ open, onClose }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
+ const [visible, setVisible] = useState(false);
+ const [closing, setClosing] = useState(false);
const inputRef = useRef(null);
const { articles } = useArticles();
const navigate = useNavigate();
useEffect(() => {
if (open) {
+ setClosing(false);
+ setVisible(true);
setQuery('');
setResults([]);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
+ const triggerClose = useCallback(() => {
+ setClosing(true);
+ setTimeout(() => {
+ setVisible(false);
+ setClosing(false);
+ onClose();
+ }, CLOSE_DURATION);
+ }, [onClose]);
+
useEffect(() => {
const t = setTimeout(() => {
setResults(query.trim() ? search(query, articles) : []);
@@ -28,19 +43,26 @@ export default function SearchBar({ open, onClose }) {
}, [query, articles]);
const handleKey = useCallback((e) => {
- if (e.key === 'Escape') onClose();
- }, [onClose]);
+ if (e.key === 'Escape') triggerClose();
+ }, [triggerClose]);
const goToArticle = (slug) => {
- onClose();
- navigate(`/article/${slug}`);
+ triggerClose();
+ setTimeout(() => navigate(`/article/${slug}`), CLOSE_DURATION);
};
- if (!open) return null;
+ if (!visible && !open) return null;
return (
-
-
e.stopPropagation()}>
+
+
e.stopPropagation()}
+ >
{results.length > 0 && (
diff --git a/src/components/ui/SearchBar.module.css b/src/components/ui/SearchBar.module.css
index dd77b6e..2290361 100644
--- a/src/components/ui/SearchBar.module.css
+++ b/src/components/ui/SearchBar.module.css
@@ -8,7 +8,11 @@
align-items: flex-start;
justify-content: center;
padding-top: 100px;
- animation: fadeIn 150ms ease;
+ animation: fadeIn 150ms ease both;
+}
+
+.overlayOut {
+ animation: fadeOut 180ms ease both;
}
.panel {
@@ -16,7 +20,21 @@
max-width: 560px;
border-radius: var(--radius-lg);
overflow: hidden;
- animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
+ animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
+}
+
+.panelOut {
+ animation: slideUp 180ms cubic-bezier(0.4, 0, 1, 1) both;
+}
+
+@keyframes fadeOut {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+
+@keyframes slideUp {
+ from { opacity: 1; transform: translateY(0) scale(1); }
+ to { opacity: 0; transform: translateY(-12px) scale(0.97); }
}
.inputRow {
diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx
index 8bdcc9c..c4f8bda 100644
--- a/src/pages/AdminPage.jsx
+++ b/src/pages/AdminPage.jsx
@@ -1,5 +1,7 @@
+import { useState } from 'react';
import { Link } from 'react-router-dom';
import AdminGate from '../components/admin/AdminGate';
+import ConfirmModal from '../components/ui/ConfirmModal';
import { useArticles } from '../contexts/ArticleContext';
import { formatDateShort } from '../utils/dateFormat';
import styles from './AdminPage.module.css';
@@ -8,11 +10,7 @@ function AdminContent() {
const { articles, deleteArticle } = useArticles();
const sorted = [...articles].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
- const handleDelete = (slug, title) => {
- if (window.confirm(`确认删除「${title}」?此操作不可撤销。`)) {
- deleteArticle(slug);
- }
- };
+ const [pendingDelete, setPendingDelete] = useState(null); // { slug, title }
return (
@@ -69,7 +67,7 @@ function AdminContent() {
@@ -82,6 +80,16 @@ function AdminContent() {
还没有文章,点击新建
)}
+
+
{ deleteArticle(pendingDelete.slug); setPendingDelete(null); }}
+ onCancel={() => setPendingDelete(null)}
+ />
);
}