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:
2026-05-01 18:10:59 +08:00
commit c5994759fb
59 changed files with 5582 additions and 0 deletions

View 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>
);
}

View 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);
}

View 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('图片文件不能超过 2MBbase64 会使文章体积变大)');
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>
);
}

View 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);
}