diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 359de5b..68e5096 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,9 @@ "WebFetch(domain:ngrok.com)", "Bash(python:*)", "WebFetch(domain:developers.cloudflare.com)", - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(mkdir:*)", + "Bash(cp:*)" ], "deny": [], "ask": [] diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..6f173a4 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,210 @@ + + + + + + 자료실 - CRUD 시스템 + + + + + +
+
+

📚 자료실 관리 시스템

+

파일과 문서를 효율적으로 관리하세요

+
+ + +
+
+ +
+ + + +
+ +
+

📁 새 자료 추가

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
📁
+

파일을 여기로 드래그하거나 클릭하여 선택하세요

+

여러 파일을 동시에 선택할 수 있습니다

+ 지원 형식: 모든 파일 형식 +
+
+
+
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+

📋 자료 목록

+
+ +
+
+ +
+ + + + + + + + + + + + + + + + +
번호카테고리제목첨부등록일관리
📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!
+ + +
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/admin/script.js b/admin/script.js new file mode 100644 index 0000000..d6576ba --- /dev/null +++ b/admin/script.js @@ -0,0 +1,1513 @@ +class FileManager { + constructor() { + this.files = []; + this.allFiles = []; // 전체 파일 목록 + this.currentPage = 1; // 현재 페이지 + this.selectedFiles = []; // 선택된 파일들 + this.currentEditId = null; + this.currentUser = null; + this.isOnline = navigator.onLine; + this.realtimeSubscription = null; + this.authMode = 'login'; // 'login' or 'signup' + this.isOfflineMode = true; // 강제 오프라인 모드 + + this.init(); + } + + async init() { + console.log('🔍 FileManager 초기화 시작'); + + // Supabase 초기화 - 임시로 false로 설정 (Storage 오류 우회) + const supabaseInitialized = false; // initializeSupabase(); + console.log('🔍 supabaseInitialized:', supabaseInitialized); + + if (supabaseInitialized) { + console.log('✅ Supabase 모드로 실행합니다.'); + await this.initializeAuth(); + } else { + console.log('⚠️ 오프라인 모드로 실행합니다.'); + // 오프라인 모드에서는 가상 사용자 설정 + this.currentUser = { id: 'offline-user', email: 'offline@local.com' }; + console.log('🔍 가상 사용자 설정:', this.currentUser); + + this.files = this.loadFiles(); + console.log('🔍 파일 로드 완료:', this.files.length, '개'); + + this.showOfflineMode(); + this.updateAuthUI(); + console.log('🔍 UI 업데이트 완료'); + } + + this.bindEvents(); + this.renderFiles(); + this.updateEmptyState(); + this.setupOnlineStatusListener(); + + // 인증 함수들을 빈 함수로 덮어씌움 (완전 차단) + this.overrideAuthFunctions(); + } + + bindEvents() { + // 기존 이벤트 + document.getElementById('fileForm').addEventListener('submit', (e) => this.handleSubmit(e)); + document.getElementById('cancelBtn').addEventListener('click', () => this.clearForm()); + document.getElementById('searchBtn').addEventListener('click', () => this.handleSearch()); + document.getElementById('searchInput').addEventListener('keyup', (e) => { + if (e.key === 'Enter') this.handleSearch(); + }); + document.getElementById('categoryFilter').addEventListener('change', () => this.handleSearch()); + document.getElementById('sortBy').addEventListener('change', () => this.renderFiles()); + document.getElementById('editForm').addEventListener('submit', (e) => this.handleEditSubmit(e)); + document.getElementById('closeModal').addEventListener('click', () => this.closeEditModal()); + document.getElementById('fileUpload').addEventListener('change', (e) => this.handleFileUpload(e)); + + // 페이지네이션 이벤트 + document.getElementById('prevPage').addEventListener('click', () => this.goToPrevPage()); + document.getElementById('nextPage').addEventListener('click', () => this.goToNextPage()); + + // 드래그 앤 드롭 이벤트 + this.setupDragAndDrop(); + + // 인증 이벤트 (오프라인 모드에서는 비활성화) + if (window.supabase) { + document.getElementById('loginBtn').addEventListener('click', () => this.openAuthModal('login')); + document.getElementById('signupBtn').addEventListener('click', () => this.openAuthModal('signup')); + document.getElementById('logoutBtn').addEventListener('click', () => this.handleLogout()); + document.getElementById('authForm').addEventListener('submit', (e) => this.handleAuthSubmit(e)); + document.getElementById('authCancelBtn').addEventListener('click', () => this.closeAuthModal()); + document.getElementById('authSwitchLink').addEventListener('click', (e) => { + e.preventDefault(); + this.toggleAuthMode(); + }); + } else { + // 오프라인 모드에서는 로그인 버튼 클릭 차단 + document.getElementById('loginBtn').addEventListener('click', (e) => { + e.preventDefault(); + alert('현재 오프라인 모드입니다. 로그인 기능을 사용할 수 없습니다.'); + }); + document.getElementById('signupBtn').addEventListener('click', (e) => { + e.preventDefault(); + alert('현재 오프라인 모드입니다. 회원가입 기능을 사용할 수 없습니다.'); + }); + } + + // 모달 이벤트 + window.addEventListener('click', (e) => { + if (e.target === document.getElementById('editModal')) { + this.closeEditModal(); + } + if (e.target === document.getElementById('authModal')) { + this.closeAuthModal(); + } + }); + } + + generateId() { + return Date.now().toString(36) + Math.random().toString(36).substr(2); + } + + // 파일 확장자 추출 (안전한 형태로) + getFileExtension(fileName) { + const lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex > 0 && lastDotIndex < fileName.length - 1) { + return fileName.substring(lastDotIndex).toLowerCase(); + } + return ''; // 확장자가 없는 경우 + } + + // 브라우저별 다운로드 폴더 경로 추정 + getDownloadFolderPath() { + const userAgent = navigator.userAgent.toLowerCase(); + const platform = navigator.platform.toLowerCase(); + + if (platform.includes('win')) { + return '다운로드 폴더 (C:\\Users\\사용자명\\Downloads)'; + } else if (platform.includes('mac')) { + return '다운로드 폴더 (~/Downloads)'; + } else if (platform.includes('linux')) { + return '다운로드 폴더 (~/Downloads)'; + } else { + return '브라우저 기본 다운로드 폴더'; + } + } + + // 인증 관련 메서드들 + async initializeAuth() { + try { + const user = await getCurrentUser(); + if (user) { + this.currentUser = user; + this.updateAuthUI(true); + await this.loadUserFiles(); + this.setupRealtimeSubscription(); + } else { + this.updateAuthUI(false); + // 게스트 모드: 공개 파일 로드 + await this.loadPublicFiles(); + this.showGuestMode(); + } + + setupAuthListener((event, session) => { + if (event === 'SIGNED_IN') { + this.currentUser = session.user; + this.updateAuthUI(true); + this.loadUserFiles(); + this.setupRealtimeSubscription(); + } else if (event === 'SIGNED_OUT') { + this.currentUser = null; + this.updateAuthUI(false); + this.loadPublicFiles(); + this.showGuestMode(); + this.cleanupRealtimeSubscription(); + } + }); + } catch (error) { + console.error('인증 초기화 오류:', error); + } + } + + updateAuthUI(isAuthenticated) { + const authButtons = document.getElementById('authButtons'); + const userInfo = document.getElementById('userInfo'); + const userEmail = document.getElementById('userEmail'); + const formSection = document.querySelector('.form-section'); + + if (isAuthenticated && this.currentUser) { + authButtons.style.display = 'none'; + userInfo.style.display = 'flex'; + userEmail.textContent = this.currentUser.email; + formSection.style.display = 'block'; + this.updateSyncStatus(); + this.hideGuestMode(); + } else { + authButtons.style.display = 'flex'; + userInfo.style.display = 'none'; + formSection.style.display = 'none'; + } + } + + updateSyncStatus(status = 'auto') { + const syncStatusElement = document.getElementById('syncStatus'); + if (!syncStatusElement) return; + + // 자동 상태 판단 + if (status === 'auto') { + if (!isSupabaseConfigured()) { + status = 'offline'; + } else if (this.currentUser) { + status = 'online'; + } else { + status = 'offline'; + } + } + + // 상태 업데이트 + syncStatusElement.className = `sync-status ${status}`; + switch (status) { + case 'online': + syncStatusElement.textContent = '🟢 온라인'; + break; + case 'offline': + syncStatusElement.textContent = '🟡 오프라인'; + break; + case 'syncing': + syncStatusElement.textContent = '🔄 동기화 중'; + break; + case 'error': + syncStatusElement.textContent = '🔴 오류'; + break; + } + } + + openAuthModal(mode) { + // 오프라인 모드에서는 모달 열지 않음 + if (!window.supabase) { + alert('현재 오프라인 모드입니다. 로그인 기능을 사용할 수 없습니다.'); + return; + } + + this.authMode = mode; + const modal = document.getElementById('authModal'); + const title = document.getElementById('authModalTitle'); + const submitBtn = document.getElementById('authSubmitBtn'); + const confirmPasswordGroup = document.getElementById('confirmPasswordGroup'); + const switchText = document.getElementById('authSwitchText'); + + if (mode === 'signup') { + title.textContent = '👤 회원가입'; + submitBtn.textContent = '👤 회원가입'; + confirmPasswordGroup.style.display = 'block'; + switchText.innerHTML = '이미 계정이 있으신가요? 로그인하기'; + } else { + title.textContent = '🔑 로그인'; + submitBtn.textContent = '🔑 로그인'; + confirmPasswordGroup.style.display = 'none'; + switchText.innerHTML = '계정이 없으신가요? 회원가입하기'; + } + + // 이벤트 리스너 재바인딩 + const newSwitchLink = document.getElementById('authSwitchLink'); + newSwitchLink.addEventListener('click', (e) => { + e.preventDefault(); + this.toggleAuthMode(); + }); + + modal.style.display = 'block'; + } + + closeAuthModal() { + document.getElementById('authModal').style.display = 'none'; + document.getElementById('authForm').reset(); + document.getElementById('authLoading').style.display = 'none'; + } + + toggleAuthMode() { + this.authMode = this.authMode === 'login' ? 'signup' : 'login'; + this.openAuthModal(this.authMode); + } + + async handleAuthSubmit(e) { + e.preventDefault(); + + // 강제 오프라인 모드 - 모든 인증 시도 차단 + if (this.isOfflineMode || !window.supabase) { + alert('현재 오프라인 모드입니다. 로그인 기능을 사용할 수 없습니다.'); + this.closeAuthModal(); + return; + } + + const email = document.getElementById('authEmail').value.trim(); + const password = document.getElementById('authPassword').value; + const confirmPassword = document.getElementById('authConfirmPassword').value; + + if (!email || !password) { + alert('이메일과 비밀번호를 입력해주세요.'); + return; + } + + if (this.authMode === 'signup' && password !== confirmPassword) { + alert('비밀번호가 일치하지 않습니다.'); + return; + } + + this.showAuthLoading(true); + + try { + if (this.authMode === 'signup') { + await signUp(email, password); + alert('회원가입이 완료되었습니다! 이메일을 확인해주세요.'); + } else { + await signIn(email, password); + } + this.closeAuthModal(); + } catch (error) { + console.error('인증 오류:', error); + alert(`${this.authMode === 'signup' ? '회원가입' : '로그인'} 중 오류가 발생했습니다: ${error.message}`); + } finally { + this.showAuthLoading(false); + } + } + + async handleLogout() { + // 오프라인 모드에서는 로그아웃 불가 + if (!window.supabase) { + alert('현재 오프라인 모드입니다. 로그아웃 기능을 사용할 수 없습니다.'); + return; + } + + try { + await signOut(); + this.showNotification('로그아웃되었습니다.', 'success'); + } catch (error) { + console.error('로그아웃 오류:', error); + alert('로그아웃 중 오류가 발생했습니다.'); + } + } + + showAuthLoading(show) { + const loading = document.getElementById('authLoading'); + const form = document.getElementById('authForm'); + + if (show) { + loading.style.display = 'block'; + form.style.display = 'none'; + } else { + loading.style.display = 'none'; + form.style.display = 'block'; + } + } + + // 인증 함수 완전 차단 + overrideAuthFunctions() { + // 전역 인증 함수들을 빈 함수로 덮어씌움 + if (typeof signIn === 'function') { + window.signIn = () => { + throw new Error('오프라인 모드에서는 로그인할 수 없습니다.'); + }; + } + if (typeof signUp === 'function') { + window.signUp = () => { + throw new Error('오프라인 모드에서는 회원가입할 수 없습니다.'); + }; + } + if (typeof signOut === 'function') { + window.signOut = () => { + throw new Error('오프라인 모드에서는 로그아웃할 수 없습니다.'); + }; + } + if (typeof getCurrentUser === 'function') { + window.getCurrentUser = () => Promise.resolve(null); + } + } + + // 인증 UI 업데이트 + updateAuthUI(isAuthenticated = true) { + const authButtons = document.getElementById('authButtons'); + const userInfo = document.getElementById('userInfo'); + const userEmail = document.getElementById('userEmail'); + const formSection = document.querySelector('.form-section'); + + // 오프라인 모드에서는 항상 로그인된 것처럼 처리 + if ((isAuthenticated && this.currentUser) || !window.supabase) { + if (authButtons) authButtons.style.display = 'none'; + if (userInfo) userInfo.style.display = 'flex'; + if (userEmail) userEmail.textContent = this.currentUser?.email || '오프라인 사용자'; + if (formSection) formSection.style.display = 'block'; + if (window.supabase && this.updateSyncStatus) { + this.updateSyncStatus('online'); + } + this.hideGuestMode(); + } else { + if (authButtons) authButtons.style.display = 'flex'; + if (userInfo) userInfo.style.display = 'none'; + if (formSection) formSection.style.display = 'none'; + this.showGuestMode(); + } + } + + // 오프라인 모드 관련 + showOfflineMode() { + const container = document.querySelector('.container'); + const offlineNotice = document.createElement('div'); + offlineNotice.className = 'offline-mode'; + offlineNotice.innerHTML = '⚠️ 오프라인 모드: 로컬 저장소를 사용합니다. 로그인 없이 모든 기능을 사용할 수 있습니다.'; + container.insertBefore(offlineNotice, container.firstChild.nextSibling); + } + + // 게스트 모드 관련 + showGuestMode() { + this.hideGuestMode(); // 기존 알림 제거 + const container = document.querySelector('.container'); + const guestNotice = document.createElement('div'); + guestNotice.className = 'guest-mode'; + guestNotice.id = 'guestModeNotice'; + guestNotice.innerHTML = ` +
+ 👤 오프라인 모드 - 로컬 저장소를 사용합니다 + ⚠️ 인터넷 연결 시 Supabase 기능을 사용할 수 있습니다 +
+ `; + container.insertBefore(guestNotice, container.firstChild.nextSibling); + } + + hideGuestMode() { + const guestNotice = document.getElementById('guestModeNotice'); + if (guestNotice) { + guestNotice.remove(); + } + } + + // 공개 파일 로드 (게스트용) + async loadPublicFiles() { + if (isSupabaseConfigured()) { + try { + // Supabase에서 모든 파일 로드 (RLS로 공개 파일만 접근 가능) + const data = await SupabaseHelper.getFiles('public'); + this.files = data.map(file => ({ + ...file, + files: file.file_attachments || [], + isReadOnly: true + })); + } catch (error) { + console.error('공개 파일 로딩 오류:', error); + // localStorage 폴백 + this.files = this.loadFiles().map(file => ({ ...file, isReadOnly: true })); + } + } else { + // 오프라인 모드: localStorage의 파일을 읽기 전용으로 로드 + this.files = this.loadFiles().map(file => ({ ...file, isReadOnly: true })); + } + this.renderFiles(); + this.updateEmptyState(); + } + + setupOnlineStatusListener() { + window.addEventListener('online', () => { + this.isOnline = true; + this.showNotification('온라인 상태가 되었습니다.', 'success'); + }); + + window.addEventListener('offline', () => { + this.isOnline = false; + this.showNotification('오프라인 상태입니다.', 'info'); + }); + } + + // Supabase 데이터베이스 연동 메서드들 + async loadUserFiles() { + if (!this.currentUser || !isSupabaseConfigured()) { + this.files = this.loadFiles(); // localStorage 폴백 + this.updateSyncStatus('offline'); + return; + } + + try { + this.updateSyncStatus('syncing'); + const data = await SupabaseHelper.getFiles(this.currentUser.id); + this.files = data.map(file => ({ + ...file, + files: file.file_attachments || [] // 첨부파일 정보 매핑 + })); + this.renderFiles(); + this.updateEmptyState(); + this.updateSyncStatus('online'); + } catch (error) { + console.error('파일 로딩 오류:', error); + this.showNotification('파일을 불러오는 중 오류가 발생했습니다.', 'error'); + this.updateSyncStatus('error'); + // 오류 시 localStorage 폴백 + this.files = this.loadFiles(); + } + } + + async addFileToSupabase(fileData) { + if (!this.currentUser || !isSupabaseConfigured()) { + return this.addFileLocally(fileData); + } + + try { + this.updateSyncStatus('syncing'); + console.log('파일 데이터 추가 중...', fileData); + + const result = await SupabaseHelper.addFile(fileData, this.currentUser.id); + console.log('파일 데이터 추가 성공:', result); + + // 첨부파일이 있는 경우 파일 업로드 처리 + if (fileData.files && fileData.files.length > 0) { + console.log(`${fileData.files.length}개의 첨부파일 업로드 시작...`); + await this.uploadAttachments(result.id, fileData.files); + console.log('모든 첨부파일 업로드 완료'); + } + + this.showNotification('새 자료가 성공적으로 추가되었습니다!', 'success'); + await this.loadUserFiles(); // 목록 새로고침 + this.updateSyncStatus('online'); + this.clearForm(); // 폼 초기화 + + } catch (error) { + console.error('파일 추가 오류:', error); + + // 더 구체적인 에러 메시지 제공 + let errorMessage = '파일 추가 중 오류가 발생했습니다.'; + if (error.message) { + errorMessage += ` (${error.message})`; + } + + this.showNotification(errorMessage, 'error'); + this.updateSyncStatus('error'); + + // 콘솔에 상세 오류 정보 출력 + if (error.details) { + console.error('오류 상세:', error.details); + } + if (error.hint) { + console.error('오류 힌트:', error.hint); + } + } + } + + async updateFileInSupabase(id, updates) { + if (!this.currentUser || !isSupabaseConfigured()) { + return this.updateFileLocally(id, updates); + } + + try { + await SupabaseHelper.updateFile(id, updates, this.currentUser.id); + this.showNotification('자료가 성공적으로 수정되었습니다!', 'success'); + await this.loadUserFiles(); // 목록 새로고침 + } catch (error) { + console.error('파일 수정 오류:', error); + this.showNotification('파일 수정 중 오류가 발생했습니다.', 'error'); + } + } + + async deleteFileFromSupabase(id) { + if (!this.currentUser || !isSupabaseConfigured()) { + return this.deleteFileLocally(id); + } + + try { + // 첨부파일들을 Storage에서 삭제 + await this.deleteAttachmentsFromStorage(id); + + // 데이터베이스에서 파일 삭제 (CASCADE로 첨부파일 정보도 함께 삭제) + await SupabaseHelper.deleteFile(id, this.currentUser.id); + this.showNotification('자료가 성공적으로 삭제되었습니다!', 'success'); + await this.loadUserFiles(); // 목록 새로고침 + } catch (error) { + console.error('파일 삭제 오류:', error); + this.showNotification('파일 삭제 중 오류가 발생했습니다.', 'error'); + } + } + + // localStorage 폴백 메서드들 + addFileLocally(fileData) { + // 로컬 저장용 데이터 생성 (ID와 타임스탬프 추가) + const localFileData = { + id: this.generateId(), + ...fileData, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }; + + // 첨부파일이 있는 경우, localStorage에 바로 저장 가능한 형태로 처리 + if (fileData.files && fileData.files.length > 0) { + // 첨부파일 데이터가 이미 base64 형태로 준비되어 있으므로 그대로 사용 + localFileData.files = fileData.files.map(file => ({ + name: file.name, + original_name: file.name, + size: file.size, + type: file.type, + data: file.data // base64 데이터 + })); + } else { + localFileData.files = []; + } + + this.files.push(localFileData); + this.saveFiles(); + this.renderFiles(); + this.updateEmptyState(); + this.showNotification('새 자료가 성공적으로 추가되었습니다! (로컬 저장)', 'success'); + this.clearForm(); // 폼 초기화 + } + + updateFileLocally(id, updates) { + const fileIndex = this.files.findIndex(f => f.id === id); + if (fileIndex !== -1) { + this.files[fileIndex] = { + ...this.files[fileIndex], + ...updates, + updated_at: new Date().toISOString() + }; + this.saveFiles(); + this.renderFiles(); + this.showNotification('자료가 성공적으로 수정되었습니다! (로컬 저장)', 'success'); + } + } + + deleteFileLocally(id) { + this.files = this.files.filter(f => f.id !== id); + this.saveFiles(); + this.renderFiles(); + this.updateEmptyState(); + this.showNotification('자료가 성공적으로 삭제되었습니다! (로컬 저장)', 'success'); + } + + // 실시간 구독 관련 + setupRealtimeSubscription() { + if (!this.currentUser || !isSupabaseConfigured()) return; + + this.realtimeSubscription = SupabaseHelper.subscribeToFiles( + this.currentUser.id, + (payload) => { + console.log('실시간 업데이트:', payload); + this.loadUserFiles(); // 변경사항이 있으면 목록 새로고침 + } + ); + } + + cleanupRealtimeSubscription() { + if (this.realtimeSubscription) { + this.realtimeSubscription.unsubscribe(); + this.realtimeSubscription = null; + } + } + + // 파일 업로드 관련 메서드들 + async uploadAttachments(fileId, attachments) { + if (!isSupabaseConfigured() || !this.currentUser) { + console.log('오프라인 모드: 첨부파일을 base64로 저장합니다.'); + return; // 오프라인 모드에서는 base64로 저장된 상태 유지 + } + + const uploadedFiles = []; + + try { + for (let i = 0; i < attachments.length; i++) { + const attachment = attachments[i]; + + try { + console.log(`파일 업로드 중... (${i + 1}/${attachments.length}): ${attachment.name}`); + + // base64 데이터를 Blob으로 변환 + const response = await fetch(attachment.data); + const blob = await response.blob(); + + // 안전한 파일명 생성 (고유한 이름으로 저장, 원본명은 DB에 저장) + const fileExtension = this.getFileExtension(attachment.name); + const safeFileName = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}${fileExtension}`; + const filePath = `${this.currentUser.id}/${fileId}/${safeFileName}`; + + // Storage 버킷 확인 + const bucketExists = await SupabaseHelper.checkOrCreateBucket(); + if (!bucketExists) { + throw new Error('Storage 버킷 접근 권한이 없습니다. Storage 정책을 확인해주세요.'); + } + + // Supabase Storage에 업로드 + const uploadResult = await SupabaseHelper.uploadFile(blob, filePath); + console.log('Storage 업로드 성공:', uploadResult); + + // 데이터베이스에 첨부파일 정보 저장 + const attachmentResult = await this.addFileAttachment(fileId, { + original_name: attachment.name, + storage_path: filePath, + file_size: attachment.size || blob.size, + mime_type: attachment.type || blob.type + }); + + uploadedFiles.push(attachmentResult); + console.log('첨부파일 정보 저장 성공:', attachmentResult); + + } catch (fileError) { + console.error(`파일 "${attachment.name}" 업로드 실패:`, fileError); + throw new Error(`파일 "${attachment.name}" 업로드에 실패했습니다: ${fileError.message}`); + } + } + + console.log('모든 첨부파일 업로드 완료:', uploadedFiles); + return uploadedFiles; + + } catch (error) { + console.error('파일 업로드 오류:', error); + + // 부분적으로 업로드된 파일들 정리 (선택사항) + try { + for (const uploadedFile of uploadedFiles) { + if (uploadedFile.storage_path) { + await SupabaseHelper.deleteStorageFile(uploadedFile.storage_path); + } + } + } catch (cleanupError) { + console.error('업로드 실패 파일 정리 중 오류:', cleanupError); + } + + throw error; + } + } + + async addFileAttachment(fileId, attachmentData) { + if (!isSupabaseConfigured()) { + return; // 오프라인 모드에서는 처리하지 않음 + } + + try { + // SupabaseHelper를 통해 첨부파일 정보 저장 + const result = await SupabaseHelper.addFileAttachment(fileId, attachmentData); + return result; + } catch (error) { + console.error('첨부파일 정보 저장 오류:', error); + throw error; + } + } + + async downloadFileFromStorage(filePath, originalName) { + if (!isSupabaseConfigured()) { + return; // 오프라인 모드에서는 처리하지 않음 + } + + try { + console.log('파일 다운로드 시도:', filePath, originalName); + + // Storage 버킷 확인 + const bucketExists = await SupabaseHelper.checkOrCreateBucket(); + if (!bucketExists) { + throw new Error('Storage 버킷 접근 권한이 없습니다. Storage 정책을 확인해주세요.'); + } + + const url = await SupabaseHelper.getFileUrl(filePath); + console.log('다운로드 URL 생성:', url); + + // 다운로드 링크 생성 + const link = document.createElement('a'); + link.href = url; + link.download = originalName; + + // Ctrl/Cmd 키를 누른 상태에서 클릭하면 "다른 이름으로 저장" 대화상자 표시 + if (window.event && (window.event.ctrlKey || window.event.metaKey)) { + link.target = '_blank'; + // 브라우저의 다운로드 관리자로 보내기 + } + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + console.log('파일 다운로드 완료:', originalName); + } catch (error) { + console.error('파일 다운로드 오류:', error); + + // 더 구체적인 오류 메시지 제공 + let errorMessage = '파일 다운로드 중 오류가 발생했습니다.'; + if (error.message.includes('Bucket not found')) { + errorMessage = 'Storage 버킷이 설정되지 않았습니다. 관리자에게 문의하세요.'; + } else if (error.message.includes('파일을 찾을 수 없습니다')) { + errorMessage = '파일을 찾을 수 없습니다. 파일이 삭제되었을 수 있습니다.'; + } + + this.showNotification(errorMessage, 'error'); + } + } + + async deleteAttachmentsFromStorage(fileId) { + if (!isSupabaseConfigured() || !this.currentUser) { + return; // 오프라인 모드에서는 처리하지 않음 + } + + try { + // 파일의 모든 첨부파일 경로 가져오기 + const { data: attachments, error } = await supabase + .from('file_attachments') + .select('storage_path') + .eq('file_id', fileId); + + if (error) throw error; + + // 각 파일을 Storage에서 삭제 + for (const attachment of attachments) { + await SupabaseHelper.deleteStorageFile(attachment.storage_path); + } + } catch (error) { + console.error('첨부파일 삭제 오류:', error); + } + } + + handleSubmit(e) { + e.preventDefault(); + + if (!this.currentUser) { + this.showNotification('로그인이 필요합니다.', 'error'); + return; + } + + const title = document.getElementById('fileTitle').value.trim(); + const description = document.getElementById('fileDescription').value.trim(); + const category = document.getElementById('fileCategory').value; + const tags = document.getElementById('fileTags').value.split(',').map(tag => tag.trim()).filter(tag => tag); + const fileInput = document.getElementById('fileUpload'); + + if (!title || !category) { + alert('제목과 카테고리는 필수 입력 항목입니다.'); + return; + } + + const fileData = { + title, + description, + category, + tags, + files: [] // 첨부파일 임시 저장용 (Supabase 전송시 제외됨) + }; + + // this.selectedFiles 사용 (드래그앤드롭으로 선택한 파일들 포함) + if (this.selectedFiles && this.selectedFiles.length > 0) { + let processedFiles = 0; + Array.from(this.selectedFiles).forEach(file => { + const reader = new FileReader(); + reader.onload = (e) => { + fileData.files.push({ + name: file.name, + size: file.size, + type: file.type, + data: e.target.result + }); + + processedFiles++; + if (processedFiles === this.selectedFiles.length) { + this.addFileToSupabase(fileData); + } + }; + reader.readAsDataURL(file); + }); + } else { + this.addFileToSupabase(fileData); + } + } + + async addFile(fileData) { + // 호환성을 위해 유지, 실제로는 addFileToSupabase 사용 + await this.addFileToSupabase(fileData); + this.clearForm(); + } + + // 드래그 앤 드롭 설정 + setupDragAndDrop() { + const uploadArea = document.getElementById('fileUploadArea'); + const fileInput = document.getElementById('fileUpload'); + + // 드래그 이벤트 + uploadArea.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadArea.classList.add('drag-over'); + }); + + uploadArea.addEventListener('dragleave', (e) => { + e.preventDefault(); + uploadArea.classList.remove('drag-over'); + }); + + uploadArea.addEventListener('drop', (e) => { + e.preventDefault(); + uploadArea.classList.remove('drag-over'); + + const files = Array.from(e.dataTransfer.files); + this.handleMultipleFiles(files); + }); + + // 클릭 이벤트 + uploadArea.addEventListener('click', () => { + fileInput.click(); + }); + } + + handleFileUpload(e) { + const files = Array.from(e.target.files); + this.handleMultipleFiles(files); + } + + handleMultipleFiles(files) { + this.selectedFiles = files; + this.updateFilePreview(); + } + + updateFilePreview() { + const container = document.getElementById('selectedFiles'); + + if (!this.selectedFiles || this.selectedFiles.length === 0) { + container.innerHTML = ''; + return; + } + + const totalSize = this.selectedFiles.reduce((sum, file) => sum + file.size, 0); + + container.innerHTML = ` +
+ 📎 선택된 파일: ${this.selectedFiles.length}개 (총 ${this.formatFileSize(totalSize)}) +
+ ${this.selectedFiles.map((file, index) => ` +
+
+
${this.getFileIcon(file.name)}
+
+
${file.name}
+
${this.formatFileSize(file.size)}
+
+
+ +
+ `).join('')} + `; + } + + getFileIcon(fileName) { + const ext = fileName.split('.').pop().toLowerCase(); + const iconMap = { + 'pdf': '📄', + 'doc': '📝', 'docx': '📝', + 'xls': '📊', 'xlsx': '📊', + 'ppt': '📽️', 'pptx': '📽️', + 'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️', + 'mp4': '🎥', 'avi': '🎥', 'mov': '🎥', + 'mp3': '🎵', 'wav': '🎵', + 'zip': '📦', 'rar': '📦', + 'txt': '📄' + }; + return iconMap[ext] || '📄'; + } + + removeFile(index) { + if (this.selectedFiles) { + this.selectedFiles.splice(index, 1); + this.updateFilePreview(); + + // 파일 input 업데이트 + const dt = new DataTransfer(); + this.selectedFiles.forEach(file => dt.items.add(file)); + document.getElementById('fileUpload').files = dt.files; + } + } + + createFilesList() { + const fileGroup = document.querySelector('#fileUpload').closest('.form-group'); + const filesList = document.createElement('div'); + filesList.className = 'files-list'; + fileGroup.appendChild(filesList); + return filesList; + } + + removeFileFromInput(index) { + const fileInput = document.getElementById('fileUpload'); + const dt = new DataTransfer(); + const files = Array.from(fileInput.files); + + files.forEach((file, i) => { + if (i !== index) { + dt.items.add(file); + } + }); + + fileInput.files = dt.files; + this.handleFileUpload({ target: fileInput }); + } + + renderFiles() { + const tbody = document.getElementById('fileList'); + const sortBy = document.getElementById('sortBy').value; + + let sortedFiles = [...this.files]; + + switch (sortBy) { + case 'title': + sortedFiles.sort((a, b) => a.title.localeCompare(b.title)); + break; + case 'category': + sortedFiles.sort((a, b) => a.category.localeCompare(b.category)); + break; + case 'date': + default: + sortedFiles.sort((a, b) => new Date(b.created_at || b.createdAt) - new Date(a.created_at || a.createdAt)); + break; + } + + this.allFiles = sortedFiles; // 전체 파일 목록 저장 + this.updatePagination(); + + if (sortedFiles.length === 0) { + tbody.innerHTML = ` + + 📂 등록된 자료가 없습니다. 새 자료를 추가해보세요! + + `; + return; + } + + // 페이지네이션 적용 + const itemsPerPage = 10; + const currentPage = this.currentPage || 1; + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const paginatedFiles = sortedFiles.slice(startIndex, endIndex); + + tbody.innerHTML = paginatedFiles.map((file, index) => + this.createFileRowHTML(file, startIndex + index + 1) + ).join(''); + } + + createFileRowHTML(file, rowNumber) { + const createdDate = new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR'); + const hasAttachments = file.files && file.files.length > 0; + + return ` + + ${rowNumber} + + ${file.category} + + +
+ ${this.escapeHtml(file.title)} + ${file.description ? `
${this.escapeHtml(file.description.substring(0, 80))}${file.description.length > 80 ? '...' : ''}` : ''} + ${file.tags && file.tags.length > 0 ? + `
${file.tags.map(tag => `#${this.escapeHtml(tag)}`).join('')}
` : '' + } +
+ + + ${hasAttachments ? + `
${file.files.map((f, index) => + `${this.getFileIcon(f.name || f.original_name || 'unknown')}` + ).join(' ')}
` : + `-` + } + + ${createdDate} + +
+ + + ${hasAttachments ? + `` : '' + } +
+ + + `; + } + + createFileHTML(file) { + const createdDate = new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR'); + const updatedDate = new Date(file.updated_at || file.updatedAt).toLocaleDateString('ko-KR'); + const tagsHTML = file.tags.map(tag => `${tag}`).join(''); + const filesHTML = file.files.length > 0 ? + `
+ 첨부파일 (${file.files.length}개): + ${file.files.map(f => `${this.getFileIcon(f.name || f.original_name || 'unknown')} ${f.name || f.original_name || '파일'}`).join(', ')} +
` : ''; + + return ` +
+
+
+
${this.escapeHtml(file.title)}
+
+ ${file.category} + 📅 생성: ${createdDate} + ${createdDate !== updatedDate ? `✏️ 수정: ${updatedDate}` : ''} +
+
+
+ + ${file.description ? `
${this.escapeHtml(file.description)}
` : ''} + + ${file.tags.length > 0 ? `
${tagsHTML}
` : ''} + + ${filesHTML} + +
+ ${!file.isReadOnly && this.currentUser ? ` + + + ` : ''} + ${file.files.length > 0 ? `` : ''} + ${file.isReadOnly ? `👁️ 읽기 전용` : ''} +
+
+ `; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // 페이지네이션 관련 함수들 + updatePagination() { + const pagination = document.getElementById('pagination'); + const prevBtn = document.getElementById('prevPage'); + const nextBtn = document.getElementById('nextPage'); + const pageInfo = document.getElementById('pageInfo'); + + const totalFiles = this.allFiles.length; + const itemsPerPage = 10; + const totalPages = Math.ceil(totalFiles / itemsPerPage); + + if (totalPages <= 1) { + pagination.style.display = 'none'; + } else { + pagination.style.display = 'flex'; + prevBtn.disabled = this.currentPage <= 1; + nextBtn.disabled = this.currentPage >= totalPages; + pageInfo.textContent = `${this.currentPage} / ${totalPages}`; + } + } + + goToPrevPage() { + if (this.currentPage > 1) { + this.currentPage--; + this.renderFiles(); + } + } + + goToNextPage() { + const totalFiles = this.allFiles.length; + const itemsPerPage = 10; + const totalPages = Math.ceil(totalFiles / itemsPerPage); + + if (this.currentPage < totalPages) { + this.currentPage++; + this.renderFiles(); + } + } + + // 파일 상세보기 (제목 클릭 시) + viewFile(id) { + const file = this.files.find(f => f.id === id); + if (!file) return; + + // 간단한 알림으로 파일 정보 표시 + let info = `📄 ${file.title}\n\n`; + info += `📁 카테고리: ${file.category}\n`; + info += `📅 등록일: ${new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR')}\n`; + if (file.description) info += `📝 설명: ${file.description}\n`; + if (file.tags && file.tags.length > 0) info += `🏷️ 태그: ${file.tags.join(', ')}\n`; + if (file.files && file.files.length > 0) { + info += `\n📎 첨부파일 (${file.files.length}개):\n`; + file.files.forEach((attachment, index) => { + const icon = this.getFileIcon(attachment.name || attachment.original_name || 'unknown'); + info += ` ${index + 1}. ${icon} ${attachment.name || attachment.original_name || '파일'}\n`; + }); + } + + alert(info); + } + + editFile(id) { + if (!this.currentUser) { + this.showNotification('로그인이 필요합니다.', 'error'); + return; + } + + const file = this.files.find(f => f.id === id); + if (!file) return; + + if (file.isReadOnly) { + this.showNotification('읽기 전용 파일은 편집할 수 없습니다.', 'error'); + return; + } + + this.currentEditId = id; + + document.getElementById('editTitle').value = file.title; + document.getElementById('editDescription').value = file.description; + document.getElementById('editCategory').value = file.category; + document.getElementById('editTags').value = file.tags.join(', '); + + document.getElementById('editModal').style.display = 'block'; + } + + handleEditSubmit(e) { + e.preventDefault(); + + if (!this.currentEditId) return; + + const title = document.getElementById('editTitle').value.trim(); + const description = document.getElementById('editDescription').value.trim(); + const category = document.getElementById('editCategory').value; + const tags = document.getElementById('editTags').value.split(',').map(tag => tag.trim()).filter(tag => tag); + + if (!title || !category) { + alert('제목과 카테고리는 필수 입력 항목입니다.'); + return; + } + + const updates = { + title, + description, + category, + tags + }; + + this.updateFileInSupabase(this.currentEditId, updates); + this.closeEditModal(); + } + + closeEditModal() { + document.getElementById('editModal').style.display = 'none'; + this.currentEditId = null; + } + + deleteFile(id) { + if (!this.currentUser) { + this.showNotification('로그인이 필요합니다.', 'error'); + return; + } + + const file = this.files.find(f => f.id === id); + if (!file) return; + + if (file.isReadOnly) { + this.showNotification('읽기 전용 파일은 삭제할 수 없습니다.', 'error'); + return; + } + + if (confirm(`"${file.title}" 자료를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) { + this.deleteFileFromSupabase(id); + } + } + + async downloadFiles(id) { + const file = this.files.find(f => f.id === id); + if (!file || file.files.length === 0) return; + + try { + if (file.files.length === 1) { + // 단일 파일: 직접 다운로드 + await this.downloadSingleFileData(file.files[0]); + const fileName = file.files[0].original_name || file.files[0].name; + this.showNotification(`파일 다운로드 완료: ${fileName}`, 'success'); + } else { + // 다중 파일: ZIP으로 압축하여 다운로드 + await this.downloadFilesAsZip(file); + } + } catch (error) { + console.error('파일 다운로드 오류:', error); + this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error'); + } + } + + async downloadSingleFile(fileId, fileIndex) { + const file = this.files.find(f => f.id === fileId); + if (!file || !file.files[fileIndex]) return; + + try { + const fileData = file.files[fileIndex]; + await this.downloadSingleFileData(fileData); + const fileName = fileData.original_name || fileData.name; + this.showNotification(`파일 다운로드 완료: ${fileName}`, 'success'); + } catch (error) { + console.error('개별 파일 다운로드 오류:', error); + this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error'); + } + } + + async downloadSingleFileData(fileData) { + if (fileData.storage_path && isSupabaseConfigured()) { + // Supabase Storage에서 다운로드 + await this.downloadFileFromStorage(fileData.storage_path, fileData.original_name || fileData.name); + } else if (fileData.data) { + // localStorage 데이터 다운로드 (base64) + const link = document.createElement('a'); + link.href = fileData.data; + link.download = fileData.name || fileData.original_name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + } + + async downloadFilesAsZip(file) { + const zip = new JSZip(); + const fileTitle = file.title.replace(/[<>:"/\\|?*]/g, '_'); // 파일명에 부적절한 문자 제거 + + for (const fileData of file.files) { + try { + let fileContent; + const fileName = fileData.original_name || fileData.name || 'file'; + + if (fileData.storage_path && isSupabaseConfigured()) { + // Supabase Storage에서 파일 가져오기 + const response = await fetch(await SupabaseHelper.getFileUrl(fileData.storage_path)); + fileContent = await response.blob(); + } else if (fileData.data) { + // localStorage의 base64 데이터 변환 + const response = await fetch(fileData.data); + fileContent = await response.blob(); + } + + if (fileContent) { + zip.file(fileName, fileContent); + } + } catch (error) { + console.error(`파일 ${fileData.name} 처리 오류:`, error); + } + } + + // ZIP 파일 생성 및 다운로드 + const zipBlob = await zip.generateAsync({ type: "blob" }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(zipBlob); + link.download = `${fileTitle}_첨부파일.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + + this.showNotification(`ZIP 파일 다운로드 완료: ${fileTitle}_첨부파일.zip (${file.files.length}개 파일)`, 'success'); + } + + handleSearch() { + const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim(); + const categoryFilter = document.getElementById('categoryFilter').value; + + let filteredFiles = this.files; + + if (searchTerm) { + filteredFiles = filteredFiles.filter(file => + file.title.toLowerCase().includes(searchTerm) || + file.description.toLowerCase().includes(searchTerm) || + file.tags.some(tag => tag.toLowerCase().includes(searchTerm)) + ); + } + + if (categoryFilter) { + filteredFiles = filteredFiles.filter(file => file.category === categoryFilter); + } + + this.renderFilteredFiles(filteredFiles); + } + + renderFilteredFiles(files) { + const container = document.getElementById('fileList'); + const sortBy = document.getElementById('sortBy').value; + + let sortedFiles = [...files]; + + switch (sortBy) { + case 'title': + sortedFiles.sort((a, b) => a.title.localeCompare(b.title)); + break; + case 'category': + sortedFiles.sort((a, b) => a.category.localeCompare(b.category)); + break; + case 'date': + default: + sortedFiles.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + break; + } + + if (sortedFiles.length === 0) { + container.innerHTML = '

🔍 검색 결과가 없습니다. 다른 키워드로 검색해보세요!

'; + return; + } + + container.innerHTML = sortedFiles.map(file => this.createFileHTML(file)).join(''); + } + + clearForm() { + document.getElementById('fileForm').reset(); + const filesList = document.querySelector('.files-list'); + if (filesList) { + filesList.innerHTML = ''; + } + // 선택된 파일들과 미리보기 초기화 + this.selectedFiles = []; + const selectedFilesContainer = document.getElementById('selectedFiles'); + if (selectedFilesContainer) { + selectedFilesContainer.innerHTML = ''; + } + } + + formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + updateEmptyState() { + const container = document.getElementById('fileList'); + if (this.files.length === 0) { + container.innerHTML = '

📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!

'; + } + } + + showNotification(message, type = 'info') { + // 기존 알림이 있으면 제거 + const existingNotification = document.querySelector('.notification'); + if (existingNotification) { + existingNotification.remove(); + } + + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + notification.textContent = message; + + document.body.appendChild(notification); + + // 3초 후 자동 제거 + setTimeout(() => { + if (notification.parentNode) { + notification.remove(); + } + }, 3000); + } + + loadFiles() { + try { + const stored = localStorage.getItem('fileManagerData'); + const files = stored ? JSON.parse(stored) : []; + + // 기존 localStorage 데이터의 호환성을 위해 컬럼명 변환 + return files.map(file => ({ + ...file, + created_at: file.created_at || file.createdAt || new Date().toISOString(), + updated_at: file.updated_at || file.updatedAt || new Date().toISOString() + })); + } catch (error) { + console.error('파일 데이터를 불러오는 중 오류가 발생했습니다:', error); + return []; + } + } + + saveFiles() { + try { + localStorage.setItem('fileManagerData', JSON.stringify(this.files)); + } catch (error) { + console.error('파일 데이터를 저장하는 중 오류가 발생했습니다:', error); + alert('데이터 저장 중 오류가 발생했습니다. 브라우저의 저장공간을 확인해주세요.'); + } + } + + exportData() { + const dataStr = JSON.stringify(this.files, null, 2); + const dataBlob = new Blob([dataStr], {type: 'application/json'}); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement('a'); + link.href = url; + link.download = `자료실_백업_${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + this.showNotification('데이터가 성공적으로 내보내기되었습니다!', 'success'); + } + + importData(event) { + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const importedData = JSON.parse(e.target.result); + if (Array.isArray(importedData)) { + if (confirm('기존 데이터를 모두 삭제하고 새 데이터를 가져오시겠습니까?')) { + this.files = importedData; + this.saveFiles(); + this.renderFiles(); + this.updateEmptyState(); + this.showNotification('데이터가 성공적으로 가져와졌습니다!', 'success'); + } + } else { + alert('올바르지 않은 파일 형식입니다.'); + } + } catch (error) { + alert('파일을 읽는 중 오류가 발생했습니다.'); + console.error(error); + } + }; + reader.readAsText(file); + } +} + +const style = document.createElement('style'); +style.textContent = ` + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + @keyframes slideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } + } +`; +document.head.appendChild(style); + +const fileManager = new FileManager(); + +document.addEventListener('DOMContentLoaded', () => { + console.log('📚 자료실 관리 시스템이 초기화되었습니다.'); +}); \ No newline at end of file diff --git a/admin/styles.css b/admin/styles.css new file mode 100644 index 0000000..1e87c07 --- /dev/null +++ b/admin/styles.css @@ -0,0 +1,1029 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: #333; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +header { + background: rgba(255, 255, 255, 0.95); + padding: 30px; + border-radius: 15px; + text-align: center; + margin-bottom: 30px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(10px); +} + +header h1 { + color: #4a5568; + font-size: 2.5rem; + margin-bottom: 10px; +} + +header p { + color: #666; + font-size: 1.1rem; +} + +.auth-section { + margin-top: 20px; + display: flex; + justify-content: center; + align-items: center; +} + +.auth-buttons { + display: flex; + gap: 10px; +} + +.auth-btn { + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; + font-weight: 500; + background: #667eea; + color: white; +} + +.auth-btn:hover { + background: #5a67d8; + transform: translateY(-1px); +} + +.user-info { + display: flex; + align-items: center; + gap: 15px; + background: rgba(72, 187, 120, 0.1); + padding: 10px 15px; + border-radius: 8px; + border: 1px solid rgba(72, 187, 120, 0.3); +} + +.user-info span { + color: #2f855a; + font-weight: 500; +} + +.auth-switch { + text-align: center; + margin-top: 15px; + color: #666; +} + +.auth-switch a { + color: #667eea; + text-decoration: none; + font-weight: 500; +} + +.auth-switch a:hover { + text-decoration: underline; +} + +.loading { + text-align: center; + padding: 20px; + color: #666; +} + +.loading p { + margin: 0; + font-style: italic; +} + +.offline-mode { + background: rgba(255, 193, 7, 0.1); + border: 1px solid rgba(255, 193, 7, 0.3); + padding: 10px 15px; + border-radius: 8px; + margin-bottom: 20px; + text-align: center; + color: #856404; +} + +/* 게스트 모드 스타일 */ +.guest-mode { + background: linear-gradient(135deg, #a8e6cf, #88d8a3); + color: #2d3436; + padding: 15px 20px; + border-radius: 8px; + margin: 20px 0; + border: 2px solid #00b894; + box-shadow: 0 2px 10px rgba(0, 184, 148, 0.2); +} + +.guest-mode-content { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.guest-login-btn { + background: #00b894; + color: white; + border: none; + padding: 8px 16px; + border-radius: 5px; + cursor: pointer; + font-weight: 500; + transition: all 0.3s ease; +} + +.guest-login-btn:hover { + background: #00a085; + transform: translateY(-1px); +} + +.read-only-badge { + background: #74b9ff; + color: white; + padding: 6px 12px; + border-radius: 15px; + font-size: 0.8em; + font-weight: 500; + white-space: nowrap; + display: inline-flex; + align-items: center; + margin-left: auto; +} + +.sync-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; +} + +.sync-status.online { + background: rgba(72, 187, 120, 0.1); + color: #2f855a; +} + +.sync-status.offline { + background: rgba(255, 193, 7, 0.1); + color: #856404; +} + +.sync-status.syncing { + background: rgba(102, 126, 234, 0.1); + color: #4c51bf; +} + +.search-section { + background: rgba(255, 255, 255, 0.95); + padding: 20px; + border-radius: 15px; + margin-bottom: 30px; + display: flex; + gap: 15px; + align-items: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(10px); + flex-wrap: wrap; +} + +.search-section input, +.search-section select { + padding: 12px 15px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + transition: all 0.3s ease; + flex: 1; + min-width: 200px; +} + +.search-section input:focus, +.search-section select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.search-section button { + padding: 12px 20px; + background: #667eea; + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1rem; + transition: all 0.3s ease; + white-space: nowrap; +} + +.search-section button:hover { + background: #5a67d8; + transform: translateY(-2px); +} + +.form-section { + background: rgba(255, 255, 255, 0.95); + padding: 30px; + border-radius: 15px; + margin-bottom: 30px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(10px); +} + +.form-section h2 { + color: #4a5568; + margin-bottom: 25px; + font-size: 1.8rem; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #4a5568; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 12px 15px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + transition: all 0.3s ease; + font-family: inherit; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.form-group small { + color: #666; + font-size: 0.9rem; + margin-top: 5px; + display: block; +} + +.form-buttons { + display: flex; + gap: 15px; + margin-top: 25px; +} + +.form-buttons button { + padding: 12px 25px; + border: none; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; + font-weight: 600; +} + +#submitBtn { + background: #48bb78; + color: white; +} + +#submitBtn:hover { + background: #38a169; + transform: translateY(-2px); +} + +#cancelBtn { + background: #e2e8f0; + color: #4a5568; +} + +#cancelBtn:hover { + background: #cbd5e0; + transform: translateY(-2px); +} + +.list-section { + background: rgba(255, 255, 255, 0.95); + padding: 30px; + border-radius: 15px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(10px); +} + +.list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; + flex-wrap: wrap; + gap: 15px; +} + +.list-header h2 { + color: #4a5568; + font-size: 1.8rem; +} + +.sort-options select { + padding: 10px 15px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; +} + +/* 게시판 스타일 */ +.board-container { + background: rgba(255, 255, 255, 0.95); + border-radius: 12px; + padding: 20px; + margin-top: 20px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(10px); +} + +.board-table { + width: 100%; + border-collapse: collapse; + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.board-table thead { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.board-table th { + padding: 15px 12px; + text-align: center; + font-weight: 600; + font-size: 0.95rem; + border-right: 1px solid rgba(255, 255, 255, 0.2); +} + +.board-table th:last-child { + border-right: none; +} + +.board-table tbody tr { + border-bottom: 1px solid #e5e7eb; + transition: background-color 0.2s ease; +} + +.board-table tbody tr:hover { + background-color: #f8fafc; +} + +.board-table tbody tr:last-child { + border-bottom: none; +} + +.board-table td { + padding: 12px; + text-align: center; + vertical-align: middle; + font-size: 0.9rem; +} + +/* 컬럼 너비 설정 */ +.col-no { width: 60px; } +.col-category { width: 100px; } +.col-title { width: auto; min-width: 200px; text-align: left; } +.col-attachment { width: 80px; } +.col-date { width: 120px; } +.col-actions { width: 150px; } + +/* 제목 스타일 */ +.board-title { + color: #374151; + font-weight: 500; + text-decoration: none; + cursor: pointer; + display: block; + padding: 8px; + border-radius: 4px; + transition: all 0.2s ease; +} + +.board-title:hover { + background-color: #e0e7ff; + color: #4f46e5; +} + +/* 카테고리 배지 */ +.category-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; + color: white; +} + +.category-문서 { background: #3b82f6; } +.category-이미지 { background: #10b981; } +.category-동영상 { background: #f59e0b; } +.category-프레젠테이션 { background: #ef4444; } +.category-기타 { background: #6b7280; } + +/* 첨부파일 아이콘 */ +.attachment-icon { + font-size: 1.2rem; + color: #10b981; +} + +.attachment-icons { + display: flex; + gap: 2px; + justify-content: center; + align-items: center; + flex-wrap: wrap; + font-size: 1rem; + line-height: 1.2; +} + +.attachment-icons span { + display: inline-block; +} + +.attachment-icon-clickable { + cursor: pointer; + transition: all 0.2s ease; + padding: 2px; + border-radius: 3px; +} + +.attachment-icon-clickable:hover { + background-color: #e0e7ff; + transform: scale(1.1); +} + +.no-attachment { + color: #9ca3af; +} + +/* 액션 버튼 */ +.action-buttons { + display: flex; + gap: 5px; + justify-content: center; + align-items: center; +} + +.action-btn { + padding: 6px 12px; + border: none; + border-radius: 4px; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s ease; + font-weight: 500; +} + +.btn-edit { + background: #3b82f6; + color: white; +} + +.btn-edit:hover { + background: #2563eb; + transform: translateY(-1px); +} + +.btn-delete { + background: #ef4444; + color: white; +} + +.btn-delete:hover { + background: #dc2626; + transform: translateY(-1px); +} + +.btn-download { + background: #10b981; + color: white; +} + +.btn-download:hover { + background: #059669; + transform: translateY(-1px); +} + +/* 빈 상태 */ +.empty-state td { + padding: 60px 20px; + text-align: center; + color: #6b7280; + font-size: 1.1rem; +} + +/* 파일 업로드 영역 */ +.file-upload-area { + position: relative; + border: 2px dashed #d1d5db; + border-radius: 8px; + padding: 30px 20px; + text-align: center; + background: #f9fafb; + transition: all 0.3s ease; + cursor: pointer; +} + +.file-upload-area:hover { + border-color: #667eea; + background: #f0f4ff; +} + +.file-upload-area.drag-over { + border-color: #667eea; + background: #e0e7ff; + transform: scale(1.02); +} + +.file-upload-area input[type="file"] { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +.upload-placeholder { + pointer-events: none; +} + +.upload-icon { + font-size: 3rem; + margin-bottom: 15px; +} + +.upload-placeholder p { + margin: 8px 0; + color: #374151; +} + +.upload-placeholder strong { + color: #667eea; +} + +.upload-placeholder small { + color: #6b7280; + font-size: 0.85rem; +} + +/* 선택된 파일 목록 */ +.selected-files { + margin-top: 15px; +} + +.file-item-preview { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 15px; + margin-bottom: 8px; + background: white; + border: 1px solid #e5e7eb; + border-radius: 6px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.file-info { + display: flex; + align-items: center; + flex: 1; +} + +.file-icon { + margin-right: 10px; + font-size: 1.2rem; +} + +.file-details { + flex: 1; +} + +.file-name { + font-weight: 500; + color: #374151; + margin-bottom: 2px; +} + +.file-size { + font-size: 0.8rem; + color: #6b7280; +} + +.file-remove { + background: #ef4444; + color: white; + border: none; + border-radius: 50%; + width: 24px; + height: 24px; + cursor: pointer; + font-size: 0.8rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.file-remove:hover { + background: #dc2626; + transform: scale(1.1); +} + +.files-summary { + margin-top: 10px; + padding: 10px; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 4px; + font-size: 0.9rem; + color: #0369a1; + text-align: center; +} + +.file-list { + /* 기존 grid 스타일 비활성화 */ +} + +.file-item { + background: #f7fafc; + border: 2px solid #e2e8f0; + border-radius: 12px; + padding: 20px; + transition: all 0.3s ease; + position: relative; +} + +.file-item:hover { + border-color: #667eea; + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(102, 126, 234, 0.15); +} + +.file-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; + gap: 15px; +} + +.file-title { + font-size: 1.3rem; + font-weight: 600; + color: #2d3748; + margin-bottom: 5px; +} + +.file-meta { + display: flex; + gap: 15px; + font-size: 0.9rem; + color: #666; + margin-bottom: 10px; + flex-wrap: wrap; + align-items: center; +} + +.file-meta span { + white-space: nowrap; +} + +.file-description { + color: #4a5568; + margin-bottom: 15px; + line-height: 1.5; +} + +.file-tags { + display: flex; + gap: 8px; + margin-bottom: 15px; + flex-wrap: wrap; +} + +.tag { + background: #e6fffa; + color: #234e52; + padding: 4px 10px; + border-radius: 20px; + font-size: 0.85rem; + border: 1px solid #b2f5ea; +} + +.file-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; + margin-top: 10px; +} + +.file-actions button { + padding: 8px 15px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; + font-weight: 500; +} + +.edit-btn { + background: #3182ce; + color: white; +} + +.edit-btn:hover { + background: #2c5282; +} + +.delete-btn { + background: #e53e3e; + color: white; +} + +.delete-btn:hover { + background: #c53030; +} + +.download-btn { + background: #38a169; + color: white; +} + +.download-btn:hover { + background: #2f855a; +} + +.empty-state { + text-align: center; + padding: 60px 20px; + color: #666; +} + +.empty-state p { + font-size: 1.2rem; +} + +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(5px); +} + +.modal-content { + background: white; + margin: 5% auto; + padding: 30px; + border-radius: 15px; + width: 90%; + max-width: 600px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: modalSlideIn 0.3s ease; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-50px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-content h2 { + color: #4a5568; + margin-bottom: 25px; + font-size: 1.8rem; +} + +.files-list { + margin-top: 10px; +} + +.file-attachment { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #f7fafc; + border-radius: 6px; + margin-bottom: 5px; + border: 1px solid #e2e8f0; +} + +.file-attachment span { + font-size: 0.9rem; + color: #4a5568; +} + +.remove-file { + background: #e53e3e; + color: white; + border: none; + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + font-size: 0.8rem; +} + +.remove-file:hover { + background: #c53030; +} + +.category-badge { + background: #bee3f8; + color: #2c5282; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 500; + border: 1px solid #90cdf4; +} + +@media (max-width: 768px) { + .container { + padding: 15px; + } + + header h1 { + font-size: 2rem; + } + + .search-section { + flex-direction: column; + align-items: stretch; + } + + .search-section input, + .search-section select, + .search-section button { + width: 100%; + min-width: auto; + } + + .form-buttons { + flex-direction: column; + } + + .file-header { + flex-direction: column; + align-items: flex-start; + } + + .file-actions { + justify-content: flex-start; + } + + .list-header { + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 480px) { + .modal-content { + margin: 10% auto; + width: 95%; + padding: 20px; + } + + .file-actions { + flex-direction: column; + } + + .file-actions button { + width: 100%; + } + + .guest-mode-content { + flex-direction: column; + text-align: center; + } + + .guest-login-btn { + margin-top: 10px; + } + + .notification { + left: 10px; + right: 10px; + top: 10px; + max-width: none; + margin: 0; + } + + .file-meta { + gap: 8px; + } + + .file-meta span { + font-size: 0.8rem; + } + + header h1 { + font-size: 2rem; + } +} + +/* 알림 메시지 스타일 */ +.notification { + position: fixed; + top: 20px; + right: 20px; + max-width: 400px; + padding: 15px 20px; + border-radius: 8px; + color: white; + font-weight: 500; + z-index: 10000; + animation: slideInRight 0.3s ease, fadeOut 0.3s ease 2.7s; + animation-fill-mode: forwards; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + word-wrap: break-word; + line-height: 1.5; + white-space: pre-line; +} + +.notification.success { + background: linear-gradient(135deg, #48bb78, #38a169); + border-left: 4px solid #2f855a; +} + +.notification.error { + background: linear-gradient(135deg, #f56565, #e53e3e); + border-left: 4px solid #c53030; +} + +.notification.info { + background: linear-gradient(135deg, #4299e1, #3182ce); + border-left: 4px solid #2c5282; +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + transform: translateX(100%); + } +} \ No newline at end of file diff --git a/admin/supabase-config.js b/admin/supabase-config.js new file mode 100644 index 0000000..1dd95ee --- /dev/null +++ b/admin/supabase-config.js @@ -0,0 +1,119 @@ +// Supabase configuration (오프라인 모드) +// ⚠️ 오프라인 모드로 강제 설정됨 +const SUPABASE_CONFIG = { + url: 'https://kncudtzthmjegowbgnto.supabase.co', + anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtuY3VkdHp0aG1qZWdvd2JnbnRvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTU1Njc5OTksImV4cCI6MjA3MTE0Mzk5OX0.NlJN2vdgM96RvyVJE6ILQeDVUOU9X2F9vUn-jr_xlKc' +}; + +// Supabase 클라이언트 초기화 (강제 비활성화) +let supabase = null; + +// 설정이 유효한지 확인 +function isSupabaseConfigured() { + return false; // 강제로 false 반환 +} + +// Supabase 클라이언트 초기화 함수 (오프라인 모드 강제) +function initializeSupabase() { + console.log('⚠️ 오프라인 모드로 강제 설정되었습니다.'); + return false; +} + +// 인증 상태 변경 리스너 (오프라인 모드용 - 빈 함수) +function setupAuthListener(callback) { + // 오프라인 모드에서는 아무것도 하지 않음 + return; +} + +// 현재 사용자 가져오기 (오프라인 모드용 - null 반환) +async function getCurrentUser() { + return null; +} + +// 로그인 (오프라인 모드용 - 빈 함수) +async function signIn(email, password) { + throw new Error('오프라인 모드에서는 로그인할 수 없습니다.'); +} + +// 회원가입 (오프라인 모드용 - 빈 함수) +async function signUp(email, password, metadata = {}) { + throw new Error('오프라인 모드에서는 회원가입할 수 없습니다.'); +} + +// 로그아웃 (오프라인 모드용 - 빈 함수) +async function signOut() { + throw new Error('오프라인 모드에서는 로그아웃할 수 없습니다.'); +} + +// 데이터베이스 헬퍼 함수들 (오프라인 모드용) +const SupabaseHelper = { + // 파일 목록 가져오기 (오프라인 모드용) + async getFiles(userId) { + console.log('🔍 SupabaseHelper.getFiles 호출됨 (오프라인 모드)'); + throw new Error('오프라인 모드에서는 Supabase 데이터베이스를 사용할 수 없습니다.'); + }, + + // 파일 추가 (오프라인 모드용) + async addFile(fileData, userId) { + console.log('🔍 SupabaseHelper.addFile 호출됨 (오프라인 모드)'); + throw new Error('오프라인 모드에서는 Supabase 데이터베이스를 사용할 수 없습니다.'); + }, + + // 파일 수정 (오프라인 모드용) + async updateFile(id, updates, userId) { + console.log('🔍 SupabaseHelper.updateFile 호출됨 (오프라인 모드)'); + throw new Error('오프라인 모드에서는 Supabase 데이터베이스를 사용할 수 없습니다.'); + }, + + // 파일 삭제 (오프라인 모드용) + async deleteFile(id, userId) { + console.log('🔍 SupabaseHelper.deleteFile 호출됨 (오프라인 모드)'); + throw new Error('오프라인 모드에서는 Supabase 데이터베이스를 사용할 수 없습니다.'); + }, + + // 실시간 구독 설정 (오프라인 모드용) + subscribeToFiles(userId, callback) { + console.log('🔍 SupabaseHelper.subscribeToFiles 호출됨 (오프라인 모드)'); + return null; + }, + + // 파일 업로드 (오프라인 모드용) + async uploadFile(file, filePath) { + console.log('🔍 SupabaseHelper.uploadFile 호출됨 (오프라인 모드)'); + throw new Error('오프라인 모드에서는 Supabase Storage를 사용할 수 없습니다.'); + }, + + // 파일 다운로드 URL 가져오기 (오프라인 모드용) + async getFileUrl(filePath) { + console.log('🔍 SupabaseHelper.getFileUrl 호출됨 (오프라인 모드)'); + throw new Error('오프라인 모드에서는 Supabase Storage를 사용할 수 없습니다.'); + }, + + // 파일 삭제 (Storage) (오프라인 모드용) + async deleteStorageFile(filePath) { + console.log('🔍 SupabaseHelper.deleteStorageFile 호출됨 (오프라인 모드)'); + throw new Error('오프라인 모드에서는 Supabase Storage를 사용할 수 없습니다.'); + }, + + // 첨부파일 정보 추가 (오프라인 모드용) + async addFileAttachment(fileId, attachmentData) { + console.log('🔍 SupabaseHelper.addFileAttachment 호출됨 (오프라인 모드)'); + throw new Error('오프라인 모드에서는 Supabase 데이터베이스를 사용할 수 없습니다.'); + }, + + // Storage 버킷 확인 및 생성 (오프라인 모드용) + async checkOrCreateBucket() { + console.log('🔍 SupabaseHelper.checkOrCreateBucket 호출됨 (오프라인 모드)'); + return false; + } +}; + +// 전역으로 내보내기 +window.SupabaseHelper = SupabaseHelper; +window.initializeSupabase = initializeSupabase; +window.isSupabaseConfigured = isSupabaseConfigured; +window.setupAuthListener = setupAuthListener; +window.getCurrentUser = getCurrentUser; +window.signIn = signIn; +window.signUp = signUp; +window.signOut = signOut; \ No newline at end of file diff --git a/clean-storage-setup.sql b/clean-storage-setup.sql new file mode 100644 index 0000000..01dbb5e --- /dev/null +++ b/clean-storage-setup.sql @@ -0,0 +1,62 @@ +-- 완전 초기화 후 Storage 정책 재설정 + +-- 1단계: 모든 기존 Storage 정책 삭제 +DROP POLICY IF EXISTS "Users can upload to own folder" ON storage.objects; +DROP POLICY IF EXISTS "Users can view own files" ON storage.objects; +DROP POLICY IF EXISTS "Users can update own files" ON storage.objects; +DROP POLICY IF EXISTS "Users can delete own files" ON storage.objects; +DROP POLICY IF EXISTS "Public upload for testing" ON storage.objects; +DROP POLICY IF EXISTS "Public read for testing" ON storage.objects; + +-- 혹시 다른 이름으로 생성된 정책들도 삭제 +DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON storage.objects; +DROP POLICY IF EXISTS "Enable select for authenticated users only" ON storage.objects; +DROP POLICY IF EXISTS "Enable update for authenticated users only" ON storage.objects; +DROP POLICY IF EXISTS "Enable delete for authenticated users only" ON storage.objects; + +-- 2단계: RLS 활성화 확인 (보통 이미 활성화되어 있음) +ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY; + +-- 3단계: 새 정책 생성 +-- 업로드 정책 +CREATE POLICY "Users can upload to own folder" +ON storage.objects +FOR INSERT +WITH CHECK ( + bucket_id = 'files' AND + auth.uid()::text = (storage.foldername(name))[1] +); + +-- 조회 정책 +CREATE POLICY "Users can view own files" +ON storage.objects +FOR SELECT +USING ( + bucket_id = 'files' AND + auth.uid()::text = (storage.foldername(name))[1] +); + +-- 업데이트 정책 +CREATE POLICY "Users can update own files" +ON storage.objects +FOR UPDATE +USING ( + bucket_id = 'files' AND + auth.uid()::text = (storage.foldername(name))[1] +); + +-- 삭제 정책 +CREATE POLICY "Users can delete own files" +ON storage.objects +FOR DELETE +USING ( + bucket_id = 'files' AND + auth.uid()::text = (storage.foldername(name))[1] +); + +-- 4단계: 정책 생성 확인 +SELECT + 'Storage policies created successfully!' as message, + COUNT(*) as policy_count +FROM pg_policies +WHERE schemaname = 'storage' AND tablename = 'objects'; \ No newline at end of file diff --git a/index.html b/index.html index 105426c..9b01609 100644 --- a/index.html +++ b/index.html @@ -12,17 +12,6 @@

📚 자료실 관리 시스템

파일과 문서를 효율적으로 관리하세요

-
-
- - -
- -
@@ -38,48 +27,6 @@
-
-

📁 새 자료 추가

-
-
- - -
- -
- - -
- -
- - -
- -
- - - 여러 파일을 선택할 수 있습니다 -
- -
- - -
- -
- - -
-
-
@@ -93,87 +40,34 @@
-
-
-

📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!

-
+
+ + + + + + + + + + + + + + + + +
번호카테고리제목첨부등록일다운로드
📂 등록된 자료가 없습니다.
+
+ +
- - - - - diff --git a/script.js b/script.js index 3c32433..0925f61 100644 --- a/script.js +++ b/script.js @@ -2,960 +2,43 @@ class FileManager { constructor() { this.files = []; this.currentEditId = null; - this.currentUser = null; this.isOnline = navigator.onLine; - this.realtimeSubscription = null; - this.authMode = 'login'; // 'login' or 'signup' + this.currentPage = 1; + this.itemsPerPage = 10; + this.filteredFiles = []; this.init(); } async init() { - // Supabase 초기화 - const supabaseInitialized = initializeSupabase(); - - if (supabaseInitialized) { - console.log('✅ Supabase 모드로 실행합니다.'); - await this.initializeAuth(); - } else { - console.log('⚠️ 오프라인 모드로 실행합니다.'); - this.files = this.loadFiles(); - this.showOfflineMode(); - } - + // 오프라인 모드로만 실행 + this.files = this.loadFiles(); + this.filteredFiles = [...this.files]; this.bindEvents(); this.renderFiles(); - this.updateEmptyState(); - this.setupOnlineStatusListener(); + this.updatePagination(); } bindEvents() { - // 기존 이벤트 - document.getElementById('fileForm').addEventListener('submit', (e) => this.handleSubmit(e)); - document.getElementById('cancelBtn').addEventListener('click', () => this.clearForm()); + // 검색 및 정렬 이벤트만 유지 document.getElementById('searchBtn').addEventListener('click', () => this.handleSearch()); document.getElementById('searchInput').addEventListener('keyup', (e) => { if (e.key === 'Enter') this.handleSearch(); }); document.getElementById('categoryFilter').addEventListener('change', () => this.handleSearch()); - document.getElementById('sortBy').addEventListener('change', () => this.renderFiles()); - document.getElementById('editForm').addEventListener('submit', (e) => this.handleEditSubmit(e)); - document.getElementById('closeModal').addEventListener('click', () => this.closeEditModal()); - document.getElementById('fileUpload').addEventListener('change', (e) => this.handleFileUpload(e)); - - // 인증 이벤트 - document.getElementById('loginBtn').addEventListener('click', () => this.openAuthModal('login')); - document.getElementById('signupBtn').addEventListener('click', () => this.openAuthModal('signup')); - document.getElementById('logoutBtn').addEventListener('click', () => this.handleLogout()); - document.getElementById('authForm').addEventListener('submit', (e) => this.handleAuthSubmit(e)); - document.getElementById('authCancelBtn').addEventListener('click', () => this.closeAuthModal()); - document.getElementById('authSwitchLink').addEventListener('click', (e) => { - e.preventDefault(); - this.toggleAuthMode(); - }); - - // 모달 이벤트 - window.addEventListener('click', (e) => { - if (e.target === document.getElementById('editModal')) { - this.closeEditModal(); - } - if (e.target === document.getElementById('authModal')) { - this.closeAuthModal(); - } - }); + document.getElementById('sortBy').addEventListener('change', () => this.handleSearch()); + + // 페이지네이션 이벤트 + document.getElementById('prevPage').addEventListener('click', () => this.goToPrevPage()); + document.getElementById('nextPage').addEventListener('click', () => this.goToNextPage()); } generateId() { return Date.now().toString(36) + Math.random().toString(36).substr(2); } - // 파일 확장자 추출 (안전한 형태로) - getFileExtension(fileName) { - const lastDotIndex = fileName.lastIndexOf('.'); - if (lastDotIndex > 0 && lastDotIndex < fileName.length - 1) { - return fileName.substring(lastDotIndex).toLowerCase(); - } - return ''; // 확장자가 없는 경우 - } - - // 브라우저별 다운로드 폴더 경로 추정 - getDownloadFolderPath() { - const userAgent = navigator.userAgent.toLowerCase(); - const platform = navigator.platform.toLowerCase(); - - if (platform.includes('win')) { - return '다운로드 폴더 (C:\\Users\\사용자명\\Downloads)'; - } else if (platform.includes('mac')) { - return '다운로드 폴더 (~/Downloads)'; - } else if (platform.includes('linux')) { - return '다운로드 폴더 (~/Downloads)'; - } else { - return '브라우저 기본 다운로드 폴더'; - } - } - - // 인증 관련 메서드들 - async initializeAuth() { - try { - const user = await getCurrentUser(); - if (user) { - this.currentUser = user; - this.updateAuthUI(true); - await this.loadUserFiles(); - this.setupRealtimeSubscription(); - } else { - this.updateAuthUI(false); - // 게스트 모드: 공개 파일 로드 - await this.loadPublicFiles(); - this.showGuestMode(); - } - - setupAuthListener((event, session) => { - if (event === 'SIGNED_IN') { - this.currentUser = session.user; - this.updateAuthUI(true); - this.loadUserFiles(); - this.setupRealtimeSubscription(); - } else if (event === 'SIGNED_OUT') { - this.currentUser = null; - this.updateAuthUI(false); - this.loadPublicFiles(); - this.showGuestMode(); - this.cleanupRealtimeSubscription(); - } - }); - } catch (error) { - console.error('인증 초기화 오류:', error); - } - } - - updateAuthUI(isAuthenticated) { - const authButtons = document.getElementById('authButtons'); - const userInfo = document.getElementById('userInfo'); - const userEmail = document.getElementById('userEmail'); - const formSection = document.querySelector('.form-section'); - - if (isAuthenticated && this.currentUser) { - authButtons.style.display = 'none'; - userInfo.style.display = 'flex'; - userEmail.textContent = this.currentUser.email; - formSection.style.display = 'block'; - this.updateSyncStatus(); - this.hideGuestMode(); - } else { - authButtons.style.display = 'flex'; - userInfo.style.display = 'none'; - formSection.style.display = 'none'; - } - } - - updateSyncStatus(status = 'auto') { - const syncStatusElement = document.getElementById('syncStatus'); - if (!syncStatusElement) return; - - // 자동 상태 판단 - if (status === 'auto') { - if (!isSupabaseConfigured()) { - status = 'offline'; - } else if (this.currentUser) { - status = 'online'; - } else { - status = 'offline'; - } - } - - // 상태 업데이트 - syncStatusElement.className = `sync-status ${status}`; - switch (status) { - case 'online': - syncStatusElement.textContent = '🟢 온라인'; - break; - case 'offline': - syncStatusElement.textContent = '🟡 오프라인'; - break; - case 'syncing': - syncStatusElement.textContent = '🔄 동기화 중'; - break; - case 'error': - syncStatusElement.textContent = '🔴 오류'; - break; - } - } - - openAuthModal(mode) { - this.authMode = mode; - const modal = document.getElementById('authModal'); - const title = document.getElementById('authModalTitle'); - const submitBtn = document.getElementById('authSubmitBtn'); - const confirmPasswordGroup = document.getElementById('confirmPasswordGroup'); - const switchText = document.getElementById('authSwitchText'); - - if (mode === 'signup') { - title.textContent = '👤 회원가입'; - submitBtn.textContent = '👤 회원가입'; - confirmPasswordGroup.style.display = 'block'; - switchText.innerHTML = '이미 계정이 있으신가요? 로그인하기'; - } else { - title.textContent = '🔑 로그인'; - submitBtn.textContent = '🔑 로그인'; - confirmPasswordGroup.style.display = 'none'; - switchText.innerHTML = '계정이 없으신가요? 회원가입하기'; - } - - // 이벤트 리스너 재바인딩 - const newSwitchLink = document.getElementById('authSwitchLink'); - newSwitchLink.addEventListener('click', (e) => { - e.preventDefault(); - this.toggleAuthMode(); - }); - - modal.style.display = 'block'; - } - - closeAuthModal() { - document.getElementById('authModal').style.display = 'none'; - document.getElementById('authForm').reset(); - document.getElementById('authLoading').style.display = 'none'; - } - - toggleAuthMode() { - this.authMode = this.authMode === 'login' ? 'signup' : 'login'; - this.openAuthModal(this.authMode); - } - - async handleAuthSubmit(e) { - e.preventDefault(); - - const email = document.getElementById('authEmail').value.trim(); - const password = document.getElementById('authPassword').value; - const confirmPassword = document.getElementById('authConfirmPassword').value; - - if (!email || !password) { - alert('이메일과 비밀번호를 입력해주세요.'); - return; - } - - if (this.authMode === 'signup' && password !== confirmPassword) { - alert('비밀번호가 일치하지 않습니다.'); - return; - } - - this.showAuthLoading(true); - - try { - if (this.authMode === 'signup') { - await signUp(email, password); - alert('회원가입이 완료되었습니다! 이메일을 확인해주세요.'); - } else { - await signIn(email, password); - } - this.closeAuthModal(); - } catch (error) { - console.error('인증 오류:', error); - alert(`${this.authMode === 'signup' ? '회원가입' : '로그인'} 중 오류가 발생했습니다: ${error.message}`); - } finally { - this.showAuthLoading(false); - } - } - - async handleLogout() { - try { - await signOut(); - this.showNotification('로그아웃되었습니다.', 'success'); - } catch (error) { - console.error('로그아웃 오류:', error); - alert('로그아웃 중 오류가 발생했습니다.'); - } - } - - showAuthLoading(show) { - const loading = document.getElementById('authLoading'); - const form = document.getElementById('authForm'); - - if (show) { - loading.style.display = 'block'; - form.style.display = 'none'; - } else { - loading.style.display = 'none'; - form.style.display = 'block'; - } - } - - // 오프라인 모드 관련 - showOfflineMode() { - const container = document.querySelector('.container'); - const offlineNotice = document.createElement('div'); - offlineNotice.className = 'offline-mode'; - offlineNotice.innerHTML = '⚠️ 오프라인 모드: 로컬 저장소를 사용합니다. Supabase 설정을 확인해주세요.'; - container.insertBefore(offlineNotice, container.firstChild.nextSibling); - } - - // 게스트 모드 관련 - showGuestMode() { - this.hideGuestMode(); // 기존 알림 제거 - const container = document.querySelector('.container'); - const guestNotice = document.createElement('div'); - guestNotice.className = 'guest-mode'; - guestNotice.id = 'guestModeNotice'; - guestNotice.innerHTML = ` -
- 👤 게스트 모드 - 파일 보기 및 다운로드만 가능합니다 - -
- `; - container.insertBefore(guestNotice, container.firstChild.nextSibling); - } - - hideGuestMode() { - const guestNotice = document.getElementById('guestModeNotice'); - if (guestNotice) { - guestNotice.remove(); - } - } - - // 공개 파일 로드 (게스트용) - async loadPublicFiles() { - if (isSupabaseConfigured()) { - try { - // Supabase에서 모든 파일 로드 (RLS로 공개 파일만 접근 가능) - const data = await SupabaseHelper.getFiles('public'); - this.files = data.map(file => ({ - ...file, - files: file.file_attachments || [], - isReadOnly: true - })); - } catch (error) { - console.error('공개 파일 로딩 오류:', error); - // localStorage 폴백 - this.files = this.loadFiles().map(file => ({ ...file, isReadOnly: true })); - } - } else { - // 오프라인 모드: localStorage의 파일을 읽기 전용으로 로드 - this.files = this.loadFiles().map(file => ({ ...file, isReadOnly: true })); - } - this.renderFiles(); - this.updateEmptyState(); - } - - setupOnlineStatusListener() { - window.addEventListener('online', () => { - this.isOnline = true; - this.showNotification('온라인 상태가 되었습니다.', 'success'); - }); - - window.addEventListener('offline', () => { - this.isOnline = false; - this.showNotification('오프라인 상태입니다.', 'info'); - }); - } - - // Supabase 데이터베이스 연동 메서드들 - async loadUserFiles() { - if (!this.currentUser || !isSupabaseConfigured()) { - this.files = this.loadFiles(); // localStorage 폴백 - this.updateSyncStatus('offline'); - return; - } - - try { - this.updateSyncStatus('syncing'); - const data = await SupabaseHelper.getFiles(this.currentUser.id); - this.files = data.map(file => ({ - ...file, - files: file.file_attachments || [] // 첨부파일 정보 매핑 - })); - this.renderFiles(); - this.updateEmptyState(); - this.updateSyncStatus('online'); - } catch (error) { - console.error('파일 로딩 오류:', error); - this.showNotification('파일을 불러오는 중 오류가 발생했습니다.', 'error'); - this.updateSyncStatus('error'); - // 오류 시 localStorage 폴백 - this.files = this.loadFiles(); - } - } - - async addFileToSupabase(fileData) { - if (!this.currentUser || !isSupabaseConfigured()) { - return this.addFileLocally(fileData); - } - - try { - this.updateSyncStatus('syncing'); - console.log('파일 데이터 추가 중...', fileData); - - const result = await SupabaseHelper.addFile(fileData, this.currentUser.id); - console.log('파일 데이터 추가 성공:', result); - - // 첨부파일이 있는 경우 파일 업로드 처리 - if (fileData.files && fileData.files.length > 0) { - console.log(`${fileData.files.length}개의 첨부파일 업로드 시작...`); - await this.uploadAttachments(result.id, fileData.files); - console.log('모든 첨부파일 업로드 완료'); - } - - this.showNotification('새 자료가 성공적으로 추가되었습니다!', 'success'); - await this.loadUserFiles(); // 목록 새로고침 - this.updateSyncStatus('online'); - this.clearForm(); // 폼 초기화 - - } catch (error) { - console.error('파일 추가 오류:', error); - - // 더 구체적인 에러 메시지 제공 - let errorMessage = '파일 추가 중 오류가 발생했습니다.'; - if (error.message) { - errorMessage += ` (${error.message})`; - } - - this.showNotification(errorMessage, 'error'); - this.updateSyncStatus('error'); - - // 콘솔에 상세 오류 정보 출력 - if (error.details) { - console.error('오류 상세:', error.details); - } - if (error.hint) { - console.error('오류 힌트:', error.hint); - } - } - } - - async updateFileInSupabase(id, updates) { - if (!this.currentUser || !isSupabaseConfigured()) { - return this.updateFileLocally(id, updates); - } - - try { - await SupabaseHelper.updateFile(id, updates, this.currentUser.id); - this.showNotification('자료가 성공적으로 수정되었습니다!', 'success'); - await this.loadUserFiles(); // 목록 새로고침 - } catch (error) { - console.error('파일 수정 오류:', error); - this.showNotification('파일 수정 중 오류가 발생했습니다.', 'error'); - } - } - - async deleteFileFromSupabase(id) { - if (!this.currentUser || !isSupabaseConfigured()) { - return this.deleteFileLocally(id); - } - - try { - // 첨부파일들을 Storage에서 삭제 - await this.deleteAttachmentsFromStorage(id); - - // 데이터베이스에서 파일 삭제 (CASCADE로 첨부파일 정보도 함께 삭제) - await SupabaseHelper.deleteFile(id, this.currentUser.id); - this.showNotification('자료가 성공적으로 삭제되었습니다!', 'success'); - await this.loadUserFiles(); // 목록 새로고침 - } catch (error) { - console.error('파일 삭제 오류:', error); - this.showNotification('파일 삭제 중 오류가 발생했습니다.', 'error'); - } - } - - // localStorage 폴백 메서드들 - addFileLocally(fileData) { - // 로컬 저장용 데이터 생성 (ID와 타임스탬프 추가) - const localFileData = { - id: this.generateId(), - ...fileData, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }; - - this.files.push(localFileData); - this.saveFiles(); - this.renderFiles(); - this.updateEmptyState(); - this.showNotification('새 자료가 성공적으로 추가되었습니다! (로컬 저장)', 'success'); - this.clearForm(); // 폼 초기화 - } - - updateFileLocally(id, updates) { - const fileIndex = this.files.findIndex(f => f.id === id); - if (fileIndex !== -1) { - this.files[fileIndex] = { - ...this.files[fileIndex], - ...updates, - updated_at: new Date().toISOString() - }; - this.saveFiles(); - this.renderFiles(); - this.showNotification('자료가 성공적으로 수정되었습니다! (로컬 저장)', 'success'); - } - } - - deleteFileLocally(id) { - this.files = this.files.filter(f => f.id !== id); - this.saveFiles(); - this.renderFiles(); - this.updateEmptyState(); - this.showNotification('자료가 성공적으로 삭제되었습니다! (로컬 저장)', 'success'); - } - - // 실시간 구독 관련 - setupRealtimeSubscription() { - if (!this.currentUser || !isSupabaseConfigured()) return; - - this.realtimeSubscription = SupabaseHelper.subscribeToFiles( - this.currentUser.id, - (payload) => { - console.log('실시간 업데이트:', payload); - this.loadUserFiles(); // 변경사항이 있으면 목록 새로고침 - } - ); - } - - cleanupRealtimeSubscription() { - if (this.realtimeSubscription) { - this.realtimeSubscription.unsubscribe(); - this.realtimeSubscription = null; - } - } - - // 파일 업로드 관련 메서드들 - async uploadAttachments(fileId, attachments) { - if (!isSupabaseConfigured() || !this.currentUser) { - console.log('오프라인 모드: 첨부파일을 base64로 저장합니다.'); - return; // 오프라인 모드에서는 base64로 저장된 상태 유지 - } - - const uploadedFiles = []; - - try { - for (let i = 0; i < attachments.length; i++) { - const attachment = attachments[i]; - - try { - console.log(`파일 업로드 중... (${i + 1}/${attachments.length}): ${attachment.name}`); - - // base64 데이터를 Blob으로 변환 - const response = await fetch(attachment.data); - const blob = await response.blob(); - - // 안전한 파일명 생성 (고유한 이름으로 저장, 원본명은 DB에 저장) - const fileExtension = this.getFileExtension(attachment.name); - const safeFileName = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}${fileExtension}`; - const filePath = `${this.currentUser.id}/${fileId}/${safeFileName}`; - - // Storage 버킷 확인 - const bucketExists = await SupabaseHelper.checkOrCreateBucket(); - if (!bucketExists) { - throw new Error('Storage 버킷 접근 권한이 없습니다. Storage 정책을 확인해주세요.'); - } - - // Supabase Storage에 업로드 - const uploadResult = await SupabaseHelper.uploadFile(blob, filePath); - console.log('Storage 업로드 성공:', uploadResult); - - // 데이터베이스에 첨부파일 정보 저장 - const attachmentResult = await this.addFileAttachment(fileId, { - original_name: attachment.name, - storage_path: filePath, - file_size: attachment.size || blob.size, - mime_type: attachment.type || blob.type - }); - - uploadedFiles.push(attachmentResult); - console.log('첨부파일 정보 저장 성공:', attachmentResult); - - } catch (fileError) { - console.error(`파일 "${attachment.name}" 업로드 실패:`, fileError); - throw new Error(`파일 "${attachment.name}" 업로드에 실패했습니다: ${fileError.message}`); - } - } - - console.log('모든 첨부파일 업로드 완료:', uploadedFiles); - return uploadedFiles; - - } catch (error) { - console.error('파일 업로드 오류:', error); - - // 부분적으로 업로드된 파일들 정리 (선택사항) - try { - for (const uploadedFile of uploadedFiles) { - if (uploadedFile.storage_path) { - await SupabaseHelper.deleteStorageFile(uploadedFile.storage_path); - } - } - } catch (cleanupError) { - console.error('업로드 실패 파일 정리 중 오류:', cleanupError); - } - - throw error; - } - } - - async addFileAttachment(fileId, attachmentData) { - if (!isSupabaseConfigured()) { - return; // 오프라인 모드에서는 처리하지 않음 - } - - try { - // SupabaseHelper를 통해 첨부파일 정보 저장 - const result = await SupabaseHelper.addFileAttachment(fileId, attachmentData); - return result; - } catch (error) { - console.error('첨부파일 정보 저장 오류:', error); - throw error; - } - } - - async downloadFileFromStorage(filePath, originalName) { - if (!isSupabaseConfigured()) { - return; // 오프라인 모드에서는 처리하지 않음 - } - - try { - console.log('파일 다운로드 시도:', filePath, originalName); - - // Storage 버킷 확인 - const bucketExists = await SupabaseHelper.checkOrCreateBucket(); - if (!bucketExists) { - throw new Error('Storage 버킷 접근 권한이 없습니다. Storage 정책을 확인해주세요.'); - } - - const url = await SupabaseHelper.getFileUrl(filePath); - console.log('다운로드 URL 생성:', url); - - // 다운로드 링크 생성 - const link = document.createElement('a'); - link.href = url; - link.download = originalName; - - // Ctrl/Cmd 키를 누른 상태에서 클릭하면 "다른 이름으로 저장" 대화상자 표시 - if (window.event && (window.event.ctrlKey || window.event.metaKey)) { - link.target = '_blank'; - // 브라우저의 다운로드 관리자로 보내기 - } - - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - console.log('파일 다운로드 완료:', originalName); - } catch (error) { - console.error('파일 다운로드 오류:', error); - - // 더 구체적인 오류 메시지 제공 - let errorMessage = '파일 다운로드 중 오류가 발생했습니다.'; - if (error.message.includes('Bucket not found')) { - errorMessage = 'Storage 버킷이 설정되지 않았습니다. 관리자에게 문의하세요.'; - } else if (error.message.includes('파일을 찾을 수 없습니다')) { - errorMessage = '파일을 찾을 수 없습니다. 파일이 삭제되었을 수 있습니다.'; - } - - this.showNotification(errorMessage, 'error'); - } - } - - async deleteAttachmentsFromStorage(fileId) { - if (!isSupabaseConfigured() || !this.currentUser) { - return; // 오프라인 모드에서는 처리하지 않음 - } - - try { - // 파일의 모든 첨부파일 경로 가져오기 - const { data: attachments, error } = await supabase - .from('file_attachments') - .select('storage_path') - .eq('file_id', fileId); - - if (error) throw error; - - // 각 파일을 Storage에서 삭제 - for (const attachment of attachments) { - await SupabaseHelper.deleteStorageFile(attachment.storage_path); - } - } catch (error) { - console.error('첨부파일 삭제 오류:', error); - } - } - - handleSubmit(e) { - e.preventDefault(); - - if (!this.currentUser) { - this.showNotification('로그인이 필요합니다.', 'error'); - return; - } - - const title = document.getElementById('fileTitle').value.trim(); - const description = document.getElementById('fileDescription').value.trim(); - const category = document.getElementById('fileCategory').value; - const tags = document.getElementById('fileTags').value.split(',').map(tag => tag.trim()).filter(tag => tag); - const fileInput = document.getElementById('fileUpload'); - - if (!title || !category) { - alert('제목과 카테고리는 필수 입력 항목입니다.'); - return; - } - - const fileData = { - title, - description, - category, - tags, - files: [] // 첨부파일 임시 저장용 (Supabase 전송시 제외됨) - }; - - if (fileInput.files.length > 0) { - Array.from(fileInput.files).forEach(file => { - const reader = new FileReader(); - reader.onload = (e) => { - fileData.files.push({ - name: file.name, - size: file.size, - type: file.type, - data: e.target.result - }); - - if (fileData.files.length === fileInput.files.length) { - this.addFileToSupabase(fileData); - } - }; - reader.readAsDataURL(file); - }); - } else { - this.addFileToSupabase(fileData); - } - } - - async addFile(fileData) { - // 호환성을 위해 유지, 실제로는 addFileToSupabase 사용 - await this.addFileToSupabase(fileData); - this.clearForm(); - } - - handleFileUpload(e) { - const files = Array.from(e.target.files); - const filesList = document.querySelector('.files-list') || this.createFilesList(); - - filesList.innerHTML = ''; - - files.forEach((file, index) => { - const fileItem = document.createElement('div'); - fileItem.className = 'file-attachment'; - fileItem.innerHTML = ` - 📎 ${file.name} (${this.formatFileSize(file.size)}) - - `; - filesList.appendChild(fileItem); - }); - } - - createFilesList() { - const fileGroup = document.querySelector('#fileUpload').closest('.form-group'); - const filesList = document.createElement('div'); - filesList.className = 'files-list'; - fileGroup.appendChild(filesList); - return filesList; - } - - removeFileFromInput(index) { - const fileInput = document.getElementById('fileUpload'); - const dt = new DataTransfer(); - const files = Array.from(fileInput.files); - - files.forEach((file, i) => { - if (i !== index) { - dt.items.add(file); - } - }); - - fileInput.files = dt.files; - this.handleFileUpload({ target: fileInput }); - } - - renderFiles() { - const container = document.getElementById('fileList'); - const sortBy = document.getElementById('sortBy').value; - - let sortedFiles = [...this.files]; - - switch (sortBy) { - case 'title': - sortedFiles.sort((a, b) => a.title.localeCompare(b.title)); - break; - case 'category': - sortedFiles.sort((a, b) => a.category.localeCompare(b.category)); - break; - case 'date': - default: - sortedFiles.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - break; - } - - if (sortedFiles.length === 0) { - container.innerHTML = '

📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!

'; - return; - } - - container.innerHTML = sortedFiles.map(file => this.createFileHTML(file)).join(''); - } - - createFileHTML(file) { - const createdDate = new Date(file.created_at).toLocaleDateString('ko-KR'); - const updatedDate = new Date(file.updated_at).toLocaleDateString('ko-KR'); - const tagsHTML = file.tags.map(tag => `${tag}`).join(''); - const filesHTML = file.files.length > 0 ? - `
- 첨부파일 (${file.files.length}개): - ${file.files.map(f => `📎 ${f.name}`).join(', ')} -
` : ''; - - return ` -
-
-
-
${this.escapeHtml(file.title)}
-
- ${file.category} - 📅 생성: ${createdDate} - ${createdDate !== updatedDate ? `✏️ 수정: ${updatedDate}` : ''} -
-
-
- - ${file.description ? `
${this.escapeHtml(file.description)}
` : ''} - - ${file.tags.length > 0 ? `
${tagsHTML}
` : ''} - - ${filesHTML} - -
- ${!file.isReadOnly && this.currentUser ? ` - - - ` : ''} - ${file.files.length > 0 ? `` : ''} - ${file.isReadOnly ? `👁️ 읽기 전용` : ''} -
-
- `; - } - - escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - editFile(id) { - if (!this.currentUser) { - this.showNotification('로그인이 필요합니다.', 'error'); - return; - } - - const file = this.files.find(f => f.id === id); - if (!file) return; - - if (file.isReadOnly) { - this.showNotification('읽기 전용 파일은 편집할 수 없습니다.', 'error'); - return; - } - - this.currentEditId = id; - - document.getElementById('editTitle').value = file.title; - document.getElementById('editDescription').value = file.description; - document.getElementById('editCategory').value = file.category; - document.getElementById('editTags').value = file.tags.join(', '); - - document.getElementById('editModal').style.display = 'block'; - } - - handleEditSubmit(e) { - e.preventDefault(); - - if (!this.currentEditId) return; - - const title = document.getElementById('editTitle').value.trim(); - const description = document.getElementById('editDescription').value.trim(); - const category = document.getElementById('editCategory').value; - const tags = document.getElementById('editTags').value.split(',').map(tag => tag.trim()).filter(tag => tag); - - if (!title || !category) { - alert('제목과 카테고리는 필수 입력 항목입니다.'); - return; - } - - const updates = { - title, - description, - category, - tags - }; - - this.updateFileInSupabase(this.currentEditId, updates); - this.closeEditModal(); - } - - closeEditModal() { - document.getElementById('editModal').style.display = 'none'; - this.currentEditId = null; - } - - deleteFile(id) { - if (!this.currentUser) { - this.showNotification('로그인이 필요합니다.', 'error'); - return; - } - - const file = this.files.find(f => f.id === id); - if (!file) return; - - if (file.isReadOnly) { - this.showNotification('읽기 전용 파일은 삭제할 수 없습니다.', 'error'); - return; - } - - if (confirm(`"${file.title}" 자료를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) { - this.deleteFileFromSupabase(id); - } - } - - async downloadFiles(id) { - const file = this.files.find(f => f.id === id); - if (!file || file.files.length === 0) return; - - try { - for (const fileData of file.files) { - if (fileData.storage_path && isSupabaseConfigured()) { - // Supabase Storage에서 다운로드 - await this.downloadFileFromStorage(fileData.storage_path, fileData.original_name || fileData.name); - } else if (fileData.data) { - // localStorage 데이터 다운로드 (base64) - const link = document.createElement('a'); - link.href = fileData.data; - link.download = fileData.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } - } - const fileNames = file.files.map(f => f.original_name || f.name).join(', '); - const downloadFolder = this.getDownloadFolderPath(); - - // 첫 번째 다운로드인지 확인 - const isFirstDownload = !localStorage.getItem('hasDownloadedBefore'); - if (isFirstDownload) { - localStorage.setItem('hasDownloadedBefore', 'true'); - this.showNotification(`파일 다운로드 완료: ${fileNames}\n저장 위치: ${downloadFolder}\n\n💡 팁: 브라우저 설정에서 다운로드 폴더를 변경할 수 있습니다.`, 'success'); - } else { - this.showNotification(`파일 다운로드 완료: ${fileNames}`, 'success'); - } - } catch (error) { - console.error('파일 다운로드 오류:', error); - this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error'); - } - } - handleSearch() { - const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim(); + const searchTerm = document.getElementById('searchInput').value.toLowerCase(); const categoryFilter = document.getElementById('categoryFilter').value; let filteredFiles = this.files; @@ -964,7 +47,7 @@ class FileManager { filteredFiles = filteredFiles.filter(file => file.title.toLowerCase().includes(searchTerm) || file.description.toLowerCase().includes(searchTerm) || - file.tags.some(tag => tag.toLowerCase().includes(searchTerm)) + (file.tags && file.tags.some(tag => tag.toLowerCase().includes(searchTerm))) ); } @@ -972,93 +55,253 @@ class FileManager { filteredFiles = filteredFiles.filter(file => file.category === categoryFilter); } - this.renderFilteredFiles(filteredFiles); + this.filteredFiles = filteredFiles; + this.currentPage = 1; // 검색 시 첫 페이지로 리셋 + this.renderFiles(); + this.updatePagination(); } - renderFilteredFiles(files) { - const container = document.getElementById('fileList'); + renderFiles() { + const fileList = document.getElementById('fileList'); const sortBy = document.getElementById('sortBy').value; - let sortedFiles = [...files]; + // 정렬 + const sortedFiles = [...this.filteredFiles].sort((a, b) => { + switch (sortBy) { + case 'title': + return a.title.localeCompare(b.title); + case 'category': + return a.category.localeCompare(b.category); + case 'date': + default: + return new Date(b.createdAt) - new Date(a.createdAt); + } + }); + + // 페이지네이션 적용 + const startIndex = (this.currentPage - 1) * this.itemsPerPage; + const endIndex = startIndex + this.itemsPerPage; + const paginatedFiles = sortedFiles.slice(startIndex, endIndex); - switch (sortBy) { - case 'title': - sortedFiles.sort((a, b) => a.title.localeCompare(b.title)); - break; - case 'category': - sortedFiles.sort((a, b) => a.category.localeCompare(b.category)); - break; - case 'date': - default: - sortedFiles.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - break; - } - if (sortedFiles.length === 0) { - container.innerHTML = '

🔍 검색 결과가 없습니다. 다른 키워드로 검색해보세요!

'; + fileList.innerHTML = ` + + 📂 조건에 맞는 자료가 없습니다. + + `; return; } - container.innerHTML = sortedFiles.map(file => this.createFileHTML(file)).join(''); + fileList.innerHTML = paginatedFiles.map((file, index) => + this.createFileRowHTML(file, startIndex + index + 1) + ).join(''); } - clearForm() { - document.getElementById('fileForm').reset(); - const filesList = document.querySelector('.files-list'); - if (filesList) { - filesList.innerHTML = ''; + createFileRowHTML(file, rowNumber) { + const createdDate = new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR'); + const hasAttachments = file.files && file.files.length > 0; + + return ` + + ${rowNumber} + + ${file.category} + + +
+ ${this.escapeHtml(file.title)} + ${file.description ? `
${this.escapeHtml(file.description)}` : ''} + ${file.tags && file.tags.length > 0 ? + `
${file.tags.map(tag => `#${this.escapeHtml(tag)}`).join('')}
` : '' + } +
+ + + ${hasAttachments ? + `
${file.files.map((f, index) => + `${this.getFileIcon(f.name || f.original_name || 'unknown')}` + ).join(' ')}
` : + `-` + } + + ${createdDate} + + ${hasAttachments ? + `` : + `-` + } + + + `; + } + + getFileIcon(fileName) { + const ext = fileName.split('.').pop().toLowerCase(); + const iconMap = { + 'pdf': '📄', + 'doc': '📝', 'docx': '📝', + 'xls': '📊', 'xlsx': '📊', + 'ppt': '📽️', 'pptx': '📽️', + 'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️', + 'mp4': '🎥', 'avi': '🎥', 'mov': '🎥', + 'mp3': '🎵', 'wav': '🎵', + 'zip': '📦', 'rar': '📦', + 'txt': '📄' + }; + return iconMap[ext] || '📄'; + } + + viewFileInfo(id) { + const file = this.files.find(f => f.id === id); + if (!file) return; + + let info = `📋 자료 정보\n\n`; + info += `📌 제목: ${file.title}\n`; + info += `📂 카테고리: ${file.category}\n`; + info += `📅 등록일: ${new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR')}\n`; + if (file.description) info += `📝 설명: ${file.description}\n`; + if (file.tags && file.tags.length > 0) info += `🏷️ 태그: ${file.tags.join(', ')}\n`; + if (file.files && file.files.length > 0) { + info += `\n📎 첨부파일 (${file.files.length}개):\n`; + file.files.forEach((attachment, index) => { + const icon = this.getFileIcon(attachment.name || attachment.original_name || 'unknown'); + info += ` ${index + 1}. ${icon} ${attachment.name || attachment.original_name || '파일'}\n`; + }); + } + + alert(info); + } + + async downloadFiles(id) { + const file = this.files.find(f => f.id === id); + if (!file) { + this.showNotification('파일을 찾을 수 없습니다.', 'error'); + return; + } + if (!file.files || file.files.length === 0) { + this.showNotification('첨부파일이 없습니다.', 'error'); + return; + } + + try { + if (file.files.length === 1) { + // 단일 파일: 직접 다운로드 + await this.downloadSingleFileData(file.files[0]); + this.showNotification(`파일 다운로드 완료: ${file.files[0].name || file.files[0].original_name}`, 'success'); + } else { + // 다중 파일: localStorage에서 base64 데이터를 각각 다운로드 + for (const fileData of file.files) { + await this.downloadSingleFileData(fileData); + } + this.showNotification(`${file.files.length}개 파일 다운로드 완료`, 'success'); + } + } catch (error) { + console.error('파일 다운로드 오류:', error); + this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error'); } } - formatFileSize(bytes) { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + async downloadSingleFile(fileId, fileIndex) { + const file = this.files.find(f => f.id === fileId); + if (!file) { + this.showNotification('파일을 찾을 수 없습니다.', 'error'); + return; + } + if (!file.files || !file.files[fileIndex]) { + this.showNotification('첨부파일을 찾을 수 없습니다.', 'error'); + return; + } + + try { + const fileData = file.files[fileIndex]; + await this.downloadSingleFileData(fileData); + this.showNotification(`파일 다운로드 완료: ${fileData.name || fileData.original_name}`, 'success'); + } catch (error) { + console.error('개별 파일 다운로드 오류:', error); + this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error'); + } } - updateEmptyState() { - const container = document.getElementById('fileList'); - if (this.files.length === 0) { - container.innerHTML = '

📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!

'; + async downloadSingleFileData(fileData) { + if (fileData.data) { + // localStorage의 base64 데이터 다운로드 + const link = document.createElement('a'); + link.href = fileData.data; + link.download = fileData.name || fileData.original_name || 'file'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); } } showNotification(message, type = 'info') { - // 기존 알림이 있으면 제거 - const existingNotification = document.querySelector('.notification'); - if (existingNotification) { - existingNotification.remove(); - } - + // 간단한 알림 표시 const notification = document.createElement('div'); notification.className = `notification ${type}`; notification.textContent = message; + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 15px 20px; + border-radius: 8px; + color: white; + font-weight: 500; + z-index: 10000; + max-width: 400px; + background: ${type === 'success' ? '#48bb78' : type === 'error' ? '#f56565' : '#4299e1'}; + `; document.body.appendChild(notification); - // 3초 후 자동 제거 setTimeout(() => { - if (notification.parentNode) { - notification.remove(); + if (document.body.contains(notification)) { + document.body.removeChild(notification); } }, 3000); } + updatePagination() { + const totalPages = Math.ceil(this.filteredFiles.length / this.itemsPerPage); + const pagination = document.getElementById('pagination'); + const prevBtn = document.getElementById('prevPage'); + const nextBtn = document.getElementById('nextPage'); + const pageInfo = document.getElementById('pageInfo'); + + if (totalPages <= 1) { + pagination.style.display = 'none'; + } else { + pagination.style.display = 'flex'; + prevBtn.disabled = this.currentPage <= 1; + nextBtn.disabled = this.currentPage >= totalPages; + pageInfo.textContent = `${this.currentPage} / ${totalPages}`; + } + } + + goToPrevPage() { + if (this.currentPage > 1) { + this.currentPage--; + this.renderFiles(); + this.updatePagination(); + } + } + + goToNextPage() { + const totalPages = Math.ceil(this.filteredFiles.length / this.itemsPerPage); + if (this.currentPage < totalPages) { + this.currentPage++; + this.renderFiles(); + this.updatePagination(); + } + } + + loadFiles() { try { const stored = localStorage.getItem('fileManagerData'); - const files = stored ? JSON.parse(stored) : []; - - // 기존 localStorage 데이터의 호환성을 위해 컬럼명 변환 - return files.map(file => ({ - ...file, - created_at: file.created_at || file.createdAt || new Date().toISOString(), - updated_at: file.updated_at || file.updatedAt || new Date().toISOString() - })); + return stored ? JSON.parse(stored) : []; } catch (error) { - console.error('파일 데이터를 불러오는 중 오류가 발생했습니다:', error); + console.error('파일 로드 중 오류:', error); return []; } } @@ -1067,68 +310,43 @@ class FileManager { try { localStorage.setItem('fileManagerData', JSON.stringify(this.files)); } catch (error) { - console.error('파일 데이터를 저장하는 중 오류가 발생했습니다:', error); - alert('데이터 저장 중 오류가 발생했습니다. 브라우저의 저장공간을 확인해주세요.'); + console.error('파일 저장 중 오류:', error); } } - exportData() { - const dataStr = JSON.stringify(this.files, null, 2); - const dataBlob = new Blob([dataStr], {type: 'application/json'}); - const url = URL.createObjectURL(dataBlob); - const link = document.createElement('a'); - link.href = url; - link.download = `자료실_백업_${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - this.showNotification('데이터가 성공적으로 내보내기되었습니다!', 'success'); + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; } - importData(event) { - const file = event.target.files[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = (e) => { - try { - const importedData = JSON.parse(e.target.result); - if (Array.isArray(importedData)) { - if (confirm('기존 데이터를 모두 삭제하고 새 데이터를 가져오시겠습니까?')) { - this.files = importedData; - this.saveFiles(); - this.renderFiles(); - this.updateEmptyState(); - this.showNotification('데이터가 성공적으로 가져와졌습니다!', 'success'); - } - } else { - alert('올바르지 않은 파일 형식입니다.'); - } - } catch (error) { - alert('파일을 읽는 중 오류가 발생했습니다.'); - console.error(error); - } - }; - reader.readAsText(file); + showMessage(message, type = 'success') { + const messageEl = document.createElement('div'); + messageEl.className = `message ${type}`; + messageEl.textContent = message; + messageEl.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: ${type === 'error' ? '#ef4444' : '#10b981'}; + color: white; + padding: 1rem 1.5rem; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + z-index: 1000; + animation: slideIn 0.3s ease-out; + `; + + document.body.appendChild(messageEl); + setTimeout(() => { + messageEl.style.animation = 'slideOut 0.3s ease-out'; + setTimeout(() => messageEl.remove(), 300); + }, 3000); } } -const style = document.createElement('style'); -style.textContent = ` - @keyframes slideIn { - from { transform: translateX(100%); opacity: 0; } - to { transform: translateX(0); opacity: 1; } - } - @keyframes slideOut { - from { transform: translateX(0); opacity: 1; } - to { transform: translateX(100%); opacity: 0; } - } -`; -document.head.appendChild(style); - -const fileManager = new FileManager(); - +// 전역 인스턴스 생성 +let fileManager; document.addEventListener('DOMContentLoaded', () => { - console.log('📚 자료실 관리 시스템이 초기화되었습니다.'); + fileManager = new FileManager(); }); \ No newline at end of file diff --git a/storage-policies.sql b/storage-policies.sql new file mode 100644 index 0000000..95ec94c --- /dev/null +++ b/storage-policies.sql @@ -0,0 +1,39 @@ +-- Storage 전용 정책 (Supabase Dashboard → Storage → Policies에서 실행) + +-- 1. 파일 업로드 정책 +CREATE POLICY "Users can upload to own folder" +ON storage.objects +FOR INSERT +WITH CHECK ( + bucket_id = 'files' AND + auth.uid()::text = (storage.foldername(name))[1] +); + +-- 2. 파일 조회 정책 +CREATE POLICY "Users can view own files" +ON storage.objects +FOR SELECT +USING ( + bucket_id = 'files' AND + auth.uid()::text = (storage.foldername(name))[1] +); + +-- 3. 파일 업데이트 정책 +CREATE POLICY "Users can update own files" +ON storage.objects +FOR UPDATE +USING ( + bucket_id = 'files' AND + auth.uid()::text = (storage.foldername(name))[1] +); + +-- 4. 파일 삭제 정책 +CREATE POLICY "Users can delete own files" +ON storage.objects +FOR DELETE +USING ( + bucket_id = 'files' AND + auth.uid()::text = (storage.foldername(name))[1] +); + +-- 참고: storage.foldername(name)[1]은 'user_id/filename.txt'에서 'user_id' 부분을 추출합니다. \ No newline at end of file diff --git a/styles.css b/styles.css index 08eece0..dd9690b 100644 --- a/styles.css +++ b/styles.css @@ -39,6 +39,47 @@ header p { font-size: 1.1rem; } +/* 페이지네이션 스타일 */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + margin-top: 30px; + padding: 20px; + background: rgba(255, 255, 255, 0.9); + border-radius: 10px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.page-btn { + background: #4299e1; + color: white; + border: none; + padding: 10px 20px; + border-radius: 8px; + cursor: pointer; + font-size: 1rem; + transition: all 0.3s ease; +} + +.page-btn:hover:not(:disabled) { + background: #3182ce; + transform: translateY(-2px); +} + +.page-btn:disabled { + background: #a0aec0; + cursor: not-allowed; + transform: none; +} + +#pageInfo { + font-weight: 600; + color: #4a5568; + font-size: 1.1rem; +} + .auth-section { margin-top: 20px; display: flex; @@ -360,6 +401,215 @@ header p { cursor: pointer; } +/* 게시판 스타일 */ +.board-container { + background: rgba(255, 255, 255, 0.95); + border-radius: 12px; + padding: 20px; + margin-top: 20px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(10px); +} + +.board-table { + width: 100%; + border-collapse: collapse; + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.board-table thead { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.board-table th { + padding: 15px 12px; + text-align: center; + font-weight: 600; + font-size: 0.95rem; + border-right: 1px solid rgba(255, 255, 255, 0.2); +} + +.board-table th:last-child { + border-right: none; +} + +.board-table tbody tr { + border-bottom: 1px solid #e5e7eb; + transition: background-color 0.2s ease; +} + +.board-table tbody tr:hover { + background-color: #f8fafc; +} + +.board-table tbody tr:last-child { + border-bottom: none; +} + +.board-table td { + padding: 12px; + text-align: center; + vertical-align: middle; + font-size: 0.9rem; +} + +/* 컬럼 너비 설정 */ +.col-no { width: 60px; } +.col-category { width: 100px; } +.col-title { width: auto; min-width: 200px; text-align: left; } +.col-attachment { width: 80px; } +.col-date { width: 120px; } +.col-actions { width: 150px; } + +/* 제목 스타일 */ +.board-title { + color: #374151; + font-weight: 500; + text-decoration: none; + cursor: pointer; + display: block; + padding: 8px; + border-radius: 4px; + transition: all 0.2s ease; +} + +.board-title:hover { + background-color: #e0e7ff; + color: #4f46e5; +} + +/* 카테고리 배지 */ +.category-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; + color: white; +} + +.category-문서 { background: #3b82f6; } +.category-이미지 { background: #10b981; } +.category-동영상 { background: #f59e0b; } +.category-프레젠테이션 { background: #ef4444; } +.category-기타 { background: #6b7280; } + +/* 첨부파일 아이콘 */ +.attachment-icon { + font-size: 1.2rem; + color: #10b981; +} + +.attachment-icons { + display: flex; + gap: 2px; + justify-content: center; + align-items: center; + flex-wrap: wrap; + font-size: 1rem; + line-height: 1.2; +} + +.attachment-icons span { + display: inline-block; +} + +.attachment-icon-clickable { + cursor: pointer; + transition: all 0.2s ease; + padding: 2px; + border-radius: 3px; +} + +.attachment-icon-clickable:hover { + background-color: #e0e7ff; + transform: scale(1.1); +} + +.no-attachment { + color: #9ca3af; +} + +/* 액션 버튼 */ +.action-buttons { + display: flex; + gap: 5px; + justify-content: center; + align-items: center; +} + +.action-btn { + padding: 6px 12px; + border: none; + border-radius: 4px; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s ease; + font-weight: 500; +} + +.btn-download { + background: #10b981; + color: white; +} + +.btn-download:hover { + background: #059669; + transform: translateY(-1px); +} + +/* 빈 상태 */ +.empty-state td { + padding: 60px 20px; + text-align: center; + color: #6b7280; + font-size: 1.1rem; +} + +/* 페이지네이션 스타일 */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 15px; + margin-top: 20px; + padding: 20px 0; +} + +.page-btn { + padding: 8px 16px; + border: 2px solid #e2e8f0; + border-radius: 6px; + background: white; + color: #4a5568; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.2s ease; +} + +.page-btn:hover:not(:disabled) { + border-color: #667eea; + color: #667eea; + background: #f0f4ff; +} + +.page-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + color: #9ca3af; +} + +#pageInfo { + font-weight: 600; + color: #4a5568; + font-size: 1rem; +} + .file-list { display: grid; gap: 20px; @@ -470,10 +720,47 @@ header p { .download-btn { background: #38a169; color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; + font-weight: 500; } .download-btn:hover { background: #2f855a; + transform: translateY(-1px); +} + +.no-files { + color: #a0aec0; + font-style: italic; + font-size: 0.9rem; +} + +.file-attachments { + margin-top: 10px; + padding: 8px 12px; + background: rgba(59, 130, 246, 0.1); + border-radius: 6px; + border: 1px solid rgba(59, 130, 246, 0.2); +} + +.file-attachments strong { + color: #1e40af; + margin-right: 8px; +} + +.attachment { + display: inline-block; + background: rgba(59, 130, 246, 0.2); + color: #1e40af; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.85rem; + margin: 2px; } .empty-state { diff --git a/temp-public-policy.sql b/temp-public-policy.sql new file mode 100644 index 0000000..e0813fa --- /dev/null +++ b/temp-public-policy.sql @@ -0,0 +1,16 @@ +-- 임시 공개 접근 정책 (테스트용만 사용!) +-- 보안상 권장하지 않음 - 운영환경에서는 사용하지 마세요 + +-- 모든 사용자가 files 버킷에 업로드 가능 (임시) +CREATE POLICY "Public upload for testing" +ON storage.objects +FOR INSERT +WITH CHECK (bucket_id = 'files'); + +-- 모든 사용자가 files 버킷 파일 조회 가능 (임시) +CREATE POLICY "Public read for testing" +ON storage.objects +FOR SELECT +USING (bucket_id = 'files'); + +-- 주의: 이 정책들은 테스트 후 반드시 삭제하고 위의 사용자별 정책으로 교체하세요! \ No newline at end of file diff --git a/게시판.png b/게시판.png new file mode 100644 index 0000000..099b68d Binary files /dev/null and b/게시판.png differ diff --git a/오류.png b/오류.png new file mode 100644 index 0000000..7b98a61 Binary files /dev/null and b/오류.png differ