7.5 추천/신고, 조회수 카운트 o
This commit is contained in:
15
src/app/api/posts/[id]/view/route.ts
Normal file
15
src/app/api/posts/[id]/view/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
|
||||
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
const userId = getUserIdFromRequest(req);
|
||||
const ip = req.headers.get("x-forwarded-for") || undefined;
|
||||
const userAgent = req.headers.get("user-agent") || undefined;
|
||||
await prisma.postViewLog.create({ data: { postId: id, userId: userId ?? null, ip, userAgent } });
|
||||
await prisma.postStat.upsert({ where: { postId: id }, update: { views: { increment: 1 } }, create: { postId: id, views: 1 } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
24
src/app/api/uploads/route.ts
Normal file
24
src/app/api/uploads/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const form = await req.formData();
|
||||
const file = form.get("file") as File | null;
|
||||
if (!file) return NextResponse.json({ error: "file required" }, { status: 400 });
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
const ext = path.extname(file.name || "") || ".bin";
|
||||
const uploadsDir = path.join(process.cwd(), "public", "uploads");
|
||||
await fs.mkdir(uploadsDir, { recursive: true });
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`;
|
||||
const filepath = path.join(uploadsDir, filename);
|
||||
await fs.writeFile(filepath, buffer);
|
||||
const url = `/uploads/${filename}`;
|
||||
return NextResponse.json({ url });
|
||||
}
|
||||
|
||||
|
||||
33
src/app/components/UploadButton.tsx
Normal file
33
src/app/components/UploadButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
|
||||
export function UploadButton({ onUploaded }: { onUploaded: (url: string) => void }) {
|
||||
const { show } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", f);
|
||||
try {
|
||||
setLoading(true);
|
||||
const r = await fetch("/api/uploads", { method: "POST", body: fd });
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(JSON.stringify(data));
|
||||
onUploaded(data.url);
|
||||
show("업로드 완료");
|
||||
} catch (e) {
|
||||
show("업로드 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
e.currentTarget.value = "";
|
||||
}
|
||||
}
|
||||
return <label style={{ display: "inline-block" }}>
|
||||
<span style={{ padding: "6px 10px", border: "1px solid #ddd", borderRadius: 6, cursor: "pointer" }}>{loading ? "업로드 중..." : "이미지 업로드"}</span>
|
||||
<input type="file" accept="image/*" onChange={onChange} style={{ display: "none" }} />
|
||||
</label>;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
import { UploadButton } from "@/app/components/UploadButton";
|
||||
|
||||
export default function EditPostPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
@@ -43,6 +44,7 @@ export default function EditPostPage() {
|
||||
<h1>글 수정</h1>
|
||||
<input placeholder="제목" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||
<textarea placeholder="내용" value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} rows={10} />
|
||||
<UploadButton onUploaded={(url) => setForm((f) => (!f ? f : { ...f, content: `${f.content}\n` }))} />
|
||||
<button disabled={loading} onClick={submit}>{loading ? "저장 중..." : "저장"}</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default async function PostDetail({ params }: { params: { id: string } }) {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? ""}/api/posts/${params.id}`, { cache: "no-store" });
|
||||
if (!res.ok) return notFound();
|
||||
const { post } = await res.json();
|
||||
return (
|
||||
<ClientPostDetail post={post} />
|
||||
);
|
||||
}
|
||||
|
||||
function ClientPostDetail({ post }: { post: { id: string; title: string; content: string } }) {
|
||||
// client only
|
||||
// @ts-expect-error react client hook use
|
||||
const { show } = useToast();
|
||||
// @ts-expect-error react client hook use
|
||||
useEffect(() => {
|
||||
fetch(`/api/posts/${post.id}/view`, { method: "POST" });
|
||||
}, [post.id]);
|
||||
return (
|
||||
<div>
|
||||
<h1>{post.title}</h1>
|
||||
<div style={{ display: "flex", gap: 8, margin: "8px 0" }}>
|
||||
<button onClick={async () => { await fetch(`/api/posts/${post.id}/recommend`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientHash: "anon" }) }); show("추천했습니다"); }}>추천</button>
|
||||
<button onClick={async () => { const reason = prompt("신고 사유?") || ""; if(!reason) return; await fetch(`/api/posts/${post.id}/report`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ reason }) }); show("신고되었습니다"); }}>신고</button>
|
||||
</div>
|
||||
<p style={{ whiteSpace: "pre-wrap" }}>{post.content}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
import { UploadButton } from "@/app/components/UploadButton";
|
||||
|
||||
export default function NewPostPage() {
|
||||
const router = useRouter();
|
||||
@@ -32,6 +33,7 @@ export default function NewPostPage() {
|
||||
<input placeholder="boardId" value={form.boardId} onChange={(e) => setForm({ ...form, boardId: e.target.value })} />
|
||||
<input placeholder="제목" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||
<textarea placeholder="내용" value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} rows={10} />
|
||||
<UploadButton onUploaded={(url) => setForm((f) => ({ ...f, content: `${f.content}\n` }))} />
|
||||
<button disabled={loading} onClick={submit}>{loading ? "저장 중..." : "등록"}</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user