From d6b57cbec7d43fbaae1957cdee067c72ac733ea5 Mon Sep 17 00:00:00 2001 From: choi Date: Tue, 14 Jan 2025 17:07:03 +0900 Subject: [PATCH] =?UTF-8?q?1/14=20=EC=A0=84=EB=8B=AC=EC=82=AC=ED=95=AD=201?= =?UTF-8?q?=EC=B0=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.mjs | 15 ++- src/app/albums/[id]/page.tsx | 1 - src/app/layout.tsx | 52 ++++----- src/components/albums/AlbumInfo.tsx | 6 +- src/components/home/AlbumSection.tsx | 67 ++++++++---- src/components/layout/BackgroundImage.tsx | 56 +++++++--- src/components/layout/Sidebar.tsx | 10 +- src/components/playlists/PlaylistInfo.tsx | 16 +-- src/components/profile/AlbumList.tsx | 6 +- src/components/profile/EditProfileModal.tsx | 14 ++- src/components/profile/ProfileHeader.tsx | 17 ++- src/components/upload/TrackUpload.tsx | 4 +- src/contexts/auth/AuthContext.tsx | 113 +++++++++----------- src/contexts/auth/UserContext.tsx | 60 +++++++++++ src/contexts/auth/types.ts | 20 +++- src/hooks/use-local-storage.ts | 33 ++++++ src/lib/image.ts | 42 ++++++++ src/lib/server-auth.ts | 5 +- 18 files changed, 366 insertions(+), 171 deletions(-) create mode 100644 src/contexts/auth/UserContext.tsx create mode 100644 src/hooks/use-local-storage.ts create mode 100644 src/lib/image.ts diff --git a/next.config.mjs b/next.config.mjs index 72a796a..26a0ecb 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -3,9 +3,9 @@ const nextConfig = { output: "standalone", images: { formats: ["image/avif", "image/webp"], - minimumCacheTTL: 60, - deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], - imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], + minimumCacheTTL: 60 * 60 * 24 * 30, + deviceSizes: [640, 750, 828, 1080, 1200, 1920], + imageSizes: [16, 32, 64, 96, 128, 256], remotePatterns: [ { protocol: "https", @@ -27,17 +27,22 @@ const nextConfig = { // 정적 이미지 최적화 webpack(config) { config.module.rules.push({ - test: /\.(webp)$/i, + test: /\.(webp|avif)$/i, use: [ { loader: 'image-webpack-loader', options: { webp: { - quality: 75, + quality: 50, lossless: false, progressive: true, optimizationLevel: 3, }, + avif: { + quality: 50, + lossless: false, + speed: 5, + }, }, }, ], diff --git a/src/app/albums/[id]/page.tsx b/src/app/albums/[id]/page.tsx index 9d4a0d6..ffdf7b3 100644 --- a/src/app/albums/[id]/page.tsx +++ b/src/app/albums/[id]/page.tsx @@ -14,7 +14,6 @@ async function getAlbum(id: string) { "Content-Type": "application/json", }, credentials: 'include', - cache: 'no-store', }); if (!response.ok) { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1acc346..a100596 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,3 +1,4 @@ +import { cache } from 'react'; import "./globals.css"; import type { Metadata } from "next"; import { AuthProvider } from "@/contexts/auth/AuthContext"; @@ -6,25 +7,13 @@ import { Header } from "@/components/layout/Header"; import { BackgroundImage } from "@/components/layout/BackgroundImage"; import { getAuthCookie } from "@/lib/server-auth"; import { ThemeProvider } from "@/contexts/theme/ThemeContext"; +import { UserProvider } from "@/contexts/auth/UserContext"; -export const metadata: Metadata = { - title: "SOFO", - description: "SOund FOrest", - icons: { - icon: "/images/logo.svg", - }, -}; - -export const viewport = { - width: 'device-width', - initialScale: 1, -}; - -async function getUser() { +// getUser를 layout 레벨에서 캐싱 +const getUser = cache(async () => { const accessToken = getAuthCookie(); if (!accessToken) { - console.log('No access token found'); return null; } @@ -33,42 +22,53 @@ async function getUser() { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, - Cookie: `access_token=${accessToken}`, }, credentials: "include", - cache: 'no-store', }); if (!response.ok) return null; - const userData = await response.json(); - return userData; + return response.json(); } catch (error) { console.error('Failed to fetch user:', error); return null; } -} +}); + +export const metadata: Metadata = { + title: "SOFO", + description: "SOund FOrest", + icons: { + icon: "/images/logo.svg", + }, +}; + +export const viewport = { + width: 'device-width', + initialScale: 1, +}; export default async function RootLayout({ children, -}: Readonly<{ +}: { children: React.ReactNode; -}>) { +}) { + // 캐시된 getUser 함수 사용 const user = await getUser(); return ( - -
+ + -
+
{children}
-
+ diff --git a/src/components/albums/AlbumInfo.tsx b/src/components/albums/AlbumInfo.tsx index 892ad03..c4ed71e 100644 --- a/src/components/albums/AlbumInfo.tsx +++ b/src/components/albums/AlbumInfo.tsx @@ -6,6 +6,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { User } from "lucide-react"; import { AlbumActions } from "./AlbumActions"; import { useAuth } from "@/contexts/auth/AuthContext"; +import { useUser } from "@/contexts/auth/UserContext"; import { useState } from "react"; import { EditAlbumModal } from "./EditAlbumModal"; @@ -25,9 +26,10 @@ interface AlbumInfoProps { } export function AlbumInfo({ album, artist }: AlbumInfoProps) { - const { user } = useAuth(); + const { isAuthenticated } = useAuth(); + const { user } = useUser(); const [showEditModal, setShowEditModal] = useState(false); - const isOwner = user?.uuid === artist.uuid; + const isOwner = isAuthenticated && user?.uuid === artist.uuid; return (
diff --git a/src/components/home/AlbumSection.tsx b/src/components/home/AlbumSection.tsx index 0a85e9a..0f60f6d 100644 --- a/src/components/home/AlbumSection.tsx +++ b/src/components/home/AlbumSection.tsx @@ -7,30 +7,42 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { formatDate } from "@/lib/format"; import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { RefreshCcw } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; export function AlbumSection() { const [albums, setAlbums] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const { toast } = useToast(); - useEffect(() => { - const fetchAlbums = async () => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/albums?page=0&pageSize=6`, - { - credentials: 'include', - } - ); - if (!response.ok) throw new Error('앨범 목록을 불러오는데 실패했습니다.'); - const data = await response.json(); - setAlbums(data); - } catch (error) { - console.error('Failed to fetch albums:', error); - } finally { - setIsLoading(false); - } - }; + const fetchAlbums = async () => { + try { + setError(null); + setIsLoading(true); + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/albums?page=0&pageSize=6`, + { + credentials: 'include', + } + ); + if (!response.ok) throw new Error('앨범 목록을 불러오는데 실패했습니다.'); + const data = await response.json(); + setAlbums(data); + } catch (error) { + console.error('Failed to fetch albums:', error); + setError(error instanceof Error ? error.message : "앨범 목록을 불러오는데 실패했습니다."); + toast({ + variant: "destructive", + description: error instanceof Error ? error.message : "앨범 목록을 불러오는데 실패했습니다.", + }); + } finally { + setIsLoading(false); + } + }; + useEffect(() => { fetchAlbums(); }, []); @@ -51,6 +63,25 @@ export function AlbumSection() { ); } + if (error) { + return ( +
+

최신 앨범

+
+

{error}

+ +
+
+ ); + } + return (

최신 앨범

diff --git a/src/components/layout/BackgroundImage.tsx b/src/components/layout/BackgroundImage.tsx index c8bbb95..d078a55 100644 --- a/src/components/layout/BackgroundImage.tsx +++ b/src/components/layout/BackgroundImage.tsx @@ -1,30 +1,52 @@ +"use client"; + import Image from "next/image"; +import { useTheme } from "@/contexts/theme/ThemeContext"; +import { useState } from "react"; +import { cn } from "@/lib/utils"; export function BackgroundImage() { + const { theme } = useTheme(); + const [isLoading, setIsLoading] = useState(true); + + const handleImageLoad = () => { + setIsLoading(false); + }; + return (
-
+
Background -
-
- Background Dark
+ +
); } \ No newline at end of file diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 80cc2f6..dd7ce26 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import Image from "next/image"; import { useAuth } from "@/contexts/auth/AuthContext"; +import { useUser } from "@/contexts/auth/UserContext"; import { Tooltip, TooltipContent, @@ -18,7 +19,8 @@ import { LoginModal } from "@/components/auth/LoginModal"; export function Sidebar() { const pathname = usePathname(); - const { user, isAuthenticated } = useAuth(); + const { isAuthenticated } = useAuth(); + const { user } = useUser(); const [showLoginModal, setShowLoginModal] = useState(false); const sidebarItems = [ @@ -31,7 +33,7 @@ export function Sidebar() { fill priority sizes="16px" - quality={90} + quality={50} className="object-contain" />
@@ -39,9 +41,9 @@ export function Sidebar() { label: "홈", href: "/" }, - { icon: Search, label: "검색", href: "/" }, + { icon: Search, label: "검색", href: "" }, { icon: Upload, label: "업로드", href: "/upload" }, - { icon: Bell, label: "알림", href: "/" }, + { icon: Bell, label: "알림", href: "" }, { icon: ({ className }: { className?: string }) => isAuthenticated ? ( diff --git a/src/components/playlists/PlaylistInfo.tsx b/src/components/playlists/PlaylistInfo.tsx index a63500d..103d785 100644 --- a/src/components/playlists/PlaylistInfo.tsx +++ b/src/components/playlists/PlaylistInfo.tsx @@ -5,6 +5,7 @@ import { User, ListMusic } from "lucide-react"; import Link from "next/link"; import { PlaylistActions } from "./PlaylistActions"; import { useAuth } from "@/contexts/auth/AuthContext"; +import { useUser } from "@/contexts/auth/UserContext"; interface PlaylistInfoProps { playlist: { @@ -24,9 +25,10 @@ interface PlaylistInfoProps { } export function PlaylistInfo({ playlist }: PlaylistInfoProps) { - const { user } = useAuth(); - const creator = playlist.playlistItemResponseDtos[0]?.trackGetResponseDto.artistResponseDto; - const isOwner = user?.uuid === creator?.uuid; + const { isAuthenticated } = useAuth(); + const { user } = useUser(); + + const isOwner = isAuthenticated && user?.uuid === playlist.uuid; return (
@@ -56,18 +58,18 @@ export function PlaylistInfo({ playlist }: PlaylistInfoProps) { )}
- {creator && ( + {playlist.playlistItemResponseDtos[0]?.trackGetResponseDto.artistResponseDto && ( - + - {creator.name} + {playlist.playlistItemResponseDtos[0]?.trackGetResponseDto.artistResponseDto.name} )}

diff --git a/src/components/profile/AlbumList.tsx b/src/components/profile/AlbumList.tsx index 8fdc2a3..031f175 100644 --- a/src/components/profile/AlbumList.tsx +++ b/src/components/profile/AlbumList.tsx @@ -57,10 +57,10 @@ export function AlbumList({ albums }: AlbumListProps) { sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" - priority={index < 4} // 처음 4개 이미지는 우선 로딩 - loading={index < 4 ? "eager" : "lazy"} + priority={index < 3} // 처음 3개 이미지는 우선 로딩 + loading={index < 3 ? "eager" : "lazy"} className="object-cover transition-transform duration-500 group-hover:scale-105" - quality={75} // 품질 조정 + quality={50} // 품질 조정 /> {/* 그라데이션 오버레이 */}

diff --git a/src/components/profile/EditProfileModal.tsx b/src/components/profile/EditProfileModal.tsx index 42727e3..73a9208 100644 --- a/src/components/profile/EditProfileModal.tsx +++ b/src/components/profile/EditProfileModal.tsx @@ -10,10 +10,11 @@ import { import { Button } from "@/components/ui/button"; import { useState } from "react"; import { useToast } from "@/hooks/use-toast"; -import { useAuth } from "@/contexts/auth/AuthContext"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { User, Upload } from "lucide-react"; import { checkAuth } from "@/lib/auth"; +import { useUser } from "@/contexts/auth/UserContext"; +import { convertToWebP } from "@/lib/image"; interface EditProfileModalProps { isOpen: boolean; @@ -25,7 +26,7 @@ const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg' export function EditProfileModal({ isOpen, onClose }: EditProfileModalProps) { const { toast } = useToast(); - const { user, updateUser } = useAuth(); + const { user, updateUser } = useUser(); const [isLoading, setIsLoading] = useState(false); const [previewImage, setPreviewImage] = useState(null); @@ -43,11 +44,14 @@ export function EditProfileModal({ isOpen, onClose }: EditProfileModalProps) { setIsLoading(true); validateFile(file); + // WebP로 변환 + const optimizedFile = await convertToWebP(file); + const { accessToken } = await checkAuth(); // 1. S3에 이미지 업로드 const formData = new FormData(); - formData.append('file', file); + formData.append('file', optimizedFile); // 변환된 파일 사용 const uploadResponse = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/uploads/images`, { method: 'POST', @@ -60,13 +64,13 @@ export function EditProfileModal({ isOpen, onClose }: EditProfileModalProps) { // 2. 프로필 이미지 업데이트 const updateResponse = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/artists/profile-image`, { - method: 'PATCH', + method: 'PUT', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ - "imageUrl": imageUrl as string, + "imageUrl": imageUrl, }), credentials: 'include', }); diff --git a/src/components/profile/ProfileHeader.tsx b/src/components/profile/ProfileHeader.tsx index 6236c1b..9c23cd8 100644 --- a/src/components/profile/ProfileHeader.tsx +++ b/src/components/profile/ProfileHeader.tsx @@ -1,5 +1,6 @@ "use client"; +import { useUser } from "@/contexts/auth/UserContext"; import { useAuth } from "@/contexts/auth/AuthContext"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { User, MoreVertical, Edit2 } from "lucide-react"; @@ -18,19 +19,17 @@ interface ProfileHeaderProps { } export function ProfileHeader({ userId }: ProfileHeaderProps) { - const { user } = useAuth(); + const { user } = useUser(); + const { isAuthenticated } = useAuth(); const [showEditModal, setShowEditModal] = useState(false); - const isOwner = user?.uuid === userId; + const isOwner = isAuthenticated && user?.uuid === userId; return (
- {/* 배경 이미지 */}
- {/* 프로필 정보 */}
- {/* 프로필 이미지 */}
@@ -40,11 +39,9 @@ export function ProfileHeader({ userId }: ProfileHeaderProps) {
- {/* 유저 정보 */}

{user?.name}

- {/* 더보기 메뉴 */} {isOwner && ( @@ -68,9 +65,9 @@ export function ProfileHeader({ userId }: ProfileHeaderProps) {
- setShowEditModal(false)} + setShowEditModal(false)} />
); diff --git a/src/components/upload/TrackUpload.tsx b/src/components/upload/TrackUpload.tsx index cd88cb1..84c3c2e 100644 --- a/src/components/upload/TrackUpload.tsx +++ b/src/components/upload/TrackUpload.tsx @@ -9,9 +9,9 @@ import { useState, useEffect } from "react"; import { useToast } from "@/hooks/use-toast"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { useAuth } from "@/contexts/auth/AuthContext"; import { AlbumSelect } from "./AlbumSelect"; import { checkAuth } from "@/lib/auth"; +import { useUser } from "@/contexts/auth/UserContext"; interface Album { uuid: string; @@ -29,7 +29,7 @@ interface TrackForm { export function TrackUpload() { const router = useRouter(); const { toast } = useToast(); - const { user } = useAuth(); + const { user } = useUser(); const [isLoading, setIsLoading] = useState(false); const [albums, setAlbums] = useState([]); const [selectedAlbum, setSelectedAlbum] = useState(""); diff --git a/src/contexts/auth/AuthContext.tsx b/src/contexts/auth/AuthContext.tsx index 3ac240e..6e94847 100644 --- a/src/contexts/auth/AuthContext.tsx +++ b/src/contexts/auth/AuthContext.tsx @@ -1,91 +1,76 @@ "use client"; -import { createContext, useContext, useEffect, useState } from "react"; -import { AuthContextType, User } from "./types"; -import { checkAuth } from "@/lib/auth"; +import { createContext, useContext, useCallback, useMemo, useReducer } from "react"; +import { AuthContextType, AuthState } from "./types"; -const AuthContext = createContext(null); +const initialState: AuthState = { + isAuthenticated: false, + isLoading: false, + error: null, +}; -interface AuthProviderProps { - children: React.ReactNode; - initialUser: User | null; -} +type AuthAction = + | { type: "SET_LOADING"; payload: boolean } + | { type: "SET_AUTH"; payload: boolean } + | { type: "SET_ERROR"; payload: string | null }; -export function AuthProvider({ children, initialUser }: AuthProviderProps) { - const [user, setUser] = useState(initialUser); - const [isLoading, setIsLoading] = useState(false); +function authReducer(state: AuthState, action: AuthAction): AuthState { + switch (action.type) { + case "SET_LOADING": + return { ...state, isLoading: action.payload }; + case "SET_AUTH": + return { ...state, isAuthenticated: action.payload }; + case "SET_ERROR": + return { ...state, error: action.payload }; + default: + return state; + } +} - // 인증 상태 확인 - useEffect(() => { - const verifyAuth = async () => { - setIsLoading(true); - try { - const { isAuthenticated } = await checkAuth(); - if (!isAuthenticated && user) { - setUser(null); - } else { - setUser(initialUser); - } - } catch (error) { - console.error('Auth verification failed:', error); - } finally { - setIsLoading(false); - } - }; +const AuthContext = createContext(null); - verifyAuth(); - }, [user, initialUser]); +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [state, dispatch] = useReducer(authReducer, initialState); - const login = () => { - setIsLoading(true); - const googleOAuthUrl = process.env.NEXT_PUBLIC_GOOGLE_OAUTH_URL; - if (!googleOAuthUrl) { - console.error('OAuth URL is not defined'); - setIsLoading(false); - return; + const login = useCallback(async () => { + dispatch({ type: "SET_LOADING", payload: true }); + try { + const googleOAuthUrl = process.env.NEXT_PUBLIC_GOOGLE_OAUTH_URL; + if (!googleOAuthUrl) throw new Error('OAuth URL is not defined'); + window.location.href = googleOAuthUrl; + } catch (error) { + dispatch({ type: "SET_ERROR", payload: "로그인에 실패했습니다" }); + } finally { + dispatch({ type: "SET_LOADING", payload: false }); } - window.location.href = googleOAuthUrl; - }; + }, []); - const logout = async () => { + const logout = useCallback(async () => { + dispatch({ type: "SET_LOADING", payload: true }); try { - setIsLoading(true); - - // 1. 서버에 로그아웃 요청 const response = await fetch('/api/auth/logout', { method: 'POST', credentials: 'include', }); - if (!response.ok) throw new Error('Logout failed'); - - // 2. 클라이언트 상태 초기화 - setUser(null); - // 3. 페이지 새로고침 (선택사항) + dispatch({ type: "SET_AUTH", payload: false }); window.location.href = '/'; } catch (error) { - console.error('Logout error:', error); + dispatch({ type: "SET_ERROR", payload: "로그아웃에 실패했습니다" }); } finally { - setIsLoading(false); + dispatch({ type: "SET_LOADING", payload: false }); } - }; + }, []); - const updateUser = (newUser: User) => { - setUser(newUser); - }; + const value = useMemo(() => ({ + ...state, + login, + logout, + }), [state, login, logout]); return ( - + {children} ); diff --git a/src/contexts/auth/UserContext.tsx b/src/contexts/auth/UserContext.tsx new file mode 100644 index 0000000..2411662 --- /dev/null +++ b/src/contexts/auth/UserContext.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { createContext, useContext, useCallback, useMemo, useReducer } from "react"; +import { UserContextType, UserState, User } from "./types"; + +type UserAction = + | { type: "SET_USER"; payload: User | null } + | { type: "CLEAR_USER" }; + +function userReducer(state: UserState, action: UserAction): UserState { + switch (action.type) { + case "SET_USER": + return { ...state, user: action.payload }; + case "CLEAR_USER": + return { ...state, user: null }; + default: + return state; + } +} + +const UserContext = createContext(null); + +interface UserProviderProps { + children: React.ReactNode; + initialUser: User | null; +} + +export function UserProvider({ children, initialUser }: UserProviderProps) { + const [state, dispatch] = useReducer(userReducer, { + user: initialUser, + }); + + const updateUser = useCallback((user: User) => { + dispatch({ type: "SET_USER", payload: user }); + }, []); + + const clearUser = useCallback(() => { + dispatch({ type: "CLEAR_USER" }); + }, []); + + const value = useMemo(() => ({ + ...state, + updateUser, + clearUser, + }), [state, updateUser, clearUser]); + + return ( + + {children} + + ); +} + +export const useUser = () => { + const context = useContext(UserContext); + if (!context) { + throw new Error("useUser must be used within a UserProvider"); + } + return context; +}; \ No newline at end of file diff --git a/src/contexts/auth/types.ts b/src/contexts/auth/types.ts index 7bfb7b8..305c361 100644 --- a/src/contexts/auth/types.ts +++ b/src/contexts/auth/types.ts @@ -6,11 +6,25 @@ export interface User { artistImage: string; } -export interface AuthContextType { - user: User | null; +export interface AuthState { isAuthenticated: boolean; isLoading: boolean; + error: string | null; +} + +export interface AuthActions { login: () => void; logout: () => void; +} + +export interface UserState { + user: User | null; +} + +export interface UserActions { updateUser: (user: User) => void; -} \ No newline at end of file + clearUser: () => void; +} + +export interface AuthContextType extends AuthState, AuthActions {} +export interface UserContextType extends UserState, UserActions {} \ No newline at end of file diff --git a/src/hooks/use-local-storage.ts b/src/hooks/use-local-storage.ts new file mode 100644 index 0000000..4a5ff74 --- /dev/null +++ b/src/hooks/use-local-storage.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useState, useEffect } from "react"; + +export function useLocalStorage(key: string, initialValue: T) { + // 초기값 설정 + const [storedValue, setStoredValue] = useState(() => { + try { + if (typeof window === "undefined") { + return initialValue; + } + + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(error); + return initialValue; + } + }); + + // 값이 변경될 때마다 localStorage 업데이트 + useEffect(() => { + try { + if (typeof window !== "undefined") { + window.localStorage.setItem(key, JSON.stringify(storedValue)); + } + } catch (error) { + console.error(error); + } + }, [key, storedValue]); + + return [storedValue, setStoredValue] as const; +} \ No newline at end of file diff --git a/src/lib/image.ts b/src/lib/image.ts new file mode 100644 index 0000000..07f5cfd --- /dev/null +++ b/src/lib/image.ts @@ -0,0 +1,42 @@ +export async function convertToWebP(file: File): Promise { + // 이미 WebP 형식이면 그대로 반환 + if (file.type === 'image/webp') { + return file; + } + + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Canvas context creation failed')); + return; + } + + ctx.drawImage(img, 0, 0); + + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Blob creation failed')); + return; + } + + const webpFile = new File([blob], file.name.replace(/\.[^/.]+$/, '.webp'), { + type: 'image/webp' + }); + resolve(webpFile); + }, + 'image/webp', + 0.8 // 품질 설정 (0.8 = 80%) + ); + }; + + img.onerror = () => reject(new Error('Image loading failed')); + img.src = URL.createObjectURL(file); + }); +} \ No newline at end of file diff --git a/src/lib/server-auth.ts b/src/lib/server-auth.ts index a19da53..5a71fa8 100644 --- a/src/lib/server-auth.ts +++ b/src/lib/server-auth.ts @@ -10,8 +10,5 @@ export function getAuthCookie() { // 서버 컴포넌트에서 로그아웃 처리 export async function serverLogout() { const cookieStore = cookies(); - cookieStore.set('access_token', '', { - expires: new Date(0), - path: '/', - }); + cookieStore.delete('access_token'); } \ No newline at end of file