Initial commit: anime-minimalist personal blog
- React 18 + Vite + React Router v6 - Glass morphism cards with rounded corners - Dark/light theme toggle - Article CRUD with localStorage persistence - Search, admin panel (PIN: 2501), sakura petal canvas animation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
58
src/components/admin/AdminGate.jsx
Normal file
58
src/components/admin/AdminGate.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import styles from './AdminGate.module.css';
|
||||
|
||||
const CORRECT_PIN = '2501';
|
||||
|
||||
export default function AdminGate({ children }) {
|
||||
const authed = sessionStorage.getItem('blog_admin_auth') === 'true';
|
||||
const [pin, setPin] = useState('');
|
||||
const [shake, setShake] = useState(false);
|
||||
const [passed, setPassed] = useState(authed);
|
||||
|
||||
const handleDigit = (d) => {
|
||||
if (pin.length >= 4) return;
|
||||
const next = pin + d;
|
||||
setPin(next);
|
||||
if (next.length === 4) {
|
||||
if (next === CORRECT_PIN) {
|
||||
sessionStorage.setItem('blog_admin_auth', 'true');
|
||||
setPassed(true);
|
||||
} else {
|
||||
setShake(true);
|
||||
setTimeout(() => { setPin(''); setShake(false); }, 600);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => setPin(p => p.slice(0, -1));
|
||||
|
||||
if (passed) return children;
|
||||
|
||||
return (
|
||||
<div className={styles.gate}>
|
||||
<div className={`${styles.panel} glass-card`}>
|
||||
<h1 className={styles.title}>管理员入口</h1>
|
||||
<p className={styles.sub}>请输入 PIN 码</p>
|
||||
|
||||
<div className={`${styles.dots} ${shake ? styles.shake : ''}`}>
|
||||
{[0,1,2,3].map(i => (
|
||||
<div key={i} className={`${styles.dot} ${i < pin.length ? styles.filled : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.keypad}>
|
||||
{['1','2','3','4','5','6','7','8','9','','0','⌫'].map((k, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`${styles.key} ${k === '' ? styles.empty : ''} ${k === '⌫' ? styles.del : ''}`}
|
||||
onClick={() => k === '⌫' ? handleDelete() : k !== '' ? handleDigit(k) : null}
|
||||
disabled={k === ''}
|
||||
>
|
||||
{k}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/components/admin/AdminGate.module.css
Normal file
109
src/components/admin/AdminGate.module.css
Normal file
@@ -0,0 +1,109 @@
|
||||
.gate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 320px;
|
||||
padding: var(--space-7) var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
animation: scaleIn 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: calc(-1 * var(--space-3));
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-border);
|
||||
background: transparent;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.dot.filled {
|
||||
background: var(--color-accent-strong);
|
||||
border-color: var(--color-accent-strong);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@keyframes shakeX {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-8px); }
|
||||
40% { transform: translateX(8px); }
|
||||
60% { transform: translateX(-5px); }
|
||||
80% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
.shake {
|
||||
animation: shakeX 0.5s ease;
|
||||
}
|
||||
.shake .dot.filled {
|
||||
background: #e05555;
|
||||
border-color: #e05555;
|
||||
}
|
||||
|
||||
.keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
background: rgba(var(--color-surface-raw), 0.5);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.key:hover:not(:disabled) {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-muted);
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.key:active:not(:disabled) { transform: scale(0.96); }
|
||||
|
||||
.key.empty {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.key.del {
|
||||
font-size: 1rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
173
src/components/admin/MarkdownToolbar.jsx
Normal file
173
src/components/admin/MarkdownToolbar.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import styles from './MarkdownToolbar.module.css';
|
||||
|
||||
const TOOLS = [
|
||||
{ label: 'B', title: '加粗', wrap: (s) => `<strong>${s}</strong>` },
|
||||
{ label: 'I', title: '斜体', wrap: (s) => `<em>${s}</em>` },
|
||||
{ label: 'H2', title: '二级标题', wrap: (s) => `<h2>${s}</h2>` },
|
||||
{ label: '❝', title: '引用', wrap: (s) => `<blockquote>${s}</blockquote>` },
|
||||
{ label: '</>', title: '代码', wrap: (s) => `<code>${s}</code>` },
|
||||
];
|
||||
|
||||
export default function MarkdownToolbar({ textareaRef }) {
|
||||
const [imgPopover, setImgPopover] = useState(false);
|
||||
const [imgUrl, setImgUrl] = useState('');
|
||||
const [imgAlt, setImgAlt] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const insertAt = (html) => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const pos = ta.selectionEnd;
|
||||
const before = ta.value.slice(0, pos);
|
||||
const after = ta.value.slice(pos);
|
||||
const next = before + '\n' + html + '\n' + after;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
||||
setter.call(ta, next);
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
ta.focus();
|
||||
};
|
||||
|
||||
const apply = (wrapFn) => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const selected = ta.value.slice(start, end) || '内容';
|
||||
const wrapped = wrapFn(selected);
|
||||
const before = ta.value.slice(0, start);
|
||||
const after = ta.value.slice(end);
|
||||
const next = before + wrapped + after;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
||||
setter.call(ta, next);
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
ta.focus();
|
||||
ta.setSelectionRange(start + wrapped.length, start + wrapped.length);
|
||||
};
|
||||
|
||||
const insertImgUrl = () => {
|
||||
if (!imgUrl.trim()) return;
|
||||
const alt = imgAlt.trim() || '图片';
|
||||
insertAt(`<img src="${imgUrl.trim()}" alt="${alt}" />`);
|
||||
setImgUrl('');
|
||||
setImgAlt('');
|
||||
setImgPopover(false);
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
alert('图片文件不能超过 2MB(base64 会使文章体积变大)');
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const src = ev.target.result;
|
||||
const alt = file.name.replace(/\.[^.]+$/, '') || '图片';
|
||||
insertAt(`<img src="${src}" alt="${alt}" />`);
|
||||
setUploading(false);
|
||||
setImgPopover(false);
|
||||
e.target.value = '';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.toolbarWrap}>
|
||||
<div className={styles.toolbar}>
|
||||
{TOOLS.map(t => (
|
||||
<button
|
||||
key={t.label}
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
title={t.title}
|
||||
onMouseDown={(e) => { e.preventDefault(); apply(t.wrap); }}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className={styles.separator} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
title="插入图片"
|
||||
onMouseDown={(e) => { e.preventDefault(); setImgPopover(v => !v); }}
|
||||
>
|
||||
🖼
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{imgPopover && (
|
||||
<div className={`${styles.popover} glass-card`}>
|
||||
<p className={styles.popoverTitle}>插入图片</p>
|
||||
|
||||
<div className={styles.popoverSection}>
|
||||
<label className={styles.popoverLabel}>图片 URL</label>
|
||||
<div className={styles.urlRow}>
|
||||
<input
|
||||
className={styles.popoverInput}
|
||||
value={imgUrl}
|
||||
onChange={e => setImgUrl(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && insertImgUrl()}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={insertImgUrl}
|
||||
disabled={!imgUrl.trim()}
|
||||
>
|
||||
插入
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className={styles.popoverInput}
|
||||
value={imgAlt}
|
||||
onChange={e => setImgAlt(e.target.value)}
|
||||
placeholder="图片描述(alt,可选)"
|
||||
style={{ marginTop: 6 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.divider}>
|
||||
<span>或</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.popoverSection}>
|
||||
<label className={styles.popoverLabel}>上传本地图片(≤ 2MB,转 base64)</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
{uploading ? '处理中…' : '选择图片文件'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.popoverClose}
|
||||
onClick={() => setImgPopover(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/components/admin/MarkdownToolbar.module.css
Normal file
135
src/components/admin/MarkdownToolbar.module.css
Normal file
@@ -0,0 +1,135 @@
|
||||
.toolbarWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tool {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-ink-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.tool:hover {
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-ink);
|
||||
border-color: var(--color-muted);
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--color-border);
|
||||
margin: 0 var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Image popover ── */
|
||||
.popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
width: 360px;
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.popoverTitle {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.popoverSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.popoverLabel {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.urlRow {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.popoverInput {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-ink);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popoverInput:focus {
|
||||
border-color: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
.popoverInput::placeholder {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
color: var(--color-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.popoverClose {
|
||||
align-self: flex-end;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.popoverClose:hover {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
Reference in New Issue
Block a user