@@ -162,7 +180,7 @@ export default function MarkdownToolbar({ textareaRef }) {
diff --git a/src/components/admin/MarkdownToolbar.module.css b/src/components/admin/MarkdownToolbar.module.css
index 25bbfcd..8248740 100644
--- a/src/components/admin/MarkdownToolbar.module.css
+++ b/src/components/admin/MarkdownToolbar.module.css
@@ -1,3 +1,12 @@
+@keyframes popoverIn {
+ from { opacity: 0; transform: translateY(-6px) scale(0.96); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+@keyframes popoverOut {
+ from { opacity: 1; transform: translateY(0) scale(1); }
+ to { opacity: 0; transform: translateY(-6px) scale(0.96); }
+}
+
.toolbarWrap {
position: relative;
}
@@ -32,6 +41,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,9 +66,12 @@
display: flex;
flex-direction: column;
gap: var(--space-4);
- animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
+ transform-origin: top left;
}
+.popoverIn { animation: popoverIn 220ms cubic-bezier(0.34, 1.4, 0.64, 1) both; }
+.popoverOut { animation: popoverOut 180ms cubic-bezier(0.4, 0, 0.8, 0) both; }
+
.popoverTitle {
font-size: 0.875rem;
font-weight: 600;
@@ -93,14 +111,8 @@
width: 100%;
}
-.popoverInput:focus {
- border-color: var(--color-accent-strong);
-}
-
-.popoverInput::placeholder {
- color: var(--color-muted);
- font-size: 0.75rem;
-}
+.popoverInput:focus { border-color: var(--color-accent-strong); }
+.popoverInput::placeholder { color: var(--color-muted); font-size: 0.75rem; }
.divider {
display: flex;
@@ -130,6 +142,4 @@
transition: color var(--transition-fast);
}
-.popoverClose:hover {
- color: var(--color-ink);
-}
+.popoverClose:hover { color: var(--color-ink); }
diff --git a/src/components/layout/AppShell.jsx b/src/components/layout/AppShell.jsx
index 10a1671..ac69aa1 100644
--- a/src/components/layout/AppShell.jsx
+++ b/src/components/layout/AppShell.jsx
@@ -3,6 +3,7 @@ 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();
@@ -22,7 +23,9 @@ export default function AppShell() {
-
+
+
+
>
diff --git a/src/components/layout/AppShell.module.css b/src/components/layout/AppShell.module.css
new file mode 100644
index 0000000..a7df955
--- /dev/null
+++ b/src/components/layout/AppShell.module.css
@@ -0,0 +1,8 @@
+@keyframes pageEnter {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.page {
+ animation: pageEnter 240ms cubic-bezier(0.34, 1.2, 0.64, 1) both;
+}
diff --git a/src/components/ui/ConfirmModal.jsx b/src/components/ui/ConfirmModal.jsx
new file mode 100644
index 0000000..c106d2f
--- /dev/null
+++ b/src/components/ui/ConfirmModal.jsx
@@ -0,0 +1,55 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import styles from './ConfirmModal.module.css';
+
+const CLOSE_MS = 200;
+
+export default function ConfirmModal({ open, title, message, confirmLabel = '确认', danger = false, onConfirm, onCancel }) {
+ const [phase, setPhase] = useState('closed'); // 'closed' | 'open' | 'closing'
+ const timer = useRef(null);
+
+ useEffect(() => {
+ if (open && phase === 'closed') setPhase('open');
+ }, [open, phase]);
+
+ const triggerClose = useCallback((cb) => {
+ setPhase('closing');
+ clearTimeout(timer.current);
+ timer.current = setTimeout(() => {
+ setPhase('closed');
+ cb?.();
+ }, CLOSE_MS);
+ }, []);
+
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape' && phase === 'open') triggerClose(onCancel); };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [phase, triggerClose, onCancel]);
+
+ if (phase === 'closed') return null;
+ const closing = phase === 'closing';
+
+ 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..48b3a78
--- /dev/null
+++ b/src/components/ui/ConfirmModal.module.css
@@ -0,0 +1,68 @@
+@keyframes overlayIn { from { opacity: 0; } to { opacity: 1; } }
+@keyframes overlayOut { from { opacity: 1; } to { opacity: 0; } }
+@keyframes modalIn {
+ from { opacity: 0; transform: scale(0.93) translateY(10px); }
+ to { opacity: 1; transform: scale(1) translateY(0); }
+}
+@keyframes modalOut {
+ from { opacity: 1; transform: scale(1) translateY(0); }
+ to { opacity: 0; transform: scale(0.93) translateY(10px); }
+}
+
+.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);
+}
+
+.overlayIn { animation: overlayIn 180ms ease both; }
+.overlayOut { animation: overlayOut 200ms ease both; }
+
+.modal {
+ width: 100%;
+ max-width: 360px;
+ padding: var(--space-6);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.modalIn { animation: modalIn 240ms cubic-bezier(0.34, 1.4, 0.64, 1) both; }
+.modalOut { animation: modalOut 200ms cubic-bezier(0.4, 0, 0.8, 0) 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;
+}
diff --git a/src/components/ui/SearchBar.jsx b/src/components/ui/SearchBar.jsx
index 70141cd..286f67a 100644
--- a/src/components/ui/SearchBar.jsx
+++ b/src/components/ui/SearchBar.jsx
@@ -5,20 +5,40 @@ import { search } from '../../utils/searchIndex';
import { formatDateShort } from '../../utils/dateFormat';
import styles from './SearchBar.module.css';
+const CLOSE_MS = 200;
+
export default function SearchBar({ open, onClose }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
+ const [phase, setPhase] = useState('closed'); // 'closed' | 'opening' | 'open' | 'closing'
const inputRef = useRef(null);
const { articles } = useArticles();
const navigate = useNavigate();
+ const timer = useRef(null);
useEffect(() => {
- if (open) {
+ if (open && phase === 'closed') {
setQuery('');
setResults([]);
- setTimeout(() => inputRef.current?.focus(), 50);
+ setPhase('open');
+ setTimeout(() => inputRef.current?.focus(), 30);
}
- }, [open]);
+ }, [open, phase]);
+
+ const triggerClose = useCallback(() => {
+ setPhase('closing');
+ clearTimeout(timer.current);
+ timer.current = setTimeout(() => {
+ setPhase('closed');
+ onClose();
+ }, CLOSE_MS);
+ }, [onClose]);
+
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape' && phase === 'open') triggerClose(); };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [phase, triggerClose]);
useEffect(() => {
const t = setTimeout(() => {
@@ -27,20 +47,24 @@ export default function SearchBar({ open, onClose }) {
return () => clearTimeout(t);
}, [query, articles]);
- const handleKey = useCallback((e) => {
- if (e.key === 'Escape') onClose();
- }, [onClose]);
-
const goToArticle = (slug) => {
- onClose();
- navigate(`/article/${slug}`);
+ triggerClose();
+ setTimeout(() => navigate(`/article/${slug}`), CLOSE_MS);
};
- if (!open) return null;
+ if (phase === 'closed') return null;
+
+ const closing = phase === 'closing';
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..6d222d8 100644
--- a/src/components/ui/SearchBar.module.css
+++ b/src/components/ui/SearchBar.module.css
@@ -1,3 +1,21 @@
+/* ── Keyframes defined locally so they are always resolved ── */
+@keyframes overlayIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes overlayOut {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+@keyframes panelIn {
+ from { opacity: 0; transform: translateY(-10px) scale(0.97); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+@keyframes panelOut {
+ from { opacity: 1; transform: translateY(0) scale(1); }
+ to { opacity: 0; transform: translateY(-10px) scale(0.97); }
+}
+
.overlay {
position: fixed;
inset: 0;
@@ -8,17 +26,21 @@
align-items: flex-start;
justify-content: center;
padding-top: 100px;
- animation: fadeIn 150ms ease;
}
+.overlayIn { animation: overlayIn 180ms ease both; }
+.overlayOut { animation: overlayOut 200ms ease both; }
+
.panel {
width: 100%;
max-width: 560px;
border-radius: var(--radius-lg);
overflow: hidden;
- animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
+.panelIn { animation: panelIn 220ms cubic-bezier(0.34, 1.4, 0.64, 1) both; }
+.panelOut { animation: panelOut 200ms cubic-bezier(0.4, 0, 0.8, 0) both; }
+
.inputRow {
display: flex;
align-items: center;
diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx
index 8bdcc9c..9ebea55 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';
@@ -7,12 +9,7 @@ import styles from './AdminPage.module.css';
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);
return (
@@ -69,7 +66,7 @@ function AdminContent() {
@@ -82,6 +79,16 @@ function AdminContent() {
还没有文章,点击新建
)}
+
+
{ deleteArticle(pendingDelete.slug); setPendingDelete(null); }}
+ onCancel={() => setPendingDelete(null)}
+ />
);
}