From edbf21660055e2cf895f1d76f7972935e7e1756e Mon Sep 17 00:00:00 2001 From: ry0218 Date: Wed, 27 Aug 2025 16:47:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Refactor:=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20a=20=ED=83=9C=EA=B7=B8=20=EB=B3=80=EA=B2=BD,=20?= =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=99=94=EC=A7=88=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/page.tsx | 4 +- src/components/MenuForm.tsx | 8 +- src/components/MenuPhotoSection.tsx | 10 +- src/components/PhotoDownload.tsx | 189 ++++++++++++---------------- src/components/PhotoUpload.tsx | 10 +- src/components/auth/LoginGuard.tsx | 53 ++++---- src/hooks/useTips.ts | 5 +- src/utils/imageUtils.ts | 144 +++++++++++++++++++++ 8 files changed, 282 insertions(+), 141 deletions(-) create mode 100644 src/utils/imageUtils.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index 53353dc..7eaaa9f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -426,7 +426,7 @@ export default function Home() { {/* Network Status Section */} -
+ {/*
{!isAvailable && (

@@ -434,7 +434,7 @@ export default function Home() {

)} -
+
*/} {AlertDialogComponent} diff --git a/src/components/MenuForm.tsx b/src/components/MenuForm.tsx index 0d9adbe..2e599f6 100644 --- a/src/components/MenuForm.tsx +++ b/src/components/MenuForm.tsx @@ -11,6 +11,7 @@ import { useNativeBridge } from "@/utils/nativeBridge"; import { useSearchParams, usePathname } from "next/navigation"; import { uploadPhoto } from "@/utils/photoUpload"; import { usePhotosByFood } from "@/hooks/usePhoto"; +import { getThumbnailUrl } from "@/utils/imageUtils"; interface MenuFormProps { mode: "create" | "edit"; @@ -156,8 +157,11 @@ const MenuForm: React.FC = ({ // 새로 찍은 사진 처리 console.log("새로 찍은 사진:", result.tempFileURL); const fullImageUrl = originalPhoto - ? `${process.env.NEXT_PUBLIC_IMAGE_URL}/${result.tempFileURL}?s=${originalPhoto.imageWidth}x${originalPhoto.imageHeight}&t=crop&q=70` - : `${process.env.NEXT_PUBLIC_IMAGE_URL}/${result.tempFileURL}`; + ? getThumbnailUrl(result.tempFileURL, { + width: originalPhoto.imageWidth, + height: originalPhoto.imageHeight + }) + : getThumbnailUrl(result.tempFileURL); // 현재 표시할 이미지 URL 업데이트 setCurrentImageUrl(fullImageUrl); diff --git a/src/components/MenuPhotoSection.tsx b/src/components/MenuPhotoSection.tsx index d91b09c..190df9f 100644 --- a/src/components/MenuPhotoSection.tsx +++ b/src/components/MenuPhotoSection.tsx @@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label"; import { Photo } from "@/lib/api/types"; import PhotoUpload from "@/components/PhotoUpload"; import { usePhotosByFood } from "@/hooks/usePhoto"; +import { getThumbnailUrl } from "@/utils/imageUtils"; interface MenuPhotoSectionProps { mode: "create" | "edit"; @@ -47,12 +48,15 @@ const MenuPhotoSection: React.FC = ({ const originalPhoto = photoData?.result?.content?.[0]; - // 이미지 URL 생성 함수 + // 이미지 URL 생성 함수 (공통 유틸리티 사용) const getImageUrl = (imageUrl: string) => { if (originalPhoto) { - return `${process.env.NEXT_PUBLIC_IMAGE_URL}/${imageUrl}?s=${originalPhoto.imageWidth}x${originalPhoto.imageHeight}&t=crop&q=70`; + return getThumbnailUrl(imageUrl, { + width: originalPhoto.imageWidth, + height: originalPhoto.imageHeight + }); } - return `${process.env.NEXT_PUBLIC_IMAGE_URL}/${imageUrl}`; + return getThumbnailUrl(imageUrl); }; if (mode === "create") { // 1. 사진 없이 생성하는 경우 & 2. 네이티브에서 사진촬영 후 생성하는 경우 diff --git a/src/components/PhotoDownload.tsx b/src/components/PhotoDownload.tsx index e3e0493..b79b2c5 100644 --- a/src/components/PhotoDownload.tsx +++ b/src/components/PhotoDownload.tsx @@ -6,6 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faDownload, faTimes } from "@fortawesome/free-solid-svg-icons"; import Image from "next/image"; import { usePhotosByFood } from "@/hooks/usePhoto"; +import { getThumbnailUrl, getCroppedImageUrl } from "@/utils/imageUtils"; interface Platform { name: string; @@ -62,8 +63,6 @@ const PhotoDownload: React.FC = ({ const [selectedPlatform, setSelectedPlatform] = useState( null ); - const [isDownloading, setIsDownloading] = useState(false); - const [croppedImageUrl, setCroppedImageUrl] = useState(null); // 음식별 사진 정보 조회 const { data: photoData } = usePhotosByFood(foodItemId, { @@ -73,88 +72,24 @@ const PhotoDownload: React.FC = ({ const originalPhoto = photoData?.result?.content?.[0]; - const handlePlatformSelect = async (platform: Platform) => { + const handlePlatformSelect = (platform: Platform) => { if (!thumbnailUrl || !originalPhoto) { alert("다운로드할 사진이 없습니다."); return; } setSelectedPlatform(platform); - setIsDownloading(true); - - try { - // 이미지 크롭 및 다운로드 처리 - const croppedUrl = await cropAndDownloadImage( - thumbnailUrl, - platform.aspectRatio, - platform.name, - foodName, - originalPhoto.imageHeight, - originalPhoto.imageWidth - ); - setCroppedImageUrl(croppedUrl); - } catch (error) { - console.error("이미지 다운로드 실패:", error); - alert("이미지 다운로드에 실패했습니다."); - } finally { - setIsDownloading(false); - } }; - const cropAndDownloadImage = async ( - imageUrl: string, - targetAspectRatio: number, - platformName: string, - fileName: string, - imageHeight: number, - imageWidth: number - ): Promise => { - return new Promise((resolve) => { - // 원본 크기를 최대한 유지하면서 타겟 비율에 맞춤 - // 이미지는 항상 가로가 세로보다 크다고 가정 - const originalAspectRatio = imageWidth / imageHeight; - let outputWidth: number; - let outputHeight: number; - - if (targetAspectRatio > originalAspectRatio) { - // 타겟 비율이 원본보다 더 가로로 긴 경우 (더 와이드한 경우) - // 가로를 기준으로 맞추고 위아래를 자름 - outputWidth = imageWidth; - outputHeight = Math.round(imageWidth / targetAspectRatio); - } else { - // 타겟 비율이 원본보다 덜 가로로 긴 경우 (더 스퀘어한 경우) - // 세로를 기준으로 맞추고 좌우를 자름 - outputHeight = imageHeight; - outputWidth = Math.round(imageHeight * targetAspectRatio); - } - - // CDN 리사이징 API 사용 - crop 타입으로 정확한 크기로 자르기 - const croppedImageUrl = `${process.env.NEXT_PUBLIC_IMAGE_URL}/${imageUrl}?s=${outputWidth}x${outputHeight}&t=crop&q=100`; - console.log("croppedImageUrl", croppedImageUrl); - // HTTP URL 직접 다운로드 (안드로이드가 감지할 수 있도록) - const timestamp = new Date() - .toISOString() - .slice(0, 19) - .replace(/:/g, "-"); - const downloadFileName = `${fileName}_${platformName}_${timestamp}.jpg`; - - const link = document.createElement("a"); - link.href = croppedImageUrl; // HTTP URL 직접 사용 - link.download = downloadFileName; - link.style.display = "none"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - console.log("link", link); - - resolve(croppedImageUrl); - }); + const getDownloadFileName = (foodName: string, platformName: string) => { + const timestamp = new Date() + .toISOString() + .slice(0, 19) + .replace(/:/g, "-"); + return `${foodName}_${platformName}_${timestamp}.jpg`; }; const handleClose = () => { - if (croppedImageUrl) { - URL.revokeObjectURL(croppedImageUrl); - } onClose(); }; @@ -175,9 +110,12 @@ const PhotoDownload: React.FC = ({ {thumbnailUrl ? ( {foodName} = ({
플랫폼을 선택하세요
- {platforms.map((platform) => ( - - ))} + ); + })}
{!thumbnailUrl && ( @@ -239,15 +208,23 @@ const PhotoDownload: React.FC = ({ )}
- {/* 미리보기 (크롭된 이미지가 있을 때) */} - {croppedImageUrl && selectedPlatform && ( + {/* 미리보기 (선택된 플랫폼이 있을 때) */} + {selectedPlatform && thumbnailUrl && originalPhoto && (
{selectedPlatform.name} 미리보기
{`${foodName} = ({ />

- 파일이 다운로드 폴더에 저장되었습니다. + 위 버튼을 클릭하면 고품질 이미지가 다운로드됩니다.

)} diff --git a/src/components/PhotoUpload.tsx b/src/components/PhotoUpload.tsx index 380d4ce..093a338 100644 --- a/src/components/PhotoUpload.tsx +++ b/src/components/PhotoUpload.tsx @@ -14,6 +14,7 @@ import { import { uploadPhoto } from "@/utils/photoUpload"; import { useNativeBridge } from "@/utils/nativeBridge"; import { usePhotosByFood } from "@/hooks/usePhoto"; +import { getThumbnailUrl } from "@/utils/imageUtils"; import { usePathname } from "next/navigation"; interface PhotoUploadProps { @@ -240,10 +241,13 @@ const PhotoUpload: React.FC = ({ if (result.tempFileURL) { console.log("카메라 촬영 성공:", result.tempFileURL); - // 원본 사진 정보가 있으면 해당 크기로, 없으면 기본 크기로 URL 생성 + // 공통 이미지 유틸리티를 사용하여 고화질 썸네일 URL 생성 const fullImageUrl = cdnPhoto - ? `${process.env.NEXT_PUBLIC_IMAGE_URL}/${result.tempFileURL}?s=${cdnPhoto.imageWidth}x${cdnPhoto.imageHeight}&t=crop&q=70` - : `${process.env.NEXT_PUBLIC_IMAGE_URL}/${result.tempFileURL}`; + ? getThumbnailUrl(result.tempFileURL, { + width: cdnPhoto.imageWidth, + height: cdnPhoto.imageHeight + }) + : getThumbnailUrl(result.tempFileURL); console.log("완전한 이미지 URL:", fullImageUrl); if (previewOnly) { diff --git a/src/components/auth/LoginGuard.tsx b/src/components/auth/LoginGuard.tsx index e7a88be..a5e04f1 100644 --- a/src/components/auth/LoginGuard.tsx +++ b/src/components/auth/LoginGuard.tsx @@ -11,6 +11,7 @@ import { useActivities, useActivityCache } from "@/hooks/useActivity"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faUser } from "@fortawesome/free-solid-svg-icons"; import { usePathname } from "next/navigation"; +import LoadingPage from "@/app/loading"; interface LoginGuardProps { children: React.ReactNode; @@ -18,7 +19,8 @@ interface LoginGuardProps { } export function LoginGuard({ children }: LoginGuardProps) { - const { tokens, setTokens, clearTokens, isLoggedIn, isLoading } = useAuthStore(); + const { tokens, setTokens, clearTokens, isLoggedIn, isLoading } = + useAuthStore(); const { bridge, isAvailable } = useNativeBridge(); const { data: userInfo, @@ -29,16 +31,16 @@ export function LoginGuard({ children }: LoginGuardProps) { const { data: activities } = useActivities(5); const { data: storesData } = useMyStores({ page: 0, size: 10 }); const pathname = usePathname(); - + // 디버깅용 로그 - console.log('🛡️ [LoginGuard] 렌더링, 상태:', { + console.log("🛡️ [LoginGuard] 렌더링, 상태:", { tokens: !!tokens, isLoggedIn, isLoading, userInfoLoading, userInfo: !!userInfo, userInfoError: !!userInfoError, - isAvailable + isAvailable, }); // 개발 모드에서 임시 로그인 (테스트용) @@ -61,8 +63,8 @@ export function LoginGuard({ children }: LoginGuardProps) { // 사용자 정보 로딩 중일 때만 로딩 화면 표시 (토큰이 있을 때) if (userInfoLoading && isLoggedIn && tokens) { - console.log('🛡️ [LoginGuard] 사용자 정보 로딩 화면 표시'); - + console.log("🛡️ [LoginGuard] 사용자 정보 로딩 화면 표시"); + return (
@@ -75,29 +77,35 @@ export function LoginGuard({ children }: LoginGuardProps) { // 토큰이 없거나 로그인 상태가 아니면 로그인 필요 화면 표시 if (!isLoggedIn || !tokens) { - console.log('🛡️ [LoginGuard] 로그인 필요 화면 표시:', { - isLoggedIn, + console.log("🛡️ [LoginGuard] 로그인 필요 화면 표시:", { + isLoggedIn, hasTokens: !!tokens, - isLoading + isLoading, }); // 기존 로그인 필요 화면으로 바로 이동 return (
-
-
- + {isAvailable ? ( + + ) : ( +
+
+ +
+

+ 로그인이 필요합니다 +

+

+ {isAvailable + ? "앱에서 로그인 후 다시 시도해주세요" + : "Chalpu 앱에서 로그인 후 이용해주세요"} +

-

- 로그인이 필요합니다 -

-

- {isAvailable - ? "앱에서 로그인 후 다시 시도해주세요" - : "Chalpu 앱에서 로그인 후 이용해주세요"} -

-
- + )} {/* 인증 에러 표시 */} {userInfoError && (
@@ -217,7 +225,6 @@ export function LoginGuard({ children }: LoginGuardProps) { ); } - // 개발 환경에서 캐시 정보 표시를 위한 데이터 const cacheInfo = getCacheInfo(); const tokenExpiryTime = tokens?.expiresIn; diff --git a/src/hooks/useTips.ts b/src/hooks/useTips.ts index 6364927..f610561 100644 --- a/src/hooks/useTips.ts +++ b/src/hooks/useTips.ts @@ -56,7 +56,8 @@ export const useTodayTip = () => { }); }; -// CDN 이미지 URL 생성 함수 +// CDN 이미지 URL 생성 함수 (고화질) export const getTipImageUrl = (tipId: string): string => { - return `${process.env.NEXT_PUBLIC_IMAGE_URL}/tip/${tipId}.webp?s=80x80&t=crop&q=70`; + // 80px로 표시하지만 240px로 가져와서 화질 개선 + return `${process.env.NEXT_PUBLIC_IMAGE_URL}/tip/${tipId}.webp?s=240x240&t=crop&q=85`; }; \ No newline at end of file diff --git a/src/utils/imageUtils.ts b/src/utils/imageUtils.ts new file mode 100644 index 0000000..0b714e1 --- /dev/null +++ b/src/utils/imageUtils.ts @@ -0,0 +1,144 @@ +/** + * 이미지 URL 생성 유틸리티 + * 메뉴판과 다운로드 화면에서 동일한 URL을 사용하여 캐싱되도록 함 + */ + +export interface ImageDimensions { + width: number; + height: number; +} + +export interface ImageUrlOptions { + width?: number; + height?: number; + quality?: number; + type?: 'crop' | 'fit' | 'fill'; +} + +/** + * CDN 이미지 URL 생성 + * @param imagePath 이미지 경로 + * @param options 옵션 + * @returns 완전한 이미지 URL + */ +export function getImageUrl( + imagePath: string | null | undefined, + options: ImageUrlOptions = {} +): string { + if (!imagePath) return ''; + + const { + width, + height, + quality = 70, + type = 'crop' + } = options; + + const baseUrl = process.env.NEXT_PUBLIC_IMAGE_URL; + if (!baseUrl) return imagePath; + + const params = new URLSearchParams(); + + if (width && height) { + params.append('s', `${width}x${height}`); + } else if (width) { + params.append('s', `${width}`); + } + + params.append('t', type); + params.append('q', quality.toString()); + + return `${baseUrl}/${imagePath}?${params.toString()}`; +} + +/** + * 원본 이미지 비율을 유지하면서 썸네일 URL 생성 + * @param imagePath 이미지 경로 + * @param originalDimensions 원본 이미지 크기 + * @param displaySize 화면에 표시되는 크기 (기본 80px) + * @returns 썸네일 URL + */ +export function getThumbnailUrl( + imagePath: string | null | undefined, + originalDimensions?: ImageDimensions, + displaySize: number = 80 +): string { + if (!imagePath) return ''; + + // 화질 개선을 위해 표시 크기의 2-3배로 CDN에서 가져오기 + const cdnSize = Math.max(displaySize * 3, 240); // 최소 240px + + if (originalDimensions) { + // 원본 비율 유지하면서 리사이즈 + const { width, height } = originalDimensions; + const aspectRatio = width / height; + + let thumbnailWidth: number; + let thumbnailHeight: number; + + if (width > height) { + // 가로가 더 긴 경우 + thumbnailWidth = Math.min(cdnSize, width); + thumbnailHeight = Math.round(thumbnailWidth / aspectRatio); + } else { + // 세로가 더 긴 경우 + thumbnailHeight = Math.min(cdnSize, height); + thumbnailWidth = Math.round(thumbnailHeight * aspectRatio); + } + + return getImageUrl(imagePath, { + width: thumbnailWidth, + height: thumbnailHeight, + quality: 85, // 화질 개선 + type: 'crop' + }); + } + + // 원본 크기 정보가 없으면 기본 썸네일 (고화질) + return getImageUrl(imagePath, { + width: cdnSize, + height: cdnSize, + quality: 85, // 화질 개선 + type: 'crop' + }); +} + +/** + * 플랫폼별 크롭된 이미지 URL 생성 + * @param imagePath 이미지 경로 + * @param aspectRatio 타겟 비율 (width/height) + * @param originalDimensions 원본 이미지 크기 + * @param quality 품질 (기본 100) + * @returns 크롭된 이미지 URL + */ +export function getCroppedImageUrl( + imagePath: string | null | undefined, + aspectRatio: number, + originalDimensions: ImageDimensions, + quality: number = 100 +): string { + if (!imagePath) return ''; + + const { width: imageWidth, height: imageHeight } = originalDimensions; + const originalAspectRatio = imageWidth / imageHeight; + + let outputWidth: number; + let outputHeight: number; + + if (aspectRatio > originalAspectRatio) { + // 타겟 비율이 원본보다 더 가로로 긴 경우 + outputWidth = imageWidth; + outputHeight = Math.round(imageWidth / aspectRatio); + } else { + // 타겟 비율이 원본보다 덜 가로로 긴 경우 + outputHeight = imageHeight; + outputWidth = Math.round(imageHeight * aspectRatio); + } + + return getImageUrl(imagePath, { + width: outputWidth, + height: outputHeight, + quality, + type: 'crop' + }); +} \ No newline at end of file From 12538ba7f18f10e2b97f911ca818a68c35b81fb6 Mon Sep 17 00:00:00 2001 From: ry0218 Date: Wed, 27 Aug 2025 16:49:30 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Fix:=20=EA=B0=84=EB=8B=A8=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useAuth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index df533eb..0d08a4b 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -128,7 +128,7 @@ export const useAuth = () => { return () => { clearTimeout(fallbackTimer); }; - }, [initializeTokens]); + }, [initializeTokens, isLoggedIn, isLoading, tokens]); return {