Initial commit: Online documentation site with CMS, notes, and message board
This commit is contained in:
60
src/components/AuthProvider.tsx
Normal file
60
src/components/AuthProvider.tsx
Normal 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;
|
||||
}
|
||||
69
src/components/CodeBlock.tsx
Normal file
69
src/components/CodeBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/components/ThemeProvider.tsx
Normal file
62
src/components/ThemeProvider.tsx
Normal 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;
|
||||
}
|
||||
167
src/components/message/MessageBoard.tsx
Normal file
167
src/components/message/MessageBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
src/components/note/NotesList.tsx
Normal file
249
src/components/note/NotesList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user