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 (
|
||||
<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-sm text-white/40">
|
||||
서버 재배포 등으로 파일이 삭제되었을 수 있습니다. 단계 수정에서 다시 첨부해 주세요.
|
||||
</p>
|
||||
<a
|
||||
href={fileDownloadUrl(fileId)}
|
||||
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 { createPortal } from 'react-dom';
|
||||
import { ExcelPreview } from './ExcelPreview';
|
||||
import { fileViewUrl } from '../../lib/apiClient';
|
||||
import { fileDownloadUrl, fileViewUrl } from '../../lib/apiClient';
|
||||
import { openLinkOnRightMonitor } from '../../lib/dualMonitor';
|
||||
import { fileDisplayName, isExcelFile, isVideoFile } from '../../lib/fileDisplay';
|
||||
import type { FileRecord, MilestoneLink } from '../../types';
|
||||
@@ -12,10 +12,32 @@ interface ResultPreviewProps {
|
||||
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) {
|
||||
const [fileId, setFileId] = useState<string | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [fileMissing, setFileMissing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length > 0) {
|
||||
@@ -27,6 +49,25 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
|
||||
}, [files]);
|
||||
|
||||
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 isImage = activeFile?.mimetype.includes('image') ?? false;
|
||||
const isVideo = activeFile ? isVideoFile(activeFile) : false;
|
||||
@@ -54,6 +95,9 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
|
||||
if (isExcel) {
|
||||
return <ExcelPreview fileId={activeFile.id} fileName={label} />;
|
||||
}
|
||||
if (fileMissing) {
|
||||
return <FileMissingNotice label={label} fileId={activeFile.id} />;
|
||||
}
|
||||
const src = fileViewUrl(activeFile.id);
|
||||
if (isImage) {
|
||||
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"
|
||||
style={{ transform: `scale(${zoom})` }}
|
||||
draggable={false}
|
||||
onError={() => setFileMissing(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isVideo) {
|
||||
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 (
|
||||
|
||||
@@ -5,6 +5,11 @@ services:
|
||||
rootDir: backend
|
||||
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
|
||||
# 첨부 파일이 재배포 후에도 유지되도록 영구 디스크 (Render 유료 플랜 필요)
|
||||
disk:
|
||||
name: uploads-data
|
||||
mountPath: /opt/render/project/src/backend/uploads
|
||||
sizeGB: 1
|
||||
envVars:
|
||||
- key: DATABASE_URL
|
||||
sync: false # Render 대시보드에서 직접 입력
|
||||
@@ -17,7 +22,7 @@ services:
|
||||
- key: JWT_EXPIRES_IN
|
||||
value: 7d
|
||||
- key: UPLOAD_DIR
|
||||
value: ./uploads
|
||||
value: /opt/render/project/src/backend/uploads
|
||||
- key: MAX_FILE_SIZE_MB
|
||||
value: 20
|
||||
- key: NODE_ENV
|
||||
|
||||
Reference in New Issue
Block a user