This commit is contained in:
koreacomp5
2025-11-02 02:46:20 +09:00
parent 27cf98eef2
commit 0bf18968ad
50 changed files with 892 additions and 121 deletions

View File

@@ -7,6 +7,7 @@ const navItems = [
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" },
{ href: "/admin/boards", label: "게시판" },
{ href: "/admin/banners", label: "배너" },
{ href: "/admin/partners", label: "제휴업체" },
{ href: "/admin/users", label: "사용자" },
{ href: "/admin/logs", label: "로그" },
];

View File

@@ -1,6 +1,7 @@
"use client";
import useSWR from "swr";
import { useState } from "react";
import { useRef, useState } from "react";
import { Modal } from "@/app/components/ui/Modal";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
@@ -8,21 +9,112 @@ export default function AdminBannersPage() {
const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher);
const banners = data?.banners ?? [];
const [form, setForm] = useState({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 });
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
const inputEl = e.currentTarget; // 이벤트 풀링 대비 사본 보관
const file = inputEl.files?.[0];
if (!file) return;
try {
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/uploads", { method: "POST", body: fd });
const json = await r.json();
if (!r.ok) throw new Error(json?.error || "upload_failed");
setForm((f) => ({ ...f, imageUrl: json.url }));
} catch (err) {
console.error(err);
alert("이미지 업로드 중 오류가 발생했습니다.");
} finally {
setUploading(false);
if (inputEl) inputEl.value = "";
}
}
async function create() {
const r = await fetch("/api/admin/banners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
if (r.ok) { setForm({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); mutate(); }
if (r.ok) { setForm({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); mutate(); setShowCreateModal(false); }
}
return (
<div>
<h1> </h1>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<input placeholder="제목" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
<input placeholder="링크 URL(선택)" value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} />
<label><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> </label>
<input type="number" placeholder="정렬" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} />
<button onClick={create}></button>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<h1> </h1>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
>
</button>
</div>
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)}>
<div className="w-[720px] max-w-[90vw]">
<div className="p-6">
<h2 className="text-lg font-bold mb-4"> </h2>
{(() => {
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}> URL()</label>
<input style={inputStyle} value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} />
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}> URL</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input style={inputStyle} value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
>
</button>
<input ref={fileInputRef} type="file" accept="image/*" onChange={onSelectFile} style={{ display: "none" }} />
{uploading && <span style={{ fontSize: 12, opacity: .7 }}> ...</span>}
</div>
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<label className="inline-flex items-center gap-2 text-sm"><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> </label>
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input type="number" style={inputStyle} value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} />
</div>
{form.imageUrl && (
<div style={{ gridColumn: "span 6" }}>
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}></div>
<img src={form.imageUrl} alt="미리보기" style={{ width: 320, height: 160, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
</div>
)}
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button
onClick={create}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
>
</button>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
</div>
</div>
);
})()}
</div>
</div>
</Modal>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{banners.map((b) => (
<li key={b.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
@@ -31,8 +123,8 @@ export default function AdminBannersPage() {
<div><strong>{b.title}</strong> {b.linkUrl && <a style={{ marginLeft: 8 }} href={b.linkUrl}></a>}</div>
<div style={{ fontSize: 12, opacity: 0.7 }}> {b.sortOrder} · {b.active ? "활성" : "비활성"}</div>
</div>
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !b.active }) }); mutate(); }}>{b.active ? "비활성" : "활성"}</button>
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "DELETE" }); mutate(); }}></button>
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !b.active }) }); mutate(); }} className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition">{b.active ? "비활성" : "활성"}</button>
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "DELETE" }); mutate(); }} className="h-8 px-3 rounded-md border border-red-200 text-red-600 text-sm cursor-pointer hover:bg-red-100 hover:border-red-300 hover:text-red-700 active:translate-y-px transition"></button>
</li>
))}
</ul>

View File

@@ -0,0 +1,45 @@
"use client";
import useSWR from "swr";
import { useState } from "react";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function AdminPartnerShopsPage() {
const { data, mutate } = useSWR<{ items: any[] }>("/api/admin/partner-shops", fetcher);
const items = data?.items ?? [];
const [form, setForm] = useState({ region: "", name: "", address: "", imageUrl: "", active: true, sortOrder: 0 });
async function create() {
const r = await fetch("/api/admin/partner-shops", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
if (r.ok) { setForm({ region: "", name: "", address: "", imageUrl: "", active: true, sortOrder: 0 }); mutate(); }
}
return (
<div>
<h1> </h1>
<div style={{ display: "flex", gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
<input placeholder="지역" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
<input placeholder="이름" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<input placeholder="주소" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} style={{ width: 320 }} />
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} style={{ width: 280 }} />
<label><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> </label>
<input type="number" placeholder="정렬" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} />
<button onClick={create}></button>
</div>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{items.map((it) => (
<li key={it.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
<img src={it.imageUrl} alt={it.name} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
<div style={{ flex: 1 }}>
<div><strong>{it.name}</strong> <span style={{ marginLeft: 8, fontSize: 12, opacity: .7 }}>{it.region}</span></div>
<div style={{ fontSize: 12, opacity: 0.7 }}>{it.address}</div>
<div style={{ fontSize: 12, opacity: 0.7 }}> {it.sortOrder} · {it.active ? "활성" : "비활성"}</div>
</div>
<button onClick={async () => { await fetch(`/api/admin/partner-shops/${it.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !it.active }) }); mutate(); }}>{it.active ? "비활성" : "활성"}</button>
<button onClick={async () => { await fetch(`/api/admin/partner-shops/${it.id}`, { method: "DELETE" }); mutate(); }}></button>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,295 @@
"use client";
import useSWR from "swr";
import { useState, useRef } from "react";
import { Modal } from "@/app/components/ui/Modal";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function AdminPartnersPage() {
const { data, mutate } = useSWR<{ partners: any[] }>("/api/admin/partners", fetcher);
const partners = data?.partners ?? [];
const [form, setForm] = useState({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const editFileInputRef = useRef<HTMLInputElement>(null);
const [editUploading, setEditUploading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<any>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
const inputEl = e.currentTarget;
const file = inputEl.files?.[0];
if (!file) return;
try {
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/uploads", { method: "POST", body: fd });
const json = await r.json();
if (!r.ok) throw new Error(json?.error || "upload_failed");
setForm((f) => ({ ...f, imageUrl: json.url }));
} catch (err) {
console.error(err);
alert("이미지 업로드 중 오류가 발생했습니다.");
} finally {
setUploading(false);
if (inputEl) inputEl.value = "";
}
}
async function onSelectEditFile(e: React.ChangeEvent<HTMLInputElement>) {
const inputEl = e.currentTarget;
const file = inputEl.files?.[0];
if (!file) return;
try {
setEditUploading(true);
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/uploads", { method: "POST", body: fd });
const json = await r.json();
if (!r.ok) throw new Error(json?.error || "upload_failed");
setEditDraft((d: any) => ({ ...(d || {}), imageUrl: json.url }));
} catch (err) {
console.error(err);
alert("이미지 업로드 중 오류가 발생했습니다.");
} finally {
setEditUploading(false);
if (inputEl) inputEl.value = "";
}
}
async function create() {
// 필수값 검증: 이름/카테고리/위도/경도
if (!form.name || !form.category || !String(form.latitude).trim() || !String(form.longitude).trim()) {
alert("이름, 카테고리, 위도, 경도를 모두 입력해 주세요.");
return;
}
const lat = Number(form.latitude);
const lon = Number(form.longitude);
if (!isFinite(lat) || !isFinite(lon)) {
alert("위도/경도는 숫자여야 합니다.");
return;
}
const payload = { ...form, latitude: lat, longitude: lon } as any;
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
if (r.ok) {
setForm({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
mutate();
setShowCreateModal(false);
}
}
return (
<div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<h1> </h1>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
>
</button>
</div>
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)}>
<div className="w-[720px] max-w-[90vw]">
<div className="p-6">
<h2 className="text-lg font-bold mb-4"> </h2>
{(() => {
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.latitude} onChange={(e) => setForm({ ...form, latitude: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.longitude} onChange={(e) => setForm({ ...form, longitude: e.target.value })} />
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}> URL</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input style={inputStyle} value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
>
</button>
<input ref={fileInputRef} type="file" accept="image/*" onChange={onSelectFile} style={{ display: "none" }} />
{uploading && <span style={{ fontSize: 12, opacity: .7 }}> ...</span>}
</div>
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>()</label>
<input style={inputStyle} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</div>
{form.imageUrl && (
<div style={{ gridColumn: "span 6" }}>
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}></div>
<img src={form.imageUrl} alt="미리보기" style={{ width: 240, height: 120, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
</div>
)}
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button
onClick={create}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
>
</button>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
</div>
</div>
);
})()}
</div>
</div>
</Modal>
<Modal open={showEditModal} onClose={() => { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}>
<div className="w-[720px] max-w-[90vw]">
<div className="p-6">
<h2 className="text-lg font-bold mb-4"> </h2>
{(() => {
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.name ?? ""} onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.category ?? ""} onChange={(e) => setEditDraft({ ...editDraft, category: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.latitude ?? ""} onChange={(e) => setEditDraft({ ...editDraft, latitude: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.longitude ?? ""} onChange={(e) => setEditDraft({ ...editDraft, longitude: e.target.value })} />
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}> URL</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input style={inputStyle} value={editDraft?.imageUrl ?? ""} onChange={(e) => setEditDraft({ ...editDraft, imageUrl: e.target.value })} />
<button
type="button"
onClick={() => editFileInputRef.current?.click()}
disabled={editUploading}
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
>
</button>
<input ref={editFileInputRef} type="file" accept="image/*" onChange={onSelectEditFile} style={{ display: "none" }} />
{editUploading && <span style={{ fontSize: 12, opacity: .7 }}> ...</span>}
</div>
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.address ?? ""} onChange={(e) => setEditDraft({ ...editDraft, address: e.target.value })} />
</div>
{editDraft?.imageUrl && (
<div style={{ gridColumn: "span 6" }}>
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}></div>
<img src={editDraft.imageUrl} alt="미리보기" style={{ width: 240, height: 120, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
</div>
)}
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button
onClick={async () => { if (!editingId) return; await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft }) }); setEditingId(null); setEditDraft(null); setShowEditModal(false); mutate(); }}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
>
</button>
<button
type="button"
onClick={() => { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
</div>
</div>
);
})()}
</div>
</div>
</Modal>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{partners.map((p) => (
<li key={p.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "grid", gridTemplateColumns: "auto 1fr 1fr 1fr auto", gap: 8, alignItems: "center" }}>
<div style={{ width: 80 }}>
{p.imageUrl ? (
<img src={p.imageUrl} alt={p.name} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
) : (
<div style={{ width: 80, height: 48, border: "1px solid #eee", borderRadius: 6, background: "#fafafa" }} />
)}
</div>
{false ? (
<></>
) : (
<>
<div>
<div><strong>{p.name}</strong> <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>{p.category}</span></div>
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
</div>
<div style={{ fontSize: 12, opacity: .7 }}> {p.latitude}</div>
<div style={{ fontSize: 12, opacity: .7 }}> {p.longitude}</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, category: p.category, latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
<button
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "DELETE" }); mutate(); }}
className="h-8 px-3 rounded-md border border-red-200 text-red-600 text-sm cursor-pointer hover:bg-red-100 hover:border-red-300 hover:text-red-700 active:translate-y-px transition"
>
</button>
<button
title="위로"
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: (p.sortOrder ?? 0) - 1 }) }); mutate(); }}
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
<button
title="아래로"
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: (p.sortOrder ?? 0) + 1 }) }); mutate(); }}
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
</div>
</>
)}
</li>
))}
</ul>
</div>
);
}

View File

@@ -9,8 +9,11 @@ export async function GET() {
const createSchema = z.object({
title: z.string().min(1),
imageUrl: z.string().url(),
linkUrl: z.string().url().optional(),
// 절대 URL 또는 /로 시작하는 상대경로 허용
imageUrl: z.string().refine((v) => /^https?:\/\//.test(v) || v.startsWith("/"), {
message: "imageUrl must be absolute URL or start with /",
}),
linkUrl: z.string().refine((v) => !v || /^https?:\/\//.test(v), { message: "linkUrl must be http(s) URL" }).optional(),
active: z.boolean().optional(),
sortOrder: z.coerce.number().int().optional(),
startAt: z.coerce.date().optional(),

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const body = await req.json().catch(() => ({}));
const data: any = {};
for (const k of ["region", "name", "address", "imageUrl", "active", "sortOrder"]) {
if (k in body) data[k] = body[k];
}
const item = await prisma.partnerShop.update({ where: { id }, data });
return NextResponse.json({ item });
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
await prisma.partnerShop.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
export async function GET() {
const items = await prisma.partnerShop.findMany({ orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }] });
return NextResponse.json({ items });
}
const createSchema = z.object({
region: z.string().min(1),
name: z.string().min(1),
address: z.string().min(1),
imageUrl: z.string().min(1),
sortOrder: z.coerce.number().int().optional(),
active: z.boolean().optional(),
});
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const item = await prisma.partnerShop.create({ data: parsed.data });
return NextResponse.json({ item }, { status: 201 });
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const body = await req.json().catch(() => ({}));
const data: any = {};
for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder"]) {
if (k in body) data[k] = body[k];
}
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
if (typeof data.sortOrder !== "undefined") data.sortOrder = Number(data.sortOrder);
try {
const partner = await prisma.partner.update({ where: { id }, data });
return NextResponse.json({ partner });
} catch (e) {
// DB에 sortOrder 컬럼이 아직 없는 환경 대비: 해당 키 제거 후 재시도
if (Object.prototype.hasOwnProperty.call(data, "sortOrder")) {
const { sortOrder, ...rest } = data;
const partner = await prisma.partner.update({ where: { id }, data: rest });
return NextResponse.json({ partner });
}
throw e;
}
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
await prisma.partner.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
export async function GET() {
try {
// 정렬용 컬럼(sortOrder)이 있는 경우 우선 사용
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
return NextResponse.json({ partners });
} catch (_) {
// 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" } });
return NextResponse.json({ partners });
}
}
const createSchema = z.object({
name: z.string().min(1),
category: z.string().min(1),
latitude: z.coerce.number(),
longitude: z.coerce.number(),
address: z.string().min(1).optional(),
imageUrl: z
.string()
.refine((v) => !v || /^https?:\/\//.test(v) || v.startsWith("/"), {
message: "imageUrl must be http(s) URL or start with /",
})
.optional(),
sortOrder: z.coerce.number().int().optional(),
});
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const partner = await prisma.partner.create({ data: parsed.data as any });
return NextResponse.json({ partner }, { status: 201 });
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function GET() {
const items = await prisma.partnerShop.findMany({
where: { active: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return NextResponse.json({ items });
}

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
import { headers } from "next/headers";
export default async function BoardsPage() {
@@ -14,7 +15,7 @@ export default async function BoardsPage() {
<h1></h1>
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{boards?.map((b: any) => (
<li key={b.id}><a href={`/boards/${b.id}`}>{b.name}</a></li>
<li key={b.id}><Link href={`/boards/${b.id}`}>{b.name}</Link></li>
))}
</ul>
</div>

View File

@@ -1,9 +1,10 @@
import Link from "next/link";
export function AppSidebar() {
return (
<aside style={{ width: 200, borderRight: "1px solid #eee", padding: 12 }}>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<li><a href="/boards"></a></li>
<li><a href="/admin"></a></li>
<li><Link href="/boards"></Link></li>
<li><Link href="/admin"></Link></li>
</ul>
</aside>
);

View File

@@ -2,12 +2,22 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { SelectedBanner } from "@/app/components/SelectedBanner";
import Link from "next/link";
import useSWR from "swr";
type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
type SubItem = { id: string; name: string; href: string };
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; activeSubId?: string }) {
const [banners, setBanners] = useState<Banner[]>([]);
// SWR 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화
const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, {
revalidateOnFocus: false,
dedupingInterval: 10 * 60 * 1000, // 10분 간 동일 요청 합치기
keepPreviousData: true,
});
const banners = data?.banners ?? [];
const [activeIndex, setActiveIndex] = useState(0);
const [isHovered, setIsHovered] = useState(false);
const [progress, setProgress] = useState(0); // 0..1
@@ -15,10 +25,6 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
const rafIdRef = useRef<number | null>(null);
const startedAtRef = useRef<number>(0);
useEffect(() => {
fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? []));
}, []);
const numSlides = banners.length;
const canAutoPlay = numSlides > 1 && !isHovered;
@@ -67,7 +73,33 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]);
if (numSlides === 0) return <SelectedBanner height={224} />;
if (numSlides === 0) {
return (
<section className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white" aria-roledescription="carousel">
<SelectedBanner className="h-56 sm:h-72 md:h-[264px]" />
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 (스켈레톤 상태에서도 동일 레이아웃 유지) */}
<div className="mt-2 h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
{Array.isArray(subItems) && subItems.length > 0 && (
<div className="flex flex-wrap items-center gap-[8px]">
{subItems.map((s) => (
<Link
key={s.id}
href={s.href}
className={
s.id === activeSubId
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] leading-[28px] whitespace-nowrap"
}
>
{s.name}
</Link>
))}
</div>
)}
</div>
</section>
);
}
return (
<section
@@ -121,11 +153,11 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
<div className="mt-2 h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
{Array.isArray(subItems) && subItems.length > 0 && (
<div className="flex items-center gap-[8px] overflow-x-auto no-scrollbar">
{subItems.map((s) => (
<a
<div className="flex flex-wrap items-center gap-[8px]">
{subItems.map((s) => (
<Link
key={s.id}
href={s.href}
href={s.href}
className={
s.id === activeSubId
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
@@ -133,7 +165,7 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
}
>
{s.name}
</a>
</Link>
))}
</div>
)}

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client";
import useSWR from "swr";
@@ -16,7 +17,7 @@ export function PersonalWidgets() {
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{(recent?.items ?? []).map((i) => (
<li key={i.id}>
<a href={`/posts/${i.id}`}>{i.title}</a>
<Link href={`/posts/${i.id}`}>{i.title}</Link>
</li>
))}
{(!recent || recent.items.length === 0) && <li> .</li>}

View File

@@ -2,6 +2,7 @@
import useSWRInfinite from "swr/infinite";
import useSWR from "swr";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import ViewsIcon from "@/app/svgs/ViewsIcon";
import LikeIcon from "@/app/svgs/LikeIcon";
@@ -129,14 +130,14 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
<div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div>
<div className="min-w-0">
<a href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900">
<Link href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900">
{p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]"></span>}
{p.title}
</a>
</Link>
{!!p.postTags?.length && (
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
{p.postTags?.map((pt) => (
<a key={pt.tag.slug} href={`/search?tag=${pt.tag.slug}`} className="hover:underline">#{pt.tag.name}</a>
<Link key={pt.tag.slug} href={`/search?tag=${pt.tag.slug}`} className="hover:underline">#{pt.tag.name}</Link>
))}
</div>
)}
@@ -227,9 +228,9 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
</button>
</div>
{newPostHref && (
<a href={newPostHref} className="shrink-0">
<Link href={newPostHref} className="shrink-0">
<button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95"></button>
</a>
</Link>
)}
</div>
) : (

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client";
import { usePermission } from "@/lib/usePermission";
@@ -7,8 +8,8 @@ export function QuickActions() {
const isAdmin = can("ADMIN", "ADMINISTER") || can("BOARD", "MODERATE");
return (
<div style={{ display: "flex", gap: 8 }}>
{canWrite && <a href="/posts/new"><button></button></a>}
{isAdmin && <a href="/admin"><button></button></a>}
{canWrite && <Link href="/posts/new"><button></button></Link>}
{isAdmin && <Link href="/admin"><button></button></Link>}
</div>
);
}

View File

@@ -2,24 +2,19 @@
import React from "react";
type Props = {
height?: number | string; // ex) 224 or '14rem'
height?: number | string; // ex) 224 or '14rem' (지정 시 고정 높이, 미지정 시 클래스 높이 사용)
className?: string;
};
// Figma 선택 요소(Container_Event)를 그대로 옮긴 플레이스홀더형 배너
export function SelectedBanner({ height = 122, className }: Props) {
export function SelectedBanner({ height, className }: Props) {
return (
<section
className={`relative w-full overflow-hidden rounded-[12px] bg-[#D9D9D9] ${className ?? ""}`}
style={{ height }}
style={height != null ? { height } : undefined}
aria-label="banner"
>
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-[6px]" style={{ bottom: 12 }}>
<span className="block h-[4px] w-[18px] rounded-full bg-[#F94B37]" aria-hidden />
{Array.from({ length: 12 }).map((_, i) => (
<span key={i} className="block h-[6px] w-[6px] rounded-full bg-[#B9B9B9]" aria-hidden />
))}
</div>
{/* 스켈레톤 상태에서는 하단 점(페이지네이션) 제거 */}
</section>
);
}

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client";
import useSWR from "swr";
@@ -8,11 +9,11 @@ export function TagFilter({ basePath, current }: { basePath: string; current?: s
const tags = data?.tags ?? [];
return (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 8 }}>
<a href={`${basePath}`} style={{ textDecoration: !current ? "underline" : "none" }}></a>
<Link href={`${basePath}`} style={{ textDecoration: !current ? "underline" : "none" }}></Link>
{tags.map((t) => (
<a key={t.slug} href={`${basePath}?tag=${t.slug}`} style={{ textDecoration: current === t.slug ? "underline" : "none" }}>
<Link key={t.slug} href={`${basePath}?tag=${t.slug}`} style={{ textDecoration: current === t.slug ? "underline" : "none" }}>
#{t.name}
</a>
</Link>
))}
</div>
);

View File

@@ -11,10 +11,11 @@ export function Modal({ open, onClose, children }: Props) {
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
background: "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 50,
}}
>
<div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", padding: 16, borderRadius: 8 }}>

View File

@@ -26,6 +26,9 @@ body {
font-family: Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
}
/* 레이아웃 점프 방지: 스크롤바 여백을 항상 확보 */
html { scrollbar-gutter: stable both-edges; }
/* 유틸: 카드 스켈레톤 색상 헬퍼 (타깃 사이트 톤 유사) */
.bg-neutral-100 { background-color: #f5f5f7; }

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client";
import React from "react";
import { Button } from "@/app/components/ui/Button";
@@ -46,7 +47,7 @@ export default function LoginPage() {
/>
<Button type="submit" disabled={loading}>{loading ? "로그인 중..." : "로그인"}</Button>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<a href="/register"></a>
<Link href="/register"></Link>
</div>
</form>
</div>

View File

@@ -1,4 +1,5 @@
import { HeroBanner } from "@/app/components/HeroBanner";
import Link from "next/link";
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
import { PostList } from "@/app/components/PostList";
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
@@ -31,8 +32,8 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
const renderBoardPanel = (board: { id: string; name: string }) => (
<div key={board.id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white">
<div className="px-3 py-2 border-b border-neutral-200 flex items-center justify-between">
<a href={`/boards/${board.id}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</a>
<a href={`/boards/${board.id}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100"></a>
<Link href={`/boards/${board.id}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</Link>
<Link href={`/boards/${board.id}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100"></Link>
</div>
<div className="flex-1 min-h-0 overflow-hidden p-0">
<PostList boardId={board.id} sort={sort} />
@@ -49,21 +50,29 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
</section>
)}
{/* 제휴 샾 가로 스크롤 (설정 온오프) */}
{showPartnerShops && (() => {
const items = [
{ id: 1, region: "경기도", name: "라온마사지샾", address: "수원시 팔달구 매산로 45", image: "/sample.jpg" },
{ id: 2, region: "강원도", name: "휴앤힐링마사지샾", address: "춘천시 중앙로 112", image: "/sample.jpg" },
{ id: 3, region: "충청북도", name: "소담마사지샾", address: "청주시 상당구 상당로 88", image: "/sample.jpg" },
{ id: 4, region: "충청남도", name: "아늑마사지샾", address: "천안시 동남구 시민로 21", image: "/sample.jpg" },
{ id: 5, region: "전라북도", name: "편안한마사지샾", address: "전주시 완산구 풍남문로 77", image: "/sample.jpg" },
{ id: 6, region: "전라남도", name: "바른마사지샾", address: "여수시 중앙로 9", image: "/sample.jpg" },
{ id: 7, region: "경상북도", name: "늘봄마사지샾", address: "대구시 중구 동성로3길 12", image: "/sample.jpg" },
{ id: 8, region: "경상남도", name: "편히쉬다마사지샾", address: "창원시 성산구 중앙대로 150", image: "/sample.jpg" },
{ id: 9, region: "제주특별자치도", name: "제주소풍마사지샾", address: "제주시 중앙로 230", image: "/sample.jpg" },
{ id: 10, region: "서울특별시", name: "도심휴식마사지샾", address: "강남구 테헤란로 427", image: "/sample.jpg" },
];
return <HorizontalCardScroller items={items} />;
{/* 제휴 샾 가로 스크롤 (설정 온오프, DB에서 불러오기)
- 우선 partners 테이블(관리자 페이지 관리 대상) 사용
- 없으면 partner_shops로 대체 */}
{showPartnerShops && (async () => {
// 우선순위: partners(관리자 관리) → partner_shops(폴백)
let partners: any[] = [];
try {
partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], take: 10 });
} catch (_) {
partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, take: 10 });
}
const items = partners.map((p: any) => ({
id: p.id,
region: p.address ? String(p.address).split(" ")[0] : p.category,
name: p.name,
address: p.address || "",
image: p.imageUrl || "/sample.jpg",
}));
if (items.length > 0) return <HorizontalCardScroller items={items} />;
const shops = await (prisma as any).partnerShop.findMany({ where: { active: true }, orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
const shopItems = shops.map((s: any) => ({ id: s.id, region: s.region, name: s.name, address: s.address, image: s.imageUrl }));
return <HorizontalCardScroller items={shopItems} />;
})()}
{/* 1행: 프로필 + 선택된 보드 2개 (최대 2개) */}

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client";
import useSWR from "swr";
@@ -10,10 +11,10 @@ export default function RankingPage({ searchParams }: { searchParams?: { period?
<div>
<h1></h1>
<div style={{ display: "flex", gap: 8, margin: "8px 0" }}>
<a href={`/ranking?period=daily`}></a>
<a href={`/ranking?period=weekly`}></a>
<a href={`/ranking?period=monthly`}></a>
<a href={`/ranking?period=all`}></a>
<Link href={`/ranking?period=daily`}></Link>
<Link href={`/ranking?period=weekly`}></Link>
<Link href={`/ranking?period=monthly`}></Link>
<Link href={`/ranking?period=all`}></Link>
</div>
<ol>
{(data?.items ?? []).map((i) => (