Initial commit: Online documentation site with CMS, notes, and message board

This commit is contained in:
hjdave
2026-04-04 16:00:52 +08:00
commit 38dc7203d1
34 changed files with 9706 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
interface User {
id: number;
username: string;
role: "admin" | "user";
}
interface AuthContextType {
authenticated: boolean;
user: User | null;
checkAuth: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [authenticated, setAuthenticated] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
if (data.authenticated) {
setAuthenticated(true);
setUser(data.user);
} else {
setAuthenticated(false);
setUser(null);
}
} catch (error) {
setAuthenticated(false);
setUser(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
checkAuth();
}, []);
return (
<AuthContext.Provider value={{ authenticated, user, checkAuth }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,69 @@
"use client";
import { useState, useRef, useEffect } from "react";
interface CodeBlockProps {
children: string;
language?: string;
}
export default function CodeBlock({ children, language }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (copied) {
const timer = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timer);
}
}, [copied]);
const handleCopy = () => {
navigator.clipboard.writeText(children);
setCopied(true);
buttonRef.current?.blur();
};
return (
<div className="my-4 rounded-lg overflow-hidden bg-zinc-900">
{language && (
<div className="bg-zinc-800 px-4 py-2 text-xs text-zinc-400 flex items-center gap-2">
<span className="font-mono">{language}</span>
</div>
)}
<div
className="relative group"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<pre className="p-4 overflow-x-auto">
<code className="text-zinc-50 font-mono text-sm">{children}</code>
</pre>
<button
ref={buttonRef}
onClick={handleCopy}
className={`absolute top-2 right-2 bg-zinc-700 hover:bg-zinc-600 text-white px-3 py-1.5 rounded text-sm transition-all duration-200 flex items-center gap-1.5 ${
isHovered ? "opacity-100" : "opacity-0"
}`}
>
{copied ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copied!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Copy
</>
)}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark";
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const savedTheme = localStorage.getItem("theme") as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
if (savedTheme === "dark") {
document.documentElement.classList.add("dark");
}
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
setTheme("dark");
document.documentElement.classList.add("dark");
}
}, []);
const toggleTheme = () => {
setTheme((prev) => {
const newTheme = prev === "light" ? "dark" : "light";
localStorage.setItem("theme", newTheme);
if (newTheme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
return newTheme;
});
};
if (!mounted) {
return <>{children}</>;
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View File

@@ -0,0 +1,167 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/components/AuthProvider";
interface Message {
id: number;
username: string;
content: string;
created_at: string;
}
export default function MessageBoard() {
const { authenticated, user } = useAuth();
const [messages, setMessages] = useState<Message[]>([]);
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({ content: "" });
const [currentUserMsgId, setCurrentUserMsgId] = useState<number | null>(null);
useEffect(() => {
fetchMessages();
}, []);
const fetchMessages = async () => {
try {
const res = await fetch("/api/messages");
const data = await res.json();
if (data.messages) {
setMessages(data.messages);
if (authenticated && user) {
const myMsg = data.messages.find((m: Message) => m.username === user.username);
setCurrentUserMsgId(myMsg?.id || null);
}
}
} catch (error) {
console.error("Failed to fetch messages:", error);
}
};
const handlePostMessage = async () => {
if (!formData.content.trim()) return;
try {
const res = await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
const result = await res.json();
if (result.success) {
setFormData({ content: "" });
setShowForm(false);
fetchMessages();
}
} catch (error) {
console.error("Failed to post message:", error);
}
};
const handleDeleteMessage = async (msgId: number, e: React.MouseEvent) => {
e.stopPropagation();
try {
const res = await fetch(`/api/messages?id=${msgId}`, { method: "DELETE" });
const result = await res.json();
if (result.success) {
fetchMessages();
}
} catch (error) {
console.error("Failed to delete message:", error);
}
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold"></h2>
{authenticated && (
<button
onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-violet-500 hover:bg-violet-600 text-white rounded-lg transition-colors"
>
{showForm ? "取消" : "写留言"}
</button>
)}
</div>
{showForm && authenticated && user && (
<div className="bg-zinc-100 dark:bg-zinc-800 rounded-lg p-6 mb-6">
<div className="mb-4">
<label className="block text-sm text-zinc-400 mb-2"></label>
<textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
className="w-full px-4 py-2 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-lg focus:outline-none focus:border-violet-500"
placeholder="在这里写下你的留言..."
rows={4}
maxLength={500}
/>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-zinc-500">
{formData.content.length}/500
</span>
<button
onClick={handlePostMessage}
disabled={!formData.content.trim()}
className="px-6 py-2 bg-violet-500 hover:bg-violet-600 disabled:bg-zinc-300 dark:disabled:bg-zinc-700 text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
)}
{!authenticated && (
<div className="bg-zinc-100 dark:bg-zinc-800 rounded-lg p-6 text-center text-zinc-500 mb-6">
</div>
)}
<div className="space-y-4">
{messages.map((msg) => (
<div
key={msg.id}
className={`bg-white dark:bg-zinc-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow ${
authenticated && user && msg.username === user.username && currentUserMsgId === msg.id
? "border-l-4 border-violet-500"
: ""
}`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center text-white font-medium">
{msg.username.charAt(0).toUpperCase()}
</div>
<span className="font-medium">{msg.username}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">
{new Date(msg.created_at).toLocaleString("zh-CN")}
</span>
{authenticated && user && msg.username === user.username && (
<button
onClick={(e) => handleDeleteMessage(msg.id, e)}
className="p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded text-red-500"
title="删除"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
</div>
<p className="text-zinc-700 dark:text-zinc-300">{msg.content}</p>
</div>
))}
{messages.length === 0 && (
<div className="text-center py-12 text-zinc-500">
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/components/AuthProvider";
interface Note {
id: number;
title: string;
content: string;
color: string;
is_pinned: boolean;
created_at: string;
}
interface NotesListProps {
userId: number | null;
}
export default function NotesList({ userId }: NotesListProps) {
const [notes, setNotes] = useState<Note[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [formData, setFormData] = useState({
title: "",
content: "",
color: "#fef08a",
isPinned: false,
});
const { authenticated, user } = useAuth();
const colors = [
"#fef08a", // yellow
"#86efac", // green
"#93c5fd", // blue
"#fda4af", // pink
"#c4b5fd", // purple
"#fb923c", // orange
];
useEffect(() => {
if (userId) {
fetchNotes();
}
}, [userId]);
const fetchNotes = async () => {
try {
const res = await fetch("/api/notes");
const data = await res.json();
if (data.notes) {
setNotes(data.notes);
}
} catch (error) {
console.error("Failed to fetch notes:", error);
}
};
const handleAddNote = async () => {
try {
const res = await fetch("/api/notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
const result = await res.json();
if (result.success) {
setFormData({ title: "", content: "", color: "#fef08a", isPinned: false });
setShowAddForm(false);
fetchNotes();
}
} catch (error) {
console.error("Failed to add note:", error);
}
};
const handleDeleteNote = async (noteId: number) => {
try {
const res = await fetch(`/api/notes?id=${noteId}`, { method: "DELETE" });
const result = await res.json();
if (result.success) {
fetchNotes();
}
} catch (error) {
console.error("Failed to delete note:", error);
}
};
const handleTogglePin = async (noteId: number) => {
try {
const res = await fetch("/api/notes/toggle-pin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: noteId }),
});
const result = await res.json();
if (result.success) {
fetchNotes();
}
} catch (error) {
console.error("Failed to toggle pin:", error);
}
};
if (!userId) {
return (
<div className="bg-zinc-100 dark:bg-zinc-800 rounded-lg p-6 text-center text-zinc-500">
便
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">便</h2>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">便</h2>
{authenticated && (
<button
onClick={() => setShowAddForm(!showAddForm)}
className="px-4 py-2 bg-violet-500 hover:bg-violet-600 text-white rounded-lg transition-colors"
>
{showAddForm ? "取消" : "+ 添加便签"}
</button>
)}
</div>
</div>
{showAddForm && (
<div className="bg-zinc-100 dark:bg-zinc-800 rounded-lg p-6 mb-6 space-y-4">
<div>
<label className="block text-sm text-zinc-400 mb-2"></label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-4 py-2 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-lg focus:outline-none focus:border-violet-500"
placeholder="便签标题"
/>
</div>
<div>
<label className="block text-sm text-zinc-400 mb-2"></label>
<textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
className="w-full px-4 py-2 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-lg focus:outline-none focus:border-violet-500"
placeholder="便签内容"
rows={4}
/>
</div>
<div>
<label className="block text-sm text-zinc-400 mb-2"></label>
<div className="flex gap-2">
{colors.map((color) => (
<button
key={color}
onClick={() => setFormData({ ...formData, color })}
className={`w-8 h-8 rounded-lg ${color === formData.color ? "ring-2 ring-violet-500 ring-offset-2" : ""}`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isPinned"
checked={formData.isPinned}
onChange={(e) => setFormData({ ...formData, isPinned: e.target.checked })}
className="rounded"
/>
<label htmlFor="isPinned" className="text-sm">
</label>
</div>
<button
onClick={handleAddNote}
disabled={!formData.title}
className="w-full px-4 py-2 bg-violet-500 hover:bg-violet-600 disabled:bg-zinc-300 dark:disabled:bg-zinc-700 text-white rounded-lg transition-colors"
>
便
</button>
</div>
)}
{authenticated && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{notes.map((note) => (
<div
key={note.id}
className="rounded-lg p-4 shadow-md hover:shadow-lg transition-shadow relative group"
style={{ backgroundColor: note.color }}
>
<div className="flex items-start justify-between mb-2">
<h3 className="font-semibold text-zinc-900 line-clamp-2">{note.title}</h3>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleTogglePin(note.id)}
className="p-1 hover:bg-black/20 rounded"
title={note.is_pinned ? "取消置顶" : "置顶"}
>
<svg className="w-4 h-4 text-zinc-800" fill={note.is_pinned ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5l2 2 3-3 5 5 5-5 2 2-7 7-5-5-3 3-2-2z" />
</svg>
</button>
<button
onClick={() => handleDeleteNote(note.id)}
className="p-1 hover:bg-red-500 hover:text-white rounded"
title="删除"
>
<svg className="w-4 h-4 text-zinc-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<p className="text-zinc-800 text-sm line-clamp-4 mb-3">{note.content}</p>
<p className="text-xs text-zinc-600">
{new Date(note.created_at).toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
))}
{notes.length === 0 && (
<div className="col-span-full text-center py-12 text-zinc-500">
便"添加便签"
</div>
)}
</div>
)}
{!authenticated && (
<div className="bg-zinc-100 dark:bg-zinc-800 rounded-lg p-6 text-center text-zinc-500">
便
</div>
)}
</div>
);
}