From ac506321e8628490edcae7bf49f8a296b59ad93d Mon Sep 17 00:00:00 2001 From: vibsin9322 Date: Tue, 19 Aug 2025 12:51:49 +0900 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Web-based=20file=20manageme?= =?UTF-8?q?nt=20system=20(=EC=9E=90=EB=A3=8C=EC=8B=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Complete CRUD functionality for file records - Hybrid Supabase cloud database + localStorage support - User authentication and authorization - File upload with cloud storage - Search, filtering, and categorization features - Responsive design with offline capabilities πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 146 +++++++ index.html | 181 +++++++++ script.js | 914 ++++++++++++++++++++++++++++++++++++++++++++ setup-guide.md | 150 ++++++++ styles.css | 572 +++++++++++++++++++++++++++ supabase-config.js | 223 +++++++++++ supabase-schema.sql | 128 +++++++ 7 files changed, 2314 insertions(+) create mode 100644 CLAUDE.md create mode 100644 index.html create mode 100644 script.js create mode 100644 setup-guide.md create mode 100644 styles.css create mode 100644 supabase-config.js create mode 100644 supabase-schema.sql diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5c278c7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,146 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a web-based file management system (μžλ£Œμ‹€) with full CRUD functionality. It's a hybrid application built with vanilla HTML, CSS, and JavaScript that supports both Supabase cloud database and localStorage for data persistence, providing seamless offline/online capabilities. + +### Key Features +- Create, Read, Update, Delete operations for file records +- File upload with multiple file support (local + cloud storage) +- User authentication and authorization +- Search and filtering by title, description, tags, and category +- Categorization system (λ¬Έμ„œ, 이미지, λ™μ˜μƒ, ν”„λ ˆμ  ν…Œμ΄μ…˜, 기타) +- Tag-based organization +- Responsive design for mobile and desktop +- Modal-based editing interface +- Cloud database with real-time synchronization +- Offline support with localStorage fallback +- Cross-device data synchronization + +## File Structure + +``` +μžλ£Œμ‹€/ +β”œβ”€β”€ index.html # Main HTML file with UI structure and auth components +β”œβ”€β”€ styles.css # Complete styling with responsive design and auth styles +β”œβ”€β”€ script.js # Core JavaScript with FileManager class and Supabase integration +β”œβ”€β”€ supabase-config.js # Supabase configuration and helper functions +β”œβ”€β”€ supabase-schema.sql # Database schema for Supabase setup +β”œβ”€β”€ setup-guide.md # Comprehensive Supabase setup guide +└── CLAUDE.md # This documentation file +``` + +## Architecture + +### Core Components + +1. **FileManager Class** (`script.js`) + - Main application controller with hybrid storage support + - Handles all CRUD operations (Supabase + localStorage fallback) + - Manages user authentication and session state + - Contains event handling and UI updates + - Real-time synchronization capabilities + +2. **Supabase Integration** (`supabase-config.js`) + - Database configuration and connection management + - Authentication helper functions (signup, login, logout) + - CRUD helper functions for files and attachments + - Storage helper functions for file uploads/downloads + - Real-time subscription management + +3. **Data Model** + ```javascript + // Files table + { + id: UUID, // Primary key + title: string, // File title (required) + description: string, // Optional description + category: string, // Required category + tags: string[], // Array of tags + user_id: UUID, // Foreign key to auth.users + created_at: timestamp, + updated_at: timestamp + } + + // File attachments table + { + id: UUID, + file_id: UUID, // Foreign key to files + original_name: string, + storage_path: string, // Supabase Storage path + file_size: integer, + mime_type: string, + created_at: timestamp + } + ``` + +4. **UI Components** + - Authentication section (login/signup/logout) + - Sync status indicator + - Search and filter section + - Add new file form with cloud upload + - File list display with sorting + - Edit modal for updates + - Responsive card-based layout + - Offline mode notifications + +### Development Commands + +This is a hybrid web application supporting both online and offline modes: + +1. **Local Development** + ```bash + # Open index.html in a web browser + # OR use a simple HTTP server: + python -m http.server 8000 + # OR + npx serve . + ``` + +2. **Supabase Setup (Required for online features)** + ```bash + # 1. Follow setup-guide.md for complete setup + # 2. Create Supabase project and database + # 3. Run supabase-schema.sql in SQL Editor + # 4. Create Storage bucket named 'files' + # 5. Update supabase-config.js with your credentials + ``` + +3. **File Access** + - Open `index.html` directly in browser + - Works offline with localStorage (limited functionality) + - Full features available with Supabase configuration + +### Technical Implementation + +- **Database**: Supabase PostgreSQL with Row Level Security (RLS) +- **Storage**: Supabase Storage for files + localStorage fallback +- **Authentication**: Supabase Auth with email/password +- **Real-time**: Supabase Realtime for live synchronization +- **File Handling**: FileReader API + Supabase Storage API +- **UI Updates**: Vanilla JavaScript DOM manipulation +- **Styling**: CSS Grid and Flexbox for responsive layouts +- **Animations**: CSS transitions and keyframe animations +- **Offline Support**: Automatic fallback to localStorage when offline + +### Data Management + +- **Online Mode**: Files stored in Supabase PostgreSQL + Storage +- **Offline Mode**: Files stored as base64 strings in localStorage +- **Hybrid Sync**: Automatic synchronization when connection restored +- User-specific data isolation with RLS policies +- Search works across title, description, and tags +- Sorting available by date, title, or category +- Categories are predefined but can be extended +- Real-time updates across devices for same user + +### Browser Compatibility + +- Modern browsers with ES6+ support +- localStorage API support required +- FileReader API for file uploads +- Fetch API for Supabase communication +- WebSocket support for real-time features +- External dependency: Supabase JavaScript client library \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..105426c --- /dev/null +++ b/index.html @@ -0,0 +1,181 @@ + + + + + + μžλ£Œμ‹€ - CRUD μ‹œμŠ€ν…œ + + + + +
+
+

πŸ“š μžλ£Œμ‹€ 관리 μ‹œμŠ€ν…œ

+

파일과 λ¬Έμ„œλ₯Ό 효율적으둜 κ΄€λ¦¬ν•˜μ„Έμš”

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

πŸ“ μƒˆ 자료 μΆ”κ°€

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + μ—¬λŸ¬ νŒŒμΌμ„ 선택할 수 μžˆμŠ΅λ‹ˆλ‹€ +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+

πŸ“‹ 자료 λͺ©λ‘

+
+ +
+
+ +
+
+

πŸ“‚ λ“±λ‘λœ μžλ£Œκ°€ μ—†μŠ΅λ‹ˆλ‹€. μƒˆ 자료λ₯Ό μΆ”κ°€ν•΄λ³΄μ„Έμš”!

+
+
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 0000000..2aec689 --- /dev/null +++ b/script.js @@ -0,0 +1,914 @@ +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.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.bindEvents(); + this.renderFiles(); + this.updateEmptyState(); + this.setupOnlineStatusListener(); + } + + 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(); + } + }); + } + + generateId() { + return Date.now().toString(36) + Math.random().toString(36).substr(2); + } + + // 인증 κ΄€λ ¨ λ©”μ„œλ“œλ“€ + async initializeAuth() { + try { + const user = await getCurrentUser(); + if (user) { + this.currentUser = user; + this.updateAuthUI(true); + await this.loadUserFiles(); + this.setupRealtimeSubscription(); + } else { + this.updateAuthUI(false); + } + + 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.files = []; + this.renderFiles(); + this.cleanupRealtimeSubscription(); + } + }); + } catch (error) { + console.error('인증 μ΄ˆκΈ°ν™” 였λ₯˜:', error); + } + } + + updateAuthUI(isAuthenticated) { + const authButtons = document.getElementById('authButtons'); + const userInfo = document.getElementById('userInfo'); + const userEmail = document.getElementById('userEmail'); + + if (isAuthenticated && this.currentUser) { + authButtons.style.display = 'none'; + userInfo.style.display = 'flex'; + userEmail.textContent = this.currentUser.email; + this.updateSyncStatus(); + } else { + authButtons.style.display = 'flex'; + userInfo.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); + } + + 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'); + const result = await SupabaseHelper.addFile(fileData, this.currentUser.id); + + // μ²¨λΆ€νŒŒμΌμ΄ μžˆλŠ” 경우 파일 μ—…λ‘œλ“œ 처리 + if (fileData.files && fileData.files.length > 0) { + await this.uploadAttachments(result.id, fileData.files); + } + + this.showNotification('μƒˆ μžλ£Œκ°€ μ„±κ³΅μ μœΌλ‘œ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€!', 'success'); + await this.loadUserFiles(); // λͺ©λ‘ μƒˆλ‘œκ³ μΉ¨ + this.updateSyncStatus('online'); + } catch (error) { + console.error('파일 μΆ”κ°€ 였λ₯˜:', error); + this.showNotification('파일 μΆ”κ°€ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.', 'error'); + this.updateSyncStatus('error'); + } + } + + 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) { + this.files.push(fileData); + this.saveFiles(); + this.renderFiles(); + this.updateEmptyState(); + this.showNotification('μƒˆ μžλ£Œκ°€ μ„±κ³΅μ μœΌλ‘œ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€! (둜컬 μ €μž₯)', 'success'); + } + + 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) { + return; // μ˜€ν”„λΌμΈ λͺ¨λ“œμ—μ„œλŠ” base64둜 μ €μž₯된 μƒνƒœ μœ μ§€ + } + + try { + for (const attachment of attachments) { + // base64 데이터λ₯Ό Blob으둜 λ³€ν™˜ + const response = await fetch(attachment.data); + const blob = await response.blob(); + + // 파일 경둜 생성 (μ‚¬μš©μžλ³„/파일ID별 폴더 ꡬ쑰) + const fileName = `${Date.now()}_${attachment.name}`; + const filePath = `${this.currentUser.id}/${fileId}/${fileName}`; + + // Supabase Storage에 μ—…λ‘œλ“œ + await SupabaseHelper.uploadFile(blob, filePath); + + // λ°μ΄ν„°λ² μ΄μŠ€μ— μ²¨λΆ€νŒŒμΌ 정보 μ €μž₯ + if (supabase) { + await supabase.from('file_attachments').insert({ + file_id: fileId, + original_name: attachment.name, + storage_path: filePath, + file_size: attachment.size || blob.size, + mime_type: attachment.type || blob.type + }); + } + } + } catch (error) { + console.error('파일 μ—…λ‘œλ“œ 였λ₯˜:', error); + throw error; + } + } + + async downloadFileFromStorage(filePath, originalName) { + if (!isSupabaseConfigured()) { + return; // μ˜€ν”„λΌμΈ λͺ¨λ“œμ—μ„œλŠ” μ²˜λ¦¬ν•˜μ§€ μ•ŠμŒ + } + + try { + const url = await SupabaseHelper.getFileUrl(filePath); + + // λ‹€μš΄λ‘œλ“œ 링크 생성 + const link = document.createElement('a'); + link.href = url; + link.download = originalName; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (error) { + console.error('파일 λ‹€μš΄λ‘œλ“œ 였λ₯˜:', error); + this.showNotification('파일 λ‹€μš΄λ‘œλ“œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.', '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(); + + 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 = { + id: this.generateId(), + title, + description, + category, + tags, + files: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + 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.createdAt) - new Date(a.createdAt)); + break; + } + + if (sortedFiles.length === 0) { + container.innerHTML = '

πŸ“‚ λ“±λ‘λœ μžλ£Œκ°€ μ—†μŠ΅λ‹ˆλ‹€. μƒˆ 자료λ₯Ό μΆ”κ°€ν•΄λ³΄μ„Έμš”!

'; + return; + } + + container.innerHTML = sortedFiles.map(file => this.createFileHTML(file)).join(''); + } + + createFileHTML(file) { + const createdDate = new Date(file.createdAt).toLocaleDateString('ko-KR'); + const updatedDate = new Date(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 => `πŸ“Ž ${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.files.length > 0 ? `` : ''} +
+
+ `; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + editFile(id) { + const file = this.files.find(f => f.id === id); + if (!file) 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) { + const file = this.files.find(f => f.id === id); + if (!file) 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); + } + } + this.showNotification(`${file.files.length}개의 파일이 λ‹€μš΄λ‘œλ“œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!`, 'success'); + } catch (error) { + console.error('파일 λ‹€μš΄λ‘œλ“œ 였λ₯˜:', error); + this.showNotification('파일 λ‹€μš΄λ‘œλ“œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.', 'error'); + } + } + + 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.createdAt) - new Date(a.createdAt)); + 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 = ''; + } + } + + 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 notification = document.createElement('div'); + notification.className = `notification notification-${type}`; + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: ${type === 'success' ? '#48bb78' : '#667eea'}; + color: white; + padding: 15px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1001; + animation: slideIn 0.3s ease; + max-width: 300px; + word-wrap: break-word; + `; + notification.textContent = message; + + document.body.appendChild(notification); + + setTimeout(() => { + notification.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 300); + }, 3000); + } + + loadFiles() { + try { + const stored = localStorage.getItem('fileManagerData'); + return stored ? JSON.parse(stored) : []; + } 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/setup-guide.md b/setup-guide.md new file mode 100644 index 0000000..f5f9ec7 --- /dev/null +++ b/setup-guide.md @@ -0,0 +1,150 @@ +# Supabase μ„€μ • κ°€μ΄λ“œ + +이 λ¬Έμ„œλŠ” μžλ£Œμ‹€ μ‹œμŠ€ν…œμ„ Supabase와 μ—°λ™ν•˜κΈ° μœ„ν•œ μ„€μ • κ°€μ΄λ“œμž…λ‹ˆλ‹€. + +## 1. Supabase ν”„λ‘œμ νŠΈ 생성 + +1. [Supabase](https://supabase.com)에 μ ‘μ†ν•˜μ—¬ 계정을 μƒμ„±ν•©λ‹ˆλ‹€. +2. μƒˆ ν”„λ‘œμ νŠΈλ₯Ό μƒμ„±ν•©λ‹ˆλ‹€. +3. ν”„λ‘œμ νŠΈ 이름과 λΉ„λ°€λ²ˆν˜Έλ₯Ό μ„€μ •ν•©λ‹ˆλ‹€. +4. 리전은 `ap-northeast-1` (Asia Pacific - Tokyo)λ₯Ό μ„ νƒν•˜λŠ” 것을 ꢌμž₯ν•©λ‹ˆλ‹€. + +## 2. λ°μ΄ν„°λ² μ΄μŠ€ μŠ€ν‚€λ§ˆ μ„€μ • + +1. Supabase λŒ€μ‹œλ³΄λ“œμ—μ„œ **SQL Editor**둜 μ΄λ™ν•©λ‹ˆλ‹€. +2. `supabase-schema.sql` 파일의 λ‚΄μš©μ„ λ³΅μ‚¬ν•˜μ—¬ μ‹€ν–‰ν•©λ‹ˆλ‹€. +3. μŠ€ν‚€λ§ˆκ°€ μ„±κ³΅μ μœΌλ‘œ μƒμ„±λ˜μ—ˆλŠ”μ§€ **Table Editor**μ—μ„œ ν™•μΈν•©λ‹ˆλ‹€. + +### μƒμ„±λ˜λŠ” ν…Œμ΄λΈ” +- `files`: 파일 메타데이터 μ €μž₯ +- `file_attachments`: μ²¨λΆ€νŒŒμΌ 정보 μ €μž₯ + +## 3. Storage 버킷 μ„€μ • + +1. Supabase λŒ€μ‹œλ³΄λ“œμ—μ„œ **Storage**둜 μ΄λ™ν•©λ‹ˆλ‹€. +2. **New bucket** λ²„νŠΌμ„ ν΄λ¦­ν•©λ‹ˆλ‹€. +3. 버킷 이름을 `files`둜 μ„€μ •ν•©λ‹ˆλ‹€. +4. **Public bucket** μ²΄ν¬λ°•μŠ€λŠ” ν•΄μ œν•©λ‹ˆλ‹€ (λ³΄μ•ˆμƒ ꢌμž₯). +5. 버킷을 μƒμ„±ν•©λ‹ˆλ‹€. + +### Storage μ •μ±… μ„€μ • +버킷 생성 ν›„ **Policies** νƒ­μ—μ„œ λ‹€μŒ 정책듀을 μΆ”κ°€ν•©λ‹ˆλ‹€: + +#### SELECT μ •μ±… (파일 쑰회) +```sql +CREATE POLICY "Users can view their own files" ON storage.objects +FOR SELECT USING ( + bucket_id = 'files' AND + auth.uid()::text = (storage.foldername(name))[1] +); +``` + +#### INSERT μ •μ±… (파일 μ—…λ‘œλ“œ) +```sql +CREATE POLICY "Users can upload their own files" ON storage.objects +FOR INSERT WITH CHECK ( + bucket_id = 'files' AND + auth.uid()::text = (storage.foldername(name))[1] +); +``` + +#### DELETE μ •μ±… (파일 μ‚­μ œ) +```sql +CREATE POLICY "Users can delete their own files" ON storage.objects +FOR DELETE USING ( + bucket_id = 'files' AND + auth.uid()::text = (storage.foldername(name))[1] +); +``` + +## 4. API ν‚€ 및 URL μ„€μ • + +1. Supabase λŒ€μ‹œλ³΄λ“œμ—μ„œ **Settings** > **API**둜 μ΄λ™ν•©λ‹ˆλ‹€. +2. λ‹€μŒ 정보λ₯Ό ν™•μΈν•©λ‹ˆλ‹€: + - **Project URL**: `https://your-project-id.supabase.co` + - **Project API keys** > **anon public**: `eyJ...` + +3. `supabase-config.js` νŒŒμΌμ„ μˆ˜μ •ν•©λ‹ˆλ‹€: +```javascript +const SUPABASE_CONFIG = { + url: 'https://your-project-id.supabase.co', // μ‹€μ œ Project URL둜 ꡐ체 + anonKey: 'eyJ...' // μ‹€μ œ anon public key둜 ꡐ체 +}; +``` + +## 5. 인증 μ„€μ • (선택사항) + +### 이메일 인증 λΉ„ν™œμ„±ν™” (개발용) +개발 ν™˜κ²½μ—μ„œ λΉ λ₯Έ ν…ŒμŠ€νŠΈλ₯Ό μœ„ν•΄ 이메일 인증을 λΉ„ν™œμ„±ν™”ν•  수 μžˆμŠ΅λ‹ˆλ‹€: + +1. **Authentication** > **Settings**둜 이동 +2. **Enable email confirmations** μ²΄ν¬λ°•μŠ€ ν•΄μ œ +3. **Save** 클릭 + +⚠️ **주의**: ν”„λ‘œλ•μ…˜ ν™˜κ²½μ—μ„œλŠ” 이메일 인증을 ν™œμ„±ν™”ν•˜λŠ” 것을 κ°•λ ₯히 ꢌμž₯ν•©λ‹ˆλ‹€. + +### 이메일 ν…œν”Œλ¦Ώ μ„€μ • (ν”„λ‘œλ•μ…˜μš©) +1. **Authentication** > **Email Templates**μ—μ„œ 이메일 ν…œν”Œλ¦Ώμ„ μ»€μŠ€ν„°λ§ˆμ΄μ§•ν•  수 μžˆμŠ΅λ‹ˆλ‹€. +2. νšŒμ‚¬ λΈŒλžœλ“œμ— 맞게 이메일 λ””μžμΈμ„ μˆ˜μ •ν•˜μ„Έμš”. + +## 6. λ³΄μ•ˆ μ„€μ • + +### Row Level Security (RLS) +μŠ€ν‚€λ§ˆ μ‹€ν–‰ μ‹œ μžλ™μœΌλ‘œ μ„€μ •λ˜μ§€λ§Œ, λ‹€μŒ 사항을 ν™•μΈν•˜μ„Έμš”: + +1. **Authentication** > **Policies**μ—μ„œ 정책이 μ˜¬λ°”λ₯΄κ²Œ μ„€μ •λ˜μ—ˆλŠ”μ§€ 확인 +2. 각 ν…Œμ΄λΈ”μ— μ‚¬μš©μžλ³„ μ ‘κ·Ό μ œν•œμ΄ μ μš©λ˜μ–΄ μžˆλŠ”μ§€ 확인 + +### ν™˜κ²½λ³€μˆ˜ λ³΄μ•ˆ +ν”„λ‘œλ•μ…˜ ν™˜κ²½μ—μ„œλŠ” API ν‚€λ₯Ό ν™˜κ²½λ³€μˆ˜λ‘œ κ΄€λ¦¬ν•˜μ„Έμš”: + +```javascript +const SUPABASE_CONFIG = { + url: process.env.SUPABASE_URL || 'YOUR_SUPABASE_PROJECT_URL', + anonKey: process.env.SUPABASE_ANON_KEY || 'YOUR_SUPABASE_ANON_KEY' +}; +``` + +## 7. ν…ŒμŠ€νŠΈ + +μ„€μ • μ™„λ£Œ ν›„ λ‹€μŒ κΈ°λŠ₯듀을 ν…ŒμŠ€νŠΈν•˜μ„Έμš”: + +1. **νšŒμ›κ°€μž…/둜그인** - μƒˆ 계정 생성 및 둜그인 +2. **파일 μΆ”κ°€** - μƒˆ 자료 μΆ”κ°€ (μ²¨λΆ€νŒŒμΌ 포함) +3. **파일 μˆ˜μ •** - κΈ°μ‘΄ 자료 μˆ˜μ • +4. **파일 μ‚­μ œ** - 자료 μ‚­μ œ (μ²¨λΆ€νŒŒμΌλ„ ν•¨κ»˜ μ‚­μ œλ˜λŠ”μ§€ 확인) +5. **파일 λ‹€μš΄λ‘œλ“œ** - μ²¨λΆ€νŒŒμΌ λ‹€μš΄λ‘œλ“œ +6. **μ‹€μ‹œκ°„ 동기화** - λ‹€λ₯Έ λΈŒλΌμš°μ €μ—μ„œ 같은 κ³„μ •μœΌλ‘œ λ‘œκ·ΈμΈν•˜μ—¬ μ‹€μ‹œκ°„ 동기화 확인 + +## 8. 문제 ν•΄κ²° + +### μ—°κ²° 였λ₯˜ +- Supabase URLκ³Ό API ν‚€κ°€ μ˜¬λ°”λ₯Έμ§€ 확인 +- λΈŒλΌμš°μ € μ½˜μ†”μ—μ„œ 였λ₯˜ λ©”μ‹œμ§€ 확인 +- CORS μ„€μ • 확인 (λŒ€λΆ€λΆ„ μžλ™μœΌλ‘œ 섀정됨) + +### κΆŒν•œ 였λ₯˜ +- RLS 정책이 μ˜¬λ°”λ₯΄κ²Œ μ„€μ •λ˜μ—ˆλŠ”μ§€ 확인 +- μ‚¬μš©μžκ°€ μ˜¬λ°”λ₯΄κ²Œ μΈμ¦λ˜μ—ˆλŠ”μ§€ 확인 + +### 파일 μ—…λ‘œλ“œ 였λ₯˜ +- Storage 버킷이 μ˜¬λ°”λ₯΄κ²Œ μƒμ„±λ˜μ—ˆλŠ”μ§€ 확인 +- Storage 정책이 μ˜¬λ°”λ₯΄κ²Œ μ„€μ •λ˜μ—ˆλŠ”μ§€ 확인 +- 파일 크기 μ œν•œ 확인 (Supabase κΈ°λ³Έκ°’: 50MB) + +## 9. μΆ”κ°€ κ°œμ„ μ‚¬ν•­ + +### μ„±λŠ₯ μ΅œμ ν™” +- λŒ€μš©λŸ‰ 파일 처리λ₯Ό μœ„ν•œ chunk μ—…λ‘œλ“œ κ΅¬ν˜„ +- 이미지 μ΅œμ ν™” 및 썸넀일 생성 +- CDN 연동 κ³ λ € + +### κΈ°λŠ₯ ν™•μž₯ +- 파일 곡유 κΈ°λŠ₯ +- 버전 관리 +- ν˜‘μ—… κΈ°λŠ₯ +- λ°±μ—… 및 볡원 κΈ°λŠ₯ + +--- + +μ„€μ • 쀑 λ¬Έμ œκ°€ λ°œμƒν•˜λ©΄ [Supabase 곡식 λ¬Έμ„œ](https://supabase.com/docs)λ₯Ό μ°Έκ³ ν•˜κ±°λ‚˜ 이슈λ₯Ό λ“±λ‘ν•΄μ£Όμ„Έμš”. \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..66cf1c0 --- /dev/null +++ b/styles.css @@ -0,0 +1,572 @@ +* { + 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; +} + +.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; +} + +.file-list { + display: grid; + gap: 20px; +} + +.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; +} + +.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; +} + +.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%; + } +} \ No newline at end of file diff --git a/supabase-config.js b/supabase-config.js new file mode 100644 index 0000000..fabf629 --- /dev/null +++ b/supabase-config.js @@ -0,0 +1,223 @@ +// Supabase configuration +// ⚠️ μ‹€μ œ μ‚¬μš© μ‹œμ—λŠ” 이 값듀을 ν™˜κ²½λ³€μˆ˜λ‚˜ μ„€μ • 파일둜 κ΄€λ¦¬ν•˜μ„Έμš” +const SUPABASE_CONFIG = { + // μ‹€μ œ Supabase ν”„λ‘œμ νŠΈ URL둜 κ΅μ²΄ν•˜μ„Έμš” + url: 'https://kncudtzthmjegowbgnto.supabase.co', + // μ‹€μ œ Supabase anon key둜 κ΅μ²΄ν•˜μ„Έμš” + anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtuY3VkdHp0aG1qZWdvd2JnbnRvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTU1Njc5OTksImV4cCI6MjA3MTE0Mzk5OX0.NlJN2vdgM96RvyVJE6ILQeDVUOU9X2F9vUn-jr_xlKc' +}; + +// Supabase ν΄λΌμ΄μ–ΈνŠΈ μ΄ˆκΈ°ν™” +let supabase; + +// 섀정이 μœ νš¨ν•œμ§€ 확인 +function isSupabaseConfigured() { + return SUPABASE_CONFIG.url !== 'YOUR_SUPABASE_PROJECT_URL' && + SUPABASE_CONFIG.anonKey !== 'YOUR_SUPABASE_ANON_KEY'; +} + +// Supabase ν΄λΌμ΄μ–ΈνŠΈ μ΄ˆκΈ°ν™” ν•¨μˆ˜ +function initializeSupabase() { + if (!isSupabaseConfigured()) { + console.warn('⚠️ Supabaseκ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. localStorageλ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€.'); + return false; + } + + try { + supabase = window.supabase.createClient(SUPABASE_CONFIG.url, SUPABASE_CONFIG.anonKey); + console.log('βœ… Supabase ν΄λΌμ΄μ–ΈνŠΈκ°€ μ΄ˆκΈ°ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + return true; + } catch (error) { + console.error('❌ Supabase μ΄ˆκΈ°ν™” 였λ₯˜:', error); + return false; + } +} + +// 인증 μƒνƒœ λ³€κ²½ λ¦¬μŠ€λ„ˆ +function setupAuthListener(callback) { + if (!supabase) return; + + supabase.auth.onAuthStateChange((event, session) => { + console.log('Auth state changed:', event, session); + if (callback) callback(event, session); + }); +} + +// ν˜„μž¬ μ‚¬μš©μž κ°€μ Έμ˜€κΈ° +async function getCurrentUser() { + if (!supabase) return null; + + try { + const { data: { user }, error } = await supabase.auth.getUser(); + if (error) throw error; + return user; + } catch (error) { + console.error('μ‚¬μš©μž 정보 κ°€μ Έμ˜€κΈ° 였λ₯˜:', error); + return null; + } +} + +// 둜그인 +async function signIn(email, password) { + if (!supabase) throw new Error('Supabaseκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.'); + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password + }); + + if (error) throw error; + return data; +} + +// νšŒμ›κ°€μž… +async function signUp(email, password, metadata = {}) { + if (!supabase) throw new Error('Supabaseκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.'); + + const { data, error } = await supabase.auth.signUp({ + email, + password, + options: { + data: metadata + } + }); + + if (error) throw error; + return data; +} + +// λ‘œκ·Έμ•„μ›ƒ +async function signOut() { + if (!supabase) throw new Error('Supabaseκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.'); + + const { error } = await supabase.auth.signOut(); + if (error) throw error; +} + +// λ°μ΄ν„°λ² μ΄μŠ€ 헬퍼 ν•¨μˆ˜λ“€ +const SupabaseHelper = { + // 파일 λͺ©λ‘ κ°€μ Έμ˜€κΈ° + async getFiles(userId) { + if (!supabase) throw new Error('Supabaseκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.'); + + const { data, error } = await supabase + .from('files') + .select(` + *, + file_attachments (*) + `) + .eq('user_id', userId) + .order('created_at', { ascending: false }); + + if (error) throw error; + return data; + }, + + // 파일 μΆ”κ°€ + async addFile(fileData, userId) { + if (!supabase) throw new Error('Supabaseκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.'); + + const { data, error } = await supabase + .from('files') + .insert([{ + ...fileData, + user_id: userId + }]) + .select() + .single(); + + if (error) throw error; + return data; + }, + + // 파일 μˆ˜μ • + async updateFile(id, updates, userId) { + if (!supabase) throw new Error('Supabaseκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.'); + + const { data, error } = await supabase + .from('files') + .update({ + ...updates, + updated_at: new Date().toISOString() + }) + .eq('id', id) + .eq('user_id', userId) + .select() + .single(); + + if (error) throw error; + return data; + }, + + // 파일 μ‚­μ œ + async deleteFile(id, userId) { + if (!supabase) throw new Error('Supabaseκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.'); + + const { error } = await supabase + .from('files') + .delete() + .eq('id', id) + .eq('user_id', userId); + + if (error) throw error; + }, + + // μ‹€μ‹œκ°„ ꡬ독 μ„€μ • + subscribeToFiles(userId, callback) { + if (!supabase) return null; + + return supabase + .channel('files') + .on('postgres_changes', { + event: '*', + schema: 'public', + table: 'files', + filter: `user_id=eq.${userId}` + }, callback) + .subscribe(); + }, + + // 파일 μ—…λ‘œλ“œ (Storage) + async uploadFile(file, filePath) { + if (!supabase) throw new Error('Supabaseκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.'); + + const { data, error } = await supabase.storage + .from('files') + .upload(filePath, file); + + if (error) throw error; + return data; + }, + + // 파일 λ‹€μš΄λ‘œλ“œ URL κ°€μ Έμ˜€κΈ° + async getFileUrl(filePath) { + if (!supabase) throw new Error('Supabaseκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.'); + + const { data } = supabase.storage + .from('files') + .getPublicUrl(filePath); + + return data.publicUrl; + }, + + // 파일 μ‚­μ œ (Storage) + async deleteStorageFile(filePath) { + if (!supabase) throw new Error('Supabaseκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.'); + + const { error } = await supabase.storage + .from('files') + .remove([filePath]); + + if (error) throw error; + } +}; + +// μ „μ—­μœΌλ‘œ 내보내기 +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/supabase-schema.sql b/supabase-schema.sql new file mode 100644 index 0000000..3d408a8 --- /dev/null +++ b/supabase-schema.sql @@ -0,0 +1,128 @@ +-- Supabase λ°μ΄ν„°λ² μ΄μŠ€ μŠ€ν‚€λ§ˆ +-- 이 νŒŒμΌμ„ Supabase SQL μ—λ””ν„°μ—μ„œ μ‹€ν–‰ν•˜μ„Έμš” + +-- 1. files ν…Œμ΄λΈ” 생성 +CREATE TABLE IF NOT EXISTS public.files ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL, + tags TEXT[] DEFAULT '{}', + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL +); + +-- 2. file_attachments ν…Œμ΄λΈ” 생성 (파일 첨뢀 정보) +CREATE TABLE IF NOT EXISTS public.file_attachments ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + file_id UUID REFERENCES public.files(id) ON DELETE CASCADE NOT NULL, + original_name TEXT NOT NULL, + storage_path TEXT NOT NULL, + file_size INTEGER NOT NULL, + mime_type TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL +); + +-- 3. Row Level Security (RLS) μ •μ±… ν™œμ„±ν™” +ALTER TABLE public.files ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.file_attachments ENABLE ROW LEVEL SECURITY; + +-- 4. files ν…Œμ΄λΈ” RLS μ •μ±… +-- μ‚¬μš©μžλŠ” μžμ‹ μ˜ 파일만 μ‘°νšŒν•  수 있음 +CREATE POLICY "Users can view their own files" ON public.files + FOR SELECT USING (auth.uid() = user_id); + +-- μ‚¬μš©μžλŠ” μžμ‹ μ˜ 파일만 생성할 수 있음 +CREATE POLICY "Users can create their own files" ON public.files + FOR INSERT WITH CHECK (auth.uid() = user_id); + +-- μ‚¬μš©μžλŠ” μžμ‹ μ˜ 파일만 μˆ˜μ •ν•  수 있음 +CREATE POLICY "Users can update their own files" ON public.files + FOR UPDATE USING (auth.uid() = user_id); + +-- μ‚¬μš©μžλŠ” μžμ‹ μ˜ 파일만 μ‚­μ œν•  수 있음 +CREATE POLICY "Users can delete their own files" ON public.files + FOR DELETE USING (auth.uid() = user_id); + +-- 5. file_attachments ν…Œμ΄λΈ” RLS μ •μ±… +-- μ‚¬μš©μžλŠ” μžμ‹ μ˜ 파일 μ²¨λΆ€λ§Œ μ‘°νšŒν•  수 있음 +CREATE POLICY "Users can view their own file attachments" ON public.file_attachments + FOR SELECT USING ( + auth.uid() = ( + SELECT user_id FROM public.files WHERE id = file_attachments.file_id + ) + ); + +-- μ‚¬μš©μžλŠ” μžμ‹ μ˜ νŒŒμΌμ—λ§Œ 첨뢀λ₯Ό 생성할 수 있음 +CREATE POLICY "Users can create attachments for their own files" ON public.file_attachments + FOR INSERT WITH CHECK ( + auth.uid() = ( + SELECT user_id FROM public.files WHERE id = file_attachments.file_id + ) + ); + +-- μ‚¬μš©μžλŠ” μžμ‹ μ˜ 파일 μ²¨λΆ€λ§Œ μ‚­μ œν•  수 있음 +CREATE POLICY "Users can delete their own file attachments" ON public.file_attachments + FOR DELETE USING ( + auth.uid() = ( + SELECT user_id FROM public.files WHERE id = file_attachments.file_id + ) + ); + +-- 6. 인덱슀 생성 (μ„±λŠ₯ μ΅œμ ν™”) +CREATE INDEX IF NOT EXISTS idx_files_user_id ON public.files(user_id); +CREATE INDEX IF NOT EXISTS idx_files_created_at ON public.files(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_files_category ON public.files(category); +CREATE INDEX IF NOT EXISTS idx_files_tags ON public.files USING GIN(tags); +CREATE INDEX IF NOT EXISTS idx_file_attachments_file_id ON public.file_attachments(file_id); + +-- 7. μ—…λ°μ΄νŠΈ 트리거 ν•¨μˆ˜ (updated_at μžλ™ κ°±μ‹ ) +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- 8. updated_at μžλ™ κ°±μ‹  트리거 +CREATE TRIGGER update_files_updated_at + BEFORE UPDATE ON public.files + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- 9. Storage 버킷 생성 (μ‹€μ œλ‘œλŠ” Supabase Dashboardμ—μ„œ 생성) +-- 버킷 이름: 'files' +-- 곡개 μ•‘μ„ΈμŠ€: false (인증된 μ‚¬μš©μžλ§Œ μ ‘κ·Ό) +-- +-- Storage 정책은 Supabase Dashboardμ—μ„œ λ‹€μŒκ³Ό 같이 μ„€μ •: +-- SELECT: μ‚¬μš©μžλŠ” μžμ‹ μ˜ 파일만 쑰회 κ°€λŠ₯ +-- INSERT: μ‚¬μš©μžλŠ” μžμ‹ μ˜ ν΄λ”μ—λ§Œ μ—…λ‘œλ“œ κ°€λŠ₯ +-- UPDATE: μ‚¬μš©μžλŠ” μžμ‹ μ˜ 파일만 μˆ˜μ • κ°€λŠ₯ +-- DELETE: μ‚¬μš©μžλŠ” μžμ‹ μ˜ 파일만 μ‚­μ œ κ°€λŠ₯ + +-- 10. μœ μš©ν•œ λ·° 생성 (파일과 첨뢀 정보 쑰인) +-- 주의: λ·°λŠ” μžλ™μœΌλ‘œ κΈ°λ³Έ ν…Œμ΄λΈ”μ˜ RLS 정책을 μƒμ†λ°›μœΌλ―€λ‘œ 별도 μ •μ±… μ„€μ • λΆˆν•„μš” +CREATE OR REPLACE VIEW public.files_with_attachments AS +SELECT + f.*, + COALESCE( + JSON_AGG( + JSON_BUILD_OBJECT( + 'id', fa.id, + 'original_name', fa.original_name, + 'storage_path', fa.storage_path, + 'file_size', fa.file_size, + 'mime_type', fa.mime_type, + 'created_at', fa.created_at + ) + ) FILTER (WHERE fa.id IS NOT NULL), + '[]'::json + ) AS attachments +FROM public.files f +LEFT JOIN public.file_attachments fa ON f.id = fa.file_id +GROUP BY f.id, f.title, f.description, f.category, f.tags, f.user_id, f.created_at, f.updated_at; + +-- μ„€μ • μ™„λ£Œ λ©”μ‹œμ§€ +SELECT 'Supabase μŠ€ν‚€λ§ˆ 섀정이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!' as message; \ No newline at end of file