Initial commit: Web-based file management system (자료실)
- 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 <noreply@anthropic.com>
This commit is contained in:
146
CLAUDE.md
Normal file
146
CLAUDE.md
Normal file
@@ -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
|
181
index.html
Normal file
181
index.html
Normal file
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>자료실 - CRUD 시스템</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>📚 자료실 관리 시스템</h1>
|
||||
<p>파일과 문서를 효율적으로 관리하세요</p>
|
||||
<div id="authSection" class="auth-section">
|
||||
<div id="authButtons" class="auth-buttons">
|
||||
<button id="loginBtn" class="auth-btn">🔑 로그인</button>
|
||||
<button id="signupBtn" class="auth-btn">👤 회원가입</button>
|
||||
</div>
|
||||
<div id="userInfo" class="user-info" style="display: none;">
|
||||
<span id="userEmail"></span>
|
||||
<span id="syncStatus" class="sync-status online">🟢 온라인</span>
|
||||
<button id="logoutBtn" class="auth-btn">🚪 로그아웃</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="search-section">
|
||||
<input type="text" id="searchInput" placeholder="제목, 설명, 카테고리로 검색...">
|
||||
<select id="categoryFilter">
|
||||
<option value="">전체 카테고리</option>
|
||||
<option value="문서">문서</option>
|
||||
<option value="이미지">이미지</option>
|
||||
<option value="동영상">동영상</option>
|
||||
<option value="프레젠테이션">프레젠테이션</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
<button id="searchBtn">🔍 검색</button>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>📁 새 자료 추가</h2>
|
||||
<form id="fileForm">
|
||||
<div class="form-group">
|
||||
<label for="fileTitle">제목 *</label>
|
||||
<input type="text" id="fileTitle" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="fileDescription">설명</label>
|
||||
<textarea id="fileDescription" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="fileCategory">카테고리 *</label>
|
||||
<select id="fileCategory" required>
|
||||
<option value="">카테고리 선택</option>
|
||||
<option value="문서">문서</option>
|
||||
<option value="이미지">이미지</option>
|
||||
<option value="동영상">동영상</option>
|
||||
<option value="프레젠테이션">프레젠테이션</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="fileUpload">파일 첨부</label>
|
||||
<input type="file" id="fileUpload" multiple>
|
||||
<small>여러 파일을 선택할 수 있습니다</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="fileTags">태그</label>
|
||||
<input type="text" id="fileTags" placeholder="쉼표로 구분하여 입력 (예: 중요, 업무, 프로젝트)">
|
||||
</div>
|
||||
|
||||
<div class="form-buttons">
|
||||
<button type="submit" id="submitBtn">📤 추가</button>
|
||||
<button type="button" id="cancelBtn">❌ 취소</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="list-section">
|
||||
<div class="list-header">
|
||||
<h2>📋 자료 목록</h2>
|
||||
<div class="sort-options">
|
||||
<select id="sortBy">
|
||||
<option value="date">최신순</option>
|
||||
<option value="title">제목순</option>
|
||||
<option value="category">카테고리순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fileList" class="file-list">
|
||||
<div class="empty-state">
|
||||
<p>📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 인증 모달 -->
|
||||
<div id="authModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2 id="authModalTitle">🔑 로그인</h2>
|
||||
<form id="authForm">
|
||||
<div class="form-group">
|
||||
<label for="authEmail">이메일 *</label>
|
||||
<input type="email" id="authEmail" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="authPassword">비밀번호 *</label>
|
||||
<input type="password" id="authPassword" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="confirmPasswordGroup" style="display: none;">
|
||||
<label for="authConfirmPassword">비밀번호 확인 *</label>
|
||||
<input type="password" id="authConfirmPassword">
|
||||
</div>
|
||||
|
||||
<div class="form-buttons">
|
||||
<button type="submit" id="authSubmitBtn">🔑 로그인</button>
|
||||
<button type="button" id="authCancelBtn">❌ 취소</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-switch">
|
||||
<p id="authSwitchText">계정이 없으신가요? <a href="#" id="authSwitchLink">회원가입하기</a></p>
|
||||
</div>
|
||||
</form>
|
||||
<div id="authLoading" class="loading" style="display: none;">
|
||||
<p>처리 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수정 모달 -->
|
||||
<div id="editModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>✏️ 자료 수정</h2>
|
||||
<form id="editForm">
|
||||
<div class="form-group">
|
||||
<label for="editTitle">제목 *</label>
|
||||
<input type="text" id="editTitle" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editDescription">설명</label>
|
||||
<textarea id="editDescription" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editCategory">카테고리 *</label>
|
||||
<select id="editCategory" required>
|
||||
<option value="문서">문서</option>
|
||||
<option value="이미지">이미지</option>
|
||||
<option value="동영상">동영상</option>
|
||||
<option value="프레젠테이션">프레젠테이션</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editTags">태그</label>
|
||||
<input type="text" id="editTags" placeholder="쉼표로 구분하여 입력">
|
||||
</div>
|
||||
|
||||
<div class="form-buttons">
|
||||
<button type="submit">💾 저장</button>
|
||||
<button type="button" id="closeModal">❌ 취소</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="supabase-config.js"></script>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
914
script.js
Normal file
914
script.js
Normal file
@@ -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 = '이미 계정이 있으신가요? <a href="#" id="authSwitchLink">로그인하기</a>';
|
||||
} else {
|
||||
title.textContent = '🔑 로그인';
|
||||
submitBtn.textContent = '🔑 로그인';
|
||||
confirmPasswordGroup.style.display = 'none';
|
||||
switchText.innerHTML = '계정이 없으신가요? <a href="#" id="authSwitchLink">회원가입하기</a>';
|
||||
}
|
||||
|
||||
// 이벤트 리스너 재바인딩
|
||||
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 = `
|
||||
<span>📎 ${file.name} (${this.formatFileSize(file.size)})</span>
|
||||
<button type="button" class="remove-file" onclick="fileManager.removeFileFromInput(${index})">제거</button>
|
||||
`;
|
||||
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 = '<div class="empty-state"><p>📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!</p></div>';
|
||||
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 => `<span class="tag">${tag}</span>`).join('');
|
||||
const filesHTML = file.files.length > 0 ?
|
||||
`<div class="file-attachments">
|
||||
<strong>첨부파일 (${file.files.length}개):</strong>
|
||||
${file.files.map(f => `<span class="file-name">📎 ${f.name}</span>`).join(', ')}
|
||||
</div>` : '';
|
||||
|
||||
return `
|
||||
<div class="file-item" data-id="${file.id}">
|
||||
<div class="file-header">
|
||||
<div>
|
||||
<div class="file-title">${this.escapeHtml(file.title)}</div>
|
||||
<div class="file-meta">
|
||||
<span class="category-badge">${file.category}</span>
|
||||
<span>📅 생성: ${createdDate}</span>
|
||||
${createdDate !== updatedDate ? `<span>✏️ 수정: ${updatedDate}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${file.description ? `<div class="file-description">${this.escapeHtml(file.description)}</div>` : ''}
|
||||
|
||||
${file.tags.length > 0 ? `<div class="file-tags">${tagsHTML}</div>` : ''}
|
||||
|
||||
${filesHTML}
|
||||
|
||||
<div class="file-actions">
|
||||
<button class="edit-btn" onclick="fileManager.editFile('${file.id}')">✏️ 수정</button>
|
||||
<button class="delete-btn" onclick="fileManager.deleteFile('${file.id}')">🗑️ 삭제</button>
|
||||
${file.files.length > 0 ? `<button class="download-btn" onclick="fileManager.downloadFiles('${file.id}')">💾 다운로드</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = '<div class="empty-state"><p>🔍 검색 결과가 없습니다. 다른 키워드로 검색해보세요!</p></div>';
|
||||
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 = '<div class="empty-state"><p>📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
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('📚 자료실 관리 시스템이 초기화되었습니다.');
|
||||
});
|
150
setup-guide.md
Normal file
150
setup-guide.md
Normal file
@@ -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)를 참고하거나 이슈를 등록해주세요.
|
572
styles.css
Normal file
572
styles.css
Normal file
@@ -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%;
|
||||
}
|
||||
}
|
223
supabase-config.js
Normal file
223
supabase-config.js
Normal file
@@ -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;
|
128
supabase-schema.sql
Normal file
128
supabase-schema.sql
Normal file
@@ -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;
|
Reference in New Issue
Block a user