fix: 前端设计规范对齐 + UX 改进 #1
@@ -2,6 +2,18 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "**",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { MarkdownEditor } from "@/components/editor/MarkdownEditor";
|
||||
import { ImageUpload } from "@/components/editor/ImageUpload";
|
||||
import { PostContent } from "@/components/post/PostContent";
|
||||
import { SaveStatus } from "@/components/SaveStatus";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface EditPostPageProps {
|
||||
@@ -22,7 +23,7 @@ export default function EditPostEditor({ params }: EditPostPageProps) {
|
||||
const [published, setPublished] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPost() {
|
||||
@@ -45,15 +46,16 @@ export default function EditPostEditor({ params }: EditPostPageProps) {
|
||||
if (!title.trim()) return;
|
||||
|
||||
setSaving(true);
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
await api.updatePost(id, {
|
||||
title,
|
||||
content,
|
||||
published,
|
||||
});
|
||||
setSaved(true);
|
||||
setSaveStatus("saved");
|
||||
} catch {
|
||||
alert("Failed to save post");
|
||||
setSaveStatus("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -65,11 +67,11 @@ export default function EditPostEditor({ params }: EditPostPageProps) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (saved) {
|
||||
const timer = setTimeout(() => setSaved(false), 3000);
|
||||
if (saveStatus === "saved" || saveStatus === "error") {
|
||||
const timer = setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [saved]);
|
||||
}, [saveStatus]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -92,6 +94,8 @@ export default function EditPostEditor({ params }: EditPostPageProps) {
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4 ml-6">
|
||||
<SaveStatus status={saveStatus} />
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -103,7 +107,7 @@ export default function EditPostEditor({ params }: EditPostPageProps) {
|
||||
</label>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving || !title.trim()}>
|
||||
{saving ? "Saving..." : saved ? "Saved." : "Save"}
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,7 +116,7 @@ export default function EditPostEditor({ params }: EditPostPageProps) {
|
||||
<ImageUpload onUpload={handleImageUpload} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 h-[calc(100vh-280px)]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 h-[calc(100vh-280px)]">
|
||||
<div className="border border-neutral-200 p-6 overflow-auto">
|
||||
<MarkdownEditor value={content} onChange={setContent} />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/Button";
|
||||
import { MarkdownEditor } from "@/components/editor/MarkdownEditor";
|
||||
import { ImageUpload } from "@/components/editor/ImageUpload";
|
||||
import { PostContent } from "@/components/post/PostContent";
|
||||
import { SaveStatus } from "@/components/SaveStatus";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export default function NewPostEditor() {
|
||||
@@ -15,24 +16,25 @@ export default function NewPostEditor() {
|
||||
const [content, setContent] = useState("");
|
||||
const [published, setPublished] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!title.trim()) return;
|
||||
|
||||
setSaving(true);
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
const post = await api.createPost({
|
||||
title,
|
||||
content,
|
||||
published,
|
||||
});
|
||||
setSaved(true);
|
||||
setSaveStatus("saved");
|
||||
setTimeout(() => {
|
||||
router.push(`/admin/editor/${post.id}`);
|
||||
}, 1000);
|
||||
} catch {
|
||||
alert("Failed to save post");
|
||||
setSaveStatus("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -44,11 +46,11 @@ export default function NewPostEditor() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (saved) {
|
||||
const timer = setTimeout(() => setSaved(false), 3000);
|
||||
if (saveStatus === "saved" || saveStatus === "error") {
|
||||
const timer = setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [saved]);
|
||||
}, [saveStatus]);
|
||||
|
||||
return (
|
||||
<Container className="py-12">
|
||||
@@ -62,6 +64,8 @@ export default function NewPostEditor() {
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4 ml-6">
|
||||
<SaveStatus status={saveStatus} />
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -73,7 +77,7 @@ export default function NewPostEditor() {
|
||||
</label>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving || !title.trim()}>
|
||||
{saving ? "Saving..." : saved ? "Saved." : "Save"}
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +86,7 @@ export default function NewPostEditor() {
|
||||
<ImageUpload onUpload={handleImageUpload} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 h-[calc(100vh-280px)]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 h-[calc(100vh-280px)]">
|
||||
<div className="border border-neutral-200 p-6 overflow-auto">
|
||||
<MarkdownEditor value={content} onChange={setContent} />
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,6 @@ function DraftsContent() {
|
||||
size="sm"
|
||||
onClick={() => handlePublish(post.id)}
|
||||
disabled={publishing === post.id}
|
||||
className="border-green-600 text-green-600 hover:bg-green-600 hover:text-white"
|
||||
>
|
||||
{publishing === post.id ? "Publishing..." : "Publish"}
|
||||
</Button>
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${inter.variable} ${jetbrainsMono.variable} font-sans font-medium antialiased bg-white text-black min-h-screen flex flex-col`}
|
||||
className={`${inter.variable} ${jetbrainsMono.variable} font-sans font-normal antialiased bg-white text-black min-h-screen flex flex-col`}
|
||||
>
|
||||
<AuthProvider>
|
||||
<Header />
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function HomePage() {
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<p className="text-neutral-400 text-lg">{error}</p>
|
||||
<p className="text-red-600 text-lg">{error}</p>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
|
||||
@@ -17,7 +17,7 @@ interface EditPostPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const AUTOSAVE_DELAY = 300;
|
||||
const AUTOSAVE_DELAY = 2000;
|
||||
|
||||
function EditPostContent({ id }: { id: string }) {
|
||||
const router = useRouter();
|
||||
@@ -173,7 +173,7 @@ function EditPostContent({ id }: { id: string }) {
|
||||
<ImageUpload onUpload={handleImageUpload} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 h-[calc(100vh-280px)]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 h-[calc(100vh-280px)]">
|
||||
<div className="border border-neutral-200 p-6 overflow-auto">
|
||||
<MarkdownEditor value={content} onChange={handleContentChange} />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
@@ -49,8 +49,7 @@ export default function PostPage({ params }: PostPageProps) {
|
||||
};
|
||||
|
||||
if (error) {
|
||||
router.push("/not-found");
|
||||
return null;
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -93,14 +93,14 @@ export default function RegisterPage() {
|
||||
? "border-red-500 animate-shake"
|
||||
: password.length > 0
|
||||
? passwordValid
|
||||
? "border-green-500"
|
||||
: "border-yellow-500"
|
||||
? "border-black"
|
||||
: "border-neutral-400"
|
||||
: "border-neutral-200 focus:border-black"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute right-0 top-1/2 -translate-y-1/2 text-xs ${
|
||||
passwordValid ? "text-green-500" : "text-neutral-400"
|
||||
passwordValid ? "text-black" : "text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{password.length}/{MIN_PASSWORD_LENGTH}
|
||||
|
||||
@@ -87,10 +87,10 @@ export function ImageUpload({ onUpload }: ImageUploadProps) {
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={clsx(
|
||||
"border-2 border-dashed p-8 text-center cursor-pointer transition-colors",
|
||||
"border p-8 text-center cursor-pointer transition-colors",
|
||||
isDragging && "border-black bg-neutral-50",
|
||||
error && "border-red-500 animate-shake",
|
||||
!isDragging && !error && "border-neutral-300 hover:border-neutral-400"
|
||||
!isDragging && !error && "border-neutral-200 hover:border-neutral-400"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
|
||||
@@ -10,9 +10,9 @@ interface PostCardProps {
|
||||
|
||||
export function PostCard({ post, isOwner = false }: PostCardProps) {
|
||||
return (
|
||||
<article className="grid grid-cols-12 gap-6 md:gap-12 mb-24">
|
||||
<article className="group grid grid-cols-12 gap-6 md:gap-12 mb-24 cursor-pointer">
|
||||
{post.cover_image && (
|
||||
<div className="col-span-12 md:col-span-5">
|
||||
<div className="col-span-12 md:col-span-5 relative">
|
||||
<Link href={`/posts/${post.id}`}>
|
||||
<div className="aspect-[3/2] overflow-hidden bg-neutral-100">
|
||||
<Image
|
||||
@@ -20,7 +20,7 @@ export function PostCard({ post, isOwner = false }: PostCardProps) {
|
||||
alt={post.title}
|
||||
width={600}
|
||||
height={400}
|
||||
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
|
||||
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-105 group-hover:opacity-90"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -37,7 +37,7 @@ export function PostCard({ post, isOwner = false }: PostCardProps) {
|
||||
<PostMeta date={post.created_at} />
|
||||
|
||||
<Link href={`/posts/${post.id}`}>
|
||||
<h2 className="text-3xl md:text-5xl font-bold tracking-tighter mt-4 hover:opacity-70 transition-opacity leading-tight">
|
||||
<h2 className="text-3xl md:text-5xl font-bold tracking-tighter mt-4 leading-[1.1] group-hover:underline decoration-1 underline-offset-8">
|
||||
{post.title}
|
||||
{isOwner && !post.published && (
|
||||
<span className="text-neutral-400 text-sm ml-2 font-normal">(Draft)</span>
|
||||
@@ -45,16 +45,16 @@ export function PostCard({ post, isOwner = false }: PostCardProps) {
|
||||
</h2>
|
||||
</Link>
|
||||
|
||||
<p className="text-neutral-600 mt-6 leading-relaxed line-clamp-3">
|
||||
<p className="text-lg text-neutral-600 mt-6 leading-relaxed line-clamp-3">
|
||||
{post.content.slice(0, 200).replace(/[#*`]/g, "")}
|
||||
{post.content.length > 200 ? "..." : ""}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={`/posts/${post.id}`}
|
||||
className="mt-6 text-sm font-bold tracking-widest uppercase border-b border-black pb-0.5 hover:border-transparent transition-colors self-start"
|
||||
className="mt-6 flex items-center text-sm font-bold tracking-widest uppercase group-hover:translate-x-2 transition-transform duration-300 self-start"
|
||||
>
|
||||
Read More
|
||||
Read More <span className="ml-2">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -10,7 +10,7 @@ interface PostContentProps {
|
||||
|
||||
export function PostContent({ content }: PostContentProps) {
|
||||
return (
|
||||
<div className="prose prose-lg max-w-none prose-headings:tracking-tight prose-headings:font-bold prose-p:leading-relaxed prose-p:text-neutral-700 prose-a:border-b prose-a:border-black prose-a:no-underline hover:prose-a:border-transparent prose-img:my-8 prose-pre:bg-[#f6f8fa] prose-pre:p-6 prose-code:font-mono prose-code:text-base">
|
||||
<div className="prose prose-lg max-w-none prose-headings:tracking-tight prose-headings:font-bold prose-p:leading-relaxed prose-p:text-neutral-700 prose-a:border-b prose-a:border-black prose-a:no-underline hover:prose-a:border-transparent prose-img:my-8 prose-pre:bg-[#fafafa] prose-pre:p-6 prose-code:font-mono prose-code:text-base">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeHighlight, { detect: true }]]}
|
||||
@@ -24,7 +24,7 @@ export function PostContent({ content }: PostContentProps) {
|
||||
/>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-[#f6f8fa] p-6 font-mono text-base text-black rounded-none border border-neutral-200 whitespace-pre-wrap break-words overflow-hidden">
|
||||
<pre className="bg-[#fafafa] p-6 font-mono text-base text-black rounded-none border border-neutral-200 whitespace-pre-wrap break-words overflow-hidden">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
|
||||
@@ -14,7 +14,7 @@ export function PostMeta({ date }: PostMetaProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<time className="text-xs font-bold tracking-widest uppercase text-neutral-400">
|
||||
<time className="text-xs font-bold tracking-widest uppercase text-neutral-500">
|
||||
{formatDate(date)}
|
||||
</time>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user