장바구니: - /m/items 우측 패널 lg: → md: 브레이크포인트 (768px+ 부터 노출) - 패널 테두리 강조 (emerald-300 2px) + 그림자 강화 - [담기] 클릭 시 우측 상단 토스트 피드백 (장바구니 추가됨) 이미지 업로드: - docker-compose.prod.yml 에 ./public/uploads:/app/public/uploads 볼륨 마운트 (컨테이너 재빌드 시에도 이미지 보존) - Dockerfile: /app/public/uploads/items 디렉토리 + nextjs 소유권 미리 설정 - scripts/deploy.sh: 호스트 public/uploads/items 디렉토리 보장 + 권한 777 - /api/m/items/upload-image: 저장 경로 fallback (3개 후보) + 명확한 에러 메시지
This commit is contained in:
+2
-1
@@ -36,7 +36,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
# 파일 업로드 디렉토리
|
||||
RUN mkdir -p /data_storage && chown nextjs:nodejs /data_storage
|
||||
RUN mkdir -p /data_storage /app/public/uploads/items && \
|
||||
chown -R nextjs:nodejs /data_storage /app/public/uploads
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -13,11 +13,12 @@ services:
|
||||
- .env.production
|
||||
volumes:
|
||||
- ./data_storage:/data_storage
|
||||
# 업로드 이미지 영구 저장 (컨테이너 재빌드 시에도 보존)
|
||||
- ./public/uploads:/app/public/uploads
|
||||
# 자가 배포: webhook 이 호스트의 deploy.sh 를 실행하기 위함
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./scripts/deploy.sh:/deploy/deploy.sh:ro
|
||||
# source 디렉토리를 컨테이너 안에서 git pull 하기 위해 호스트의 소스를 마운트
|
||||
# (호스트 ~/momo-erp/source 를 /deploy/source 로)
|
||||
- $PWD:/deploy/source
|
||||
networks:
|
||||
- traefik-net
|
||||
|
||||
@@ -10,6 +10,10 @@ echo "[$(date)] git fetch + reset --hard origin/main"
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
|
||||
# 업로드 디렉토리 보장 (컨테이너 마운트 경로)
|
||||
mkdir -p public/uploads/items
|
||||
chmod -R 777 public/uploads || true
|
||||
|
||||
echo "[$(date)] docker compose up --build"
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ export default function ItemsBrowse() {
|
||||
}
|
||||
return [...c, { item, qty: 1 }];
|
||||
});
|
||||
// 시각 피드백: 우측 상단 토스트
|
||||
Swal.fire({
|
||||
toast: true, position: "top-end", icon: "success",
|
||||
title: `장바구니에 추가됨: ${item.ITEM_NAME}`,
|
||||
showConfirmButton: false, timer: 1500, timerProgressBar: true,
|
||||
});
|
||||
};
|
||||
|
||||
const updateQty = (objid: string, delta: number) => {
|
||||
@@ -123,7 +129,7 @@ export default function ItemsBrowse() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid lg:grid-cols-[1fr_380px] gap-6">
|
||||
<div className="grid md:grid-cols-[1fr_340px] gap-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">품목 검색</h1>
|
||||
@@ -194,7 +200,7 @@ export default function ItemsBrowse() {
|
||||
</div>
|
||||
|
||||
{/* 장바구니 */}
|
||||
<aside className="lg:sticky lg:top-6 self-start bg-white border border-slate-200 rounded-xl p-5 shadow-sm h-fit">
|
||||
<aside className="md:sticky md:top-4 self-start bg-white border-2 border-emerald-300 rounded-xl p-5 shadow-lg h-fit">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 font-bold text-slate-800">
|
||||
<ShoppingCart size={16} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { writeFile, mkdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
import { createObjectId } from "@/lib/utils";
|
||||
@@ -11,28 +11,57 @@ export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
|
||||
const form = await req.formData();
|
||||
const file = form.get("file") as File | null;
|
||||
if (!file) return NextResponse.json({ success: false, message: "파일이 없습니다." }, { status: 400 });
|
||||
if (!ALLOWED.includes(file.type)) {
|
||||
return NextResponse.json({ success: false, message: "이미지 파일만 업로드 가능합니다." }, { status: 400 });
|
||||
try {
|
||||
const form = await req.formData();
|
||||
const file = form.get("file") as File | null;
|
||||
if (!file) return NextResponse.json({ success: false, message: "파일이 없습니다." }, { status: 400 });
|
||||
if (!ALLOWED.includes(file.type)) {
|
||||
return NextResponse.json({ success: false, message: "이미지 파일만 업로드 가능합니다." }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ success: false, message: "파일 크기는 5MB 이하여야 합니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
// 저장 경로 후보 (운영 도커 마운트 / 로컬 dev / 폴백)
|
||||
const candidates = [
|
||||
"/app/public/uploads/items", // 컨테이너 표준
|
||||
path.join(process.cwd(), "public", "uploads", "items"), // 로컬 dev
|
||||
"/data_storage/uploads/items", // 폴백
|
||||
];
|
||||
|
||||
let baseDir: string | null = null;
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
await mkdir(path.join(c, ymd), { recursive: true });
|
||||
await stat(path.join(c, ymd));
|
||||
baseDir = c;
|
||||
break;
|
||||
} catch { /* 다음 후보 */ }
|
||||
}
|
||||
if (!baseDir) {
|
||||
console.error("[upload] 저장 디렉토리 생성 실패 — 모든 후보 권한 없음");
|
||||
return NextResponse.json({ success: false, message: "서버 저장소 권한 오류 — 운영자에게 문의" }, { status: 500 });
|
||||
}
|
||||
|
||||
const ext = (file.name.match(/\.[a-zA-Z0-9]+$/)?.[0] ?? ".jpg").toLowerCase();
|
||||
const safeExt = /^\.(jpg|jpeg|png|webp|gif)$/.test(ext) ? ext : ".jpg";
|
||||
const fname = createObjectId() + safeExt;
|
||||
const fpath = path.join(baseDir, ymd, fname);
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(fpath, buf);
|
||||
|
||||
// URL 은 /uploads/items/.. 로 통일 (Next.js public 정적 서빙)
|
||||
const url = baseDir === "/data_storage/uploads/items"
|
||||
? `/api/file/items/${ymd}/${fname}` // data_storage는 별도 핸들러 필요
|
||||
: `/uploads/items/${ymd}/${fname}`;
|
||||
console.log("[upload] saved:", fpath, "→", url);
|
||||
return NextResponse.json({ success: true, url, savedAt: fpath });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error("[upload] error:", msg);
|
||||
return NextResponse.json({ success: false, message: `업로드 실패: ${msg}` }, { status: 500 });
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ success: false, message: "파일 크기는 5MB 이하여야 합니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}`;
|
||||
const dir = path.join(process.cwd(), "public", "uploads", "items", ymd);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const ext = (file.name.match(/\.[a-zA-Z0-9]+$/)?.[0] ?? ".jpg").toLowerCase();
|
||||
const safeExt = /^\.(jpg|jpeg|png|webp|gif)$/.test(ext) ? ext : ".jpg";
|
||||
const fname = createObjectId() + safeExt;
|
||||
const fpath = path.join(dir, fname);
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(fpath, buf);
|
||||
|
||||
const url = `/uploads/items/${ymd}/${fname}`;
|
||||
return NextResponse.json({ success: true, url });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user