Add open/close transition animations to SearchBar, image popover, delete confirm modal, and page routing
- SearchBar: overlay fade + panel slide-down with spring enter / ease-in exit (200ms)
- MarkdownToolbar image popover: scale+slide with same pattern (180ms)
- ConfirmModal: new component replacing window.confirm, scale+translate animations
- AdminPage: uses ConfirmModal for article deletion
- AppShell: enter-only page transition (key={location.key}) avoids double-load issue
All @keyframes defined locally in each CSS module to ensure reliable scoping.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,11 @@
|
|||||||
"mcp__Claude_in_Chrome__tabs_context_mcp",
|
"mcp__Claude_in_Chrome__tabs_context_mcp",
|
||||||
"Bash(git init *)",
|
"Bash(git init *)",
|
||||||
"Bash(git add *)",
|
"Bash(git add *)",
|
||||||
"Bash(git commit -m ' *)"
|
"Bash(git commit -m ' *)",
|
||||||
|
"Bash(git remote *)",
|
||||||
|
"Bash(git push *)",
|
||||||
|
"Bash(git commit -m 'docs: 重写 README 为中文,添加英文版跳转链接 *)",
|
||||||
|
"Bash(git commit -m 'Add open/close transition animations to SearchBar, image popover, delete confirm modal, and page routing *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useCallback } from 'react';
|
||||||
import styles from './MarkdownToolbar.module.css';
|
import styles from './MarkdownToolbar.module.css';
|
||||||
|
|
||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
@@ -9,12 +9,30 @@ const TOOLS = [
|
|||||||
{ label: '</>', title: '代码', wrap: (s) => `<code>${s}</code>` },
|
{ label: '</>', title: '代码', wrap: (s) => `<code>${s}</code>` },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CLOSE_MS = 180;
|
||||||
|
|
||||||
export default function MarkdownToolbar({ textareaRef }) {
|
export default function MarkdownToolbar({ textareaRef }) {
|
||||||
const [imgPopover, setImgPopover] = useState(false);
|
const [imgPopover, setImgPopover] = useState(false);
|
||||||
|
const [popoverClosing, setPopoverClosing] = useState(false);
|
||||||
const [imgUrl, setImgUrl] = useState('');
|
const [imgUrl, setImgUrl] = useState('');
|
||||||
const [imgAlt, setImgAlt] = useState('');
|
const [imgAlt, setImgAlt] = useState('');
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
const closeTimer = useRef(null);
|
||||||
|
|
||||||
|
const closePopover = useCallback(() => {
|
||||||
|
setPopoverClosing(true);
|
||||||
|
clearTimeout(closeTimer.current);
|
||||||
|
closeTimer.current = setTimeout(() => {
|
||||||
|
setImgPopover(false);
|
||||||
|
setPopoverClosing(false);
|
||||||
|
}, CLOSE_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePopover = useCallback(() => {
|
||||||
|
if (imgPopover) closePopover();
|
||||||
|
else { setImgPopover(true); setPopoverClosing(false); }
|
||||||
|
}, [imgPopover, closePopover]);
|
||||||
|
|
||||||
const insertAt = (html) => {
|
const insertAt = (html) => {
|
||||||
const ta = textareaRef.current;
|
const ta = textareaRef.current;
|
||||||
@@ -52,7 +70,7 @@ export default function MarkdownToolbar({ textareaRef }) {
|
|||||||
insertAt(`<img src="${imgUrl.trim()}" alt="${alt}" />`);
|
insertAt(`<img src="${imgUrl.trim()}" alt="${alt}" />`);
|
||||||
setImgUrl('');
|
setImgUrl('');
|
||||||
setImgAlt('');
|
setImgAlt('');
|
||||||
setImgPopover(false);
|
closePopover();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (e) => {
|
const handleFileChange = (e) => {
|
||||||
@@ -69,7 +87,7 @@ export default function MarkdownToolbar({ textareaRef }) {
|
|||||||
const alt = file.name.replace(/\.[^.]+$/, '') || '图片';
|
const alt = file.name.replace(/\.[^.]+$/, '') || '图片';
|
||||||
insertAt(`<img src="${src}" alt="${alt}" />`);
|
insertAt(`<img src="${src}" alt="${alt}" />`);
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
setImgPopover(false);
|
closePopover();
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
@@ -94,16 +112,16 @@ export default function MarkdownToolbar({ textareaRef }) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.tool}
|
className={`${styles.tool} ${imgPopover ? styles.toolActive : ''}`}
|
||||||
title="插入图片"
|
title="插入图片"
|
||||||
onMouseDown={(e) => { e.preventDefault(); setImgPopover(v => !v); }}
|
onMouseDown={(e) => { e.preventDefault(); togglePopover(); }}
|
||||||
>
|
>
|
||||||
🖼
|
🖼
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{imgPopover && (
|
{imgPopover && (
|
||||||
<div className={`${styles.popover} glass-card`}>
|
<div className={`${styles.popover} ${popoverClosing ? styles.popoverOut : styles.popoverIn} glass-card`}>
|
||||||
<p className={styles.popoverTitle}>插入图片</p>
|
<p className={styles.popoverTitle}>插入图片</p>
|
||||||
|
|
||||||
<div className={styles.popoverSection}>
|
<div className={styles.popoverSection}>
|
||||||
@@ -162,7 +180,7 @@ export default function MarkdownToolbar({ textareaRef }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.popoverClose}
|
className={styles.popoverClose}
|
||||||
onClick={() => setImgPopover(false)}
|
onClick={closePopover}
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 {
|
.toolbarWrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -32,6 +41,12 @@
|
|||||||
border-color: var(--color-muted);
|
border-color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolActive {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-ink);
|
||||||
|
border-color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -51,9 +66,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
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 {
|
.popoverTitle {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -93,14 +111,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popoverInput:focus {
|
.popoverInput:focus { border-color: var(--color-accent-strong); }
|
||||||
border-color: var(--color-accent-strong);
|
.popoverInput::placeholder { color: var(--color-muted); font-size: 0.75rem; }
|
||||||
}
|
|
||||||
|
|
||||||
.popoverInput::placeholder {
|
|
||||||
color: var(--color-muted);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -130,6 +142,4 @@
|
|||||||
transition: color var(--transition-fast);
|
transition: color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popoverClose:hover {
|
.popoverClose:hover { color: var(--color-ink); }
|
||||||
color: var(--color-ink);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
import PetalCanvas from '../ui/PetalCanvas';
|
import PetalCanvas from '../ui/PetalCanvas';
|
||||||
|
import styles from './AppShell.module.css';
|
||||||
|
|
||||||
export default function AppShell() {
|
export default function AppShell() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -22,7 +23,9 @@ export default function AppShell() {
|
|||||||
<PetalCanvas trigger={petalTrigger} />
|
<PetalCanvas trigger={petalTrigger} />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="page-main" style={{ position: 'relative', zIndex: 1 }}>
|
<main className="page-main" style={{ position: 'relative', zIndex: 1 }}>
|
||||||
|
<div key={location.key} className={styles.page}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
8
src/components/layout/AppShell.module.css
Normal file
8
src/components/layout/AppShell.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
55
src/components/ui/ConfirmModal.jsx
Normal file
55
src/components/ui/ConfirmModal.jsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||||
|
onClick={() => triggerClose(onCancel)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn} glass-card`}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{title && <h3 className={styles.title}>{title}</h3>}
|
||||||
|
{message && <p className={styles.message}>{message}</p>}
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button className="btn" onClick={() => triggerClose(onCancel)}>取消</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${danger ? styles.btnDanger : 'btn--primary'}`}
|
||||||
|
onClick={() => triggerClose(onConfirm)}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/components/ui/ConfirmModal.module.css
Normal file
68
src/components/ui/ConfirmModal.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -5,20 +5,40 @@ import { search } from '../../utils/searchIndex';
|
|||||||
import { formatDateShort } from '../../utils/dateFormat';
|
import { formatDateShort } from '../../utils/dateFormat';
|
||||||
import styles from './SearchBar.module.css';
|
import styles from './SearchBar.module.css';
|
||||||
|
|
||||||
|
const CLOSE_MS = 200;
|
||||||
|
|
||||||
export default function SearchBar({ open, onClose }) {
|
export default function SearchBar({ open, onClose }) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
|
const [phase, setPhase] = useState('closed'); // 'closed' | 'opening' | 'open' | 'closing'
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
const { articles } = useArticles();
|
const { articles } = useArticles();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const timer = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open && phase === 'closed') {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
setResults([]);
|
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(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
@@ -27,20 +47,24 @@ export default function SearchBar({ open, onClose }) {
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [query, articles]);
|
}, [query, articles]);
|
||||||
|
|
||||||
const handleKey = useCallback((e) => {
|
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const goToArticle = (slug) => {
|
const goToArticle = (slug) => {
|
||||||
onClose();
|
triggerClose();
|
||||||
navigate(`/article/${slug}`);
|
setTimeout(() => navigate(`/article/${slug}`), CLOSE_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!open) return null;
|
if (phase === 'closed') return null;
|
||||||
|
|
||||||
|
const closing = phase === 'closing';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.overlay} onClick={onClose} onKeyDown={handleKey}>
|
<div
|
||||||
<div className={`${styles.panel} glass`} onClick={e => e.stopPropagation()}>
|
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||||
|
onClick={triggerClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.panel} ${closing ? styles.panelOut : styles.panelIn} glass`}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div className={styles.inputRow}>
|
<div className={styles.inputRow}>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className={styles.icon}>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className={styles.icon}>
|
||||||
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2"/>
|
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2"/>
|
||||||
@@ -53,7 +77,7 @@ export default function SearchBar({ open, onClose }) {
|
|||||||
onChange={e => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
placeholder="搜索文章…"
|
placeholder="搜索文章…"
|
||||||
/>
|
/>
|
||||||
<button className={styles.close} onClick={onClose}>✕</button>
|
<button className={styles.close} onClick={triggerClose}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{results.length > 0 && (
|
{results.length > 0 && (
|
||||||
|
|||||||
@@ -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 {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -8,17 +26,21 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 100px;
|
padding-top: 100px;
|
||||||
animation: fadeIn 150ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlayIn { animation: overlayIn 180ms ease both; }
|
||||||
|
.overlayOut { animation: overlayOut 200ms ease both; }
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
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 {
|
.inputRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import AdminGate from '../components/admin/AdminGate';
|
import AdminGate from '../components/admin/AdminGate';
|
||||||
|
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||||
import { useArticles } from '../contexts/ArticleContext';
|
import { useArticles } from '../contexts/ArticleContext';
|
||||||
import { formatDateShort } from '../utils/dateFormat';
|
import { formatDateShort } from '../utils/dateFormat';
|
||||||
import styles from './AdminPage.module.css';
|
import styles from './AdminPage.module.css';
|
||||||
@@ -7,12 +9,7 @@ import styles from './AdminPage.module.css';
|
|||||||
function AdminContent() {
|
function AdminContent() {
|
||||||
const { articles, deleteArticle } = useArticles();
|
const { articles, deleteArticle } = useArticles();
|
||||||
const sorted = [...articles].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
const sorted = [...articles].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||||
|
const [pendingDelete, setPendingDelete] = useState(null);
|
||||||
const handleDelete = (slug, title) => {
|
|
||||||
if (window.confirm(`确认删除「${title}」?此操作不可撤销。`)) {
|
|
||||||
deleteArticle(slug);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`container ${styles.page}`}>
|
<div className={`container ${styles.page}`}>
|
||||||
@@ -69,7 +66,7 @@ function AdminContent() {
|
|||||||
<button
|
<button
|
||||||
className="btn btn--ghost"
|
className="btn btn--ghost"
|
||||||
style={{ color: 'var(--color-accent-strong)' }}
|
style={{ color: 'var(--color-accent-strong)' }}
|
||||||
onClick={() => handleDelete(a.slug, a.title)}
|
onClick={() => setPendingDelete({ slug: a.slug, title: a.title })}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
@@ -82,6 +79,16 @@ function AdminContent() {
|
|||||||
<p className={styles.empty}>还没有文章,<Link to="/admin/new">点击新建</Link></p>
|
<p className={styles.empty}>还没有文章,<Link to="/admin/new">点击新建</Link></p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!pendingDelete}
|
||||||
|
title={`删除「${pendingDelete?.title}」`}
|
||||||
|
message="此操作不可撤销,文章将被永久删除。"
|
||||||
|
confirmLabel="确认删除"
|
||||||
|
danger
|
||||||
|
onConfirm={() => { deleteArticle(pendingDelete.slug); setPendingDelete(null); }}
|
||||||
|
onCancel={() => setPendingDelete(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user