fix: persist uploads on Render disk and show missing file notice
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -48,6 +48,9 @@ export function ExcelPreview({ fileId, fileName }: ExcelPreviewProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
<p className="text-lg text-white/60">{error}</p>
|
<p className="text-lg text-white/60">{error}</p>
|
||||||
|
<p className="text-sm text-white/40">
|
||||||
|
서버 재배포 등으로 파일이 삭제되었을 수 있습니다. 단계 수정에서 다시 첨부해 주세요.
|
||||||
|
</p>
|
||||||
<a
|
<a
|
||||||
href={fileDownloadUrl(fileId)}
|
href={fileDownloadUrl(fileId)}
|
||||||
className="rounded bg-white/10 px-4 py-2 text-sm font-bold text-white hover:bg-white/20"
|
className="rounded bg-white/10 px-4 py-2 text-sm font-bold text-white hover:bg-white/20"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { ExcelPreview } from './ExcelPreview';
|
import { ExcelPreview } from './ExcelPreview';
|
||||||
import { fileViewUrl } from '../../lib/apiClient';
|
import { fileDownloadUrl, fileViewUrl } from '../../lib/apiClient';
|
||||||
import { openLinkOnRightMonitor } from '../../lib/dualMonitor';
|
import { openLinkOnRightMonitor } from '../../lib/dualMonitor';
|
||||||
import { fileDisplayName, isExcelFile, isVideoFile } from '../../lib/fileDisplay';
|
import { fileDisplayName, isExcelFile, isVideoFile } from '../../lib/fileDisplay';
|
||||||
import type { FileRecord, MilestoneLink } from '../../types';
|
import type { FileRecord, MilestoneLink } from '../../types';
|
||||||
@@ -12,10 +12,32 @@ interface ResultPreviewProps {
|
|||||||
hasSelectedStage: boolean;
|
hasSelectedStage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FileMissingNotice({ label, fileId }: { label: string; fileId: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-lg flex-col items-center gap-3 px-6 text-center">
|
||||||
|
<p className="text-xl font-bold text-white/70">{label}</p>
|
||||||
|
<p className="text-base leading-relaxed text-white/45">
|
||||||
|
첨부 파일을 서버에서 찾을 수 없습니다.
|
||||||
|
<br />
|
||||||
|
코드 배포·서버 재시작 시 파일이 삭제될 수 있습니다.
|
||||||
|
<br />
|
||||||
|
단계 수정에서 같은 파일을 다시 첨부해 주세요.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={fileDownloadUrl(fileId)}
|
||||||
|
className="rounded-lg bg-white/10 px-4 py-2 text-sm font-bold text-white/80 hover:bg-white/20"
|
||||||
|
>
|
||||||
|
다운로드 재시도
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewProps) {
|
export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewProps) {
|
||||||
const [fileId, setFileId] = useState<string | null>(null);
|
const [fileId, setFileId] = useState<string | null>(null);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
const [fileMissing, setFileMissing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
@@ -27,6 +49,25 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
|
|||||||
}, [files]);
|
}, [files]);
|
||||||
|
|
||||||
const activeFile = fileId ? files.find((f) => f.id === fileId) ?? null : null;
|
const activeFile = fileId ? files.find((f) => f.id === fileId) ?? null : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeFile || isExcelFile(activeFile)) {
|
||||||
|
setFileMissing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setFileMissing(false);
|
||||||
|
fetch(fileViewUrl(activeFile.id), { method: 'HEAD' })
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setFileMissing(!res.ok);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setFileMissing(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeFile?.id, activeFile?.mimetype]);
|
||||||
const fileIndex = activeFile ? files.findIndex((f) => f.id === activeFile.id) : -1;
|
const fileIndex = activeFile ? files.findIndex((f) => f.id === activeFile.id) : -1;
|
||||||
const isImage = activeFile?.mimetype.includes('image') ?? false;
|
const isImage = activeFile?.mimetype.includes('image') ?? false;
|
||||||
const isVideo = activeFile ? isVideoFile(activeFile) : false;
|
const isVideo = activeFile ? isVideoFile(activeFile) : false;
|
||||||
@@ -54,6 +95,9 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
|
|||||||
if (isExcel) {
|
if (isExcel) {
|
||||||
return <ExcelPreview fileId={activeFile.id} fileName={label} />;
|
return <ExcelPreview fileId={activeFile.id} fileName={label} />;
|
||||||
}
|
}
|
||||||
|
if (fileMissing) {
|
||||||
|
return <FileMissingNotice label={label} fileId={activeFile.id} />;
|
||||||
|
}
|
||||||
const src = fileViewUrl(activeFile.id);
|
const src = fileViewUrl(activeFile.id);
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
return (
|
return (
|
||||||
@@ -63,12 +107,19 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
|
|||||||
className="max-h-full max-w-full object-contain transition-transform duration-150"
|
className="max-h-full max-w-full object-contain transition-transform duration-150"
|
||||||
style={{ transform: `scale(${zoom})` }}
|
style={{ transform: `scale(${zoom})` }}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
onError={() => setFileMissing(true)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
return (
|
return (
|
||||||
<video src={src} controls className="max-h-full max-w-full" title={label} />
|
<video
|
||||||
|
src={src}
|
||||||
|
controls
|
||||||
|
className="max-h-full max-w-full"
|
||||||
|
title={label}
|
||||||
|
onError={() => setFileMissing(true)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ services:
|
|||||||
rootDir: backend
|
rootDir: backend
|
||||||
buildCommand: npm install --include=dev && (npx prisma migrate deploy || true) && npx prisma db push && npx prisma generate && npm run build
|
buildCommand: npm install --include=dev && (npx prisma migrate deploy || true) && npx prisma db push && npx prisma generate && npm run build
|
||||||
startCommand: npx prisma db push && npm start
|
startCommand: npx prisma db push && npm start
|
||||||
|
# 첨부 파일이 재배포 후에도 유지되도록 영구 디스크 (Render 유료 플랜 필요)
|
||||||
|
disk:
|
||||||
|
name: uploads-data
|
||||||
|
mountPath: /opt/render/project/src/backend/uploads
|
||||||
|
sizeGB: 1
|
||||||
envVars:
|
envVars:
|
||||||
- key: DATABASE_URL
|
- key: DATABASE_URL
|
||||||
sync: false # Render 대시보드에서 직접 입력
|
sync: false # Render 대시보드에서 직접 입력
|
||||||
@@ -17,7 +22,7 @@ services:
|
|||||||
- key: JWT_EXPIRES_IN
|
- key: JWT_EXPIRES_IN
|
||||||
value: 7d
|
value: 7d
|
||||||
- key: UPLOAD_DIR
|
- key: UPLOAD_DIR
|
||||||
value: ./uploads
|
value: /opt/render/project/src/backend/uploads
|
||||||
- key: MAX_FILE_SIZE_MB
|
- key: MAX_FILE_SIZE_MB
|
||||||
value: 20
|
value: 20
|
||||||
- key: NODE_ENV
|
- key: NODE_ENV
|
||||||
|
|||||||
Reference in New Issue
Block a user