diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 359de5b..68e5096 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -8,7 +8,9 @@
"WebFetch(domain:ngrok.com)",
"Bash(python:*)",
"WebFetch(domain:developers.cloudflare.com)",
- "Bash(git add:*)"
+ "Bash(git add:*)",
+ "Bash(mkdir:*)",
+ "Bash(cp:*)"
],
"deny": [],
"ask": []
diff --git a/admin/index.html b/admin/index.html
new file mode 100644
index 0000000..6f173a4
--- /dev/null
+++ b/admin/index.html
@@ -0,0 +1,210 @@
+
+
+
+
+
+ 자료실 - CRUD 시스템
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 번호 |
+ 카테고리 |
+ 제목 |
+ 첨부 |
+ 등록일 |
+ 관리 |
+
+
+
+
+ 📂 등록된 자료가 없습니다. 새 자료를 추가해보세요! |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
✏️ 자료 수정
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin/script.js b/admin/script.js
new file mode 100644
index 0000000..d6576ba
--- /dev/null
+++ b/admin/script.js
@@ -0,0 +1,1513 @@
+class FileManager {
+ constructor() {
+ this.files = [];
+ this.allFiles = []; // 전체 파일 목록
+ this.currentPage = 1; // 현재 페이지
+ this.selectedFiles = []; // 선택된 파일들
+ this.currentEditId = null;
+ this.currentUser = null;
+ this.isOnline = navigator.onLine;
+ this.realtimeSubscription = null;
+ this.authMode = 'login'; // 'login' or 'signup'
+ this.isOfflineMode = true; // 강제 오프라인 모드
+
+ this.init();
+ }
+
+ async init() {
+ console.log('🔍 FileManager 초기화 시작');
+
+ // Supabase 초기화 - 임시로 false로 설정 (Storage 오류 우회)
+ const supabaseInitialized = false; // initializeSupabase();
+ console.log('🔍 supabaseInitialized:', supabaseInitialized);
+
+ if (supabaseInitialized) {
+ console.log('✅ Supabase 모드로 실행합니다.');
+ await this.initializeAuth();
+ } else {
+ console.log('⚠️ 오프라인 모드로 실행합니다.');
+ // 오프라인 모드에서는 가상 사용자 설정
+ this.currentUser = { id: 'offline-user', email: 'offline@local.com' };
+ console.log('🔍 가상 사용자 설정:', this.currentUser);
+
+ this.files = this.loadFiles();
+ console.log('🔍 파일 로드 완료:', this.files.length, '개');
+
+ this.showOfflineMode();
+ this.updateAuthUI();
+ console.log('🔍 UI 업데이트 완료');
+ }
+
+ this.bindEvents();
+ this.renderFiles();
+ this.updateEmptyState();
+ this.setupOnlineStatusListener();
+
+ // 인증 함수들을 빈 함수로 덮어씌움 (완전 차단)
+ this.overrideAuthFunctions();
+ }
+
+ bindEvents() {
+ // 기존 이벤트
+ document.getElementById('fileForm').addEventListener('submit', (e) => this.handleSubmit(e));
+ document.getElementById('cancelBtn').addEventListener('click', () => this.clearForm());
+ document.getElementById('searchBtn').addEventListener('click', () => this.handleSearch());
+ document.getElementById('searchInput').addEventListener('keyup', (e) => {
+ if (e.key === 'Enter') this.handleSearch();
+ });
+ document.getElementById('categoryFilter').addEventListener('change', () => this.handleSearch());
+ document.getElementById('sortBy').addEventListener('change', () => this.renderFiles());
+ document.getElementById('editForm').addEventListener('submit', (e) => this.handleEditSubmit(e));
+ document.getElementById('closeModal').addEventListener('click', () => this.closeEditModal());
+ document.getElementById('fileUpload').addEventListener('change', (e) => this.handleFileUpload(e));
+
+ // 페이지네이션 이벤트
+ document.getElementById('prevPage').addEventListener('click', () => this.goToPrevPage());
+ document.getElementById('nextPage').addEventListener('click', () => this.goToNextPage());
+
+ // 드래그 앤 드롭 이벤트
+ this.setupDragAndDrop();
+
+ // 인증 이벤트 (오프라인 모드에서는 비활성화)
+ if (window.supabase) {
+ document.getElementById('loginBtn').addEventListener('click', () => this.openAuthModal('login'));
+ document.getElementById('signupBtn').addEventListener('click', () => this.openAuthModal('signup'));
+ document.getElementById('logoutBtn').addEventListener('click', () => this.handleLogout());
+ document.getElementById('authForm').addEventListener('submit', (e) => this.handleAuthSubmit(e));
+ document.getElementById('authCancelBtn').addEventListener('click', () => this.closeAuthModal());
+ document.getElementById('authSwitchLink').addEventListener('click', (e) => {
+ e.preventDefault();
+ this.toggleAuthMode();
+ });
+ } else {
+ // 오프라인 모드에서는 로그인 버튼 클릭 차단
+ document.getElementById('loginBtn').addEventListener('click', (e) => {
+ e.preventDefault();
+ alert('현재 오프라인 모드입니다. 로그인 기능을 사용할 수 없습니다.');
+ });
+ document.getElementById('signupBtn').addEventListener('click', (e) => {
+ e.preventDefault();
+ alert('현재 오프라인 모드입니다. 회원가입 기능을 사용할 수 없습니다.');
+ });
+ }
+
+ // 모달 이벤트
+ window.addEventListener('click', (e) => {
+ if (e.target === document.getElementById('editModal')) {
+ this.closeEditModal();
+ }
+ if (e.target === document.getElementById('authModal')) {
+ this.closeAuthModal();
+ }
+ });
+ }
+
+ generateId() {
+ return Date.now().toString(36) + Math.random().toString(36).substr(2);
+ }
+
+ // 파일 확장자 추출 (안전한 형태로)
+ getFileExtension(fileName) {
+ const lastDotIndex = fileName.lastIndexOf('.');
+ if (lastDotIndex > 0 && lastDotIndex < fileName.length - 1) {
+ return fileName.substring(lastDotIndex).toLowerCase();
+ }
+ return ''; // 확장자가 없는 경우
+ }
+
+ // 브라우저별 다운로드 폴더 경로 추정
+ getDownloadFolderPath() {
+ const userAgent = navigator.userAgent.toLowerCase();
+ const platform = navigator.platform.toLowerCase();
+
+ if (platform.includes('win')) {
+ return '다운로드 폴더 (C:\\Users\\사용자명\\Downloads)';
+ } else if (platform.includes('mac')) {
+ return '다운로드 폴더 (~/Downloads)';
+ } else if (platform.includes('linux')) {
+ return '다운로드 폴더 (~/Downloads)';
+ } else {
+ return '브라우저 기본 다운로드 폴더';
+ }
+ }
+
+ // 인증 관련 메서드들
+ async initializeAuth() {
+ try {
+ const user = await getCurrentUser();
+ if (user) {
+ this.currentUser = user;
+ this.updateAuthUI(true);
+ await this.loadUserFiles();
+ this.setupRealtimeSubscription();
+ } else {
+ this.updateAuthUI(false);
+ // 게스트 모드: 공개 파일 로드
+ await this.loadPublicFiles();
+ this.showGuestMode();
+ }
+
+ setupAuthListener((event, session) => {
+ if (event === 'SIGNED_IN') {
+ this.currentUser = session.user;
+ this.updateAuthUI(true);
+ this.loadUserFiles();
+ this.setupRealtimeSubscription();
+ } else if (event === 'SIGNED_OUT') {
+ this.currentUser = null;
+ this.updateAuthUI(false);
+ this.loadPublicFiles();
+ this.showGuestMode();
+ this.cleanupRealtimeSubscription();
+ }
+ });
+ } catch (error) {
+ console.error('인증 초기화 오류:', error);
+ }
+ }
+
+ updateAuthUI(isAuthenticated) {
+ const authButtons = document.getElementById('authButtons');
+ const userInfo = document.getElementById('userInfo');
+ const userEmail = document.getElementById('userEmail');
+ const formSection = document.querySelector('.form-section');
+
+ if (isAuthenticated && this.currentUser) {
+ authButtons.style.display = 'none';
+ userInfo.style.display = 'flex';
+ userEmail.textContent = this.currentUser.email;
+ formSection.style.display = 'block';
+ this.updateSyncStatus();
+ this.hideGuestMode();
+ } else {
+ authButtons.style.display = 'flex';
+ userInfo.style.display = 'none';
+ formSection.style.display = 'none';
+ }
+ }
+
+ updateSyncStatus(status = 'auto') {
+ const syncStatusElement = document.getElementById('syncStatus');
+ if (!syncStatusElement) return;
+
+ // 자동 상태 판단
+ if (status === 'auto') {
+ if (!isSupabaseConfigured()) {
+ status = 'offline';
+ } else if (this.currentUser) {
+ status = 'online';
+ } else {
+ status = 'offline';
+ }
+ }
+
+ // 상태 업데이트
+ syncStatusElement.className = `sync-status ${status}`;
+ switch (status) {
+ case 'online':
+ syncStatusElement.textContent = '🟢 온라인';
+ break;
+ case 'offline':
+ syncStatusElement.textContent = '🟡 오프라인';
+ break;
+ case 'syncing':
+ syncStatusElement.textContent = '🔄 동기화 중';
+ break;
+ case 'error':
+ syncStatusElement.textContent = '🔴 오류';
+ break;
+ }
+ }
+
+ openAuthModal(mode) {
+ // 오프라인 모드에서는 모달 열지 않음
+ if (!window.supabase) {
+ alert('현재 오프라인 모드입니다. 로그인 기능을 사용할 수 없습니다.');
+ return;
+ }
+
+ this.authMode = mode;
+ const modal = document.getElementById('authModal');
+ const title = document.getElementById('authModalTitle');
+ const submitBtn = document.getElementById('authSubmitBtn');
+ const confirmPasswordGroup = document.getElementById('confirmPasswordGroup');
+ const switchText = document.getElementById('authSwitchText');
+
+ if (mode === 'signup') {
+ title.textContent = '👤 회원가입';
+ submitBtn.textContent = '👤 회원가입';
+ confirmPasswordGroup.style.display = 'block';
+ switchText.innerHTML = '이미 계정이 있으신가요? 로그인하기';
+ } else {
+ title.textContent = '🔑 로그인';
+ submitBtn.textContent = '🔑 로그인';
+ confirmPasswordGroup.style.display = 'none';
+ switchText.innerHTML = '계정이 없으신가요? 회원가입하기';
+ }
+
+ // 이벤트 리스너 재바인딩
+ const newSwitchLink = document.getElementById('authSwitchLink');
+ newSwitchLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.toggleAuthMode();
+ });
+
+ modal.style.display = 'block';
+ }
+
+ closeAuthModal() {
+ document.getElementById('authModal').style.display = 'none';
+ document.getElementById('authForm').reset();
+ document.getElementById('authLoading').style.display = 'none';
+ }
+
+ toggleAuthMode() {
+ this.authMode = this.authMode === 'login' ? 'signup' : 'login';
+ this.openAuthModal(this.authMode);
+ }
+
+ async handleAuthSubmit(e) {
+ e.preventDefault();
+
+ // 강제 오프라인 모드 - 모든 인증 시도 차단
+ if (this.isOfflineMode || !window.supabase) {
+ alert('현재 오프라인 모드입니다. 로그인 기능을 사용할 수 없습니다.');
+ this.closeAuthModal();
+ return;
+ }
+
+ const email = document.getElementById('authEmail').value.trim();
+ const password = document.getElementById('authPassword').value;
+ const confirmPassword = document.getElementById('authConfirmPassword').value;
+
+ if (!email || !password) {
+ alert('이메일과 비밀번호를 입력해주세요.');
+ return;
+ }
+
+ if (this.authMode === 'signup' && password !== confirmPassword) {
+ alert('비밀번호가 일치하지 않습니다.');
+ return;
+ }
+
+ this.showAuthLoading(true);
+
+ try {
+ if (this.authMode === 'signup') {
+ await signUp(email, password);
+ alert('회원가입이 완료되었습니다! 이메일을 확인해주세요.');
+ } else {
+ await signIn(email, password);
+ }
+ this.closeAuthModal();
+ } catch (error) {
+ console.error('인증 오류:', error);
+ alert(`${this.authMode === 'signup' ? '회원가입' : '로그인'} 중 오류가 발생했습니다: ${error.message}`);
+ } finally {
+ this.showAuthLoading(false);
+ }
+ }
+
+ async handleLogout() {
+ // 오프라인 모드에서는 로그아웃 불가
+ if (!window.supabase) {
+ alert('현재 오프라인 모드입니다. 로그아웃 기능을 사용할 수 없습니다.');
+ return;
+ }
+
+ try {
+ await signOut();
+ this.showNotification('로그아웃되었습니다.', 'success');
+ } catch (error) {
+ console.error('로그아웃 오류:', error);
+ alert('로그아웃 중 오류가 발생했습니다.');
+ }
+ }
+
+ showAuthLoading(show) {
+ const loading = document.getElementById('authLoading');
+ const form = document.getElementById('authForm');
+
+ if (show) {
+ loading.style.display = 'block';
+ form.style.display = 'none';
+ } else {
+ loading.style.display = 'none';
+ form.style.display = 'block';
+ }
+ }
+
+ // 인증 함수 완전 차단
+ overrideAuthFunctions() {
+ // 전역 인증 함수들을 빈 함수로 덮어씌움
+ if (typeof signIn === 'function') {
+ window.signIn = () => {
+ throw new Error('오프라인 모드에서는 로그인할 수 없습니다.');
+ };
+ }
+ if (typeof signUp === 'function') {
+ window.signUp = () => {
+ throw new Error('오프라인 모드에서는 회원가입할 수 없습니다.');
+ };
+ }
+ if (typeof signOut === 'function') {
+ window.signOut = () => {
+ throw new Error('오프라인 모드에서는 로그아웃할 수 없습니다.');
+ };
+ }
+ if (typeof getCurrentUser === 'function') {
+ window.getCurrentUser = () => Promise.resolve(null);
+ }
+ }
+
+ // 인증 UI 업데이트
+ updateAuthUI(isAuthenticated = true) {
+ const authButtons = document.getElementById('authButtons');
+ const userInfo = document.getElementById('userInfo');
+ const userEmail = document.getElementById('userEmail');
+ const formSection = document.querySelector('.form-section');
+
+ // 오프라인 모드에서는 항상 로그인된 것처럼 처리
+ if ((isAuthenticated && this.currentUser) || !window.supabase) {
+ if (authButtons) authButtons.style.display = 'none';
+ if (userInfo) userInfo.style.display = 'flex';
+ if (userEmail) userEmail.textContent = this.currentUser?.email || '오프라인 사용자';
+ if (formSection) formSection.style.display = 'block';
+ if (window.supabase && this.updateSyncStatus) {
+ this.updateSyncStatus('online');
+ }
+ this.hideGuestMode();
+ } else {
+ if (authButtons) authButtons.style.display = 'flex';
+ if (userInfo) userInfo.style.display = 'none';
+ if (formSection) formSection.style.display = 'none';
+ this.showGuestMode();
+ }
+ }
+
+ // 오프라인 모드 관련
+ showOfflineMode() {
+ const container = document.querySelector('.container');
+ const offlineNotice = document.createElement('div');
+ offlineNotice.className = 'offline-mode';
+ offlineNotice.innerHTML = '⚠️ 오프라인 모드: 로컬 저장소를 사용합니다. 로그인 없이 모든 기능을 사용할 수 있습니다.';
+ container.insertBefore(offlineNotice, container.firstChild.nextSibling);
+ }
+
+ // 게스트 모드 관련
+ showGuestMode() {
+ this.hideGuestMode(); // 기존 알림 제거
+ const container = document.querySelector('.container');
+ const guestNotice = document.createElement('div');
+ guestNotice.className = 'guest-mode';
+ guestNotice.id = 'guestModeNotice';
+ guestNotice.innerHTML = `
+
+ 👤 오프라인 모드 - 로컬 저장소를 사용합니다
+ ⚠️ 인터넷 연결 시 Supabase 기능을 사용할 수 있습니다
+
+ `;
+ container.insertBefore(guestNotice, container.firstChild.nextSibling);
+ }
+
+ hideGuestMode() {
+ const guestNotice = document.getElementById('guestModeNotice');
+ if (guestNotice) {
+ guestNotice.remove();
+ }
+ }
+
+ // 공개 파일 로드 (게스트용)
+ async loadPublicFiles() {
+ if (isSupabaseConfigured()) {
+ try {
+ // Supabase에서 모든 파일 로드 (RLS로 공개 파일만 접근 가능)
+ const data = await SupabaseHelper.getFiles('public');
+ this.files = data.map(file => ({
+ ...file,
+ files: file.file_attachments || [],
+ isReadOnly: true
+ }));
+ } catch (error) {
+ console.error('공개 파일 로딩 오류:', error);
+ // localStorage 폴백
+ this.files = this.loadFiles().map(file => ({ ...file, isReadOnly: true }));
+ }
+ } else {
+ // 오프라인 모드: localStorage의 파일을 읽기 전용으로 로드
+ this.files = this.loadFiles().map(file => ({ ...file, isReadOnly: true }));
+ }
+ this.renderFiles();
+ this.updateEmptyState();
+ }
+
+ setupOnlineStatusListener() {
+ window.addEventListener('online', () => {
+ this.isOnline = true;
+ this.showNotification('온라인 상태가 되었습니다.', 'success');
+ });
+
+ window.addEventListener('offline', () => {
+ this.isOnline = false;
+ this.showNotification('오프라인 상태입니다.', 'info');
+ });
+ }
+
+ // Supabase 데이터베이스 연동 메서드들
+ async loadUserFiles() {
+ if (!this.currentUser || !isSupabaseConfigured()) {
+ this.files = this.loadFiles(); // localStorage 폴백
+ this.updateSyncStatus('offline');
+ return;
+ }
+
+ try {
+ this.updateSyncStatus('syncing');
+ const data = await SupabaseHelper.getFiles(this.currentUser.id);
+ this.files = data.map(file => ({
+ ...file,
+ files: file.file_attachments || [] // 첨부파일 정보 매핑
+ }));
+ this.renderFiles();
+ this.updateEmptyState();
+ this.updateSyncStatus('online');
+ } catch (error) {
+ console.error('파일 로딩 오류:', error);
+ this.showNotification('파일을 불러오는 중 오류가 발생했습니다.', 'error');
+ this.updateSyncStatus('error');
+ // 오류 시 localStorage 폴백
+ this.files = this.loadFiles();
+ }
+ }
+
+ async addFileToSupabase(fileData) {
+ if (!this.currentUser || !isSupabaseConfigured()) {
+ return this.addFileLocally(fileData);
+ }
+
+ try {
+ this.updateSyncStatus('syncing');
+ console.log('파일 데이터 추가 중...', fileData);
+
+ const result = await SupabaseHelper.addFile(fileData, this.currentUser.id);
+ console.log('파일 데이터 추가 성공:', result);
+
+ // 첨부파일이 있는 경우 파일 업로드 처리
+ if (fileData.files && fileData.files.length > 0) {
+ console.log(`${fileData.files.length}개의 첨부파일 업로드 시작...`);
+ await this.uploadAttachments(result.id, fileData.files);
+ console.log('모든 첨부파일 업로드 완료');
+ }
+
+ this.showNotification('새 자료가 성공적으로 추가되었습니다!', 'success');
+ await this.loadUserFiles(); // 목록 새로고침
+ this.updateSyncStatus('online');
+ this.clearForm(); // 폼 초기화
+
+ } catch (error) {
+ console.error('파일 추가 오류:', error);
+
+ // 더 구체적인 에러 메시지 제공
+ let errorMessage = '파일 추가 중 오류가 발생했습니다.';
+ if (error.message) {
+ errorMessage += ` (${error.message})`;
+ }
+
+ this.showNotification(errorMessage, 'error');
+ this.updateSyncStatus('error');
+
+ // 콘솔에 상세 오류 정보 출력
+ if (error.details) {
+ console.error('오류 상세:', error.details);
+ }
+ if (error.hint) {
+ console.error('오류 힌트:', error.hint);
+ }
+ }
+ }
+
+ async updateFileInSupabase(id, updates) {
+ if (!this.currentUser || !isSupabaseConfigured()) {
+ return this.updateFileLocally(id, updates);
+ }
+
+ try {
+ await SupabaseHelper.updateFile(id, updates, this.currentUser.id);
+ this.showNotification('자료가 성공적으로 수정되었습니다!', 'success');
+ await this.loadUserFiles(); // 목록 새로고침
+ } catch (error) {
+ console.error('파일 수정 오류:', error);
+ this.showNotification('파일 수정 중 오류가 발생했습니다.', 'error');
+ }
+ }
+
+ async deleteFileFromSupabase(id) {
+ if (!this.currentUser || !isSupabaseConfigured()) {
+ return this.deleteFileLocally(id);
+ }
+
+ try {
+ // 첨부파일들을 Storage에서 삭제
+ await this.deleteAttachmentsFromStorage(id);
+
+ // 데이터베이스에서 파일 삭제 (CASCADE로 첨부파일 정보도 함께 삭제)
+ await SupabaseHelper.deleteFile(id, this.currentUser.id);
+ this.showNotification('자료가 성공적으로 삭제되었습니다!', 'success');
+ await this.loadUserFiles(); // 목록 새로고침
+ } catch (error) {
+ console.error('파일 삭제 오류:', error);
+ this.showNotification('파일 삭제 중 오류가 발생했습니다.', 'error');
+ }
+ }
+
+ // localStorage 폴백 메서드들
+ addFileLocally(fileData) {
+ // 로컬 저장용 데이터 생성 (ID와 타임스탬프 추가)
+ const localFileData = {
+ id: this.generateId(),
+ ...fileData,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString()
+ };
+
+ // 첨부파일이 있는 경우, localStorage에 바로 저장 가능한 형태로 처리
+ if (fileData.files && fileData.files.length > 0) {
+ // 첨부파일 데이터가 이미 base64 형태로 준비되어 있으므로 그대로 사용
+ localFileData.files = fileData.files.map(file => ({
+ name: file.name,
+ original_name: file.name,
+ size: file.size,
+ type: file.type,
+ data: file.data // base64 데이터
+ }));
+ } else {
+ localFileData.files = [];
+ }
+
+ this.files.push(localFileData);
+ this.saveFiles();
+ this.renderFiles();
+ this.updateEmptyState();
+ this.showNotification('새 자료가 성공적으로 추가되었습니다! (로컬 저장)', 'success');
+ this.clearForm(); // 폼 초기화
+ }
+
+ updateFileLocally(id, updates) {
+ const fileIndex = this.files.findIndex(f => f.id === id);
+ if (fileIndex !== -1) {
+ this.files[fileIndex] = {
+ ...this.files[fileIndex],
+ ...updates,
+ updated_at: new Date().toISOString()
+ };
+ this.saveFiles();
+ this.renderFiles();
+ this.showNotification('자료가 성공적으로 수정되었습니다! (로컬 저장)', 'success');
+ }
+ }
+
+ deleteFileLocally(id) {
+ this.files = this.files.filter(f => f.id !== id);
+ this.saveFiles();
+ this.renderFiles();
+ this.updateEmptyState();
+ this.showNotification('자료가 성공적으로 삭제되었습니다! (로컬 저장)', 'success');
+ }
+
+ // 실시간 구독 관련
+ setupRealtimeSubscription() {
+ if (!this.currentUser || !isSupabaseConfigured()) return;
+
+ this.realtimeSubscription = SupabaseHelper.subscribeToFiles(
+ this.currentUser.id,
+ (payload) => {
+ console.log('실시간 업데이트:', payload);
+ this.loadUserFiles(); // 변경사항이 있으면 목록 새로고침
+ }
+ );
+ }
+
+ cleanupRealtimeSubscription() {
+ if (this.realtimeSubscription) {
+ this.realtimeSubscription.unsubscribe();
+ this.realtimeSubscription = null;
+ }
+ }
+
+ // 파일 업로드 관련 메서드들
+ async uploadAttachments(fileId, attachments) {
+ if (!isSupabaseConfigured() || !this.currentUser) {
+ console.log('오프라인 모드: 첨부파일을 base64로 저장합니다.');
+ return; // 오프라인 모드에서는 base64로 저장된 상태 유지
+ }
+
+ const uploadedFiles = [];
+
+ try {
+ for (let i = 0; i < attachments.length; i++) {
+ const attachment = attachments[i];
+
+ try {
+ console.log(`파일 업로드 중... (${i + 1}/${attachments.length}): ${attachment.name}`);
+
+ // base64 데이터를 Blob으로 변환
+ const response = await fetch(attachment.data);
+ const blob = await response.blob();
+
+ // 안전한 파일명 생성 (고유한 이름으로 저장, 원본명은 DB에 저장)
+ const fileExtension = this.getFileExtension(attachment.name);
+ const safeFileName = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}${fileExtension}`;
+ const filePath = `${this.currentUser.id}/${fileId}/${safeFileName}`;
+
+ // Storage 버킷 확인
+ const bucketExists = await SupabaseHelper.checkOrCreateBucket();
+ if (!bucketExists) {
+ throw new Error('Storage 버킷 접근 권한이 없습니다. Storage 정책을 확인해주세요.');
+ }
+
+ // Supabase Storage에 업로드
+ const uploadResult = await SupabaseHelper.uploadFile(blob, filePath);
+ console.log('Storage 업로드 성공:', uploadResult);
+
+ // 데이터베이스에 첨부파일 정보 저장
+ const attachmentResult = await this.addFileAttachment(fileId, {
+ original_name: attachment.name,
+ storage_path: filePath,
+ file_size: attachment.size || blob.size,
+ mime_type: attachment.type || blob.type
+ });
+
+ uploadedFiles.push(attachmentResult);
+ console.log('첨부파일 정보 저장 성공:', attachmentResult);
+
+ } catch (fileError) {
+ console.error(`파일 "${attachment.name}" 업로드 실패:`, fileError);
+ throw new Error(`파일 "${attachment.name}" 업로드에 실패했습니다: ${fileError.message}`);
+ }
+ }
+
+ console.log('모든 첨부파일 업로드 완료:', uploadedFiles);
+ return uploadedFiles;
+
+ } catch (error) {
+ console.error('파일 업로드 오류:', error);
+
+ // 부분적으로 업로드된 파일들 정리 (선택사항)
+ try {
+ for (const uploadedFile of uploadedFiles) {
+ if (uploadedFile.storage_path) {
+ await SupabaseHelper.deleteStorageFile(uploadedFile.storage_path);
+ }
+ }
+ } catch (cleanupError) {
+ console.error('업로드 실패 파일 정리 중 오류:', cleanupError);
+ }
+
+ throw error;
+ }
+ }
+
+ async addFileAttachment(fileId, attachmentData) {
+ if (!isSupabaseConfigured()) {
+ return; // 오프라인 모드에서는 처리하지 않음
+ }
+
+ try {
+ // SupabaseHelper를 통해 첨부파일 정보 저장
+ const result = await SupabaseHelper.addFileAttachment(fileId, attachmentData);
+ return result;
+ } catch (error) {
+ console.error('첨부파일 정보 저장 오류:', error);
+ throw error;
+ }
+ }
+
+ async downloadFileFromStorage(filePath, originalName) {
+ if (!isSupabaseConfigured()) {
+ return; // 오프라인 모드에서는 처리하지 않음
+ }
+
+ try {
+ console.log('파일 다운로드 시도:', filePath, originalName);
+
+ // Storage 버킷 확인
+ const bucketExists = await SupabaseHelper.checkOrCreateBucket();
+ if (!bucketExists) {
+ throw new Error('Storage 버킷 접근 권한이 없습니다. Storage 정책을 확인해주세요.');
+ }
+
+ const url = await SupabaseHelper.getFileUrl(filePath);
+ console.log('다운로드 URL 생성:', url);
+
+ // 다운로드 링크 생성
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = originalName;
+
+ // Ctrl/Cmd 키를 누른 상태에서 클릭하면 "다른 이름으로 저장" 대화상자 표시
+ if (window.event && (window.event.ctrlKey || window.event.metaKey)) {
+ link.target = '_blank';
+ // 브라우저의 다운로드 관리자로 보내기
+ }
+
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ console.log('파일 다운로드 완료:', originalName);
+ } catch (error) {
+ console.error('파일 다운로드 오류:', error);
+
+ // 더 구체적인 오류 메시지 제공
+ let errorMessage = '파일 다운로드 중 오류가 발생했습니다.';
+ if (error.message.includes('Bucket not found')) {
+ errorMessage = 'Storage 버킷이 설정되지 않았습니다. 관리자에게 문의하세요.';
+ } else if (error.message.includes('파일을 찾을 수 없습니다')) {
+ errorMessage = '파일을 찾을 수 없습니다. 파일이 삭제되었을 수 있습니다.';
+ }
+
+ this.showNotification(errorMessage, 'error');
+ }
+ }
+
+ async deleteAttachmentsFromStorage(fileId) {
+ if (!isSupabaseConfigured() || !this.currentUser) {
+ return; // 오프라인 모드에서는 처리하지 않음
+ }
+
+ try {
+ // 파일의 모든 첨부파일 경로 가져오기
+ const { data: attachments, error } = await supabase
+ .from('file_attachments')
+ .select('storage_path')
+ .eq('file_id', fileId);
+
+ if (error) throw error;
+
+ // 각 파일을 Storage에서 삭제
+ for (const attachment of attachments) {
+ await SupabaseHelper.deleteStorageFile(attachment.storage_path);
+ }
+ } catch (error) {
+ console.error('첨부파일 삭제 오류:', error);
+ }
+ }
+
+ handleSubmit(e) {
+ e.preventDefault();
+
+ if (!this.currentUser) {
+ this.showNotification('로그인이 필요합니다.', 'error');
+ return;
+ }
+
+ const title = document.getElementById('fileTitle').value.trim();
+ const description = document.getElementById('fileDescription').value.trim();
+ const category = document.getElementById('fileCategory').value;
+ const tags = document.getElementById('fileTags').value.split(',').map(tag => tag.trim()).filter(tag => tag);
+ const fileInput = document.getElementById('fileUpload');
+
+ if (!title || !category) {
+ alert('제목과 카테고리는 필수 입력 항목입니다.');
+ return;
+ }
+
+ const fileData = {
+ title,
+ description,
+ category,
+ tags,
+ files: [] // 첨부파일 임시 저장용 (Supabase 전송시 제외됨)
+ };
+
+ // this.selectedFiles 사용 (드래그앤드롭으로 선택한 파일들 포함)
+ if (this.selectedFiles && this.selectedFiles.length > 0) {
+ let processedFiles = 0;
+ Array.from(this.selectedFiles).forEach(file => {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ fileData.files.push({
+ name: file.name,
+ size: file.size,
+ type: file.type,
+ data: e.target.result
+ });
+
+ processedFiles++;
+ if (processedFiles === this.selectedFiles.length) {
+ this.addFileToSupabase(fileData);
+ }
+ };
+ reader.readAsDataURL(file);
+ });
+ } else {
+ this.addFileToSupabase(fileData);
+ }
+ }
+
+ async addFile(fileData) {
+ // 호환성을 위해 유지, 실제로는 addFileToSupabase 사용
+ await this.addFileToSupabase(fileData);
+ this.clearForm();
+ }
+
+ // 드래그 앤 드롭 설정
+ setupDragAndDrop() {
+ const uploadArea = document.getElementById('fileUploadArea');
+ const fileInput = document.getElementById('fileUpload');
+
+ // 드래그 이벤트
+ uploadArea.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ uploadArea.classList.add('drag-over');
+ });
+
+ uploadArea.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ uploadArea.classList.remove('drag-over');
+ });
+
+ uploadArea.addEventListener('drop', (e) => {
+ e.preventDefault();
+ uploadArea.classList.remove('drag-over');
+
+ const files = Array.from(e.dataTransfer.files);
+ this.handleMultipleFiles(files);
+ });
+
+ // 클릭 이벤트
+ uploadArea.addEventListener('click', () => {
+ fileInput.click();
+ });
+ }
+
+ handleFileUpload(e) {
+ const files = Array.from(e.target.files);
+ this.handleMultipleFiles(files);
+ }
+
+ handleMultipleFiles(files) {
+ this.selectedFiles = files;
+ this.updateFilePreview();
+ }
+
+ updateFilePreview() {
+ const container = document.getElementById('selectedFiles');
+
+ if (!this.selectedFiles || this.selectedFiles.length === 0) {
+ container.innerHTML = '';
+ return;
+ }
+
+ const totalSize = this.selectedFiles.reduce((sum, file) => sum + file.size, 0);
+
+ container.innerHTML = `
+
+ 📎 선택된 파일: ${this.selectedFiles.length}개 (총 ${this.formatFileSize(totalSize)})
+
+ ${this.selectedFiles.map((file, index) => `
+
+
+
${this.getFileIcon(file.name)}
+
+
${file.name}
+
${this.formatFileSize(file.size)}
+
+
+
+
+ `).join('')}
+ `;
+ }
+
+ getFileIcon(fileName) {
+ const ext = fileName.split('.').pop().toLowerCase();
+ const iconMap = {
+ 'pdf': '📄',
+ 'doc': '📝', 'docx': '📝',
+ 'xls': '📊', 'xlsx': '📊',
+ 'ppt': '📽️', 'pptx': '📽️',
+ 'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️',
+ 'mp4': '🎥', 'avi': '🎥', 'mov': '🎥',
+ 'mp3': '🎵', 'wav': '🎵',
+ 'zip': '📦', 'rar': '📦',
+ 'txt': '📄'
+ };
+ return iconMap[ext] || '📄';
+ }
+
+ removeFile(index) {
+ if (this.selectedFiles) {
+ this.selectedFiles.splice(index, 1);
+ this.updateFilePreview();
+
+ // 파일 input 업데이트
+ const dt = new DataTransfer();
+ this.selectedFiles.forEach(file => dt.items.add(file));
+ document.getElementById('fileUpload').files = dt.files;
+ }
+ }
+
+ createFilesList() {
+ const fileGroup = document.querySelector('#fileUpload').closest('.form-group');
+ const filesList = document.createElement('div');
+ filesList.className = 'files-list';
+ fileGroup.appendChild(filesList);
+ return filesList;
+ }
+
+ removeFileFromInput(index) {
+ const fileInput = document.getElementById('fileUpload');
+ const dt = new DataTransfer();
+ const files = Array.from(fileInput.files);
+
+ files.forEach((file, i) => {
+ if (i !== index) {
+ dt.items.add(file);
+ }
+ });
+
+ fileInput.files = dt.files;
+ this.handleFileUpload({ target: fileInput });
+ }
+
+ renderFiles() {
+ const tbody = document.getElementById('fileList');
+ const sortBy = document.getElementById('sortBy').value;
+
+ let sortedFiles = [...this.files];
+
+ switch (sortBy) {
+ case 'title':
+ sortedFiles.sort((a, b) => a.title.localeCompare(b.title));
+ break;
+ case 'category':
+ sortedFiles.sort((a, b) => a.category.localeCompare(b.category));
+ break;
+ case 'date':
+ default:
+ sortedFiles.sort((a, b) => new Date(b.created_at || b.createdAt) - new Date(a.created_at || a.createdAt));
+ break;
+ }
+
+ this.allFiles = sortedFiles; // 전체 파일 목록 저장
+ this.updatePagination();
+
+ if (sortedFiles.length === 0) {
+ tbody.innerHTML = `
+
+ 📂 등록된 자료가 없습니다. 새 자료를 추가해보세요! |
+
+ `;
+ return;
+ }
+
+ // 페이지네이션 적용
+ const itemsPerPage = 10;
+ const currentPage = this.currentPage || 1;
+ const startIndex = (currentPage - 1) * itemsPerPage;
+ const endIndex = startIndex + itemsPerPage;
+ const paginatedFiles = sortedFiles.slice(startIndex, endIndex);
+
+ tbody.innerHTML = paginatedFiles.map((file, index) =>
+ this.createFileRowHTML(file, startIndex + index + 1)
+ ).join('');
+ }
+
+ createFileRowHTML(file, rowNumber) {
+ const createdDate = new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR');
+ const hasAttachments = file.files && file.files.length > 0;
+
+ return `
+
+ ${rowNumber} |
+
+ ${file.category}
+ |
+
+
+ ${this.escapeHtml(file.title)}
+ ${file.description ? ` ${this.escapeHtml(file.description.substring(0, 80))}${file.description.length > 80 ? '...' : ''}` : ''}
+ ${file.tags && file.tags.length > 0 ?
+ ` ${file.tags.map(tag => `#${this.escapeHtml(tag)}`).join('')} ` : ''
+ }
+
+ |
+
+ ${hasAttachments ?
+ ` ${file.files.map((f, index) =>
+ `${this.getFileIcon(f.name || f.original_name || 'unknown')}`
+ ).join(' ')} ` :
+ `-`
+ }
+ |
+ ${createdDate} |
+
+
+
+
+ ${hasAttachments ?
+ `` : ''
+ }
+
+ |
+
+ `;
+ }
+
+ createFileHTML(file) {
+ const createdDate = new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR');
+ const updatedDate = new Date(file.updated_at || file.updatedAt).toLocaleDateString('ko-KR');
+ const tagsHTML = file.tags.map(tag => `${tag}`).join('');
+ const filesHTML = file.files.length > 0 ?
+ `
+ 첨부파일 (${file.files.length}개):
+ ${file.files.map(f => `${this.getFileIcon(f.name || f.original_name || 'unknown')} ${f.name || f.original_name || '파일'}`).join(', ')}
+
` : '';
+
+ return `
+
+
+
+ ${file.description ? `
${this.escapeHtml(file.description)}
` : ''}
+
+ ${file.tags.length > 0 ? `
${tagsHTML}
` : ''}
+
+ ${filesHTML}
+
+
+ ${!file.isReadOnly && this.currentUser ? `
+
+
+ ` : ''}
+ ${file.files.length > 0 ? `` : ''}
+ ${file.isReadOnly ? `👁️ 읽기 전용` : ''}
+
+
+ `;
+ }
+
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ // 페이지네이션 관련 함수들
+ updatePagination() {
+ const pagination = document.getElementById('pagination');
+ const prevBtn = document.getElementById('prevPage');
+ const nextBtn = document.getElementById('nextPage');
+ const pageInfo = document.getElementById('pageInfo');
+
+ const totalFiles = this.allFiles.length;
+ const itemsPerPage = 10;
+ const totalPages = Math.ceil(totalFiles / itemsPerPage);
+
+ if (totalPages <= 1) {
+ pagination.style.display = 'none';
+ } else {
+ pagination.style.display = 'flex';
+ prevBtn.disabled = this.currentPage <= 1;
+ nextBtn.disabled = this.currentPage >= totalPages;
+ pageInfo.textContent = `${this.currentPage} / ${totalPages}`;
+ }
+ }
+
+ goToPrevPage() {
+ if (this.currentPage > 1) {
+ this.currentPage--;
+ this.renderFiles();
+ }
+ }
+
+ goToNextPage() {
+ const totalFiles = this.allFiles.length;
+ const itemsPerPage = 10;
+ const totalPages = Math.ceil(totalFiles / itemsPerPage);
+
+ if (this.currentPage < totalPages) {
+ this.currentPage++;
+ this.renderFiles();
+ }
+ }
+
+ // 파일 상세보기 (제목 클릭 시)
+ viewFile(id) {
+ const file = this.files.find(f => f.id === id);
+ if (!file) return;
+
+ // 간단한 알림으로 파일 정보 표시
+ let info = `📄 ${file.title}\n\n`;
+ info += `📁 카테고리: ${file.category}\n`;
+ info += `📅 등록일: ${new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR')}\n`;
+ if (file.description) info += `📝 설명: ${file.description}\n`;
+ if (file.tags && file.tags.length > 0) info += `🏷️ 태그: ${file.tags.join(', ')}\n`;
+ if (file.files && file.files.length > 0) {
+ info += `\n📎 첨부파일 (${file.files.length}개):\n`;
+ file.files.forEach((attachment, index) => {
+ const icon = this.getFileIcon(attachment.name || attachment.original_name || 'unknown');
+ info += ` ${index + 1}. ${icon} ${attachment.name || attachment.original_name || '파일'}\n`;
+ });
+ }
+
+ alert(info);
+ }
+
+ editFile(id) {
+ if (!this.currentUser) {
+ this.showNotification('로그인이 필요합니다.', 'error');
+ return;
+ }
+
+ const file = this.files.find(f => f.id === id);
+ if (!file) return;
+
+ if (file.isReadOnly) {
+ this.showNotification('읽기 전용 파일은 편집할 수 없습니다.', 'error');
+ return;
+ }
+
+ this.currentEditId = id;
+
+ document.getElementById('editTitle').value = file.title;
+ document.getElementById('editDescription').value = file.description;
+ document.getElementById('editCategory').value = file.category;
+ document.getElementById('editTags').value = file.tags.join(', ');
+
+ document.getElementById('editModal').style.display = 'block';
+ }
+
+ handleEditSubmit(e) {
+ e.preventDefault();
+
+ if (!this.currentEditId) return;
+
+ const title = document.getElementById('editTitle').value.trim();
+ const description = document.getElementById('editDescription').value.trim();
+ const category = document.getElementById('editCategory').value;
+ const tags = document.getElementById('editTags').value.split(',').map(tag => tag.trim()).filter(tag => tag);
+
+ if (!title || !category) {
+ alert('제목과 카테고리는 필수 입력 항목입니다.');
+ return;
+ }
+
+ const updates = {
+ title,
+ description,
+ category,
+ tags
+ };
+
+ this.updateFileInSupabase(this.currentEditId, updates);
+ this.closeEditModal();
+ }
+
+ closeEditModal() {
+ document.getElementById('editModal').style.display = 'none';
+ this.currentEditId = null;
+ }
+
+ deleteFile(id) {
+ if (!this.currentUser) {
+ this.showNotification('로그인이 필요합니다.', 'error');
+ return;
+ }
+
+ const file = this.files.find(f => f.id === id);
+ if (!file) return;
+
+ if (file.isReadOnly) {
+ this.showNotification('읽기 전용 파일은 삭제할 수 없습니다.', 'error');
+ return;
+ }
+
+ if (confirm(`"${file.title}" 자료를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) {
+ this.deleteFileFromSupabase(id);
+ }
+ }
+
+ async downloadFiles(id) {
+ const file = this.files.find(f => f.id === id);
+ if (!file || file.files.length === 0) return;
+
+ try {
+ if (file.files.length === 1) {
+ // 단일 파일: 직접 다운로드
+ await this.downloadSingleFileData(file.files[0]);
+ const fileName = file.files[0].original_name || file.files[0].name;
+ this.showNotification(`파일 다운로드 완료: ${fileName}`, 'success');
+ } else {
+ // 다중 파일: ZIP으로 압축하여 다운로드
+ await this.downloadFilesAsZip(file);
+ }
+ } catch (error) {
+ console.error('파일 다운로드 오류:', error);
+ this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error');
+ }
+ }
+
+ async downloadSingleFile(fileId, fileIndex) {
+ const file = this.files.find(f => f.id === fileId);
+ if (!file || !file.files[fileIndex]) return;
+
+ try {
+ const fileData = file.files[fileIndex];
+ await this.downloadSingleFileData(fileData);
+ const fileName = fileData.original_name || fileData.name;
+ this.showNotification(`파일 다운로드 완료: ${fileName}`, 'success');
+ } catch (error) {
+ console.error('개별 파일 다운로드 오류:', error);
+ this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error');
+ }
+ }
+
+ async downloadSingleFileData(fileData) {
+ if (fileData.storage_path && isSupabaseConfigured()) {
+ // Supabase Storage에서 다운로드
+ await this.downloadFileFromStorage(fileData.storage_path, fileData.original_name || fileData.name);
+ } else if (fileData.data) {
+ // localStorage 데이터 다운로드 (base64)
+ const link = document.createElement('a');
+ link.href = fileData.data;
+ link.download = fileData.name || fileData.original_name;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ }
+
+ async downloadFilesAsZip(file) {
+ const zip = new JSZip();
+ const fileTitle = file.title.replace(/[<>:"/\\|?*]/g, '_'); // 파일명에 부적절한 문자 제거
+
+ for (const fileData of file.files) {
+ try {
+ let fileContent;
+ const fileName = fileData.original_name || fileData.name || 'file';
+
+ if (fileData.storage_path && isSupabaseConfigured()) {
+ // Supabase Storage에서 파일 가져오기
+ const response = await fetch(await SupabaseHelper.getFileUrl(fileData.storage_path));
+ fileContent = await response.blob();
+ } else if (fileData.data) {
+ // localStorage의 base64 데이터 변환
+ const response = await fetch(fileData.data);
+ fileContent = await response.blob();
+ }
+
+ if (fileContent) {
+ zip.file(fileName, fileContent);
+ }
+ } catch (error) {
+ console.error(`파일 ${fileData.name} 처리 오류:`, error);
+ }
+ }
+
+ // ZIP 파일 생성 및 다운로드
+ const zipBlob = await zip.generateAsync({ type: "blob" });
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(zipBlob);
+ link.download = `${fileTitle}_첨부파일.zip`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(link.href);
+
+ this.showNotification(`ZIP 파일 다운로드 완료: ${fileTitle}_첨부파일.zip (${file.files.length}개 파일)`, 'success');
+ }
+
+ handleSearch() {
+ const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim();
+ const categoryFilter = document.getElementById('categoryFilter').value;
+
+ let filteredFiles = this.files;
+
+ if (searchTerm) {
+ filteredFiles = filteredFiles.filter(file =>
+ file.title.toLowerCase().includes(searchTerm) ||
+ file.description.toLowerCase().includes(searchTerm) ||
+ file.tags.some(tag => tag.toLowerCase().includes(searchTerm))
+ );
+ }
+
+ if (categoryFilter) {
+ filteredFiles = filteredFiles.filter(file => file.category === categoryFilter);
+ }
+
+ this.renderFilteredFiles(filteredFiles);
+ }
+
+ renderFilteredFiles(files) {
+ const container = document.getElementById('fileList');
+ const sortBy = document.getElementById('sortBy').value;
+
+ let sortedFiles = [...files];
+
+ switch (sortBy) {
+ case 'title':
+ sortedFiles.sort((a, b) => a.title.localeCompare(b.title));
+ break;
+ case 'category':
+ sortedFiles.sort((a, b) => a.category.localeCompare(b.category));
+ break;
+ case 'date':
+ default:
+ sortedFiles.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
+ break;
+ }
+
+ if (sortedFiles.length === 0) {
+ container.innerHTML = '🔍 검색 결과가 없습니다. 다른 키워드로 검색해보세요!
';
+ return;
+ }
+
+ container.innerHTML = sortedFiles.map(file => this.createFileHTML(file)).join('');
+ }
+
+ clearForm() {
+ document.getElementById('fileForm').reset();
+ const filesList = document.querySelector('.files-list');
+ if (filesList) {
+ filesList.innerHTML = '';
+ }
+ // 선택된 파일들과 미리보기 초기화
+ this.selectedFiles = [];
+ const selectedFilesContainer = document.getElementById('selectedFiles');
+ if (selectedFilesContainer) {
+ selectedFilesContainer.innerHTML = '';
+ }
+ }
+
+ formatFileSize(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ updateEmptyState() {
+ const container = document.getElementById('fileList');
+ if (this.files.length === 0) {
+ container.innerHTML = '📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!
';
+ }
+ }
+
+ showNotification(message, type = 'info') {
+ // 기존 알림이 있으면 제거
+ const existingNotification = document.querySelector('.notification');
+ if (existingNotification) {
+ existingNotification.remove();
+ }
+
+ const notification = document.createElement('div');
+ notification.className = `notification ${type}`;
+ notification.textContent = message;
+
+ document.body.appendChild(notification);
+
+ // 3초 후 자동 제거
+ setTimeout(() => {
+ if (notification.parentNode) {
+ notification.remove();
+ }
+ }, 3000);
+ }
+
+ loadFiles() {
+ try {
+ const stored = localStorage.getItem('fileManagerData');
+ const files = stored ? JSON.parse(stored) : [];
+
+ // 기존 localStorage 데이터의 호환성을 위해 컬럼명 변환
+ return files.map(file => ({
+ ...file,
+ created_at: file.created_at || file.createdAt || new Date().toISOString(),
+ updated_at: file.updated_at || file.updatedAt || new Date().toISOString()
+ }));
+ } catch (error) {
+ console.error('파일 데이터를 불러오는 중 오류가 발생했습니다:', error);
+ return [];
+ }
+ }
+
+ saveFiles() {
+ try {
+ localStorage.setItem('fileManagerData', JSON.stringify(this.files));
+ } catch (error) {
+ console.error('파일 데이터를 저장하는 중 오류가 발생했습니다:', error);
+ alert('데이터 저장 중 오류가 발생했습니다. 브라우저의 저장공간을 확인해주세요.');
+ }
+ }
+
+ exportData() {
+ const dataStr = JSON.stringify(this.files, null, 2);
+ const dataBlob = new Blob([dataStr], {type: 'application/json'});
+ const url = URL.createObjectURL(dataBlob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `자료실_백업_${new Date().toISOString().split('T')[0]}.json`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ this.showNotification('데이터가 성공적으로 내보내기되었습니다!', 'success');
+ }
+
+ importData(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const importedData = JSON.parse(e.target.result);
+ if (Array.isArray(importedData)) {
+ if (confirm('기존 데이터를 모두 삭제하고 새 데이터를 가져오시겠습니까?')) {
+ this.files = importedData;
+ this.saveFiles();
+ this.renderFiles();
+ this.updateEmptyState();
+ this.showNotification('데이터가 성공적으로 가져와졌습니다!', 'success');
+ }
+ } else {
+ alert('올바르지 않은 파일 형식입니다.');
+ }
+ } catch (error) {
+ alert('파일을 읽는 중 오류가 발생했습니다.');
+ console.error(error);
+ }
+ };
+ reader.readAsText(file);
+ }
+}
+
+const style = document.createElement('style');
+style.textContent = `
+ @keyframes slideIn {
+ from { transform: translateX(100%); opacity: 0; }
+ to { transform: translateX(0); opacity: 1; }
+ }
+ @keyframes slideOut {
+ from { transform: translateX(0); opacity: 1; }
+ to { transform: translateX(100%); opacity: 0; }
+ }
+`;
+document.head.appendChild(style);
+
+const fileManager = new FileManager();
+
+document.addEventListener('DOMContentLoaded', () => {
+ console.log('📚 자료실 관리 시스템이 초기화되었습니다.');
+});
\ No newline at end of file
diff --git a/admin/styles.css b/admin/styles.css
new file mode 100644
index 0000000..1e87c07
--- /dev/null
+++ b/admin/styles.css
@@ -0,0 +1,1029 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ min-height: 100vh;
+}
+
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+header {
+ background: rgba(255, 255, 255, 0.95);
+ padding: 30px;
+ border-radius: 15px;
+ text-align: center;
+ margin-bottom: 30px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+ backdrop-filter: blur(10px);
+}
+
+header h1 {
+ color: #4a5568;
+ font-size: 2.5rem;
+ margin-bottom: 10px;
+}
+
+header p {
+ color: #666;
+ font-size: 1.1rem;
+}
+
+.auth-section {
+ margin-top: 20px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.auth-buttons {
+ display: flex;
+ gap: 10px;
+}
+
+.auth-btn {
+ padding: 8px 16px;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: all 0.3s ease;
+ font-weight: 500;
+ background: #667eea;
+ color: white;
+}
+
+.auth-btn:hover {
+ background: #5a67d8;
+ transform: translateY(-1px);
+}
+
+.user-info {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ background: rgba(72, 187, 120, 0.1);
+ padding: 10px 15px;
+ border-radius: 8px;
+ border: 1px solid rgba(72, 187, 120, 0.3);
+}
+
+.user-info span {
+ color: #2f855a;
+ font-weight: 500;
+}
+
+.auth-switch {
+ text-align: center;
+ margin-top: 15px;
+ color: #666;
+}
+
+.auth-switch a {
+ color: #667eea;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.auth-switch a:hover {
+ text-decoration: underline;
+}
+
+.loading {
+ text-align: center;
+ padding: 20px;
+ color: #666;
+}
+
+.loading p {
+ margin: 0;
+ font-style: italic;
+}
+
+.offline-mode {
+ background: rgba(255, 193, 7, 0.1);
+ border: 1px solid rgba(255, 193, 7, 0.3);
+ padding: 10px 15px;
+ border-radius: 8px;
+ margin-bottom: 20px;
+ text-align: center;
+ color: #856404;
+}
+
+/* 게스트 모드 스타일 */
+.guest-mode {
+ background: linear-gradient(135deg, #a8e6cf, #88d8a3);
+ color: #2d3436;
+ padding: 15px 20px;
+ border-radius: 8px;
+ margin: 20px 0;
+ border: 2px solid #00b894;
+ box-shadow: 0 2px 10px rgba(0, 184, 148, 0.2);
+}
+
+.guest-mode-content {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.guest-login-btn {
+ background: #00b894;
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 5px;
+ cursor: pointer;
+ font-weight: 500;
+ transition: all 0.3s ease;
+}
+
+.guest-login-btn:hover {
+ background: #00a085;
+ transform: translateY(-1px);
+}
+
+.read-only-badge {
+ background: #74b9ff;
+ color: white;
+ padding: 6px 12px;
+ border-radius: 15px;
+ font-size: 0.8em;
+ font-weight: 500;
+ white-space: nowrap;
+ display: inline-flex;
+ align-items: center;
+ margin-left: auto;
+}
+
+.sync-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+.sync-status.online {
+ background: rgba(72, 187, 120, 0.1);
+ color: #2f855a;
+}
+
+.sync-status.offline {
+ background: rgba(255, 193, 7, 0.1);
+ color: #856404;
+}
+
+.sync-status.syncing {
+ background: rgba(102, 126, 234, 0.1);
+ color: #4c51bf;
+}
+
+.search-section {
+ background: rgba(255, 255, 255, 0.95);
+ padding: 20px;
+ border-radius: 15px;
+ margin-bottom: 30px;
+ display: flex;
+ gap: 15px;
+ align-items: center;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+ backdrop-filter: blur(10px);
+ flex-wrap: wrap;
+}
+
+.search-section input,
+.search-section select {
+ padding: 12px 15px;
+ border: 2px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 1rem;
+ transition: all 0.3s ease;
+ flex: 1;
+ min-width: 200px;
+}
+
+.search-section input:focus,
+.search-section select:focus {
+ outline: none;
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.search-section button {
+ padding: 12px 20px;
+ background: #667eea;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 1rem;
+ transition: all 0.3s ease;
+ white-space: nowrap;
+}
+
+.search-section button:hover {
+ background: #5a67d8;
+ transform: translateY(-2px);
+}
+
+.form-section {
+ background: rgba(255, 255, 255, 0.95);
+ padding: 30px;
+ border-radius: 15px;
+ margin-bottom: 30px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+ backdrop-filter: blur(10px);
+}
+
+.form-section h2 {
+ color: #4a5568;
+ margin-bottom: 25px;
+ font-size: 1.8rem;
+}
+
+.form-group {
+ margin-bottom: 20px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 600;
+ color: #4a5568;
+}
+
+.form-group input,
+.form-group textarea,
+.form-group select {
+ width: 100%;
+ padding: 12px 15px;
+ border: 2px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 1rem;
+ transition: all 0.3s ease;
+ font-family: inherit;
+}
+
+.form-group input:focus,
+.form-group textarea:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.form-group small {
+ color: #666;
+ font-size: 0.9rem;
+ margin-top: 5px;
+ display: block;
+}
+
+.form-buttons {
+ display: flex;
+ gap: 15px;
+ margin-top: 25px;
+}
+
+.form-buttons button {
+ padding: 12px 25px;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ font-weight: 600;
+}
+
+#submitBtn {
+ background: #48bb78;
+ color: white;
+}
+
+#submitBtn:hover {
+ background: #38a169;
+ transform: translateY(-2px);
+}
+
+#cancelBtn {
+ background: #e2e8f0;
+ color: #4a5568;
+}
+
+#cancelBtn:hover {
+ background: #cbd5e0;
+ transform: translateY(-2px);
+}
+
+.list-section {
+ background: rgba(255, 255, 255, 0.95);
+ padding: 30px;
+ border-radius: 15px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+ backdrop-filter: blur(10px);
+}
+
+.list-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 25px;
+ flex-wrap: wrap;
+ gap: 15px;
+}
+
+.list-header h2 {
+ color: #4a5568;
+ font-size: 1.8rem;
+}
+
+.sort-options select {
+ padding: 10px 15px;
+ border: 2px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 1rem;
+ cursor: pointer;
+}
+
+/* 게시판 스타일 */
+.board-container {
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 12px;
+ padding: 20px;
+ margin-top: 20px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
+ backdrop-filter: blur(10px);
+}
+
+.board-table {
+ width: 100%;
+ border-collapse: collapse;
+ background: white;
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.board-table thead {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+}
+
+.board-table th {
+ padding: 15px 12px;
+ text-align: center;
+ font-weight: 600;
+ font-size: 0.95rem;
+ border-right: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+.board-table th:last-child {
+ border-right: none;
+}
+
+.board-table tbody tr {
+ border-bottom: 1px solid #e5e7eb;
+ transition: background-color 0.2s ease;
+}
+
+.board-table tbody tr:hover {
+ background-color: #f8fafc;
+}
+
+.board-table tbody tr:last-child {
+ border-bottom: none;
+}
+
+.board-table td {
+ padding: 12px;
+ text-align: center;
+ vertical-align: middle;
+ font-size: 0.9rem;
+}
+
+/* 컬럼 너비 설정 */
+.col-no { width: 60px; }
+.col-category { width: 100px; }
+.col-title { width: auto; min-width: 200px; text-align: left; }
+.col-attachment { width: 80px; }
+.col-date { width: 120px; }
+.col-actions { width: 150px; }
+
+/* 제목 스타일 */
+.board-title {
+ color: #374151;
+ font-weight: 500;
+ text-decoration: none;
+ cursor: pointer;
+ display: block;
+ padding: 8px;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+}
+
+.board-title:hover {
+ background-color: #e0e7ff;
+ color: #4f46e5;
+}
+
+/* 카테고리 배지 */
+.category-badge {
+ display: inline-block;
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: white;
+}
+
+.category-문서 { background: #3b82f6; }
+.category-이미지 { background: #10b981; }
+.category-동영상 { background: #f59e0b; }
+.category-프레젠테이션 { background: #ef4444; }
+.category-기타 { background: #6b7280; }
+
+/* 첨부파일 아이콘 */
+.attachment-icon {
+ font-size: 1.2rem;
+ color: #10b981;
+}
+
+.attachment-icons {
+ display: flex;
+ gap: 2px;
+ justify-content: center;
+ align-items: center;
+ flex-wrap: wrap;
+ font-size: 1rem;
+ line-height: 1.2;
+}
+
+.attachment-icons span {
+ display: inline-block;
+}
+
+.attachment-icon-clickable {
+ cursor: pointer;
+ transition: all 0.2s ease;
+ padding: 2px;
+ border-radius: 3px;
+}
+
+.attachment-icon-clickable:hover {
+ background-color: #e0e7ff;
+ transform: scale(1.1);
+}
+
+.no-attachment {
+ color: #9ca3af;
+}
+
+/* 액션 버튼 */
+.action-buttons {
+ display: flex;
+ gap: 5px;
+ justify-content: center;
+ align-items: center;
+}
+
+.action-btn {
+ padding: 6px 12px;
+ border: none;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-weight: 500;
+}
+
+.btn-edit {
+ background: #3b82f6;
+ color: white;
+}
+
+.btn-edit:hover {
+ background: #2563eb;
+ transform: translateY(-1px);
+}
+
+.btn-delete {
+ background: #ef4444;
+ color: white;
+}
+
+.btn-delete:hover {
+ background: #dc2626;
+ transform: translateY(-1px);
+}
+
+.btn-download {
+ background: #10b981;
+ color: white;
+}
+
+.btn-download:hover {
+ background: #059669;
+ transform: translateY(-1px);
+}
+
+/* 빈 상태 */
+.empty-state td {
+ padding: 60px 20px;
+ text-align: center;
+ color: #6b7280;
+ font-size: 1.1rem;
+}
+
+/* 파일 업로드 영역 */
+.file-upload-area {
+ position: relative;
+ border: 2px dashed #d1d5db;
+ border-radius: 8px;
+ padding: 30px 20px;
+ text-align: center;
+ background: #f9fafb;
+ transition: all 0.3s ease;
+ cursor: pointer;
+}
+
+.file-upload-area:hover {
+ border-color: #667eea;
+ background: #f0f4ff;
+}
+
+.file-upload-area.drag-over {
+ border-color: #667eea;
+ background: #e0e7ff;
+ transform: scale(1.02);
+}
+
+.file-upload-area input[type="file"] {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.upload-placeholder {
+ pointer-events: none;
+}
+
+.upload-icon {
+ font-size: 3rem;
+ margin-bottom: 15px;
+}
+
+.upload-placeholder p {
+ margin: 8px 0;
+ color: #374151;
+}
+
+.upload-placeholder strong {
+ color: #667eea;
+}
+
+.upload-placeholder small {
+ color: #6b7280;
+ font-size: 0.85rem;
+}
+
+/* 선택된 파일 목록 */
+.selected-files {
+ margin-top: 15px;
+}
+
+.file-item-preview {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 15px;
+ margin-bottom: 8px;
+ background: white;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.file-info {
+ display: flex;
+ align-items: center;
+ flex: 1;
+}
+
+.file-icon {
+ margin-right: 10px;
+ font-size: 1.2rem;
+}
+
+.file-details {
+ flex: 1;
+}
+
+.file-name {
+ font-weight: 500;
+ color: #374151;
+ margin-bottom: 2px;
+}
+
+.file-size {
+ font-size: 0.8rem;
+ color: #6b7280;
+}
+
+.file-remove {
+ background: #ef4444;
+ color: white;
+ border: none;
+ border-radius: 50%;
+ width: 24px;
+ height: 24px;
+ cursor: pointer;
+ font-size: 0.8rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.file-remove:hover {
+ background: #dc2626;
+ transform: scale(1.1);
+}
+
+.files-summary {
+ margin-top: 10px;
+ padding: 10px;
+ background: #f0f9ff;
+ border: 1px solid #bae6fd;
+ border-radius: 4px;
+ font-size: 0.9rem;
+ color: #0369a1;
+ text-align: center;
+}
+
+.file-list {
+ /* 기존 grid 스타일 비활성화 */
+}
+
+.file-item {
+ background: #f7fafc;
+ border: 2px solid #e2e8f0;
+ border-radius: 12px;
+ padding: 20px;
+ transition: all 0.3s ease;
+ position: relative;
+}
+
+.file-item:hover {
+ border-color: #667eea;
+ transform: translateY(-2px);
+ box-shadow: 0 8px 25px rgba(102, 126, 234, 0.15);
+}
+
+.file-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 15px;
+ gap: 15px;
+}
+
+.file-title {
+ font-size: 1.3rem;
+ font-weight: 600;
+ color: #2d3748;
+ margin-bottom: 5px;
+}
+
+.file-meta {
+ display: flex;
+ gap: 15px;
+ font-size: 0.9rem;
+ color: #666;
+ margin-bottom: 10px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.file-meta span {
+ white-space: nowrap;
+}
+
+.file-description {
+ color: #4a5568;
+ margin-bottom: 15px;
+ line-height: 1.5;
+}
+
+.file-tags {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 15px;
+ flex-wrap: wrap;
+}
+
+.tag {
+ background: #e6fffa;
+ color: #234e52;
+ padding: 4px 10px;
+ border-radius: 20px;
+ font-size: 0.85rem;
+ border: 1px solid #b2f5ea;
+}
+
+.file-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ align-items: center;
+ margin-top: 10px;
+}
+
+.file-actions button {
+ padding: 8px 15px;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: all 0.3s ease;
+ font-weight: 500;
+}
+
+.edit-btn {
+ background: #3182ce;
+ color: white;
+}
+
+.edit-btn:hover {
+ background: #2c5282;
+}
+
+.delete-btn {
+ background: #e53e3e;
+ color: white;
+}
+
+.delete-btn:hover {
+ background: #c53030;
+}
+
+.download-btn {
+ background: #38a169;
+ color: white;
+}
+
+.download-btn:hover {
+ background: #2f855a;
+}
+
+.empty-state {
+ text-align: center;
+ padding: 60px 20px;
+ color: #666;
+}
+
+.empty-state p {
+ font-size: 1.2rem;
+}
+
+.modal {
+ display: none;
+ position: fixed;
+ z-index: 1000;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(5px);
+}
+
+.modal-content {
+ background: white;
+ margin: 5% auto;
+ padding: 30px;
+ border-radius: 15px;
+ width: 90%;
+ max-width: 600px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+ animation: modalSlideIn 0.3s ease;
+}
+
+@keyframes modalSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-50px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.modal-content h2 {
+ color: #4a5568;
+ margin-bottom: 25px;
+ font-size: 1.8rem;
+}
+
+.files-list {
+ margin-top: 10px;
+}
+
+.file-attachment {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: #f7fafc;
+ border-radius: 6px;
+ margin-bottom: 5px;
+ border: 1px solid #e2e8f0;
+}
+
+.file-attachment span {
+ font-size: 0.9rem;
+ color: #4a5568;
+}
+
+.remove-file {
+ background: #e53e3e;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 4px 8px;
+ cursor: pointer;
+ font-size: 0.8rem;
+}
+
+.remove-file:hover {
+ background: #c53030;
+}
+
+.category-badge {
+ background: #bee3f8;
+ color: #2c5282;
+ padding: 4px 12px;
+ border-radius: 20px;
+ font-size: 0.85rem;
+ font-weight: 500;
+ border: 1px solid #90cdf4;
+}
+
+@media (max-width: 768px) {
+ .container {
+ padding: 15px;
+ }
+
+ header h1 {
+ font-size: 2rem;
+ }
+
+ .search-section {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .search-section input,
+ .search-section select,
+ .search-section button {
+ width: 100%;
+ min-width: auto;
+ }
+
+ .form-buttons {
+ flex-direction: column;
+ }
+
+ .file-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .file-actions {
+ justify-content: flex-start;
+ }
+
+ .list-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
+
+@media (max-width: 480px) {
+ .modal-content {
+ margin: 10% auto;
+ width: 95%;
+ padding: 20px;
+ }
+
+ .file-actions {
+ flex-direction: column;
+ }
+
+ .file-actions button {
+ width: 100%;
+ }
+
+ .guest-mode-content {
+ flex-direction: column;
+ text-align: center;
+ }
+
+ .guest-login-btn {
+ margin-top: 10px;
+ }
+
+ .notification {
+ left: 10px;
+ right: 10px;
+ top: 10px;
+ max-width: none;
+ margin: 0;
+ }
+
+ .file-meta {
+ gap: 8px;
+ }
+
+ .file-meta span {
+ font-size: 0.8rem;
+ }
+
+ header h1 {
+ font-size: 2rem;
+ }
+}
+
+/* 알림 메시지 스타일 */
+.notification {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ max-width: 400px;
+ padding: 15px 20px;
+ border-radius: 8px;
+ color: white;
+ font-weight: 500;
+ z-index: 10000;
+ animation: slideInRight 0.3s ease, fadeOut 0.3s ease 2.7s;
+ animation-fill-mode: forwards;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ word-wrap: break-word;
+ line-height: 1.5;
+ white-space: pre-line;
+}
+
+.notification.success {
+ background: linear-gradient(135deg, #48bb78, #38a169);
+ border-left: 4px solid #2f855a;
+}
+
+.notification.error {
+ background: linear-gradient(135deg, #f56565, #e53e3e);
+ border-left: 4px solid #c53030;
+}
+
+.notification.info {
+ background: linear-gradient(135deg, #4299e1, #3182ce);
+ border-left: 4px solid #2c5282;
+}
+
+@keyframes slideInRight {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+@keyframes fadeOut {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ transform: translateX(100%);
+ }
+}
\ No newline at end of file
diff --git a/admin/supabase-config.js b/admin/supabase-config.js
new file mode 100644
index 0000000..1dd95ee
--- /dev/null
+++ b/admin/supabase-config.js
@@ -0,0 +1,119 @@
+// Supabase configuration (오프라인 모드)
+// ⚠️ 오프라인 모드로 강제 설정됨
+const SUPABASE_CONFIG = {
+ url: 'https://kncudtzthmjegowbgnto.supabase.co',
+ anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtuY3VkdHp0aG1qZWdvd2JnbnRvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTU1Njc5OTksImV4cCI6MjA3MTE0Mzk5OX0.NlJN2vdgM96RvyVJE6ILQeDVUOU9X2F9vUn-jr_xlKc'
+};
+
+// Supabase 클라이언트 초기화 (강제 비활성화)
+let supabase = null;
+
+// 설정이 유효한지 확인
+function isSupabaseConfigured() {
+ return false; // 강제로 false 반환
+}
+
+// Supabase 클라이언트 초기화 함수 (오프라인 모드 강제)
+function initializeSupabase() {
+ console.log('⚠️ 오프라인 모드로 강제 설정되었습니다.');
+ return false;
+}
+
+// 인증 상태 변경 리스너 (오프라인 모드용 - 빈 함수)
+function setupAuthListener(callback) {
+ // 오프라인 모드에서는 아무것도 하지 않음
+ return;
+}
+
+// 현재 사용자 가져오기 (오프라인 모드용 - null 반환)
+async function getCurrentUser() {
+ return null;
+}
+
+// 로그인 (오프라인 모드용 - 빈 함수)
+async function signIn(email, password) {
+ throw new Error('오프라인 모드에서는 로그인할 수 없습니다.');
+}
+
+// 회원가입 (오프라인 모드용 - 빈 함수)
+async function signUp(email, password, metadata = {}) {
+ throw new Error('오프라인 모드에서는 회원가입할 수 없습니다.');
+}
+
+// 로그아웃 (오프라인 모드용 - 빈 함수)
+async function signOut() {
+ throw new Error('오프라인 모드에서는 로그아웃할 수 없습니다.');
+}
+
+// 데이터베이스 헬퍼 함수들 (오프라인 모드용)
+const SupabaseHelper = {
+ // 파일 목록 가져오기 (오프라인 모드용)
+ async getFiles(userId) {
+ console.log('🔍 SupabaseHelper.getFiles 호출됨 (오프라인 모드)');
+ throw new Error('오프라인 모드에서는 Supabase 데이터베이스를 사용할 수 없습니다.');
+ },
+
+ // 파일 추가 (오프라인 모드용)
+ async addFile(fileData, userId) {
+ console.log('🔍 SupabaseHelper.addFile 호출됨 (오프라인 모드)');
+ throw new Error('오프라인 모드에서는 Supabase 데이터베이스를 사용할 수 없습니다.');
+ },
+
+ // 파일 수정 (오프라인 모드용)
+ async updateFile(id, updates, userId) {
+ console.log('🔍 SupabaseHelper.updateFile 호출됨 (오프라인 모드)');
+ throw new Error('오프라인 모드에서는 Supabase 데이터베이스를 사용할 수 없습니다.');
+ },
+
+ // 파일 삭제 (오프라인 모드용)
+ async deleteFile(id, userId) {
+ console.log('🔍 SupabaseHelper.deleteFile 호출됨 (오프라인 모드)');
+ throw new Error('오프라인 모드에서는 Supabase 데이터베이스를 사용할 수 없습니다.');
+ },
+
+ // 실시간 구독 설정 (오프라인 모드용)
+ subscribeToFiles(userId, callback) {
+ console.log('🔍 SupabaseHelper.subscribeToFiles 호출됨 (오프라인 모드)');
+ return null;
+ },
+
+ // 파일 업로드 (오프라인 모드용)
+ async uploadFile(file, filePath) {
+ console.log('🔍 SupabaseHelper.uploadFile 호출됨 (오프라인 모드)');
+ throw new Error('오프라인 모드에서는 Supabase Storage를 사용할 수 없습니다.');
+ },
+
+ // 파일 다운로드 URL 가져오기 (오프라인 모드용)
+ async getFileUrl(filePath) {
+ console.log('🔍 SupabaseHelper.getFileUrl 호출됨 (오프라인 모드)');
+ throw new Error('오프라인 모드에서는 Supabase Storage를 사용할 수 없습니다.');
+ },
+
+ // 파일 삭제 (Storage) (오프라인 모드용)
+ async deleteStorageFile(filePath) {
+ console.log('🔍 SupabaseHelper.deleteStorageFile 호출됨 (오프라인 모드)');
+ throw new Error('오프라인 모드에서는 Supabase Storage를 사용할 수 없습니다.');
+ },
+
+ // 첨부파일 정보 추가 (오프라인 모드용)
+ async addFileAttachment(fileId, attachmentData) {
+ console.log('🔍 SupabaseHelper.addFileAttachment 호출됨 (오프라인 모드)');
+ throw new Error('오프라인 모드에서는 Supabase 데이터베이스를 사용할 수 없습니다.');
+ },
+
+ // Storage 버킷 확인 및 생성 (오프라인 모드용)
+ async checkOrCreateBucket() {
+ console.log('🔍 SupabaseHelper.checkOrCreateBucket 호출됨 (오프라인 모드)');
+ return false;
+ }
+};
+
+// 전역으로 내보내기
+window.SupabaseHelper = SupabaseHelper;
+window.initializeSupabase = initializeSupabase;
+window.isSupabaseConfigured = isSupabaseConfigured;
+window.setupAuthListener = setupAuthListener;
+window.getCurrentUser = getCurrentUser;
+window.signIn = signIn;
+window.signUp = signUp;
+window.signOut = signOut;
\ No newline at end of file
diff --git a/clean-storage-setup.sql b/clean-storage-setup.sql
new file mode 100644
index 0000000..01dbb5e
--- /dev/null
+++ b/clean-storage-setup.sql
@@ -0,0 +1,62 @@
+-- 완전 초기화 후 Storage 정책 재설정
+
+-- 1단계: 모든 기존 Storage 정책 삭제
+DROP POLICY IF EXISTS "Users can upload to own folder" ON storage.objects;
+DROP POLICY IF EXISTS "Users can view own files" ON storage.objects;
+DROP POLICY IF EXISTS "Users can update own files" ON storage.objects;
+DROP POLICY IF EXISTS "Users can delete own files" ON storage.objects;
+DROP POLICY IF EXISTS "Public upload for testing" ON storage.objects;
+DROP POLICY IF EXISTS "Public read for testing" ON storage.objects;
+
+-- 혹시 다른 이름으로 생성된 정책들도 삭제
+DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON storage.objects;
+DROP POLICY IF EXISTS "Enable select for authenticated users only" ON storage.objects;
+DROP POLICY IF EXISTS "Enable update for authenticated users only" ON storage.objects;
+DROP POLICY IF EXISTS "Enable delete for authenticated users only" ON storage.objects;
+
+-- 2단계: RLS 활성화 확인 (보통 이미 활성화되어 있음)
+ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
+
+-- 3단계: 새 정책 생성
+-- 업로드 정책
+CREATE POLICY "Users can upload to own folder"
+ON storage.objects
+FOR INSERT
+WITH CHECK (
+ bucket_id = 'files' AND
+ auth.uid()::text = (storage.foldername(name))[1]
+);
+
+-- 조회 정책
+CREATE POLICY "Users can view own files"
+ON storage.objects
+FOR SELECT
+USING (
+ bucket_id = 'files' AND
+ auth.uid()::text = (storage.foldername(name))[1]
+);
+
+-- 업데이트 정책
+CREATE POLICY "Users can update own files"
+ON storage.objects
+FOR UPDATE
+USING (
+ bucket_id = 'files' AND
+ auth.uid()::text = (storage.foldername(name))[1]
+);
+
+-- 삭제 정책
+CREATE POLICY "Users can delete own files"
+ON storage.objects
+FOR DELETE
+USING (
+ bucket_id = 'files' AND
+ auth.uid()::text = (storage.foldername(name))[1]
+);
+
+-- 4단계: 정책 생성 확인
+SELECT
+ 'Storage policies created successfully!' as message,
+ COUNT(*) as policy_count
+FROM pg_policies
+WHERE schemaname = 'storage' AND tablename = 'objects';
\ No newline at end of file
diff --git a/index.html b/index.html
index 105426c..9b01609 100644
--- a/index.html
+++ b/index.html
@@ -12,17 +12,6 @@
📚 자료실 관리 시스템
파일과 문서를 효율적으로 관리하세요
-
-
-
-
-
-
-
- 🟢 온라인
-
-
-
@@ -38,48 +27,6 @@
-
-
-
-
📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!
-
+
+
+
+
+ 번호 |
+ 카테고리 |
+ 제목 |
+ 첨부 |
+ 등록일 |
+ 다운로드 |
+
+
+
+
+ 📂 등록된 자료가 없습니다. |
+
+
+
+
+
+
-
-
-
-
-
-
-
✏️ 자료 수정
-
-
-
diff --git a/script.js b/script.js
index 3c32433..0925f61 100644
--- a/script.js
+++ b/script.js
@@ -2,960 +2,43 @@ class FileManager {
constructor() {
this.files = [];
this.currentEditId = null;
- this.currentUser = null;
this.isOnline = navigator.onLine;
- this.realtimeSubscription = null;
- this.authMode = 'login'; // 'login' or 'signup'
+ this.currentPage = 1;
+ this.itemsPerPage = 10;
+ this.filteredFiles = [];
this.init();
}
async init() {
- // Supabase 초기화
- const supabaseInitialized = initializeSupabase();
-
- if (supabaseInitialized) {
- console.log('✅ Supabase 모드로 실행합니다.');
- await this.initializeAuth();
- } else {
- console.log('⚠️ 오프라인 모드로 실행합니다.');
- this.files = this.loadFiles();
- this.showOfflineMode();
- }
-
+ // 오프라인 모드로만 실행
+ this.files = this.loadFiles();
+ this.filteredFiles = [...this.files];
this.bindEvents();
this.renderFiles();
- this.updateEmptyState();
- this.setupOnlineStatusListener();
+ this.updatePagination();
}
bindEvents() {
- // 기존 이벤트
- document.getElementById('fileForm').addEventListener('submit', (e) => this.handleSubmit(e));
- document.getElementById('cancelBtn').addEventListener('click', () => this.clearForm());
+ // 검색 및 정렬 이벤트만 유지
document.getElementById('searchBtn').addEventListener('click', () => this.handleSearch());
document.getElementById('searchInput').addEventListener('keyup', (e) => {
if (e.key === 'Enter') this.handleSearch();
});
document.getElementById('categoryFilter').addEventListener('change', () => this.handleSearch());
- document.getElementById('sortBy').addEventListener('change', () => this.renderFiles());
- document.getElementById('editForm').addEventListener('submit', (e) => this.handleEditSubmit(e));
- document.getElementById('closeModal').addEventListener('click', () => this.closeEditModal());
- document.getElementById('fileUpload').addEventListener('change', (e) => this.handleFileUpload(e));
-
- // 인증 이벤트
- document.getElementById('loginBtn').addEventListener('click', () => this.openAuthModal('login'));
- document.getElementById('signupBtn').addEventListener('click', () => this.openAuthModal('signup'));
- document.getElementById('logoutBtn').addEventListener('click', () => this.handleLogout());
- document.getElementById('authForm').addEventListener('submit', (e) => this.handleAuthSubmit(e));
- document.getElementById('authCancelBtn').addEventListener('click', () => this.closeAuthModal());
- document.getElementById('authSwitchLink').addEventListener('click', (e) => {
- e.preventDefault();
- this.toggleAuthMode();
- });
-
- // 모달 이벤트
- window.addEventListener('click', (e) => {
- if (e.target === document.getElementById('editModal')) {
- this.closeEditModal();
- }
- if (e.target === document.getElementById('authModal')) {
- this.closeAuthModal();
- }
- });
+ document.getElementById('sortBy').addEventListener('change', () => this.handleSearch());
+
+ // 페이지네이션 이벤트
+ document.getElementById('prevPage').addEventListener('click', () => this.goToPrevPage());
+ document.getElementById('nextPage').addEventListener('click', () => this.goToNextPage());
}
generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
- // 파일 확장자 추출 (안전한 형태로)
- getFileExtension(fileName) {
- const lastDotIndex = fileName.lastIndexOf('.');
- if (lastDotIndex > 0 && lastDotIndex < fileName.length - 1) {
- return fileName.substring(lastDotIndex).toLowerCase();
- }
- return ''; // 확장자가 없는 경우
- }
-
- // 브라우저별 다운로드 폴더 경로 추정
- getDownloadFolderPath() {
- const userAgent = navigator.userAgent.toLowerCase();
- const platform = navigator.platform.toLowerCase();
-
- if (platform.includes('win')) {
- return '다운로드 폴더 (C:\\Users\\사용자명\\Downloads)';
- } else if (platform.includes('mac')) {
- return '다운로드 폴더 (~/Downloads)';
- } else if (platform.includes('linux')) {
- return '다운로드 폴더 (~/Downloads)';
- } else {
- return '브라우저 기본 다운로드 폴더';
- }
- }
-
- // 인증 관련 메서드들
- async initializeAuth() {
- try {
- const user = await getCurrentUser();
- if (user) {
- this.currentUser = user;
- this.updateAuthUI(true);
- await this.loadUserFiles();
- this.setupRealtimeSubscription();
- } else {
- this.updateAuthUI(false);
- // 게스트 모드: 공개 파일 로드
- await this.loadPublicFiles();
- this.showGuestMode();
- }
-
- setupAuthListener((event, session) => {
- if (event === 'SIGNED_IN') {
- this.currentUser = session.user;
- this.updateAuthUI(true);
- this.loadUserFiles();
- this.setupRealtimeSubscription();
- } else if (event === 'SIGNED_OUT') {
- this.currentUser = null;
- this.updateAuthUI(false);
- this.loadPublicFiles();
- this.showGuestMode();
- this.cleanupRealtimeSubscription();
- }
- });
- } catch (error) {
- console.error('인증 초기화 오류:', error);
- }
- }
-
- updateAuthUI(isAuthenticated) {
- const authButtons = document.getElementById('authButtons');
- const userInfo = document.getElementById('userInfo');
- const userEmail = document.getElementById('userEmail');
- const formSection = document.querySelector('.form-section');
-
- if (isAuthenticated && this.currentUser) {
- authButtons.style.display = 'none';
- userInfo.style.display = 'flex';
- userEmail.textContent = this.currentUser.email;
- formSection.style.display = 'block';
- this.updateSyncStatus();
- this.hideGuestMode();
- } else {
- authButtons.style.display = 'flex';
- userInfo.style.display = 'none';
- formSection.style.display = 'none';
- }
- }
-
- updateSyncStatus(status = 'auto') {
- const syncStatusElement = document.getElementById('syncStatus');
- if (!syncStatusElement) return;
-
- // 자동 상태 판단
- if (status === 'auto') {
- if (!isSupabaseConfigured()) {
- status = 'offline';
- } else if (this.currentUser) {
- status = 'online';
- } else {
- status = 'offline';
- }
- }
-
- // 상태 업데이트
- syncStatusElement.className = `sync-status ${status}`;
- switch (status) {
- case 'online':
- syncStatusElement.textContent = '🟢 온라인';
- break;
- case 'offline':
- syncStatusElement.textContent = '🟡 오프라인';
- break;
- case 'syncing':
- syncStatusElement.textContent = '🔄 동기화 중';
- break;
- case 'error':
- syncStatusElement.textContent = '🔴 오류';
- break;
- }
- }
-
- openAuthModal(mode) {
- this.authMode = mode;
- const modal = document.getElementById('authModal');
- const title = document.getElementById('authModalTitle');
- const submitBtn = document.getElementById('authSubmitBtn');
- const confirmPasswordGroup = document.getElementById('confirmPasswordGroup');
- const switchText = document.getElementById('authSwitchText');
-
- if (mode === 'signup') {
- title.textContent = '👤 회원가입';
- submitBtn.textContent = '👤 회원가입';
- confirmPasswordGroup.style.display = 'block';
- switchText.innerHTML = '이미 계정이 있으신가요? 로그인하기';
- } else {
- title.textContent = '🔑 로그인';
- submitBtn.textContent = '🔑 로그인';
- confirmPasswordGroup.style.display = 'none';
- switchText.innerHTML = '계정이 없으신가요? 회원가입하기';
- }
-
- // 이벤트 리스너 재바인딩
- const newSwitchLink = document.getElementById('authSwitchLink');
- newSwitchLink.addEventListener('click', (e) => {
- e.preventDefault();
- this.toggleAuthMode();
- });
-
- modal.style.display = 'block';
- }
-
- closeAuthModal() {
- document.getElementById('authModal').style.display = 'none';
- document.getElementById('authForm').reset();
- document.getElementById('authLoading').style.display = 'none';
- }
-
- toggleAuthMode() {
- this.authMode = this.authMode === 'login' ? 'signup' : 'login';
- this.openAuthModal(this.authMode);
- }
-
- async handleAuthSubmit(e) {
- e.preventDefault();
-
- const email = document.getElementById('authEmail').value.trim();
- const password = document.getElementById('authPassword').value;
- const confirmPassword = document.getElementById('authConfirmPassword').value;
-
- if (!email || !password) {
- alert('이메일과 비밀번호를 입력해주세요.');
- return;
- }
-
- if (this.authMode === 'signup' && password !== confirmPassword) {
- alert('비밀번호가 일치하지 않습니다.');
- return;
- }
-
- this.showAuthLoading(true);
-
- try {
- if (this.authMode === 'signup') {
- await signUp(email, password);
- alert('회원가입이 완료되었습니다! 이메일을 확인해주세요.');
- } else {
- await signIn(email, password);
- }
- this.closeAuthModal();
- } catch (error) {
- console.error('인증 오류:', error);
- alert(`${this.authMode === 'signup' ? '회원가입' : '로그인'} 중 오류가 발생했습니다: ${error.message}`);
- } finally {
- this.showAuthLoading(false);
- }
- }
-
- async handleLogout() {
- try {
- await signOut();
- this.showNotification('로그아웃되었습니다.', 'success');
- } catch (error) {
- console.error('로그아웃 오류:', error);
- alert('로그아웃 중 오류가 발생했습니다.');
- }
- }
-
- showAuthLoading(show) {
- const loading = document.getElementById('authLoading');
- const form = document.getElementById('authForm');
-
- if (show) {
- loading.style.display = 'block';
- form.style.display = 'none';
- } else {
- loading.style.display = 'none';
- form.style.display = 'block';
- }
- }
-
- // 오프라인 모드 관련
- showOfflineMode() {
- const container = document.querySelector('.container');
- const offlineNotice = document.createElement('div');
- offlineNotice.className = 'offline-mode';
- offlineNotice.innerHTML = '⚠️ 오프라인 모드: 로컬 저장소를 사용합니다. Supabase 설정을 확인해주세요.';
- container.insertBefore(offlineNotice, container.firstChild.nextSibling);
- }
-
- // 게스트 모드 관련
- showGuestMode() {
- this.hideGuestMode(); // 기존 알림 제거
- const container = document.querySelector('.container');
- const guestNotice = document.createElement('div');
- guestNotice.className = 'guest-mode';
- guestNotice.id = 'guestModeNotice';
- guestNotice.innerHTML = `
-
- 👤 게스트 모드 - 파일 보기 및 다운로드만 가능합니다
-
-
- `;
- container.insertBefore(guestNotice, container.firstChild.nextSibling);
- }
-
- hideGuestMode() {
- const guestNotice = document.getElementById('guestModeNotice');
- if (guestNotice) {
- guestNotice.remove();
- }
- }
-
- // 공개 파일 로드 (게스트용)
- async loadPublicFiles() {
- if (isSupabaseConfigured()) {
- try {
- // Supabase에서 모든 파일 로드 (RLS로 공개 파일만 접근 가능)
- const data = await SupabaseHelper.getFiles('public');
- this.files = data.map(file => ({
- ...file,
- files: file.file_attachments || [],
- isReadOnly: true
- }));
- } catch (error) {
- console.error('공개 파일 로딩 오류:', error);
- // localStorage 폴백
- this.files = this.loadFiles().map(file => ({ ...file, isReadOnly: true }));
- }
- } else {
- // 오프라인 모드: localStorage의 파일을 읽기 전용으로 로드
- this.files = this.loadFiles().map(file => ({ ...file, isReadOnly: true }));
- }
- this.renderFiles();
- this.updateEmptyState();
- }
-
- setupOnlineStatusListener() {
- window.addEventListener('online', () => {
- this.isOnline = true;
- this.showNotification('온라인 상태가 되었습니다.', 'success');
- });
-
- window.addEventListener('offline', () => {
- this.isOnline = false;
- this.showNotification('오프라인 상태입니다.', 'info');
- });
- }
-
- // Supabase 데이터베이스 연동 메서드들
- async loadUserFiles() {
- if (!this.currentUser || !isSupabaseConfigured()) {
- this.files = this.loadFiles(); // localStorage 폴백
- this.updateSyncStatus('offline');
- return;
- }
-
- try {
- this.updateSyncStatus('syncing');
- const data = await SupabaseHelper.getFiles(this.currentUser.id);
- this.files = data.map(file => ({
- ...file,
- files: file.file_attachments || [] // 첨부파일 정보 매핑
- }));
- this.renderFiles();
- this.updateEmptyState();
- this.updateSyncStatus('online');
- } catch (error) {
- console.error('파일 로딩 오류:', error);
- this.showNotification('파일을 불러오는 중 오류가 발생했습니다.', 'error');
- this.updateSyncStatus('error');
- // 오류 시 localStorage 폴백
- this.files = this.loadFiles();
- }
- }
-
- async addFileToSupabase(fileData) {
- if (!this.currentUser || !isSupabaseConfigured()) {
- return this.addFileLocally(fileData);
- }
-
- try {
- this.updateSyncStatus('syncing');
- console.log('파일 데이터 추가 중...', fileData);
-
- const result = await SupabaseHelper.addFile(fileData, this.currentUser.id);
- console.log('파일 데이터 추가 성공:', result);
-
- // 첨부파일이 있는 경우 파일 업로드 처리
- if (fileData.files && fileData.files.length > 0) {
- console.log(`${fileData.files.length}개의 첨부파일 업로드 시작...`);
- await this.uploadAttachments(result.id, fileData.files);
- console.log('모든 첨부파일 업로드 완료');
- }
-
- this.showNotification('새 자료가 성공적으로 추가되었습니다!', 'success');
- await this.loadUserFiles(); // 목록 새로고침
- this.updateSyncStatus('online');
- this.clearForm(); // 폼 초기화
-
- } catch (error) {
- console.error('파일 추가 오류:', error);
-
- // 더 구체적인 에러 메시지 제공
- let errorMessage = '파일 추가 중 오류가 발생했습니다.';
- if (error.message) {
- errorMessage += ` (${error.message})`;
- }
-
- this.showNotification(errorMessage, 'error');
- this.updateSyncStatus('error');
-
- // 콘솔에 상세 오류 정보 출력
- if (error.details) {
- console.error('오류 상세:', error.details);
- }
- if (error.hint) {
- console.error('오류 힌트:', error.hint);
- }
- }
- }
-
- async updateFileInSupabase(id, updates) {
- if (!this.currentUser || !isSupabaseConfigured()) {
- return this.updateFileLocally(id, updates);
- }
-
- try {
- await SupabaseHelper.updateFile(id, updates, this.currentUser.id);
- this.showNotification('자료가 성공적으로 수정되었습니다!', 'success');
- await this.loadUserFiles(); // 목록 새로고침
- } catch (error) {
- console.error('파일 수정 오류:', error);
- this.showNotification('파일 수정 중 오류가 발생했습니다.', 'error');
- }
- }
-
- async deleteFileFromSupabase(id) {
- if (!this.currentUser || !isSupabaseConfigured()) {
- return this.deleteFileLocally(id);
- }
-
- try {
- // 첨부파일들을 Storage에서 삭제
- await this.deleteAttachmentsFromStorage(id);
-
- // 데이터베이스에서 파일 삭제 (CASCADE로 첨부파일 정보도 함께 삭제)
- await SupabaseHelper.deleteFile(id, this.currentUser.id);
- this.showNotification('자료가 성공적으로 삭제되었습니다!', 'success');
- await this.loadUserFiles(); // 목록 새로고침
- } catch (error) {
- console.error('파일 삭제 오류:', error);
- this.showNotification('파일 삭제 중 오류가 발생했습니다.', 'error');
- }
- }
-
- // localStorage 폴백 메서드들
- addFileLocally(fileData) {
- // 로컬 저장용 데이터 생성 (ID와 타임스탬프 추가)
- const localFileData = {
- id: this.generateId(),
- ...fileData,
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString()
- };
-
- this.files.push(localFileData);
- this.saveFiles();
- this.renderFiles();
- this.updateEmptyState();
- this.showNotification('새 자료가 성공적으로 추가되었습니다! (로컬 저장)', 'success');
- this.clearForm(); // 폼 초기화
- }
-
- updateFileLocally(id, updates) {
- const fileIndex = this.files.findIndex(f => f.id === id);
- if (fileIndex !== -1) {
- this.files[fileIndex] = {
- ...this.files[fileIndex],
- ...updates,
- updated_at: new Date().toISOString()
- };
- this.saveFiles();
- this.renderFiles();
- this.showNotification('자료가 성공적으로 수정되었습니다! (로컬 저장)', 'success');
- }
- }
-
- deleteFileLocally(id) {
- this.files = this.files.filter(f => f.id !== id);
- this.saveFiles();
- this.renderFiles();
- this.updateEmptyState();
- this.showNotification('자료가 성공적으로 삭제되었습니다! (로컬 저장)', 'success');
- }
-
- // 실시간 구독 관련
- setupRealtimeSubscription() {
- if (!this.currentUser || !isSupabaseConfigured()) return;
-
- this.realtimeSubscription = SupabaseHelper.subscribeToFiles(
- this.currentUser.id,
- (payload) => {
- console.log('실시간 업데이트:', payload);
- this.loadUserFiles(); // 변경사항이 있으면 목록 새로고침
- }
- );
- }
-
- cleanupRealtimeSubscription() {
- if (this.realtimeSubscription) {
- this.realtimeSubscription.unsubscribe();
- this.realtimeSubscription = null;
- }
- }
-
- // 파일 업로드 관련 메서드들
- async uploadAttachments(fileId, attachments) {
- if (!isSupabaseConfigured() || !this.currentUser) {
- console.log('오프라인 모드: 첨부파일을 base64로 저장합니다.');
- return; // 오프라인 모드에서는 base64로 저장된 상태 유지
- }
-
- const uploadedFiles = [];
-
- try {
- for (let i = 0; i < attachments.length; i++) {
- const attachment = attachments[i];
-
- try {
- console.log(`파일 업로드 중... (${i + 1}/${attachments.length}): ${attachment.name}`);
-
- // base64 데이터를 Blob으로 변환
- const response = await fetch(attachment.data);
- const blob = await response.blob();
-
- // 안전한 파일명 생성 (고유한 이름으로 저장, 원본명은 DB에 저장)
- const fileExtension = this.getFileExtension(attachment.name);
- const safeFileName = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}${fileExtension}`;
- const filePath = `${this.currentUser.id}/${fileId}/${safeFileName}`;
-
- // Storage 버킷 확인
- const bucketExists = await SupabaseHelper.checkOrCreateBucket();
- if (!bucketExists) {
- throw new Error('Storage 버킷 접근 권한이 없습니다. Storage 정책을 확인해주세요.');
- }
-
- // Supabase Storage에 업로드
- const uploadResult = await SupabaseHelper.uploadFile(blob, filePath);
- console.log('Storage 업로드 성공:', uploadResult);
-
- // 데이터베이스에 첨부파일 정보 저장
- const attachmentResult = await this.addFileAttachment(fileId, {
- original_name: attachment.name,
- storage_path: filePath,
- file_size: attachment.size || blob.size,
- mime_type: attachment.type || blob.type
- });
-
- uploadedFiles.push(attachmentResult);
- console.log('첨부파일 정보 저장 성공:', attachmentResult);
-
- } catch (fileError) {
- console.error(`파일 "${attachment.name}" 업로드 실패:`, fileError);
- throw new Error(`파일 "${attachment.name}" 업로드에 실패했습니다: ${fileError.message}`);
- }
- }
-
- console.log('모든 첨부파일 업로드 완료:', uploadedFiles);
- return uploadedFiles;
-
- } catch (error) {
- console.error('파일 업로드 오류:', error);
-
- // 부분적으로 업로드된 파일들 정리 (선택사항)
- try {
- for (const uploadedFile of uploadedFiles) {
- if (uploadedFile.storage_path) {
- await SupabaseHelper.deleteStorageFile(uploadedFile.storage_path);
- }
- }
- } catch (cleanupError) {
- console.error('업로드 실패 파일 정리 중 오류:', cleanupError);
- }
-
- throw error;
- }
- }
-
- async addFileAttachment(fileId, attachmentData) {
- if (!isSupabaseConfigured()) {
- return; // 오프라인 모드에서는 처리하지 않음
- }
-
- try {
- // SupabaseHelper를 통해 첨부파일 정보 저장
- const result = await SupabaseHelper.addFileAttachment(fileId, attachmentData);
- return result;
- } catch (error) {
- console.error('첨부파일 정보 저장 오류:', error);
- throw error;
- }
- }
-
- async downloadFileFromStorage(filePath, originalName) {
- if (!isSupabaseConfigured()) {
- return; // 오프라인 모드에서는 처리하지 않음
- }
-
- try {
- console.log('파일 다운로드 시도:', filePath, originalName);
-
- // Storage 버킷 확인
- const bucketExists = await SupabaseHelper.checkOrCreateBucket();
- if (!bucketExists) {
- throw new Error('Storage 버킷 접근 권한이 없습니다. Storage 정책을 확인해주세요.');
- }
-
- const url = await SupabaseHelper.getFileUrl(filePath);
- console.log('다운로드 URL 생성:', url);
-
- // 다운로드 링크 생성
- const link = document.createElement('a');
- link.href = url;
- link.download = originalName;
-
- // Ctrl/Cmd 키를 누른 상태에서 클릭하면 "다른 이름으로 저장" 대화상자 표시
- if (window.event && (window.event.ctrlKey || window.event.metaKey)) {
- link.target = '_blank';
- // 브라우저의 다운로드 관리자로 보내기
- }
-
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
-
- console.log('파일 다운로드 완료:', originalName);
- } catch (error) {
- console.error('파일 다운로드 오류:', error);
-
- // 더 구체적인 오류 메시지 제공
- let errorMessage = '파일 다운로드 중 오류가 발생했습니다.';
- if (error.message.includes('Bucket not found')) {
- errorMessage = 'Storage 버킷이 설정되지 않았습니다. 관리자에게 문의하세요.';
- } else if (error.message.includes('파일을 찾을 수 없습니다')) {
- errorMessage = '파일을 찾을 수 없습니다. 파일이 삭제되었을 수 있습니다.';
- }
-
- this.showNotification(errorMessage, 'error');
- }
- }
-
- async deleteAttachmentsFromStorage(fileId) {
- if (!isSupabaseConfigured() || !this.currentUser) {
- return; // 오프라인 모드에서는 처리하지 않음
- }
-
- try {
- // 파일의 모든 첨부파일 경로 가져오기
- const { data: attachments, error } = await supabase
- .from('file_attachments')
- .select('storage_path')
- .eq('file_id', fileId);
-
- if (error) throw error;
-
- // 각 파일을 Storage에서 삭제
- for (const attachment of attachments) {
- await SupabaseHelper.deleteStorageFile(attachment.storage_path);
- }
- } catch (error) {
- console.error('첨부파일 삭제 오류:', error);
- }
- }
-
- handleSubmit(e) {
- e.preventDefault();
-
- if (!this.currentUser) {
- this.showNotification('로그인이 필요합니다.', 'error');
- return;
- }
-
- const title = document.getElementById('fileTitle').value.trim();
- const description = document.getElementById('fileDescription').value.trim();
- const category = document.getElementById('fileCategory').value;
- const tags = document.getElementById('fileTags').value.split(',').map(tag => tag.trim()).filter(tag => tag);
- const fileInput = document.getElementById('fileUpload');
-
- if (!title || !category) {
- alert('제목과 카테고리는 필수 입력 항목입니다.');
- return;
- }
-
- const fileData = {
- title,
- description,
- category,
- tags,
- files: [] // 첨부파일 임시 저장용 (Supabase 전송시 제외됨)
- };
-
- if (fileInput.files.length > 0) {
- Array.from(fileInput.files).forEach(file => {
- const reader = new FileReader();
- reader.onload = (e) => {
- fileData.files.push({
- name: file.name,
- size: file.size,
- type: file.type,
- data: e.target.result
- });
-
- if (fileData.files.length === fileInput.files.length) {
- this.addFileToSupabase(fileData);
- }
- };
- reader.readAsDataURL(file);
- });
- } else {
- this.addFileToSupabase(fileData);
- }
- }
-
- async addFile(fileData) {
- // 호환성을 위해 유지, 실제로는 addFileToSupabase 사용
- await this.addFileToSupabase(fileData);
- this.clearForm();
- }
-
- handleFileUpload(e) {
- const files = Array.from(e.target.files);
- const filesList = document.querySelector('.files-list') || this.createFilesList();
-
- filesList.innerHTML = '';
-
- files.forEach((file, index) => {
- const fileItem = document.createElement('div');
- fileItem.className = 'file-attachment';
- fileItem.innerHTML = `
- 📎 ${file.name} (${this.formatFileSize(file.size)})
-
- `;
- filesList.appendChild(fileItem);
- });
- }
-
- createFilesList() {
- const fileGroup = document.querySelector('#fileUpload').closest('.form-group');
- const filesList = document.createElement('div');
- filesList.className = 'files-list';
- fileGroup.appendChild(filesList);
- return filesList;
- }
-
- removeFileFromInput(index) {
- const fileInput = document.getElementById('fileUpload');
- const dt = new DataTransfer();
- const files = Array.from(fileInput.files);
-
- files.forEach((file, i) => {
- if (i !== index) {
- dt.items.add(file);
- }
- });
-
- fileInput.files = dt.files;
- this.handleFileUpload({ target: fileInput });
- }
-
- renderFiles() {
- const container = document.getElementById('fileList');
- const sortBy = document.getElementById('sortBy').value;
-
- let sortedFiles = [...this.files];
-
- switch (sortBy) {
- case 'title':
- sortedFiles.sort((a, b) => a.title.localeCompare(b.title));
- break;
- case 'category':
- sortedFiles.sort((a, b) => a.category.localeCompare(b.category));
- break;
- case 'date':
- default:
- sortedFiles.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
- break;
- }
-
- if (sortedFiles.length === 0) {
- container.innerHTML = '📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!
';
- return;
- }
-
- container.innerHTML = sortedFiles.map(file => this.createFileHTML(file)).join('');
- }
-
- createFileHTML(file) {
- const createdDate = new Date(file.created_at).toLocaleDateString('ko-KR');
- const updatedDate = new Date(file.updated_at).toLocaleDateString('ko-KR');
- const tagsHTML = file.tags.map(tag => `${tag}`).join('');
- const filesHTML = file.files.length > 0 ?
- `
- 첨부파일 (${file.files.length}개):
- ${file.files.map(f => `📎 ${f.name}`).join(', ')}
-
` : '';
-
- return `
-
-
-
- ${file.description ? `
${this.escapeHtml(file.description)}
` : ''}
-
- ${file.tags.length > 0 ? `
${tagsHTML}
` : ''}
-
- ${filesHTML}
-
-
- ${!file.isReadOnly && this.currentUser ? `
-
-
- ` : ''}
- ${file.files.length > 0 ? `` : ''}
- ${file.isReadOnly ? `👁️ 읽기 전용` : ''}
-
-
- `;
- }
-
- escapeHtml(text) {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
-
- editFile(id) {
- if (!this.currentUser) {
- this.showNotification('로그인이 필요합니다.', 'error');
- return;
- }
-
- const file = this.files.find(f => f.id === id);
- if (!file) return;
-
- if (file.isReadOnly) {
- this.showNotification('읽기 전용 파일은 편집할 수 없습니다.', 'error');
- return;
- }
-
- this.currentEditId = id;
-
- document.getElementById('editTitle').value = file.title;
- document.getElementById('editDescription').value = file.description;
- document.getElementById('editCategory').value = file.category;
- document.getElementById('editTags').value = file.tags.join(', ');
-
- document.getElementById('editModal').style.display = 'block';
- }
-
- handleEditSubmit(e) {
- e.preventDefault();
-
- if (!this.currentEditId) return;
-
- const title = document.getElementById('editTitle').value.trim();
- const description = document.getElementById('editDescription').value.trim();
- const category = document.getElementById('editCategory').value;
- const tags = document.getElementById('editTags').value.split(',').map(tag => tag.trim()).filter(tag => tag);
-
- if (!title || !category) {
- alert('제목과 카테고리는 필수 입력 항목입니다.');
- return;
- }
-
- const updates = {
- title,
- description,
- category,
- tags
- };
-
- this.updateFileInSupabase(this.currentEditId, updates);
- this.closeEditModal();
- }
-
- closeEditModal() {
- document.getElementById('editModal').style.display = 'none';
- this.currentEditId = null;
- }
-
- deleteFile(id) {
- if (!this.currentUser) {
- this.showNotification('로그인이 필요합니다.', 'error');
- return;
- }
-
- const file = this.files.find(f => f.id === id);
- if (!file) return;
-
- if (file.isReadOnly) {
- this.showNotification('읽기 전용 파일은 삭제할 수 없습니다.', 'error');
- return;
- }
-
- if (confirm(`"${file.title}" 자료를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) {
- this.deleteFileFromSupabase(id);
- }
- }
-
- async downloadFiles(id) {
- const file = this.files.find(f => f.id === id);
- if (!file || file.files.length === 0) return;
-
- try {
- for (const fileData of file.files) {
- if (fileData.storage_path && isSupabaseConfigured()) {
- // Supabase Storage에서 다운로드
- await this.downloadFileFromStorage(fileData.storage_path, fileData.original_name || fileData.name);
- } else if (fileData.data) {
- // localStorage 데이터 다운로드 (base64)
- const link = document.createElement('a');
- link.href = fileData.data;
- link.download = fileData.name;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- }
- }
- const fileNames = file.files.map(f => f.original_name || f.name).join(', ');
- const downloadFolder = this.getDownloadFolderPath();
-
- // 첫 번째 다운로드인지 확인
- const isFirstDownload = !localStorage.getItem('hasDownloadedBefore');
- if (isFirstDownload) {
- localStorage.setItem('hasDownloadedBefore', 'true');
- this.showNotification(`파일 다운로드 완료: ${fileNames}\n저장 위치: ${downloadFolder}\n\n💡 팁: 브라우저 설정에서 다운로드 폴더를 변경할 수 있습니다.`, 'success');
- } else {
- this.showNotification(`파일 다운로드 완료: ${fileNames}`, 'success');
- }
- } catch (error) {
- console.error('파일 다운로드 오류:', error);
- this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error');
- }
- }
-
handleSearch() {
- const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim();
+ const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const categoryFilter = document.getElementById('categoryFilter').value;
let filteredFiles = this.files;
@@ -964,7 +47,7 @@ class FileManager {
filteredFiles = filteredFiles.filter(file =>
file.title.toLowerCase().includes(searchTerm) ||
file.description.toLowerCase().includes(searchTerm) ||
- file.tags.some(tag => tag.toLowerCase().includes(searchTerm))
+ (file.tags && file.tags.some(tag => tag.toLowerCase().includes(searchTerm)))
);
}
@@ -972,93 +55,253 @@ class FileManager {
filteredFiles = filteredFiles.filter(file => file.category === categoryFilter);
}
- this.renderFilteredFiles(filteredFiles);
+ this.filteredFiles = filteredFiles;
+ this.currentPage = 1; // 검색 시 첫 페이지로 리셋
+ this.renderFiles();
+ this.updatePagination();
}
- renderFilteredFiles(files) {
- const container = document.getElementById('fileList');
+ renderFiles() {
+ const fileList = document.getElementById('fileList');
const sortBy = document.getElementById('sortBy').value;
- let sortedFiles = [...files];
+ // 정렬
+ const sortedFiles = [...this.filteredFiles].sort((a, b) => {
+ switch (sortBy) {
+ case 'title':
+ return a.title.localeCompare(b.title);
+ case 'category':
+ return a.category.localeCompare(b.category);
+ case 'date':
+ default:
+ return new Date(b.createdAt) - new Date(a.createdAt);
+ }
+ });
+
+ // 페이지네이션 적용
+ const startIndex = (this.currentPage - 1) * this.itemsPerPage;
+ const endIndex = startIndex + this.itemsPerPage;
+ const paginatedFiles = sortedFiles.slice(startIndex, endIndex);
- switch (sortBy) {
- case 'title':
- sortedFiles.sort((a, b) => a.title.localeCompare(b.title));
- break;
- case 'category':
- sortedFiles.sort((a, b) => a.category.localeCompare(b.category));
- break;
- case 'date':
- default:
- sortedFiles.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
- break;
- }
-
if (sortedFiles.length === 0) {
- container.innerHTML = '🔍 검색 결과가 없습니다. 다른 키워드로 검색해보세요!
';
+ fileList.innerHTML = `
+
+ 📂 조건에 맞는 자료가 없습니다. |
+
+ `;
return;
}
- container.innerHTML = sortedFiles.map(file => this.createFileHTML(file)).join('');
+ fileList.innerHTML = paginatedFiles.map((file, index) =>
+ this.createFileRowHTML(file, startIndex + index + 1)
+ ).join('');
}
- clearForm() {
- document.getElementById('fileForm').reset();
- const filesList = document.querySelector('.files-list');
- if (filesList) {
- filesList.innerHTML = '';
+ createFileRowHTML(file, rowNumber) {
+ const createdDate = new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR');
+ const hasAttachments = file.files && file.files.length > 0;
+
+ return `
+
+ ${rowNumber} |
+
+ ${file.category}
+ |
+
+
+ ${this.escapeHtml(file.title)}
+ ${file.description ? ` ${this.escapeHtml(file.description)}` : ''}
+ ${file.tags && file.tags.length > 0 ?
+ ` ${file.tags.map(tag => `#${this.escapeHtml(tag)}`).join('')} ` : ''
+ }
+
+ |
+
+ ${hasAttachments ?
+ ` ${file.files.map((f, index) =>
+ `${this.getFileIcon(f.name || f.original_name || 'unknown')}`
+ ).join(' ')} ` :
+ `-`
+ }
+ |
+ ${createdDate} |
+
+ ${hasAttachments ?
+ `` :
+ `-`
+ }
+ |
+
+ `;
+ }
+
+ getFileIcon(fileName) {
+ const ext = fileName.split('.').pop().toLowerCase();
+ const iconMap = {
+ 'pdf': '📄',
+ 'doc': '📝', 'docx': '📝',
+ 'xls': '📊', 'xlsx': '📊',
+ 'ppt': '📽️', 'pptx': '📽️',
+ 'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️',
+ 'mp4': '🎥', 'avi': '🎥', 'mov': '🎥',
+ 'mp3': '🎵', 'wav': '🎵',
+ 'zip': '📦', 'rar': '📦',
+ 'txt': '📄'
+ };
+ return iconMap[ext] || '📄';
+ }
+
+ viewFileInfo(id) {
+ const file = this.files.find(f => f.id === id);
+ if (!file) return;
+
+ let info = `📋 자료 정보\n\n`;
+ info += `📌 제목: ${file.title}\n`;
+ info += `📂 카테고리: ${file.category}\n`;
+ info += `📅 등록일: ${new Date(file.created_at || file.createdAt).toLocaleDateString('ko-KR')}\n`;
+ if (file.description) info += `📝 설명: ${file.description}\n`;
+ if (file.tags && file.tags.length > 0) info += `🏷️ 태그: ${file.tags.join(', ')}\n`;
+ if (file.files && file.files.length > 0) {
+ info += `\n📎 첨부파일 (${file.files.length}개):\n`;
+ file.files.forEach((attachment, index) => {
+ const icon = this.getFileIcon(attachment.name || attachment.original_name || 'unknown');
+ info += ` ${index + 1}. ${icon} ${attachment.name || attachment.original_name || '파일'}\n`;
+ });
+ }
+
+ alert(info);
+ }
+
+ async downloadFiles(id) {
+ const file = this.files.find(f => f.id === id);
+ if (!file) {
+ this.showNotification('파일을 찾을 수 없습니다.', 'error');
+ return;
+ }
+ if (!file.files || file.files.length === 0) {
+ this.showNotification('첨부파일이 없습니다.', 'error');
+ return;
+ }
+
+ try {
+ if (file.files.length === 1) {
+ // 단일 파일: 직접 다운로드
+ await this.downloadSingleFileData(file.files[0]);
+ this.showNotification(`파일 다운로드 완료: ${file.files[0].name || file.files[0].original_name}`, 'success');
+ } else {
+ // 다중 파일: localStorage에서 base64 데이터를 각각 다운로드
+ for (const fileData of file.files) {
+ await this.downloadSingleFileData(fileData);
+ }
+ this.showNotification(`${file.files.length}개 파일 다운로드 완료`, 'success');
+ }
+ } catch (error) {
+ console.error('파일 다운로드 오류:', error);
+ this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error');
}
}
- formatFileSize(bytes) {
- if (bytes === 0) return '0 Bytes';
- const k = 1024;
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ async downloadSingleFile(fileId, fileIndex) {
+ const file = this.files.find(f => f.id === fileId);
+ if (!file) {
+ this.showNotification('파일을 찾을 수 없습니다.', 'error');
+ return;
+ }
+ if (!file.files || !file.files[fileIndex]) {
+ this.showNotification('첨부파일을 찾을 수 없습니다.', 'error');
+ return;
+ }
+
+ try {
+ const fileData = file.files[fileIndex];
+ await this.downloadSingleFileData(fileData);
+ this.showNotification(`파일 다운로드 완료: ${fileData.name || fileData.original_name}`, 'success');
+ } catch (error) {
+ console.error('개별 파일 다운로드 오류:', error);
+ this.showNotification('파일 다운로드 중 오류가 발생했습니다.', 'error');
+ }
}
- updateEmptyState() {
- const container = document.getElementById('fileList');
- if (this.files.length === 0) {
- container.innerHTML = '📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!
';
+ async downloadSingleFileData(fileData) {
+ if (fileData.data) {
+ // localStorage의 base64 데이터 다운로드
+ const link = document.createElement('a');
+ link.href = fileData.data;
+ link.download = fileData.name || fileData.original_name || 'file';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
}
}
showNotification(message, type = 'info') {
- // 기존 알림이 있으면 제거
- const existingNotification = document.querySelector('.notification');
- if (existingNotification) {
- existingNotification.remove();
- }
-
+ // 간단한 알림 표시
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
+ notification.style.cssText = `
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ padding: 15px 20px;
+ border-radius: 8px;
+ color: white;
+ font-weight: 500;
+ z-index: 10000;
+ max-width: 400px;
+ background: ${type === 'success' ? '#48bb78' : type === 'error' ? '#f56565' : '#4299e1'};
+ `;
document.body.appendChild(notification);
- // 3초 후 자동 제거
setTimeout(() => {
- if (notification.parentNode) {
- notification.remove();
+ if (document.body.contains(notification)) {
+ document.body.removeChild(notification);
}
}, 3000);
}
+ updatePagination() {
+ const totalPages = Math.ceil(this.filteredFiles.length / this.itemsPerPage);
+ const pagination = document.getElementById('pagination');
+ const prevBtn = document.getElementById('prevPage');
+ const nextBtn = document.getElementById('nextPage');
+ const pageInfo = document.getElementById('pageInfo');
+
+ if (totalPages <= 1) {
+ pagination.style.display = 'none';
+ } else {
+ pagination.style.display = 'flex';
+ prevBtn.disabled = this.currentPage <= 1;
+ nextBtn.disabled = this.currentPage >= totalPages;
+ pageInfo.textContent = `${this.currentPage} / ${totalPages}`;
+ }
+ }
+
+ goToPrevPage() {
+ if (this.currentPage > 1) {
+ this.currentPage--;
+ this.renderFiles();
+ this.updatePagination();
+ }
+ }
+
+ goToNextPage() {
+ const totalPages = Math.ceil(this.filteredFiles.length / this.itemsPerPage);
+ if (this.currentPage < totalPages) {
+ this.currentPage++;
+ this.renderFiles();
+ this.updatePagination();
+ }
+ }
+
+
loadFiles() {
try {
const stored = localStorage.getItem('fileManagerData');
- const files = stored ? JSON.parse(stored) : [];
-
- // 기존 localStorage 데이터의 호환성을 위해 컬럼명 변환
- return files.map(file => ({
- ...file,
- created_at: file.created_at || file.createdAt || new Date().toISOString(),
- updated_at: file.updated_at || file.updatedAt || new Date().toISOString()
- }));
+ return stored ? JSON.parse(stored) : [];
} catch (error) {
- console.error('파일 데이터를 불러오는 중 오류가 발생했습니다:', error);
+ console.error('파일 로드 중 오류:', error);
return [];
}
}
@@ -1067,68 +310,43 @@ class FileManager {
try {
localStorage.setItem('fileManagerData', JSON.stringify(this.files));
} catch (error) {
- console.error('파일 데이터를 저장하는 중 오류가 발생했습니다:', error);
- alert('데이터 저장 중 오류가 발생했습니다. 브라우저의 저장공간을 확인해주세요.');
+ console.error('파일 저장 중 오류:', error);
}
}
- exportData() {
- const dataStr = JSON.stringify(this.files, null, 2);
- const dataBlob = new Blob([dataStr], {type: 'application/json'});
- const url = URL.createObjectURL(dataBlob);
- const link = document.createElement('a');
- link.href = url;
- link.download = `자료실_백업_${new Date().toISOString().split('T')[0]}.json`;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
- this.showNotification('데이터가 성공적으로 내보내기되었습니다!', 'success');
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
}
- importData(event) {
- const file = event.target.files[0];
- if (!file) return;
-
- const reader = new FileReader();
- reader.onload = (e) => {
- try {
- const importedData = JSON.parse(e.target.result);
- if (Array.isArray(importedData)) {
- if (confirm('기존 데이터를 모두 삭제하고 새 데이터를 가져오시겠습니까?')) {
- this.files = importedData;
- this.saveFiles();
- this.renderFiles();
- this.updateEmptyState();
- this.showNotification('데이터가 성공적으로 가져와졌습니다!', 'success');
- }
- } else {
- alert('올바르지 않은 파일 형식입니다.');
- }
- } catch (error) {
- alert('파일을 읽는 중 오류가 발생했습니다.');
- console.error(error);
- }
- };
- reader.readAsText(file);
+ showMessage(message, type = 'success') {
+ const messageEl = document.createElement('div');
+ messageEl.className = `message ${type}`;
+ messageEl.textContent = message;
+ messageEl.style.cssText = `
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ background: ${type === 'error' ? '#ef4444' : '#10b981'};
+ color: white;
+ padding: 1rem 1.5rem;
+ border-radius: 8px;
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
+ z-index: 1000;
+ animation: slideIn 0.3s ease-out;
+ `;
+
+ document.body.appendChild(messageEl);
+ setTimeout(() => {
+ messageEl.style.animation = 'slideOut 0.3s ease-out';
+ setTimeout(() => messageEl.remove(), 300);
+ }, 3000);
}
}
-const style = document.createElement('style');
-style.textContent = `
- @keyframes slideIn {
- from { transform: translateX(100%); opacity: 0; }
- to { transform: translateX(0); opacity: 1; }
- }
- @keyframes slideOut {
- from { transform: translateX(0); opacity: 1; }
- to { transform: translateX(100%); opacity: 0; }
- }
-`;
-document.head.appendChild(style);
-
-const fileManager = new FileManager();
-
+// 전역 인스턴스 생성
+let fileManager;
document.addEventListener('DOMContentLoaded', () => {
- console.log('📚 자료실 관리 시스템이 초기화되었습니다.');
+ fileManager = new FileManager();
});
\ No newline at end of file
diff --git a/storage-policies.sql b/storage-policies.sql
new file mode 100644
index 0000000..95ec94c
--- /dev/null
+++ b/storage-policies.sql
@@ -0,0 +1,39 @@
+-- Storage 전용 정책 (Supabase Dashboard → Storage → Policies에서 실행)
+
+-- 1. 파일 업로드 정책
+CREATE POLICY "Users can upload to own folder"
+ON storage.objects
+FOR INSERT
+WITH CHECK (
+ bucket_id = 'files' AND
+ auth.uid()::text = (storage.foldername(name))[1]
+);
+
+-- 2. 파일 조회 정책
+CREATE POLICY "Users can view own files"
+ON storage.objects
+FOR SELECT
+USING (
+ bucket_id = 'files' AND
+ auth.uid()::text = (storage.foldername(name))[1]
+);
+
+-- 3. 파일 업데이트 정책
+CREATE POLICY "Users can update own files"
+ON storage.objects
+FOR UPDATE
+USING (
+ bucket_id = 'files' AND
+ auth.uid()::text = (storage.foldername(name))[1]
+);
+
+-- 4. 파일 삭제 정책
+CREATE POLICY "Users can delete own files"
+ON storage.objects
+FOR DELETE
+USING (
+ bucket_id = 'files' AND
+ auth.uid()::text = (storage.foldername(name))[1]
+);
+
+-- 참고: storage.foldername(name)[1]은 'user_id/filename.txt'에서 'user_id' 부분을 추출합니다.
\ No newline at end of file
diff --git a/styles.css b/styles.css
index 08eece0..dd9690b 100644
--- a/styles.css
+++ b/styles.css
@@ -39,6 +39,47 @@ header p {
font-size: 1.1rem;
}
+/* 페이지네이션 스타일 */
+.pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 20px;
+ margin-top: 30px;
+ padding: 20px;
+ background: rgba(255, 255, 255, 0.9);
+ border-radius: 10px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
+}
+
+.page-btn {
+ background: #4299e1;
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 1rem;
+ transition: all 0.3s ease;
+}
+
+.page-btn:hover:not(:disabled) {
+ background: #3182ce;
+ transform: translateY(-2px);
+}
+
+.page-btn:disabled {
+ background: #a0aec0;
+ cursor: not-allowed;
+ transform: none;
+}
+
+#pageInfo {
+ font-weight: 600;
+ color: #4a5568;
+ font-size: 1.1rem;
+}
+
.auth-section {
margin-top: 20px;
display: flex;
@@ -360,6 +401,215 @@ header p {
cursor: pointer;
}
+/* 게시판 스타일 */
+.board-container {
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 12px;
+ padding: 20px;
+ margin-top: 20px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
+ backdrop-filter: blur(10px);
+}
+
+.board-table {
+ width: 100%;
+ border-collapse: collapse;
+ background: white;
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.board-table thead {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+}
+
+.board-table th {
+ padding: 15px 12px;
+ text-align: center;
+ font-weight: 600;
+ font-size: 0.95rem;
+ border-right: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+.board-table th:last-child {
+ border-right: none;
+}
+
+.board-table tbody tr {
+ border-bottom: 1px solid #e5e7eb;
+ transition: background-color 0.2s ease;
+}
+
+.board-table tbody tr:hover {
+ background-color: #f8fafc;
+}
+
+.board-table tbody tr:last-child {
+ border-bottom: none;
+}
+
+.board-table td {
+ padding: 12px;
+ text-align: center;
+ vertical-align: middle;
+ font-size: 0.9rem;
+}
+
+/* 컬럼 너비 설정 */
+.col-no { width: 60px; }
+.col-category { width: 100px; }
+.col-title { width: auto; min-width: 200px; text-align: left; }
+.col-attachment { width: 80px; }
+.col-date { width: 120px; }
+.col-actions { width: 150px; }
+
+/* 제목 스타일 */
+.board-title {
+ color: #374151;
+ font-weight: 500;
+ text-decoration: none;
+ cursor: pointer;
+ display: block;
+ padding: 8px;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+}
+
+.board-title:hover {
+ background-color: #e0e7ff;
+ color: #4f46e5;
+}
+
+/* 카테고리 배지 */
+.category-badge {
+ display: inline-block;
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: white;
+}
+
+.category-문서 { background: #3b82f6; }
+.category-이미지 { background: #10b981; }
+.category-동영상 { background: #f59e0b; }
+.category-프레젠테이션 { background: #ef4444; }
+.category-기타 { background: #6b7280; }
+
+/* 첨부파일 아이콘 */
+.attachment-icon {
+ font-size: 1.2rem;
+ color: #10b981;
+}
+
+.attachment-icons {
+ display: flex;
+ gap: 2px;
+ justify-content: center;
+ align-items: center;
+ flex-wrap: wrap;
+ font-size: 1rem;
+ line-height: 1.2;
+}
+
+.attachment-icons span {
+ display: inline-block;
+}
+
+.attachment-icon-clickable {
+ cursor: pointer;
+ transition: all 0.2s ease;
+ padding: 2px;
+ border-radius: 3px;
+}
+
+.attachment-icon-clickable:hover {
+ background-color: #e0e7ff;
+ transform: scale(1.1);
+}
+
+.no-attachment {
+ color: #9ca3af;
+}
+
+/* 액션 버튼 */
+.action-buttons {
+ display: flex;
+ gap: 5px;
+ justify-content: center;
+ align-items: center;
+}
+
+.action-btn {
+ padding: 6px 12px;
+ border: none;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-weight: 500;
+}
+
+.btn-download {
+ background: #10b981;
+ color: white;
+}
+
+.btn-download:hover {
+ background: #059669;
+ transform: translateY(-1px);
+}
+
+/* 빈 상태 */
+.empty-state td {
+ padding: 60px 20px;
+ text-align: center;
+ color: #6b7280;
+ font-size: 1.1rem;
+}
+
+/* 페이지네이션 스타일 */
+.pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 15px;
+ margin-top: 20px;
+ padding: 20px 0;
+}
+
+.page-btn {
+ padding: 8px 16px;
+ border: 2px solid #e2e8f0;
+ border-radius: 6px;
+ background: white;
+ color: #4a5568;
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 500;
+ transition: all 0.2s ease;
+}
+
+.page-btn:hover:not(:disabled) {
+ border-color: #667eea;
+ color: #667eea;
+ background: #f0f4ff;
+}
+
+.page-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ color: #9ca3af;
+}
+
+#pageInfo {
+ font-weight: 600;
+ color: #4a5568;
+ font-size: 1rem;
+}
+
.file-list {
display: grid;
gap: 20px;
@@ -470,10 +720,47 @@ header p {
.download-btn {
background: #38a169;
color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: all 0.3s ease;
+ font-weight: 500;
}
.download-btn:hover {
background: #2f855a;
+ transform: translateY(-1px);
+}
+
+.no-files {
+ color: #a0aec0;
+ font-style: italic;
+ font-size: 0.9rem;
+}
+
+.file-attachments {
+ margin-top: 10px;
+ padding: 8px 12px;
+ background: rgba(59, 130, 246, 0.1);
+ border-radius: 6px;
+ border: 1px solid rgba(59, 130, 246, 0.2);
+}
+
+.file-attachments strong {
+ color: #1e40af;
+ margin-right: 8px;
+}
+
+.attachment {
+ display: inline-block;
+ background: rgba(59, 130, 246, 0.2);
+ color: #1e40af;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.85rem;
+ margin: 2px;
}
.empty-state {
diff --git a/temp-public-policy.sql b/temp-public-policy.sql
new file mode 100644
index 0000000..e0813fa
--- /dev/null
+++ b/temp-public-policy.sql
@@ -0,0 +1,16 @@
+-- 임시 공개 접근 정책 (테스트용만 사용!)
+-- 보안상 권장하지 않음 - 운영환경에서는 사용하지 마세요
+
+-- 모든 사용자가 files 버킷에 업로드 가능 (임시)
+CREATE POLICY "Public upload for testing"
+ON storage.objects
+FOR INSERT
+WITH CHECK (bucket_id = 'files');
+
+-- 모든 사용자가 files 버킷 파일 조회 가능 (임시)
+CREATE POLICY "Public read for testing"
+ON storage.objects
+FOR SELECT
+USING (bucket_id = 'files');
+
+-- 주의: 이 정책들은 테스트 후 반드시 삭제하고 위의 사용자별 정책으로 교체하세요!
\ No newline at end of file
diff --git a/게시판.png b/게시판.png
new file mode 100644
index 0000000..099b68d
Binary files /dev/null and b/게시판.png differ
diff --git a/오류.png b/오류.png
new file mode 100644
index 0000000..7b98a61
Binary files /dev/null and b/오류.png differ