1 Commits

Author SHA1 Message Date
61e86bf215 fix: align frontend with design spec + UX improvements
Design spec compliance:
- Fix body font-weight: font-medium → font-normal (spec requires 400)
- PostCard: add group hover effects, underline on title hover, arrow →, translate animation
- PostCard: add text-lg to excerpt (spec Body style)
- PostMeta: fix date color neutral-400 → neutral-500 (spec Ash color)
- Homepage: fix error color neutral-400 → red-600 (spec Error color)
- Register: replace green/yellow with black/gray (spec: no non-functional colors)
- Drafts: remove green from Publish button
- PostContent: unify pre background to #fafafa (spec Vapor color)
- ImageUpload: replace dashed border with solid thin line

Bug fixes:
- PostPage: use notFound() instead of router.push('/not-found')
- next.config.ts: add images.remotePatterns for external images

UX improvements:
- Editor: responsive grid-cols-1 md:grid-cols-2 for mobile
- Editor pages: replace alert() with SaveStatus component
- Autosave delay: 300ms → 2000ms to reduce API pressure
2026-03-10 18:05:07 +08:00
13 changed files with 57 additions and 39 deletions

View File

@@ -2,6 +2,18 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
{
protocol: "http",
hostname: "**",
},
],
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -8,6 +8,7 @@ import { Skeleton } from "@/components/ui/Skeleton";
import { MarkdownEditor } from "@/components/editor/MarkdownEditor"; import { MarkdownEditor } from "@/components/editor/MarkdownEditor";
import { ImageUpload } from "@/components/editor/ImageUpload"; import { ImageUpload } from "@/components/editor/ImageUpload";
import { PostContent } from "@/components/post/PostContent"; import { PostContent } from "@/components/post/PostContent";
import { SaveStatus } from "@/components/SaveStatus";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
interface EditPostPageProps { interface EditPostPageProps {
@@ -22,7 +23,7 @@ export default function EditPostEditor({ params }: EditPostPageProps) {
const [published, setPublished] = useState(false); const [published, setPublished] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false); const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
useEffect(() => { useEffect(() => {
async function fetchPost() { async function fetchPost() {
@@ -45,15 +46,16 @@ export default function EditPostEditor({ params }: EditPostPageProps) {
if (!title.trim()) return; if (!title.trim()) return;
setSaving(true); setSaving(true);
setSaveStatus("saving");
try { try {
await api.updatePost(id, { await api.updatePost(id, {
title, title,
content, content,
published, published,
}); });
setSaved(true); setSaveStatus("saved");
} catch { } catch {
alert("Failed to save post"); setSaveStatus("error");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -65,11 +67,11 @@ export default function EditPostEditor({ params }: EditPostPageProps) {
}; };
useEffect(() => { useEffect(() => {
if (saved) { if (saveStatus === "saved" || saveStatus === "error") {
const timer = setTimeout(() => setSaved(false), 3000); const timer = setTimeout(() => setSaveStatus("idle"), 3000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [saved]); }, [saveStatus]);
if (loading) { if (loading) {
return ( return (
@@ -92,6 +94,8 @@ export default function EditPostEditor({ params }: EditPostPageProps) {
/> />
<div className="flex items-center gap-4 ml-6"> <div className="flex items-center gap-4 ml-6">
<SaveStatus status={saveStatus} />
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -103,7 +107,7 @@ export default function EditPostEditor({ params }: EditPostPageProps) {
</label> </label>
<Button onClick={handleSave} disabled={saving || !title.trim()}> <Button onClick={handleSave} disabled={saving || !title.trim()}>
{saving ? "Saving..." : saved ? "Saved." : "Save"} {saving ? "Saving..." : "Save"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -112,7 +116,7 @@ export default function EditPostEditor({ params }: EditPostPageProps) {
<ImageUpload onUpload={handleImageUpload} /> <ImageUpload onUpload={handleImageUpload} />
</div> </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"> <div className="border border-neutral-200 p-6 overflow-auto">
<MarkdownEditor value={content} onChange={setContent} /> <MarkdownEditor value={content} onChange={setContent} />
</div> </div>

View File

@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/Button";
import { MarkdownEditor } from "@/components/editor/MarkdownEditor"; import { MarkdownEditor } from "@/components/editor/MarkdownEditor";
import { ImageUpload } from "@/components/editor/ImageUpload"; import { ImageUpload } from "@/components/editor/ImageUpload";
import { PostContent } from "@/components/post/PostContent"; import { PostContent } from "@/components/post/PostContent";
import { SaveStatus } from "@/components/SaveStatus";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
export default function NewPostEditor() { export default function NewPostEditor() {
@@ -15,24 +16,25 @@ export default function NewPostEditor() {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [published, setPublished] = useState(false); const [published, setPublished] = useState(false);
const [saving, setSaving] = 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 () => { const handleSave = useCallback(async () => {
if (!title.trim()) return; if (!title.trim()) return;
setSaving(true); setSaving(true);
setSaveStatus("saving");
try { try {
const post = await api.createPost({ const post = await api.createPost({
title, title,
content, content,
published, published,
}); });
setSaved(true); setSaveStatus("saved");
setTimeout(() => { setTimeout(() => {
router.push(`/admin/editor/${post.id}`); router.push(`/admin/editor/${post.id}`);
}, 1000); }, 1000);
} catch { } catch {
alert("Failed to save post"); setSaveStatus("error");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -44,11 +46,11 @@ export default function NewPostEditor() {
}; };
useEffect(() => { useEffect(() => {
if (saved) { if (saveStatus === "saved" || saveStatus === "error") {
const timer = setTimeout(() => setSaved(false), 3000); const timer = setTimeout(() => setSaveStatus("idle"), 3000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [saved]); }, [saveStatus]);
return ( return (
<Container className="py-12"> <Container className="py-12">
@@ -62,6 +64,8 @@ export default function NewPostEditor() {
/> />
<div className="flex items-center gap-4 ml-6"> <div className="flex items-center gap-4 ml-6">
<SaveStatus status={saveStatus} />
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -73,7 +77,7 @@ export default function NewPostEditor() {
</label> </label>
<Button onClick={handleSave} disabled={saving || !title.trim()}> <Button onClick={handleSave} disabled={saving || !title.trim()}>
{saving ? "Saving..." : saved ? "Saved." : "Save"} {saving ? "Saving..." : "Save"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -82,7 +86,7 @@ export default function NewPostEditor() {
<ImageUpload onUpload={handleImageUpload} /> <ImageUpload onUpload={handleImageUpload} />
</div> </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"> <div className="border border-neutral-200 p-6 overflow-auto">
<MarkdownEditor value={content} onChange={setContent} /> <MarkdownEditor value={content} onChange={setContent} />
</div> </div>

View File

@@ -95,7 +95,6 @@ function DraftsContent() {
size="sm" size="sm"
onClick={() => handlePublish(post.id)} onClick={() => handlePublish(post.id)}
disabled={publishing === 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"} {publishing === post.id ? "Publishing..." : "Publish"}
</Button> </Button>

View File

@@ -31,7 +31,7 @@ export default function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body <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> <AuthProvider>
<Header /> <Header />

View File

@@ -43,7 +43,7 @@ export default function HomePage() {
</h1> </h1>
{error && ( {error && (
<p className="text-neutral-400 text-lg">{error}</p> <p className="text-red-600 text-lg">{error}</p>
)} )}
{loading && ( {loading && (

View File

@@ -17,7 +17,7 @@ interface EditPostPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
} }
const AUTOSAVE_DELAY = 300; const AUTOSAVE_DELAY = 2000;
function EditPostContent({ id }: { id: string }) { function EditPostContent({ id }: { id: string }) {
const router = useRouter(); const router = useRouter();
@@ -173,7 +173,7 @@ function EditPostContent({ id }: { id: string }) {
<ImageUpload onUpload={handleImageUpload} /> <ImageUpload onUpload={handleImageUpload} />
</div> </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"> <div className="border border-neutral-200 p-6 overflow-auto">
<MarkdownEditor value={content} onChange={handleContentChange} /> <MarkdownEditor value={content} onChange={handleContentChange} />
</div> </div>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect, use } from "react"; import { useState, useEffect, use } from "react";
import { useRouter } from "next/navigation"; import { useRouter, notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { Container } from "@/components/layout/Container"; import { Container } from "@/components/layout/Container";
@@ -49,8 +49,7 @@ export default function PostPage({ params }: PostPageProps) {
}; };
if (error) { if (error) {
router.push("/not-found"); notFound();
return null;
} }
return ( return (

View File

@@ -93,14 +93,14 @@ export default function RegisterPage() {
? "border-red-500 animate-shake" ? "border-red-500 animate-shake"
: password.length > 0 : password.length > 0
? passwordValid ? passwordValid
? "border-green-500" ? "border-black"
: "border-yellow-500" : "border-neutral-400"
: "border-neutral-200 focus:border-black" : "border-neutral-200 focus:border-black"
}`} }`}
/> />
<span <span
className={`absolute right-0 top-1/2 -translate-y-1/2 text-xs ${ 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} {password.length}/{MIN_PASSWORD_LENGTH}

View File

@@ -87,10 +87,10 @@ export function ImageUpload({ onUpload }: ImageUploadProps) {
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
className={clsx( 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", isDragging && "border-black bg-neutral-50",
error && "border-red-500 animate-shake", 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 <input

View File

@@ -10,9 +10,9 @@ interface PostCardProps {
export function PostCard({ post, isOwner = false }: PostCardProps) { export function PostCard({ post, isOwner = false }: PostCardProps) {
return ( 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 && ( {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}`}> <Link href={`/posts/${post.id}`}>
<div className="aspect-[3/2] overflow-hidden bg-neutral-100"> <div className="aspect-[3/2] overflow-hidden bg-neutral-100">
<Image <Image
@@ -20,7 +20,7 @@ export function PostCard({ post, isOwner = false }: PostCardProps) {
alt={post.title} alt={post.title}
width={600} width={600}
height={400} 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> </div>
</Link> </Link>
@@ -37,7 +37,7 @@ export function PostCard({ post, isOwner = false }: PostCardProps) {
<PostMeta date={post.created_at} /> <PostMeta date={post.created_at} />
<Link href={`/posts/${post.id}`}> <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} {post.title}
{isOwner && !post.published && ( {isOwner && !post.published && (
<span className="text-neutral-400 text-sm ml-2 font-normal">(Draft)</span> <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> </h2>
</Link> </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.slice(0, 200).replace(/[#*`]/g, "")}
{post.content.length > 200 ? "..." : ""} {post.content.length > 200 ? "..." : ""}
</p> </p>
<Link <Link
href={`/posts/${post.id}`} 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> </Link>
</div> </div>
</article> </article>

View File

@@ -10,7 +10,7 @@ interface PostContentProps {
export function PostContent({ content }: PostContentProps) { export function PostContent({ content }: PostContentProps) {
return ( 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 <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { detect: true }]]} rehypePlugins={[[rehypeHighlight, { detect: true }]]}
@@ -24,7 +24,7 @@ export function PostContent({ content }: PostContentProps) {
/> />
), ),
pre: ({ children }) => ( 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} {children}
</pre> </pre>
), ),

View File

@@ -14,7 +14,7 @@ export function PostMeta({ date }: PostMetaProps) {
}; };
return ( 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)} {formatDate(date)}
</time> </time>
); );