Add comprehensive file management system with admin/user separation
Features added: - Admin interface with full CRUD operations and multi-file upload - User interface with read-only access and download functionality - Board-style table layout with pagination (10 items per page) - Category-specific file icons and attachment management - Drag & drop file upload with preview and individual file removal - Individual and bulk download with ZIP compression support - Offline mode with localStorage fallback for both interfaces - Responsive design with modern UI components Technical improvements: - Separated admin (/admin/) and user (/) interfaces - Enhanced file data structure with consistent naming - Improved error handling and user notifications - Multi-file upload processing with base64 encoding - File type detection and appropriate icon mapping - Download functionality with single/multiple file handling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,9 @@
|
|||||||
"WebFetch(domain:ngrok.com)",
|
"WebFetch(domain:ngrok.com)",
|
||||||
"Bash(python:*)",
|
"Bash(python:*)",
|
||||||
"WebFetch(domain:developers.cloudflare.com)",
|
"WebFetch(domain:developers.cloudflare.com)",
|
||||||
"Bash(git add:*)"
|
"Bash(git add:*)",
|
||||||
|
"Bash(mkdir:*)",
|
||||||
|
"Bash(cp:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
210
admin/index.html
Normal file
210
admin/index.html
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>자료실 - CRUD 시스템</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>📚 자료실 관리 시스템</h1>
|
||||||
|
<p>파일과 문서를 효율적으로 관리하세요</p>
|
||||||
|
<div id="authSection" class="auth-section">
|
||||||
|
<div id="authButtons" class="auth-buttons" style="display: none;">
|
||||||
|
<button id="loginBtn" class="auth-btn">🔑 로그인</button>
|
||||||
|
<button id="signupBtn" class="auth-btn">👤 회원가입</button>
|
||||||
|
</div>
|
||||||
|
<div id="userInfo" class="user-info" style="display: flex;">
|
||||||
|
<span id="userEmail">오프라인 사용자</span>
|
||||||
|
<span id="syncStatus" class="sync-status offline">🟡 오프라인</span>
|
||||||
|
<button id="logoutBtn" class="auth-btn" style="display: none;">🚪 로그아웃</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="search-section">
|
||||||
|
<input type="text" id="searchInput" placeholder="제목, 설명, 카테고리로 검색...">
|
||||||
|
<select id="categoryFilter">
|
||||||
|
<option value="">전체 카테고리</option>
|
||||||
|
<option value="문서">문서</option>
|
||||||
|
<option value="이미지">이미지</option>
|
||||||
|
<option value="동영상">동영상</option>
|
||||||
|
<option value="프레젠테이션">프레젠테이션</option>
|
||||||
|
<option value="기타">기타</option>
|
||||||
|
</select>
|
||||||
|
<button id="searchBtn">🔍 검색</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>📁 새 자료 추가</h2>
|
||||||
|
<form id="fileForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="fileTitle">제목 *</label>
|
||||||
|
<input type="text" id="fileTitle" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="fileDescription">설명</label>
|
||||||
|
<textarea id="fileDescription" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="fileCategory">카테고리 *</label>
|
||||||
|
<select id="fileCategory" required>
|
||||||
|
<option value="">카테고리 선택</option>
|
||||||
|
<option value="문서">문서</option>
|
||||||
|
<option value="이미지">이미지</option>
|
||||||
|
<option value="동영상">동영상</option>
|
||||||
|
<option value="프레젠테이션">프레젠테이션</option>
|
||||||
|
<option value="기타">기타</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="fileUpload">파일 첨부 (여러 파일 선택 가능)</label>
|
||||||
|
<div class="file-upload-area" id="fileUploadArea">
|
||||||
|
<input type="file" id="fileUpload" multiple accept="*/*">
|
||||||
|
<div class="upload-placeholder">
|
||||||
|
<div class="upload-icon">📁</div>
|
||||||
|
<p><strong>파일을 여기로 드래그하거나 클릭하여 선택하세요</strong></p>
|
||||||
|
<p>여러 파일을 동시에 선택할 수 있습니다</p>
|
||||||
|
<small>지원 형식: 모든 파일 형식</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="selected-files" id="selectedFiles"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="fileTags">태그</label>
|
||||||
|
<input type="text" id="fileTags" placeholder="쉼표로 구분하여 입력 (예: 중요, 업무, 프로젝트)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-buttons">
|
||||||
|
<button type="submit" id="submitBtn">📤 추가</button>
|
||||||
|
<button type="button" id="cancelBtn">❌ 취소</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-section">
|
||||||
|
<div class="list-header">
|
||||||
|
<h2>📋 자료 목록</h2>
|
||||||
|
<div class="sort-options">
|
||||||
|
<select id="sortBy">
|
||||||
|
<option value="date">최신순</option>
|
||||||
|
<option value="title">제목순</option>
|
||||||
|
<option value="category">카테고리순</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="board-container">
|
||||||
|
<table class="board-table" id="boardTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-no">번호</th>
|
||||||
|
<th class="col-category">카테고리</th>
|
||||||
|
<th class="col-title">제목</th>
|
||||||
|
<th class="col-attachment">첨부</th>
|
||||||
|
<th class="col-date">등록일</th>
|
||||||
|
<th class="col-actions">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="fileList">
|
||||||
|
<tr class="empty-state">
|
||||||
|
<td colspan="6">📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pagination" id="pagination" style="display: none;">
|
||||||
|
<button id="prevPage" class="page-btn" disabled>◀ 이전</button>
|
||||||
|
<span id="pageInfo">1 / 1</span>
|
||||||
|
<button id="nextPage" class="page-btn" disabled>다음 ▶</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 인증 모달 -->
|
||||||
|
<div id="authModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 id="authModalTitle">🔑 로그인</h2>
|
||||||
|
<form id="authForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="authEmail">이메일 *</label>
|
||||||
|
<input type="email" id="authEmail" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="authPassword">비밀번호 *</label>
|
||||||
|
<input type="password" id="authPassword" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="confirmPasswordGroup" style="display: none;">
|
||||||
|
<label for="authConfirmPassword">비밀번호 확인 *</label>
|
||||||
|
<input type="password" id="authConfirmPassword">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-buttons">
|
||||||
|
<button type="submit" id="authSubmitBtn">🔑 로그인</button>
|
||||||
|
<button type="button" id="authCancelBtn">❌ 취소</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-switch">
|
||||||
|
<p id="authSwitchText">계정이 없으신가요? <a href="#" id="authSwitchLink">회원가입하기</a></p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="authLoading" class="loading" style="display: none;">
|
||||||
|
<p>처리 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 수정 모달 -->
|
||||||
|
<div id="editModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>✏️ 자료 수정</h2>
|
||||||
|
<form id="editForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editTitle">제목 *</label>
|
||||||
|
<input type="text" id="editTitle" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editDescription">설명</label>
|
||||||
|
<textarea id="editDescription" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editCategory">카테고리 *</label>
|
||||||
|
<select id="editCategory" required>
|
||||||
|
<option value="문서">문서</option>
|
||||||
|
<option value="이미지">이미지</option>
|
||||||
|
<option value="동영상">동영상</option>
|
||||||
|
<option value="프레젠테이션">프레젠테이션</option>
|
||||||
|
<option value="기타">기타</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editTags">태그</label>
|
||||||
|
<input type="text" id="editTags" placeholder="쉼표로 구분하여 입력">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-buttons">
|
||||||
|
<button type="submit">💾 저장</button>
|
||||||
|
<button type="button" id="closeModal">❌ 취소</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="supabase-config.js"></script>
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
1513
admin/script.js
Normal file
1513
admin/script.js
Normal file
File diff suppressed because it is too large
Load Diff
1029
admin/styles.css
Normal file
1029
admin/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
119
admin/supabase-config.js
Normal file
119
admin/supabase-config.js
Normal file
@@ -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;
|
62
clean-storage-setup.sql
Normal file
62
clean-storage-setup.sql
Normal file
@@ -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';
|
154
index.html
154
index.html
@@ -12,17 +12,6 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1>📚 자료실 관리 시스템</h1>
|
<h1>📚 자료실 관리 시스템</h1>
|
||||||
<p>파일과 문서를 효율적으로 관리하세요</p>
|
<p>파일과 문서를 효율적으로 관리하세요</p>
|
||||||
<div id="authSection" class="auth-section">
|
|
||||||
<div id="authButtons" class="auth-buttons">
|
|
||||||
<button id="loginBtn" class="auth-btn">🔑 로그인</button>
|
|
||||||
<button id="signupBtn" class="auth-btn">👤 회원가입</button>
|
|
||||||
</div>
|
|
||||||
<div id="userInfo" class="user-info" style="display: none;">
|
|
||||||
<span id="userEmail"></span>
|
|
||||||
<span id="syncStatus" class="sync-status online">🟢 온라인</span>
|
|
||||||
<button id="logoutBtn" class="auth-btn">🚪 로그아웃</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="search-section">
|
<div class="search-section">
|
||||||
@@ -38,48 +27,6 @@
|
|||||||
<button id="searchBtn">🔍 검색</button>
|
<button id="searchBtn">🔍 검색</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
|
||||||
<h2>📁 새 자료 추가</h2>
|
|
||||||
<form id="fileForm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="fileTitle">제목 *</label>
|
|
||||||
<input type="text" id="fileTitle" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="fileDescription">설명</label>
|
|
||||||
<textarea id="fileDescription" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="fileCategory">카테고리 *</label>
|
|
||||||
<select id="fileCategory" required>
|
|
||||||
<option value="">카테고리 선택</option>
|
|
||||||
<option value="문서">문서</option>
|
|
||||||
<option value="이미지">이미지</option>
|
|
||||||
<option value="동영상">동영상</option>
|
|
||||||
<option value="프레젠테이션">프레젠테이션</option>
|
|
||||||
<option value="기타">기타</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="fileUpload">파일 첨부</label>
|
|
||||||
<input type="file" id="fileUpload" multiple>
|
|
||||||
<small>여러 파일을 선택할 수 있습니다</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="fileTags">태그</label>
|
|
||||||
<input type="text" id="fileTags" placeholder="쉼표로 구분하여 입력 (예: 중요, 업무, 프로젝트)">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-buttons">
|
|
||||||
<button type="submit" id="submitBtn">📤 추가</button>
|
|
||||||
<button type="button" id="cancelBtn">❌ 취소</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="list-section">
|
<div class="list-section">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
@@ -93,87 +40,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="fileList" class="file-list">
|
<div class="board-container">
|
||||||
<div class="empty-state">
|
<table class="board-table" id="boardTable">
|
||||||
<p>📂 등록된 자료가 없습니다. 새 자료를 추가해보세요!</p>
|
<thead>
|
||||||
</div>
|
<tr>
|
||||||
|
<th class="col-no">번호</th>
|
||||||
|
<th class="col-category">카테고리</th>
|
||||||
|
<th class="col-title">제목</th>
|
||||||
|
<th class="col-attachment">첨부</th>
|
||||||
|
<th class="col-date">등록일</th>
|
||||||
|
<th class="col-actions">다운로드</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="fileList">
|
||||||
|
<tr class="empty-state">
|
||||||
|
<td colspan="6">📂 등록된 자료가 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination" id="pagination" style="display: none;">
|
||||||
|
<button id="prevPage" class="page-btn" disabled>◀ 이전</button>
|
||||||
|
<span id="pageInfo">1 / 1</span>
|
||||||
|
<button id="nextPage" class="page-btn" disabled>다음 ▶</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 인증 모달 -->
|
|
||||||
<div id="authModal" class="modal">
|
|
||||||
<div class="modal-content">
|
|
||||||
<h2 id="authModalTitle">🔑 로그인</h2>
|
|
||||||
<form id="authForm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="authEmail">이메일 *</label>
|
|
||||||
<input type="email" id="authEmail" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="authPassword">비밀번호 *</label>
|
|
||||||
<input type="password" id="authPassword" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" id="confirmPasswordGroup" style="display: none;">
|
|
||||||
<label for="authConfirmPassword">비밀번호 확인 *</label>
|
|
||||||
<input type="password" id="authConfirmPassword">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-buttons">
|
|
||||||
<button type="submit" id="authSubmitBtn">🔑 로그인</button>
|
|
||||||
<button type="button" id="authCancelBtn">❌ 취소</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="auth-switch">
|
|
||||||
<p id="authSwitchText">계정이 없으신가요? <a href="#" id="authSwitchLink">회원가입하기</a></p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div id="authLoading" class="loading" style="display: none;">
|
|
||||||
<p>처리 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 수정 모달 -->
|
|
||||||
<div id="editModal" class="modal">
|
|
||||||
<div class="modal-content">
|
|
||||||
<h2>✏️ 자료 수정</h2>
|
|
||||||
<form id="editForm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="editTitle">제목 *</label>
|
|
||||||
<input type="text" id="editTitle" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="editDescription">설명</label>
|
|
||||||
<textarea id="editDescription" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="editCategory">카테고리 *</label>
|
|
||||||
<select id="editCategory" required>
|
|
||||||
<option value="문서">문서</option>
|
|
||||||
<option value="이미지">이미지</option>
|
|
||||||
<option value="동영상">동영상</option>
|
|
||||||
<option value="프레젠테이션">프레젠테이션</option>
|
|
||||||
<option value="기타">기타</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="editTags">태그</label>
|
|
||||||
<input type="text" id="editTags" placeholder="쉼표로 구분하여 입력">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-buttons">
|
|
||||||
<button type="submit">💾 저장</button>
|
|
||||||
<button type="button" id="closeModal">❌ 취소</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="supabase-config.js"></script>
|
<script src="supabase-config.js"></script>
|
||||||
<script src="script.js"></script>
|
<script src="script.js"></script>
|
||||||
|
39
storage-policies.sql
Normal file
39
storage-policies.sql
Normal file
@@ -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' 부분을 추출합니다.
|
287
styles.css
287
styles.css
@@ -39,6 +39,47 @@ header p {
|
|||||||
font-size: 1.1rem;
|
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 {
|
.auth-section {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -360,6 +401,215 @@ header p {
|
|||||||
cursor: pointer;
|
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 {
|
.file-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
@@ -470,10 +720,47 @@ header p {
|
|||||||
.download-btn {
|
.download-btn {
|
||||||
background: #38a169;
|
background: #38a169;
|
||||||
color: white;
|
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 {
|
.download-btn:hover {
|
||||||
background: #2f855a;
|
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 {
|
.empty-state {
|
||||||
|
16
temp-public-policy.sql
Normal file
16
temp-public-policy.sql
Normal file
@@ -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');
|
||||||
|
|
||||||
|
-- 주의: 이 정책들은 테스트 후 반드시 삭제하고 위의 사용자별 정책으로 교체하세요!
|
Reference in New Issue
Block a user