From dd4e918e612f14c8de6ba8739f55b459cef40c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=84=B1=EC=A7=84?= Date: Mon, 20 Jul 2026 16:44:05 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=EC=9E=90=EB=A3=8C=EC=8B=A4=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EC=9C=A0=ED=98=95=20=EA=B0=9C=EC=84=A0(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 6 ++ .../resource/api/get-resource-library.ts | 13 +++- src/entities/resource/api/resource-actions.ts | 65 ++++++----------- .../model/use-resource-library-state.ts | 71 +++++++++++++------ .../manage-resources/ui/ResourceAddDialog.tsx | 56 +++++++++++++-- .../manage-resources/ui/ResourceList.tsx | 34 +++++++-- src/shared/model/database.types.ts | 11 +++ ...60720160000_add_resource_link_provider.sql | 25 +++++++ 8 files changed, 207 insertions(+), 74 deletions(-) create mode 100644 supabase/migrations/20260720160000_add_resource_link_provider.sql diff --git a/next.config.ts b/next.config.ts index f6f93a2..a896d58 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,12 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { /* config options here */ reactCompiler: true, + experimental: { + // 자료실의 실제 파일 제한(5MB)에 multipart 전송 오버헤드를 더해 Server Action 요청을 허용한다. + serverActions: { + bodySizeLimit: '6mb', + }, + }, images: { remotePatterns: [ { diff --git a/src/entities/resource/api/get-resource-library.ts b/src/entities/resource/api/get-resource-library.ts index 525f122..6f90a1b 100644 --- a/src/entities/resource/api/get-resource-library.ts +++ b/src/entities/resource/api/get-resource-library.ts @@ -12,7 +12,14 @@ import type { const workspaceIdSchema = z.guid(); -function getLinkProvider(url: string | null): ResourceLinkProvider | undefined { +function getLinkProvider( + url: string | null, + storedProvider: string | null, +): ResourceLinkProvider | undefined { + if (storedProvider && ['link', 'notion', 'figma', 'github'].includes(storedProvider)) { + return storedProvider as ResourceLinkProvider; + } + if (!url) return undefined; try { @@ -41,7 +48,7 @@ export async function getResourceLibrary(workspaceId: string): Promise> { try { const value = z - .object({ workspaceId: uuidSchema, url: externalUrlSchema }) + .object({ workspaceId: uuidSchema, url: externalUrlSchema, linkProvider: linkProviderSchema }) .merge(resourceContentSchema) .parse(input); const { supabase, member } = await getCurrentWorkspaceMember(value.workspaceId); @@ -132,6 +126,7 @@ export async function createLinkResource(input: { title: value.title, description: value.description || null, resource_type: 'link', + link_provider: value.linkProvider, url: value.url, storage_path: null, }) @@ -150,36 +145,26 @@ export async function createLinkResource(input: { } } -export async function uploadFileResource( - formData: FormData, -): Promise> { +// 브라우저에서 Storage 업로드를 마친 파일의 메타데이터만 DB에 저장합니다. +export async function createFileResource(input: { + workspaceId: string; + title: string; + description: string; + storagePath: string; +}): Promise> { try { - const workspaceId = uuidSchema.parse(formData.get('workspaceId')); - const file = formData.get('file'); const value = resourceContentSchema.parse({ - title: formData.get('title'), - description: formData.get('description'), + title: input.title, + description: input.description, }); + const workspaceId = uuidSchema.parse(input.workspaceId); + const storagePath = z.string().trim().min(1).max(512).parse(input.storagePath); - if (!(file instanceof File) || file.size === 0) { - throwResourceActionError('업로드할 파일을 선택해주세요.'); - } - - if (file.size > MAX_FILE_SIZE_BYTES) { - throwResourceActionError('파일은 5MB 이하만 업로드할 수 있습니다.'); + if (!storagePath.startsWith(`${workspaceId}/`)) { + throwResourceActionError('올바르지 않은 파일 경로입니다.'); } const { supabase, member } = await getCurrentWorkspaceMember(workspaceId); - const storagePath = `${workspaceId}/${randomUUID()}-${sanitizeFileName(file.name)}`; - const { error: uploadError } = await supabase.storage - .from(RESOURCE_STORAGE_BUCKET) - .upload(storagePath, file, { contentType: file.type || undefined, upsert: false }); - - if (uploadError) { - console.error('[resource action] 파일 업로드 실패:', uploadError); - throwResourceActionError('파일 업로드에 실패했습니다. 잠시 후 다시 시도해주세요.'); - } - const { data, error: resourceError } = await supabase .from('resources') .insert({ @@ -188,6 +173,7 @@ export async function uploadFileResource( title: value.title, description: value.description || null, resource_type: 'file', + link_provider: null, url: null, storage_path: storagePath, }) @@ -196,18 +182,13 @@ export async function uploadFileResource( if (resourceError) { console.error('[resource action] 파일 메타데이터 저장 실패:', resourceError); - const { error: removeError } = await supabase.storage - .from(RESOURCE_STORAGE_BUCKET) - .remove([storagePath]); - - if (removeError) console.error('[resource action] 업로드 보상 삭제 실패:', removeError); throwResourceActionError('파일 정보 저장에 실패했습니다. 잠시 후 다시 시도해주세요.'); } revalidateResourcePages(workspaceId); return { ok: true, data: { id: data.id } }; } catch (error) { - return toActionFailure(error, '파일 저장에 실패했습니다. 입력값을 확인해주세요.'); + return toActionFailure(error, '파일 정보 저장에 실패했습니다. 입력값을 확인해주세요.'); } } diff --git a/src/features/manage-resources/model/use-resource-library-state.ts b/src/features/manage-resources/model/use-resource-library-state.ts index 3dd700e..0193914 100644 --- a/src/features/manage-resources/model/use-resource-library-state.ts +++ b/src/features/manage-resources/model/use-resource-library-state.ts @@ -6,10 +6,10 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { toast } from 'sonner'; import { createLinkResource, + createFileResource, deleteResource, getResourceDownloadUrl, updateResource, - uploadFileResource, } from '@/entities/resource/api/resource-actions'; import { getResourceLibrary } from '@/entities/resource/api/get-resource-library'; import { resourceLibraryQueryKey } from '@/entities/resource/model/resource-query'; @@ -19,6 +19,9 @@ import type { ResourceLibraryData, ResourceType, } from '@/entities/resource'; +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; + +const RESOURCE_STORAGE_BUCKET = 'workspace-resources'; interface UseResourceLibraryStateParams { initialData: ResourceLibraryData; @@ -45,8 +48,9 @@ export function useResourceLibraryState({ const [isDialogOpen, setIsDialogOpen] = useState(false); const [dialogResourceType, setDialogResourceType] = useState('file'); const [editingResourceId, setEditingResourceId] = useState(null); + const [isUploadingFile, setIsUploadingFile] = useState(false); const createLinkMutation = useMutation({ mutationFn: createLinkResource }); - const uploadFileMutation = useMutation({ mutationFn: uploadFileResource }); + const createFileMutation = useMutation({ mutationFn: createFileResource }); const downloadMutation = useMutation({ mutationFn: getResourceDownloadUrl }); const updateMutation = useMutation({ mutationFn: updateResource }); const deleteMutation = useMutation({ mutationFn: deleteResource }); @@ -73,14 +77,38 @@ export function useResourceLibraryState({ if (values.resourceType === 'file') { if (!values.file) return false; - const formData = new FormData(); - formData.set('workspaceId', workspaceId); - formData.set('file', values.file); - formData.set('title', title); - formData.set('description', description); - const result = await uploadFileMutation.mutateAsync(formData); + setIsUploadingFile(true); + // Storage 객체 키는 한글·공백 파일명 대신 UUID와 안전한 확장자만 사용한다. + const storagePath = `${workspaceId}/${crypto.randomUUID()}${getFileExtension( + values.file.name, + )}`; + const supabase = getSupabaseBrowserClient(); + const { error: uploadError } = await supabase.storage + .from(RESOURCE_STORAGE_BUCKET) + .upload(storagePath, values.file, { + contentType: values.file.type || undefined, + upsert: false, + }); + + if (uploadError) { + console.error('[resource] Storage 파일 업로드 실패:', uploadError); + toast.error('파일 업로드에 실패했습니다. 파일 용량과 저장소 권한을 확인해주세요.'); + return false; + } + + const result = await createFileMutation.mutateAsync({ + workspaceId, + title, + description, + storagePath, + }); if (!result.ok) { + const { error: removeError } = await supabase.storage + .from(RESOURCE_STORAGE_BUCKET) + .remove([storagePath]); + + if (removeError) console.error('[resource] 메타데이터 실패 후 파일 삭제 실패:', removeError); toast.error(result.message); return false; } @@ -90,6 +118,7 @@ export function useResourceLibraryState({ title, description, url: values.url.trim(), + linkProvider: values.linkProvider, }); if (!result.ok) { @@ -101,32 +130,27 @@ export function useResourceLibraryState({ await refetch(); setIsDialogOpen(false); return true; - } catch { + } catch (error) { + console.error('[resource] 자료 저장 실패:', error); toast.error('자료 저장에 실패했습니다. 잠시 후 다시 시도해주세요.'); return false; + } finally { + setIsUploadingFile(false); } }; const openFile = async (resource: ResourceItem): Promise => { - const downloadWindow = window.open('', '_blank'); - try { const result = await downloadMutation.mutateAsync({ workspaceId, resourceId: resource.id }); if (!result.ok) { - downloadWindow?.close(); toast.error(result.message); return; } - if (downloadWindow) { - downloadWindow.opener = null; - downloadWindow.location.href = result.data.url; - } else { - window.location.assign(result.data.url); - } + // Storage signed URL의 download 응답을 현재 창에서 요청해 새 탭을 만들지 않습니다. + window.location.assign(result.data.url); } catch { - downloadWindow?.close(); toast.error('파일 다운로드 링크를 만들지 못했습니다. 잠시 후 다시 시도해주세요.'); } }; @@ -184,7 +208,8 @@ export function useResourceLibraryState({ isLoading: isPending, isSaving: createLinkMutation.isPending || - uploadFileMutation.isPending || + isUploadingFile || + createFileMutation.isPending || updateMutation.isPending || deleteMutation.isPending, openDialog: (nextResourceType: ResourceType) => { @@ -201,3 +226,9 @@ export function useResourceLibraryState({ viewer: data.viewer, }; } + +// Storage 키에는 확장자만 보존해 파일 종류를 유지하고, 원본 파일명은 자료 제목으로 보관합니다. +function getFileExtension(fileName: string): string { + const extension = fileName.split('.').at(-1)?.toLowerCase(); + return extension && /^[a-z0-9]{1,10}$/.test(extension) ? `.${extension}` : ''; +} diff --git a/src/features/manage-resources/ui/ResourceAddDialog.tsx b/src/features/manage-resources/ui/ResourceAddDialog.tsx index 84d0673..5ad0e9b 100644 --- a/src/features/manage-resources/ui/ResourceAddDialog.tsx +++ b/src/features/manage-resources/ui/ResourceAddDialog.tsx @@ -1,7 +1,7 @@ 'use client'; // 자료실에 실제 파일 또는 외부 링크를 추가하는 모달입니다. -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react'; import { CloudUpload, GitBranch, Link, X } from 'lucide-react'; import type { ResourceFormValues, ResourceLinkProvider, ResourceType } from '@/entities/resource'; import { cn } from '@/shared/lib/utils'; @@ -19,12 +19,14 @@ const linkProviders: Array<{ value: ResourceLinkProvider; icon: typeof Link; }> = [ - { label: '링크', value: 'link', icon: Link }, { label: '노션', value: 'notion', icon: Link }, { label: '피그마', value: 'figma', icon: Link }, + { label: '기타', value: 'link', icon: Link }, { label: '깃허브', value: 'github', icon: GitBranch }, ]; +const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; + export function ResourceAddDialog({ isOpen, initialResourceType, @@ -37,6 +39,8 @@ export function ResourceAddDialog({ const [linkProvider, setLinkProvider] = useState('link'); const [url, setUrl] = useState(''); const [file, setFile] = useState(null); + const [fileError, setFileError] = useState(null); + const [isDraggingFile, setIsDraggingFile] = useState(false); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); @@ -47,6 +51,8 @@ export function ResourceAddDialog({ setLinkProvider('link'); setUrl(''); setFile(null); + setFileError(null); + setIsDraggingFile(false); setTitle(''); setDescription(''); }, [initialResourceType]); @@ -69,6 +75,26 @@ export function ResourceAddDialog({ if (isSaved) resetForm(); }; + // 파일 선택과 드래그앤드롭 입력을 같은 검증 규칙으로 처리합니다. + const selectFile = (nextFile: File | null) => { + if (!nextFile) return; + + if (nextFile.size > MAX_FILE_SIZE_BYTES) { + setFile(null); + setFileError('파일은 5MB 이하만 업로드할 수 있습니다.'); + return; + } + + setFile(nextFile); + setFileError(null); + }; + + const handleFileDrop = (event: DragEvent) => { + event.preventDefault(); + setIsDraggingFile(false); + selectFile(event.dataTransfer.files.item(0)); + }; + const keepFocusInsideDialog = (event: React.KeyboardEvent) => { if (event.key !== 'Tab') { return; @@ -164,22 +190,42 @@ export function ResourceAddDialog({
{resourceType === 'file' ? ( -
diff --git a/src/views/notifications/index.ts b/src/views/notifications/index.ts new file mode 100644 index 0000000..78fb225 --- /dev/null +++ b/src/views/notifications/index.ts @@ -0,0 +1,2 @@ +// 전체 알림 화면의 외부 공개 진입점입니다. +export { NotificationsView } from './ui/NotificationsView'; diff --git a/src/views/notifications/ui/NotificationsView.tsx b/src/views/notifications/ui/NotificationsView.tsx new file mode 100644 index 0000000..26b42ab --- /dev/null +++ b/src/views/notifications/ui/NotificationsView.tsx @@ -0,0 +1,142 @@ +'use client'; + +// 워크스페이스에서 수신한 전체 알림을 페이지 단위로 조회하고 읽음 처리하는 화면입니다. +import { useMemo } from 'react'; +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { CheckCheck, ClipboardList, Megaphone } from 'lucide-react'; +import Link from 'next/link'; +import { toast } from 'sonner'; +import { + getNotificationsPage, + markNotificationsRead, + notificationsQueryKey, + type NotificationItem, + type NotificationPageData, +} from '@/entities/notification'; + +interface NotificationsViewProps { + workspaceId: string; + viewerId: string; + initialData: NotificationPageData; +} + +function getNotificationIcon(type: NotificationItem['type']) { + const className = 'h-5 w-5 text-indigo-500'; + return type === 'announcement_created' ? ( +