Compare commits
10 Commits
ced3fd03e4
...
master
Author | SHA1 | Date | |
---|---|---|---|
9422439a51 | |||
7796d9b7d5 | |||
d6a0656f12 | |||
04d92b7842 | |||
d3d8aa48b6 | |||
80f147731e | |||
ed5fa15814 | |||
896e42d9cc | |||
bda299a6c3 | |||
7be1f2ed07 |
@@ -48,7 +48,38 @@
|
|||||||
"Bash(powershell:*)",
|
"Bash(powershell:*)",
|
||||||
"Bash(schtasks:*)",
|
"Bash(schtasks:*)",
|
||||||
"Bash(cmd //c:*)",
|
"Bash(cmd //c:*)",
|
||||||
"Bash(npm install:*)"
|
"Bash(npm install:*)",
|
||||||
|
"mcp__playwright__browser_wait_for",
|
||||||
|
"mcp__context7__resolve-library-id",
|
||||||
|
"mcp__context7__get-library-docs",
|
||||||
|
"Bash(npm run init-mariadb:*)",
|
||||||
|
"Bash(npm test)",
|
||||||
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(npm run stop:*)",
|
||||||
|
"Bash(PORT=3007 node server.js)",
|
||||||
|
"Bash(HOST=119.64.1.86 PORT=3007 node server.js)",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"Bash(where claude)",
|
||||||
|
"Read(/C:\\Users\\COMTREE\\.claude/**)",
|
||||||
|
"Read(/C:\\Users\\COMTREE/**)",
|
||||||
|
"Bash(md:*)",
|
||||||
|
"Bash(.mcp_install.bat)",
|
||||||
|
"Bash(npm search mcp)",
|
||||||
|
"Read(/C:\\Users\\COMTREE\\.claude/**)",
|
||||||
|
"Bash(claude mcp add:*)",
|
||||||
|
"Bash(claude mcp:*)",
|
||||||
|
"WebSearch",
|
||||||
|
"Bash(uvx:*)",
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(tasklist:*)",
|
||||||
|
"Bash(npm audit:*)",
|
||||||
|
"Bash(git config:*)",
|
||||||
|
"Bash(git remote:*)",
|
||||||
|
"Bash(findstr:*)",
|
||||||
|
"mcp__serena__activate_project",
|
||||||
|
"mcp__serena__list_dir",
|
||||||
|
"mcp__playwright__browser_resize",
|
||||||
|
"mcp__playwright__browser_take_screenshot"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": [],
|
"ask": [],
|
||||||
|
13
.env.example
Normal file
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# 개발 환경 설정 예시
|
||||||
|
# SQLite 데이터베이스 사용 (설정 불필요)
|
||||||
|
|
||||||
|
# NAS 배포 환경
|
||||||
|
NODE_ENV=development
|
||||||
|
DEPLOY_ENV=local
|
||||||
|
|
||||||
|
# 서버 설정
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# 세션 설정
|
||||||
|
SESSION_SECRET=your-session-secret-here
|
164
CLAUDE.md
164
CLAUDE.md
@@ -1,146 +1,34 @@
|
|||||||
# CLAUDE.md
|
---
|
||||||
|
allowed-tools: [Read, Grep, Glob, Bash, Edit, MultiEdit]
|
||||||
|
description: "Clean up code, remove dead code, and optimize project structure"
|
||||||
|
---
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
# /sc:cleanup - Code and Project Cleanup
|
||||||
|
|
||||||
## Project Overview
|
## Purpose
|
||||||
|
Systematically clean up code, remove dead code, optimize imports, and improve project structure.
|
||||||
This is a web-based file management system (자료실) with full CRUD functionality. It's a hybrid application built with vanilla HTML, CSS, and JavaScript that supports both Supabase cloud database and localStorage for data persistence, providing seamless offline/online capabilities.
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
- Create, Read, Update, Delete operations for file records
|
|
||||||
- File upload with multiple file support (local + cloud storage)
|
|
||||||
- User authentication and authorization
|
|
||||||
- Search and filtering by title, description, tags, and category
|
|
||||||
- Categorization system (문서, 이미지, 동영상, 프레젠테이션, 기타)
|
|
||||||
- Tag-based organization
|
|
||||||
- Responsive design for mobile and desktop
|
|
||||||
- Modal-based editing interface
|
|
||||||
- Cloud database with real-time synchronization
|
|
||||||
- Offline support with localStorage fallback
|
|
||||||
- Cross-device data synchronization
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
|
## Usage
|
||||||
```
|
```
|
||||||
자료실/
|
/sc:cleanup [target] [--type code|imports|files|all] [--safe|--aggressive] [--dry-run]
|
||||||
├── index.html # Main HTML file with UI structure and auth components
|
|
||||||
├── styles.css # Complete styling with responsive design and auth styles
|
|
||||||
├── script.js # Core JavaScript with FileManager class and Supabase integration
|
|
||||||
├── supabase-config.js # Supabase configuration and helper functions
|
|
||||||
├── supabase-schema.sql # Database schema for Supabase setup
|
|
||||||
├── setup-guide.md # Comprehensive Supabase setup guide
|
|
||||||
└── CLAUDE.md # This documentation file
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Arguments
|
||||||
|
- `target` - Files, directories, or entire project to clean
|
||||||
|
- `--type` - Cleanup type (code, imports, files, all)
|
||||||
|
- `--safe` - Conservative cleanup (default)
|
||||||
|
- `--aggressive` - More thorough cleanup with higher risk
|
||||||
|
- `--dry-run` - Preview changes without applying them
|
||||||
|
|
||||||
### Core Components
|
## Execution
|
||||||
|
1. Analyze target for cleanup opportunities
|
||||||
|
2. Identify dead code, unused imports, and redundant files
|
||||||
|
3. Create cleanup plan with risk assessment
|
||||||
|
4. Execute cleanup operations with appropriate safety measures
|
||||||
|
5. Validate changes and report cleanup results
|
||||||
|
|
||||||
1. **FileManager Class** (`script.js`)
|
## Claude Code Integration
|
||||||
- Main application controller with hybrid storage support
|
- Uses Glob for systematic file discovery
|
||||||
- Handles all CRUD operations (Supabase + localStorage fallback)
|
- Leverages Grep for dead code detection
|
||||||
- Manages user authentication and session state
|
- Applies MultiEdit for batch cleanup operations
|
||||||
- Contains event handling and UI updates
|
- Maintains backup and rollback capabilities
|
||||||
- Real-time synchronization capabilities
|
|
||||||
|
|
||||||
2. **Supabase Integration** (`supabase-config.js`)
|
|
||||||
- Database configuration and connection management
|
|
||||||
- Authentication helper functions (signup, login, logout)
|
|
||||||
- CRUD helper functions for files and attachments
|
|
||||||
- Storage helper functions for file uploads/downloads
|
|
||||||
- Real-time subscription management
|
|
||||||
|
|
||||||
3. **Data Model**
|
|
||||||
```javascript
|
|
||||||
// Files table
|
|
||||||
{
|
|
||||||
id: UUID, // Primary key
|
|
||||||
title: string, // File title (required)
|
|
||||||
description: string, // Optional description
|
|
||||||
category: string, // Required category
|
|
||||||
tags: string[], // Array of tags
|
|
||||||
user_id: UUID, // Foreign key to auth.users
|
|
||||||
created_at: timestamp,
|
|
||||||
updated_at: timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
// File attachments table
|
|
||||||
{
|
|
||||||
id: UUID,
|
|
||||||
file_id: UUID, // Foreign key to files
|
|
||||||
original_name: string,
|
|
||||||
storage_path: string, // Supabase Storage path
|
|
||||||
file_size: integer,
|
|
||||||
mime_type: string,
|
|
||||||
created_at: timestamp
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **UI Components**
|
|
||||||
- Authentication section (login/signup/logout)
|
|
||||||
- Sync status indicator
|
|
||||||
- Search and filter section
|
|
||||||
- Add new file form with cloud upload
|
|
||||||
- File list display with sorting
|
|
||||||
- Edit modal for updates
|
|
||||||
- Responsive card-based layout
|
|
||||||
- Offline mode notifications
|
|
||||||
|
|
||||||
### Development Commands
|
|
||||||
|
|
||||||
This is a hybrid web application supporting both online and offline modes:
|
|
||||||
|
|
||||||
1. **Local Development**
|
|
||||||
```bash
|
|
||||||
# Open index.html in a web browser
|
|
||||||
# OR use a simple HTTP server:
|
|
||||||
python -m http.server 8000
|
|
||||||
# OR
|
|
||||||
npx serve .
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Supabase Setup (Required for online features)**
|
|
||||||
```bash
|
|
||||||
# 1. Follow setup-guide.md for complete setup
|
|
||||||
# 2. Create Supabase project and database
|
|
||||||
# 3. Run supabase-schema.sql in SQL Editor
|
|
||||||
# 4. Create Storage bucket named 'files'
|
|
||||||
# 5. Update supabase-config.js with your credentials
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **File Access**
|
|
||||||
- Open `index.html` directly in browser
|
|
||||||
- Works offline with localStorage (limited functionality)
|
|
||||||
- Full features available with Supabase configuration
|
|
||||||
|
|
||||||
### Technical Implementation
|
|
||||||
|
|
||||||
- **Database**: Supabase PostgreSQL with Row Level Security (RLS)
|
|
||||||
- **Storage**: Supabase Storage for files + localStorage fallback
|
|
||||||
- **Authentication**: Supabase Auth with email/password
|
|
||||||
- **Real-time**: Supabase Realtime for live synchronization
|
|
||||||
- **File Handling**: FileReader API + Supabase Storage API
|
|
||||||
- **UI Updates**: Vanilla JavaScript DOM manipulation
|
|
||||||
- **Styling**: CSS Grid and Flexbox for responsive layouts
|
|
||||||
- **Animations**: CSS transitions and keyframe animations
|
|
||||||
- **Offline Support**: Automatic fallback to localStorage when offline
|
|
||||||
|
|
||||||
### Data Management
|
|
||||||
|
|
||||||
- **Online Mode**: Files stored in Supabase PostgreSQL + Storage
|
|
||||||
- **Offline Mode**: Files stored as base64 strings in localStorage
|
|
||||||
- **Hybrid Sync**: Automatic synchronization when connection restored
|
|
||||||
- User-specific data isolation with RLS policies
|
|
||||||
- Search works across title, description, and tags
|
|
||||||
- Sorting available by date, title, or category
|
|
||||||
- Categories are predefined but can be extended
|
|
||||||
- Real-time updates across devices for same user
|
|
||||||
|
|
||||||
### Browser Compatibility
|
|
||||||
|
|
||||||
- Modern browsers with ES6+ support
|
|
||||||
- localStorage API support required
|
|
||||||
- FileReader API for file uploads
|
|
||||||
- Fetch API for Supabase communication
|
|
||||||
- WebSocket support for real-time features
|
|
||||||
- External dependency: Supabase JavaScript client library
|
|
@@ -1,101 +0,0 @@
|
|||||||
# Jaryo File Manager 자동 시작 설정 가이드
|
|
||||||
|
|
||||||
## 🚀 자동 시작 설정 방법
|
|
||||||
|
|
||||||
### 방법 1: 배치 파일 실행 (권장)
|
|
||||||
|
|
||||||
1. **관리자 권한으로 실행**
|
|
||||||
- `install-auto-startup.bat` 파일을 마우스 우클릭
|
|
||||||
- "관리자 권한으로 실행" 선택
|
|
||||||
- 안내에 따라 진행
|
|
||||||
|
|
||||||
2. **설정 완료 후**
|
|
||||||
- 컴퓨터 재시작 시 자동으로 서비스 시작
|
|
||||||
- 서비스 URL: http://99.1.110.50:3005
|
|
||||||
|
|
||||||
### 방법 2: 수동 작업 스케줄러 설정
|
|
||||||
|
|
||||||
1. **작업 스케줄러 열기**
|
|
||||||
- Windows 키 + R → `taskschd.msc` 입력 → 엔터
|
|
||||||
|
|
||||||
2. **기본 작업 만들기**
|
|
||||||
- 오른쪽 패널에서 "기본 작업 만들기" 클릭
|
|
||||||
- 이름: `JaryoFileManagerAutoStart`
|
|
||||||
- 설명: `Jaryo File Manager 자동 시작`
|
|
||||||
|
|
||||||
3. **트리거 설정**
|
|
||||||
- "컴퓨터를 시작할 때" 선택
|
|
||||||
|
|
||||||
4. **동작 설정**
|
|
||||||
- "프로그램 시작" 선택
|
|
||||||
- 프로그램/스크립트: `C:\Users\COMTREE\claude_code\jaryo\start-jaryo-service.bat`
|
|
||||||
- 시작 위치: `C:\Users\COMTREE\claude_code\jaryo`
|
|
||||||
|
|
||||||
5. **고급 설정**
|
|
||||||
- "가장 높은 권한으로 실행" 체크
|
|
||||||
- "작업이 이미 실행 중인 경우 규칙": "새 인스턴스 시작 안 함"
|
|
||||||
|
|
||||||
## 🔧 서비스 관리 명령어
|
|
||||||
|
|
||||||
### 수동 서비스 제어
|
|
||||||
```batch
|
|
||||||
# 서비스 시작
|
|
||||||
start-jaryo-service.bat
|
|
||||||
|
|
||||||
# 서비스 중지
|
|
||||||
stop-jaryo-service.bat
|
|
||||||
```
|
|
||||||
|
|
||||||
### 자동 시작 관리
|
|
||||||
```batch
|
|
||||||
# 자동 시작 설정
|
|
||||||
install-auto-startup.bat (관리자 권한 필요)
|
|
||||||
|
|
||||||
# 자동 시작 해제
|
|
||||||
uninstall-auto-startup.bat (관리자 권한 필요)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 작업 스케줄러 명령어
|
|
||||||
```cmd
|
|
||||||
# 작업 상태 확인
|
|
||||||
schtasks /query /tn "JaryoFileManagerAutoStart"
|
|
||||||
|
|
||||||
# 작업 수동 실행
|
|
||||||
schtasks /run /tn "JaryoFileManagerAutoStart"
|
|
||||||
|
|
||||||
# 작업 삭제
|
|
||||||
schtasks /delete /tn "JaryoFileManagerAutoStart" /f
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🌐 접속 정보
|
|
||||||
|
|
||||||
- **관리자 페이지**: http://99.1.110.50:3005/admin/index.html
|
|
||||||
- **메인 페이지**: http://99.1.110.50:3005/index.html
|
|
||||||
- **API**: http://99.1.110.50:3005/api/files
|
|
||||||
- **상태 확인**: http://99.1.110.50:3005/health
|
|
||||||
|
|
||||||
## 📁 로그 확인
|
|
||||||
|
|
||||||
- **로그 파일**: `C:\Users\COMTREE\claude_code\jaryo\logs\app.log`
|
|
||||||
- **로그 보기**: `type "C:\Users\COMTREE\claude_code\jaryo\logs\app.log"`
|
|
||||||
|
|
||||||
## ⚠️ 문제 해결
|
|
||||||
|
|
||||||
### 서비스가 시작되지 않는 경우
|
|
||||||
1. Node.js가 설치되어 있는지 확인: `node --version`
|
|
||||||
2. 프로젝트 디렉토리가 올바른지 확인
|
|
||||||
3. 포트 3005가 사용 중인지 확인: `netstat -an | findstr :3005`
|
|
||||||
4. 로그 파일 확인
|
|
||||||
|
|
||||||
### 자동 시작이 작동하지 않는 경우
|
|
||||||
1. 작업 스케줄러에서 작업 상태 확인
|
|
||||||
2. 관리자 권한으로 설정했는지 확인
|
|
||||||
3. 배치 파일 경로가 올바른지 확인
|
|
||||||
|
|
||||||
## 📞 지원
|
|
||||||
|
|
||||||
문제가 발생하면 다음을 확인해주세요:
|
|
||||||
1. 로그 파일 내용
|
|
||||||
2. 작업 스케줄러 작업 상태
|
|
||||||
3. 포트 사용 현황
|
|
||||||
4. Node.js 설치 상태
|
|
@@ -461,7 +461,7 @@ class AdminFileManager {
|
|||||||
|
|
||||||
async checkSession() {
|
async checkSession() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/session');
|
const response = await fetch('/api/auth/session', { credentials: 'include' });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
@@ -494,20 +494,21 @@ class AdminFileManager {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ email, password })
|
body: JSON.stringify({ email, password })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
this.currentUser = data.user;
|
this.currentUser = data.user || data.data || null;
|
||||||
this.isLoggedIn = true;
|
this.isLoggedIn = true;
|
||||||
this.showNotification('로그인되었습니다!', 'success');
|
this.showNotification('로그인되었습니다!', 'success');
|
||||||
|
|
||||||
await this.loadData();
|
await this.loadData();
|
||||||
this.updateUI();
|
this.updateUI();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.message || '로그인에 실패했습니다.');
|
throw new Error(data.error || data.message || '로그인에 실패했습니다.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('로그인 오류:', error);
|
console.error('로그인 오류:', error);
|
||||||
@@ -520,7 +521,7 @@ class AdminFileManager {
|
|||||||
|
|
||||||
async handleLogout() {
|
async handleLogout() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||||
|
|
||||||
this.currentUser = null;
|
this.currentUser = null;
|
||||||
this.isLoggedIn = false;
|
this.isLoggedIn = false;
|
||||||
@@ -1329,7 +1330,6 @@ class AdminFileManager {
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" onclick="this.closest('.modal-overlay').remove()">닫기</button>
|
<button class="btn btn-secondary" onclick="this.closest('.modal-overlay').remove()">닫기</button>
|
||||||
<button class="btn btn-primary" onclick="adminManager.editFile('${file.id}')">수정</button>
|
|
||||||
<button class="btn btn-danger" onclick="adminManager.deleteFile('${file.id}')">삭제</button>
|
<button class="btn btn-danger" onclick="adminManager.deleteFile('${file.id}')">삭제</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1367,6 +1367,22 @@ class AdminFileManager {
|
|||||||
|
|
||||||
resetCategoryForm() {
|
resetCategoryForm() {
|
||||||
document.getElementById('categoryName').value = '';
|
document.getElementById('categoryName').value = '';
|
||||||
|
this.currentEditCategoryId = null;
|
||||||
|
|
||||||
|
// 버튼 텍스트를 기본 상태로 복원
|
||||||
|
const submitBtn = document.getElementById('addCategoryBtn');
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.textContent = '➕ 카테고리 추가';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 폼 제목을 기본 상태로 복원
|
||||||
|
const formTitle = document.querySelector('#categoryTab h2');
|
||||||
|
if (formTitle) {
|
||||||
|
formTitle.textContent = '🏷️ 카테고리 관리';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('카테고리 폼이 초기화되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCategoryList() {
|
renderCategoryList() {
|
||||||
|
@@ -4,7 +4,9 @@ const fs = require('fs');
|
|||||||
|
|
||||||
class DatabaseHelper {
|
class DatabaseHelper {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.dbPath = path.join(__dirname, 'jaryo.db');
|
// 프로젝트 루트의 data 디렉토리에 데이터베이스 저장
|
||||||
|
const projectRoot = path.resolve(__dirname, '..');
|
||||||
|
this.dbPath = path.join(projectRoot, 'data', 'jaryo.db');
|
||||||
this.db = null;
|
this.db = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,13 +18,26 @@ class DatabaseHelper {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READWRITE, (err) => {
|
// 데이터베이스 디렉토리 생성
|
||||||
|
const dbDir = path.dirname(this.dbPath);
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터베이스 파일이 없으면 생성
|
||||||
|
const flags = fs.existsSync(this.dbPath) ?
|
||||||
|
sqlite3.OPEN_READWRITE :
|
||||||
|
sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE;
|
||||||
|
|
||||||
|
this.db = new sqlite3.Database(this.dbPath, flags, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('데이터베이스 연결 오류:', err.message);
|
console.error('데이터베이스 연결 오류:', err.message);
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
console.log('✅ SQLite 데이터베이스 연결됨');
|
console.log('✅ SQLite 데이터베이스 연결됨:', this.dbPath);
|
||||||
|
this.initializeTables().then(() => {
|
||||||
resolve(this.db);
|
resolve(this.db);
|
||||||
|
}).catch(reject);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -46,6 +61,78 @@ class DatabaseHelper {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 테이블 초기화
|
||||||
|
initializeTables() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const createTables = `
|
||||||
|
-- 사용자 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
role TEXT DEFAULT 'user',
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 카테고리 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 파일 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
tags TEXT DEFAULT '[]',
|
||||||
|
user_id TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 첨부파일 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS file_attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
file_id TEXT NOT NULL,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
mime_type TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 사용자 세션 테이블 (옵션)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.db.exec(createTables, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('테이블 생성 오류:', err);
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
console.log('✅ 데이터베이스 테이블 초기화 완료');
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 모든 파일 목록 가져오기
|
// 모든 파일 목록 가져오기
|
||||||
async getAllFiles(limit = 100, offset = 0) {
|
async getAllFiles(limit = 100, offset = 0) {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
@@ -277,8 +364,8 @@ class DatabaseHelper {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO file_attachments (file_id, original_name, file_name, file_path, file_size, mime_type, file_data)
|
INSERT INTO file_attachments (file_id, original_name, file_name, file_path, file_size, mime_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params = [
|
const params = [
|
||||||
@@ -287,8 +374,7 @@ class DatabaseHelper {
|
|||||||
attachmentData.file_name || attachmentData.original_name,
|
attachmentData.file_name || attachmentData.original_name,
|
||||||
attachmentData.file_path || '',
|
attachmentData.file_path || '',
|
||||||
attachmentData.file_size || 0,
|
attachmentData.file_size || 0,
|
||||||
attachmentData.mime_type || '',
|
attachmentData.mime_type || ''
|
||||||
attachmentData.file_data || null
|
|
||||||
];
|
];
|
||||||
|
|
||||||
this.db.run(query, params, function(err) {
|
this.db.run(query, params, function(err) {
|
||||||
@@ -323,7 +409,7 @@ class DatabaseHelper {
|
|||||||
await this.connect();
|
await this.connect();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const query = 'SELECT * FROM categories ORDER BY is_default DESC, name ASC';
|
const query = 'SELECT * FROM categories ORDER BY name ASC';
|
||||||
|
|
||||||
this.db.all(query, [], (err, rows) => {
|
this.db.all(query, [], (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
@@ -1,207 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
|
|
||||||
class MariaDBHelper {
|
|
||||||
constructor() {
|
|
||||||
this.connection = null;
|
|
||||||
this.config = {
|
|
||||||
socketPath: '/run/mysqld/mysqld10.sock',
|
|
||||||
user: 'jaryo_user',
|
|
||||||
password: 'JaryoPass2024!@#',
|
|
||||||
database: 'jaryo',
|
|
||||||
charset: 'utf8mb4'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect() {
|
|
||||||
try {
|
|
||||||
if (!this.connection || this.connection.connection._closing) {
|
|
||||||
this.connection = await mysql.createConnection(this.config);
|
|
||||||
console.log('✅ MariaDB 연결 성공 (Unix Socket)');
|
|
||||||
}
|
|
||||||
return this.connection;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ MariaDB 연결 실패:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async close() {
|
|
||||||
if (this.connection) {
|
|
||||||
await this.connection.end();
|
|
||||||
this.connection = null;
|
|
||||||
console.log('📝 MariaDB 연결 종료');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
generateId() {
|
|
||||||
return uuidv4();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 관리
|
|
||||||
async createUser(userData) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const id = this.generateId();
|
|
||||||
const [result] = await conn.execute(
|
|
||||||
'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)',
|
|
||||||
[id, userData.email, userData.password_hash, userData.name, userData.role || 'user']
|
|
||||||
);
|
|
||||||
return { id, ...result };
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserByEmail(email) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [rows] = await conn.execute(
|
|
||||||
'SELECT * FROM users WHERE email = ?',
|
|
||||||
[email]
|
|
||||||
);
|
|
||||||
return rows[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserById(id) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [rows] = await conn.execute(
|
|
||||||
'SELECT * FROM users WHERE id = ?',
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
return rows[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUserLastLogin(id) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [result] = await conn.execute(
|
|
||||||
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 파일 관리
|
|
||||||
async addFile(fileData) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [result] = await conn.execute(
|
|
||||||
'INSERT INTO files (id, title, description, category, tags, user_id) VALUES (?, ?, ?, ?, ?, ?)',
|
|
||||||
[fileData.id, fileData.title, fileData.description, fileData.category, JSON.stringify(fileData.tags), fileData.user_id]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllFiles(limit = 100, offset = 0) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [rows] = await conn.execute(
|
|
||||||
'SELECT f.*, u.name as user_name FROM files f LEFT JOIN users u ON f.user_id = u.id ORDER BY f.created_at DESC LIMIT ? OFFSET ?',
|
|
||||||
[limit, offset]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse tags from JSON
|
|
||||||
return rows.map(row => ({
|
|
||||||
...row,
|
|
||||||
tags: row.tags ? JSON.parse(row.tags) : []
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchFiles(searchTerm, category = null, limit = 100) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
let query = 'SELECT f.*, u.name as user_name FROM files f LEFT JOIN users u ON f.user_id = u.id WHERE (f.title LIKE ? OR f.description LIKE ?)';
|
|
||||||
let params = [`%${searchTerm}%`, `%${searchTerm}%`];
|
|
||||||
|
|
||||||
if (category) {
|
|
||||||
query += ' AND f.category = ?';
|
|
||||||
params.push(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += ' ORDER BY f.created_at DESC LIMIT ?';
|
|
||||||
params.push(limit);
|
|
||||||
|
|
||||||
const [rows] = await conn.execute(query, params);
|
|
||||||
|
|
||||||
return rows.map(row => ({
|
|
||||||
...row,
|
|
||||||
tags: row.tags ? JSON.parse(row.tags) : []
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateFile(id, updates) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [result] = await conn.execute(
|
|
||||||
'UPDATE files SET title = ?, description = ?, category = ?, tags = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
|
||||||
[updates.title, updates.description, updates.category, updates.tags, id]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteFile(id) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [result] = await conn.execute('DELETE FROM files WHERE id = ?', [id]);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 파일 첨부 관리
|
|
||||||
async addFileAttachment(fileId, attachmentData) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [result] = await conn.execute(
|
|
||||||
'INSERT INTO file_attachments (file_id, original_name, file_name, file_path, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)',
|
|
||||||
[fileId, attachmentData.original_name, attachmentData.file_name, attachmentData.file_path, attachmentData.file_size, attachmentData.mime_type]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFileAttachments(fileId) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [rows] = await conn.execute(
|
|
||||||
'SELECT * FROM file_attachments WHERE file_id = ? ORDER BY created_at',
|
|
||||||
[fileId]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteFileAttachment(attachmentId) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [result] = await conn.execute('DELETE FROM file_attachments WHERE id = ?', [attachmentId]);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 카테고리 관리
|
|
||||||
async getCategories() {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [rows] = await conn.execute('SELECT * FROM categories ORDER BY name');
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
async addCategory(name) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [result] = await conn.execute('INSERT INTO categories (name) VALUES (?)', [name]);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateCategory(id, name) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [result] = await conn.execute('UPDATE categories SET name = ? WHERE id = ?', [name, id]);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteCategory(id) {
|
|
||||||
const conn = await this.connect();
|
|
||||||
const [result] = await conn.execute('DELETE FROM categories WHERE id = ?', [id]);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 통계
|
|
||||||
async getStats() {
|
|
||||||
const conn = await this.connect();
|
|
||||||
|
|
||||||
const [userCount] = await conn.execute('SELECT COUNT(*) as count FROM users');
|
|
||||||
const [fileCount] = await conn.execute('SELECT COUNT(*) as count FROM files');
|
|
||||||
const [categoryCount] = await conn.execute('SELECT COUNT(*) as count FROM categories');
|
|
||||||
const [attachmentCount] = await conn.execute('SELECT COUNT(*) as count FROM file_attachments');
|
|
||||||
|
|
||||||
return {
|
|
||||||
users: userCount[0].count,
|
|
||||||
files: fileCount[0].count,
|
|
||||||
categories: categoryCount[0].count,
|
|
||||||
attachments: attachmentCount[0].count
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = MariaDBHelper;
|
|
@@ -9,13 +9,22 @@
|
|||||||
NAS_IP="${1:-119.64.1.86}"
|
NAS_IP="${1:-119.64.1.86}"
|
||||||
PROJECT_NAME="${2:-jaryo}"
|
PROJECT_NAME="${2:-jaryo}"
|
||||||
NAS_USER="vibsin9322"
|
NAS_USER="vibsin9322"
|
||||||
NAS_PASS="${3:-vibsin9322}" # 기본 비밀번호, 환경변수 NAS_PASS로 오버라이드 가능
|
# NAS_PASS 우선순위: 환경변수 > 스크립트 3번째 인자 > 프롬프트 방식
|
||||||
|
if [ -n "$3" ]; then
|
||||||
|
NAS_PASS="$3"
|
||||||
|
else
|
||||||
|
NAS_PASS="${NAS_PASS:-}"
|
||||||
|
fi
|
||||||
DEPLOY_DIR="/volume1/web/$PROJECT_NAME"
|
DEPLOY_DIR="/volume1/web/$PROJECT_NAME"
|
||||||
SERVICE_PORT="3005"
|
SERVICE_PORT="3005"
|
||||||
GITEA_URL="http://$NAS_IP:3000/vibsin9322/jaryo.git"
|
GITEA_URL="http://$NAS_IP:3000/vibsin9322/jaryo.git"
|
||||||
|
|
||||||
# SSH 명령어 준비
|
# SSH 명령어 준비 (NAS_PASS가 있으면 plink로 비대화식, 없으면 ssh 프롬프트)
|
||||||
SSH_CMD="ssh -p 2222 -o ConnectTimeout=10 -o StrictHostKeyChecking=no $NAS_USER@$NAS_IP"
|
if [ -n "$NAS_PASS" ]; then
|
||||||
|
SSH_CMD="plink -P 2222 -batch -pw \"$NAS_PASS\" $NAS_USER@$NAS_IP"
|
||||||
|
else
|
||||||
|
SSH_CMD="ssh -p 2222 -o ConnectTimeout=10 -o StrictHostKeyChecking=no $NAS_USER@$NAS_IP"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "🚀 시놀로지 NAS 자료실 배포 시작"
|
echo "🚀 시놀로지 NAS 자료실 배포 시작"
|
||||||
@@ -31,8 +40,12 @@ echo "=========================================="
|
|||||||
echo "📋 1단계: 사전 요구사항 확인"
|
echo "📋 1단계: 사전 요구사항 확인"
|
||||||
|
|
||||||
# SSH 방식 확인
|
# SSH 방식 확인
|
||||||
echo "🔧 SSH 접속 방식: 비밀번호 프롬프트 방식"
|
if [ -n "$NAS_PASS" ]; then
|
||||||
echo "📝 SSH 연결 시 비밀번호 입력이 필요합니다."
|
echo "🔧 SSH 접속 방식: 비밀번호 비대화식(plink)"
|
||||||
|
else
|
||||||
|
echo "🔧 SSH 접속 방식: 비밀번호 프롬프트 방식"
|
||||||
|
echo "📝 SSH 연결 시 비밀번호 입력이 필요합니다."
|
||||||
|
fi
|
||||||
|
|
||||||
# SSH 연결 테스트 (포트 2222)
|
# SSH 연결 테스트 (포트 2222)
|
||||||
echo "🔗 SSH 연결 테스트 중... (사용자: $NAS_USER, 포트: 2222)"
|
echo "🔗 SSH 연결 테스트 중... (사용자: $NAS_USER, 포트: 2222)"
|
||||||
@@ -135,27 +148,22 @@ if [ \$? -ne 0 ]; then
|
|||||||
fi
|
fi
|
||||||
echo '✅ 의존성 설치 완료'
|
echo '✅ 의존성 설치 완료'
|
||||||
|
|
||||||
# 데이터베이스 백업 및 초기화
|
# 데이터베이스 초기화 (선택사항)
|
||||||
if [ -f 'scripts/init-database.js' ]; then
|
if [ "\$INIT_DB" = "true" ] && [ -f 'scripts/init-database.js' ]; then
|
||||||
# 기존 데이터베이스 백업
|
echo '🗄️ SQLite 데이터베이스 초기화 중...'
|
||||||
DB_FILE='data/database.db'
|
echo 'ℹ️ SQLite 데이터베이스: data/jaryo.db'
|
||||||
BACKUP_FILE='data/database_backup_$(date +%Y%m%d_%H%M%S).db'
|
|
||||||
|
|
||||||
if [ -f '\$DB_FILE' ]; then
|
|
||||||
echo '💾 기존 데이터베이스 백업 중...'
|
|
||||||
cp '\$DB_FILE' '\$BACKUP_FILE'
|
|
||||||
echo '✅ 백업 완료: \$BACKUP_FILE'
|
|
||||||
|
|
||||||
# 기존 데이터 유지 - 초기화 건너뛰기
|
|
||||||
echo 'ℹ️ 기존 데이터베이스 발견 - 초기화 건너뛰기'
|
|
||||||
echo '💡 새 데이터베이스가 필요하면 수동으로 실행: npm run init-db'
|
|
||||||
else
|
|
||||||
# 새 설치 - 데이터베이스 초기화
|
|
||||||
echo '🗄️ 새 데이터베이스 초기화 중...'
|
|
||||||
export PATH='$NODE_PATH':\$PATH
|
export PATH='$NODE_PATH':\$PATH
|
||||||
'$NODE_PATH'/npm run init-db
|
if '$NODE_PATH'/npm run init-db; then
|
||||||
echo '✅ 데이터베이스 초기화 완료'
|
echo '✅ SQLite 초기화 완료'
|
||||||
|
else
|
||||||
|
echo '❌ SQLite 초기화 실패'
|
||||||
|
echo '💡 수동으로 초기화하려면:'
|
||||||
|
echo ' npm run init-db'
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo 'ℹ️ 데이터베이스 초기화 건너뜀 (INIT_DB=true로 설정시 초기화)'
|
||||||
fi
|
fi
|
||||||
"
|
"
|
||||||
|
|
||||||
@@ -201,7 +209,11 @@ fi
|
|||||||
# 서비스 시작
|
# 서비스 시작
|
||||||
echo '🚀 자료실 서비스 시작 중...'
|
echo '🚀 자료실 서비스 시작 중...'
|
||||||
cd '\$PROJECT_DIR'
|
cd '\$PROJECT_DIR'
|
||||||
PORT='$SERVICE_PORT' nohup \$NODE_PATH/node server.js > '\$LOG_FILE' 2>&1 &
|
# NAS 환경 변수 설정 (SQLite 사용)
|
||||||
|
export NODE_ENV=production
|
||||||
|
export HOST=0.0.0.0
|
||||||
|
export PORT='$SERVICE_PORT'
|
||||||
|
nohup \$NODE_PATH/node server.js > '\$LOG_FILE' 2>&1 &
|
||||||
echo \$! > '\$PID_FILE'
|
echo \$! > '\$PID_FILE'
|
||||||
|
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -266,7 +278,7 @@ chmod +x '$DEPLOY_DIR/stop-nas-service.sh'
|
|||||||
echo ""
|
echo ""
|
||||||
echo "🎬 5단계: 서비스 시작"
|
echo "🎬 5단계: 서비스 시작"
|
||||||
|
|
||||||
eval "$SSH_CMD '$DEPLOY_DIR/start-nas-service.sh"
|
eval "$SSH_CMD '$DEPLOY_DIR/start-nas-service.sh'"
|
||||||
|
|
||||||
# 6단계: 접속 테스트
|
# 6단계: 접속 테스트
|
||||||
echo ""
|
echo ""
|
||||||
@@ -295,5 +307,5 @@ if curl -s "http://$NAS_IP:$SERVICE_PORT" >/dev/null; then
|
|||||||
else
|
else
|
||||||
echo "❌ 서비스 접속 실패"
|
echo "❌ 서비스 접속 실패"
|
||||||
echo "로그 확인:"
|
echo "로그 확인:"
|
||||||
eval "$SSH_CMD 'tail -20 $DEPLOY_DIR/logs/app.log"
|
eval "$SSH_CMD 'tail -20 $DEPLOY_DIR/logs/app.log'"
|
||||||
fi
|
fi
|
104
deploy.sh
104
deploy.sh
@@ -1,104 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Git을 통한 자동 배포 스크립트
|
|
||||||
# 사용법: ./deploy.sh [branch_name]
|
|
||||||
|
|
||||||
# 설정
|
|
||||||
PROJECT_DIR="/volume1/web/jaryo"
|
|
||||||
GIT_REPO="/volume1/git/jaryo-file-manager.git"
|
|
||||||
BACKUP_DIR="/volume1/web/jaryo-backup"
|
|
||||||
LOG_FILE="/volume1/web/jaryo/logs/deploy.log"
|
|
||||||
BRANCH=${1:-main}
|
|
||||||
|
|
||||||
# 로그 함수
|
|
||||||
log() {
|
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 로그 디렉토리 생성
|
|
||||||
mkdir -p "$(dirname $LOG_FILE)"
|
|
||||||
|
|
||||||
log "=== 배포 시작 ==="
|
|
||||||
log "브랜치: $BRANCH"
|
|
||||||
log "프로젝트 디렉토리: $PROJECT_DIR"
|
|
||||||
|
|
||||||
# 1. 현재 서비스 중지
|
|
||||||
log "기존 서비스 중지 중..."
|
|
||||||
if [ -f "$PROJECT_DIR/app.pid" ]; then
|
|
||||||
PID=$(cat "$PROJECT_DIR/app.pid")
|
|
||||||
if kill -0 "$PID" 2>/dev/null; then
|
|
||||||
kill "$PID"
|
|
||||||
sleep 3
|
|
||||||
log "서비스 중지 완료 (PID: $PID)"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. 백업 생성
|
|
||||||
log "현재 버전 백업 중..."
|
|
||||||
BACKUP_NAME="backup-$(date +%Y%m%d-%H%M%S)"
|
|
||||||
if [ -d "$PROJECT_DIR" ]; then
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
cp -r "$PROJECT_DIR" "$BACKUP_DIR/$BACKUP_NAME"
|
|
||||||
log "백업 완료: $BACKUP_DIR/$BACKUP_NAME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. Git에서 최신 코드 가져오기
|
|
||||||
log "Git에서 최신 코드 가져오는 중..."
|
|
||||||
if [ ! -d "$PROJECT_DIR" ]; then
|
|
||||||
mkdir -p "$PROJECT_DIR"
|
|
||||||
cd "$PROJECT_DIR"
|
|
||||||
git clone "$GIT_REPO" .
|
|
||||||
else
|
|
||||||
cd "$PROJECT_DIR"
|
|
||||||
# 현재 변경사항 백업
|
|
||||||
git stash push -m "Auto backup before deploy $(date)"
|
|
||||||
|
|
||||||
# 원격 저장소에서 최신 정보 가져오기
|
|
||||||
git fetch origin
|
|
||||||
|
|
||||||
# 지정된 브랜치로 체크아웃
|
|
||||||
git checkout "$BRANCH"
|
|
||||||
|
|
||||||
# 원격 브랜치와 동기화
|
|
||||||
git pull origin "$BRANCH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 4. 의존성 설치
|
|
||||||
log "의존성 설치 중..."
|
|
||||||
npm install --production
|
|
||||||
|
|
||||||
# 5. 데이터베이스 마이그레이션 (필요한 경우)
|
|
||||||
log "데이터베이스 초기화 중..."
|
|
||||||
node scripts/init-database.js
|
|
||||||
|
|
||||||
# 6. 권한 설정
|
|
||||||
log "권한 설정 중..."
|
|
||||||
chmod +x *.sh
|
|
||||||
chown -R admin:users "$PROJECT_DIR"
|
|
||||||
|
|
||||||
# 7. 서비스 시작
|
|
||||||
log "새로운 서비스 시작 중..."
|
|
||||||
./start-service.sh
|
|
||||||
|
|
||||||
# 8. 서비스 상태 확인
|
|
||||||
sleep 5
|
|
||||||
if [ -f "$PROJECT_DIR/app.pid" ]; then
|
|
||||||
PID=$(cat "$PROJECT_DIR/app.pid")
|
|
||||||
if kill -0 "$PID" 2>/dev/null; then
|
|
||||||
log "✅ 배포 성공! 서비스가 정상적으로 시작되었습니다. (PID: $PID)"
|
|
||||||
else
|
|
||||||
log "❌ 배포 실패! 서비스가 시작되지 않았습니다."
|
|
||||||
log "로그 확인: tail -f $PROJECT_DIR/logs/app.log"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "❌ 배포 실패! PID 파일이 생성되지 않았습니다."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 9. 이전 백업 정리 (30일 이상 된 백업 삭제)
|
|
||||||
log "오래된 백업 정리 중..."
|
|
||||||
find "$BACKUP_DIR" -name "backup-*" -type d -mtime +30 -exec rm -rf {} \; 2>/dev/null
|
|
||||||
|
|
||||||
log "=== 배포 완료 ==="
|
|
||||||
log "서비스 URL: http://$(hostname -I | awk '{print $1}'):3005"
|
|
2587
package-lock.json
generated
2587
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node server.js",
|
"dev": "node server.js",
|
||||||
|
"init-db": "node scripts/init-database.js",
|
||||||
"build": "echo 'Build complete'"
|
"build": "echo 'Build complete'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14,13 +15,9 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.14.3",
|
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
|
||||||
"vercel": "^32.0.0"
|
|
||||||
},
|
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"file-manager",
|
"file-manager",
|
||||||
"admin"
|
"admin"
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const MariaDBHelper = require('./database/mariadb-helper');
|
const DatabaseHelper = require('./database/db-helper');
|
||||||
|
|
||||||
async function resetAdminPassword() {
|
async function resetAdminPassword() {
|
||||||
const dbHelper = new MariaDBHelper();
|
const dbHelper = new DatabaseHelper();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🔄 관리자 비밀번호 초기화 시작...');
|
console.log('🔄 관리자 비밀번호 초기화 시작...');
|
||||||
@@ -17,13 +17,16 @@ async function resetAdminPassword() {
|
|||||||
const existingUser = await dbHelper.getUserByEmail('admin@jaryo.com');
|
const existingUser = await dbHelper.getUserByEmail('admin@jaryo.com');
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
// 기존 사용자 비밀번호 업데이트
|
// 기존 사용자 비밀번호 업데이트 (SQLite 용)
|
||||||
const conn = await dbHelper.connect();
|
await dbHelper.connect();
|
||||||
const [result] = await conn.execute(
|
const query = 'UPDATE users SET password_hash = ? WHERE email = ?';
|
||||||
'UPDATE users SET password_hash = ? WHERE email = ?',
|
dbHelper.db.run(query, [hashedPassword, 'admin@jaryo.com'], function(err) {
|
||||||
[hashedPassword, 'admin@jaryo.com']
|
if (err) {
|
||||||
);
|
console.error('비밀번호 업데이트 실패:', err);
|
||||||
|
} else {
|
||||||
console.log('✅ 기존 관리자 비밀번호가 업데이트되었습니다.');
|
console.log('✅ 기존 관리자 비밀번호가 업데이트되었습니다.');
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// 새 관리자 사용자 생성
|
// 새 관리자 사용자 생성
|
||||||
const adminData = {
|
const adminData = {
|
||||||
|
79
scripts/init-database.js
Normal file
79
scripts/init-database.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const DatabaseHelper = require('../database/db-helper');
|
||||||
|
|
||||||
|
async function initializeDatabase() {
|
||||||
|
console.log('🗄️ 데이터베이스 초기화 시작...');
|
||||||
|
|
||||||
|
const dbHelper = new DatabaseHelper();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 데이터 디렉토리 생성
|
||||||
|
const dataDir = path.join(__dirname, '..', 'data');
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
console.log('📁 데이터 디렉토리 생성됨:', dataDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터베이스 연결 및 테이블 생성
|
||||||
|
await dbHelper.connect();
|
||||||
|
console.log('✅ 데이터베이스 연결 성공');
|
||||||
|
|
||||||
|
// 기본 관리자 계정 생성
|
||||||
|
const adminEmail = 'admin@jaryo.com';
|
||||||
|
const adminPassword = 'Hee150603!';
|
||||||
|
|
||||||
|
const existingUser = await dbHelper.getUserByEmail(adminEmail);
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
const saltRounds = 10;
|
||||||
|
const hashedPassword = await bcrypt.hash(adminPassword, saltRounds);
|
||||||
|
|
||||||
|
const adminData = {
|
||||||
|
email: adminEmail,
|
||||||
|
password_hash: hashedPassword,
|
||||||
|
name: '관리자',
|
||||||
|
role: 'admin'
|
||||||
|
};
|
||||||
|
|
||||||
|
await dbHelper.createUser(adminData);
|
||||||
|
console.log('👤 기본 관리자 계정 생성됨');
|
||||||
|
console.log('📧 이메일:', adminEmail);
|
||||||
|
console.log('🔑 비밀번호:', adminPassword);
|
||||||
|
} else {
|
||||||
|
console.log('👤 관리자 계정이 이미 존재합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 카테고리 생성
|
||||||
|
const defaultCategories = ['문서', '이미지', '동영상', '프레젠테이션', '기타'];
|
||||||
|
|
||||||
|
for (const categoryName of defaultCategories) {
|
||||||
|
try {
|
||||||
|
await dbHelper.addCategory(categoryName);
|
||||||
|
console.log(`📂 카테고리 생성됨: ${categoryName}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('UNIQUE constraint failed')) {
|
||||||
|
console.log(`📂 카테고리 이미 존재: ${categoryName}`);
|
||||||
|
} else {
|
||||||
|
console.error(`카테고리 생성 오류 (${categoryName}):`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎉 데이터베이스 초기화 완료!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 데이터베이스 초기화 실패:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await dbHelper.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크립트 직접 실행 시에만 초기화 실행
|
||||||
|
if (require.main === module) {
|
||||||
|
initializeDatabase().catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = initializeDatabase;
|
26
server.js
26
server.js
@@ -6,7 +6,9 @@ const fs = require('fs');
|
|||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
// 모든 환경에서 SQLite 사용
|
||||||
const DatabaseHelper = require('./database/db-helper');
|
const DatabaseHelper = require('./database/db-helper');
|
||||||
|
console.log('🗄️ SQLite 데이터베이스 사용');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3005;
|
const PORT = process.env.PORT || 3005;
|
||||||
@@ -26,9 +28,9 @@ app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
|||||||
app.use(session({
|
app.use(session({
|
||||||
secret: 'jaryo-file-manager-secret-key-2024',
|
secret: 'jaryo-file-manager-secret-key-2024',
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: true, // 세션 초기화 허용
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: process.env.NODE_ENV === 'production', // Vercel에서는 HTTPS
|
secure: false, // 개발 환경에서도 HTTP로 작동하도록 수정
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 24시간
|
maxAge: 24 * 60 * 60 * 1000 // 24시간
|
||||||
}
|
}
|
||||||
@@ -699,12 +701,12 @@ app.get('/api/stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 파일 다운로드
|
// 파일 다운로드 (SQLite 호환)
|
||||||
app.get('/api/download/:id/:attachmentId', async (req, res) => {
|
app.get('/api/download/:id/:attachmentId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id, attachmentId } = req.params;
|
const { id, attachmentId } = req.params;
|
||||||
|
|
||||||
// 첨부파일 정보 조회 (간단한 쿼리로 대체)
|
// SQLite에서 첨부파일 정보 조회
|
||||||
await db.connect();
|
await db.connect();
|
||||||
const query = 'SELECT * FROM file_attachments WHERE id = ? AND file_id = ?';
|
const query = 'SELECT * FROM file_attachments WHERE id = ? AND file_id = ?';
|
||||||
|
|
||||||
@@ -827,11 +829,19 @@ module.exports = app;
|
|||||||
|
|
||||||
// 로컬 개발 환경에서만 서버 시작
|
// 로컬 개발 환경에서만 서버 시작
|
||||||
if (process.env.NODE_ENV !== 'production' || process.env.VERCEL !== '1') {
|
if (process.env.NODE_ENV !== 'production' || process.env.VERCEL !== '1') {
|
||||||
const server = app.listen(PORT, '99.1.110.50', () => {
|
const HOST = process.env.HOST || '0.0.0.0'; // NAS 호환성을 위해 모든 인터페이스에서 수신
|
||||||
|
const server = app.listen(PORT, HOST, () => {
|
||||||
|
const serverAddress = server.address();
|
||||||
|
const host = serverAddress.address === '::' ? 'localhost' :
|
||||||
|
serverAddress.address === '0.0.0.0' ? 'localhost' :
|
||||||
|
serverAddress.address;
|
||||||
|
|
||||||
console.log(`🚀 자료실 서버가 포트 ${PORT}에서 실행중입니다.`);
|
console.log(`🚀 자료실 서버가 포트 ${PORT}에서 실행중입니다.`);
|
||||||
console.log(`📱 Admin 페이지: http://99.1.110.50:${PORT}/admin/index.html`);
|
console.log(`📍 서버 주소: ${HOST}:${PORT}`);
|
||||||
console.log(`🌐 Main 페이지: http://99.1.110.50:${PORT}/index.html`);
|
console.log(`📱 Admin 페이지: http://${host}:${PORT}/admin/index.html`);
|
||||||
console.log(`📊 API: http://99.1.110.50:${PORT}/api/files`);
|
console.log(`🌐 Main 페이지: http://${host}:${PORT}/index.html`);
|
||||||
|
console.log(`📊 API: http://${host}:${PORT}/api/files`);
|
||||||
|
console.log(`🔧 NAS 접속: http://[NAS-IP]:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 대용량 파일 다운로드를 위해 서버 타임아웃을 30분으로 설정
|
// 대용량 파일 다운로드를 위해 서버 타임아웃을 30분으로 설정
|
||||||
|
@@ -40,9 +40,17 @@ if [ ! -d "node_modules" ]; then
|
|||||||
$NPM_PATH install
|
$NPM_PATH install
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 데이터베이스 초기화
|
# SQLite 데이터베이스 초기화 (선택적)
|
||||||
echo "데이터베이스 초기화 중..."
|
if [ "$INIT_DB" = "true" ] && [ -f "scripts/init-database.js" ]; then
|
||||||
$NODE_PATH scripts/init-database.js
|
echo "SQLite 데이터베이스 초기화 중..."
|
||||||
|
if $NPM_PATH run init-db; then
|
||||||
|
echo "✅ SQLite 초기화 완료"
|
||||||
|
else
|
||||||
|
echo "⚠️ SQLite 초기화 실패"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ℹ️ 데이터베이스 초기화 건너뜀 (INIT_DB=true로 설정시 초기화)"
|
||||||
|
fi
|
||||||
|
|
||||||
# 기존 프로세스 종료
|
# 기존 프로세스 종료
|
||||||
if [ -f "$PID_FILE" ]; then
|
if [ -f "$PID_FILE" ]; then
|
||||||
@@ -57,6 +65,11 @@ fi
|
|||||||
|
|
||||||
# 서비스 시작
|
# 서비스 시작
|
||||||
echo "서비스 시작 중..."
|
echo "서비스 시작 중..."
|
||||||
|
# NAS 환경 변수 설정 (SQLite 사용)
|
||||||
|
export NODE_ENV=production
|
||||||
|
export HOST=0.0.0.0
|
||||||
|
export PORT=3005
|
||||||
|
|
||||||
nohup $NODE_PATH server.js > "$LOG_FILE" 2>&1 &
|
nohup $NODE_PATH server.js > "$LOG_FILE" 2>&1 &
|
||||||
NEW_PID=$!
|
NEW_PID=$!
|
||||||
|
|
||||||
|
@@ -1,4 +0,0 @@
|
|||||||
@echo off
|
|
||||||
cd /d "C:\Users\COMTREE\claude_code\jaryo"
|
|
||||||
echo Starting Jaryo File Manager...
|
|
||||||
node server.js
|
|
482
styles.css
482
styles.css
@@ -16,6 +16,8 @@ body {
|
|||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
@@ -559,6 +561,8 @@ header p {
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-table {
|
.board-table {
|
||||||
@@ -1469,3 +1473,481 @@ header p {
|
|||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 테이블 반응형 스타일 */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.board-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
margin: 0 -20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-table {
|
||||||
|
min-width: 650px;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-title {
|
||||||
|
min-width: 150px;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-attachment {
|
||||||
|
width: 150px;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 15px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-section {
|
||||||
|
padding: 20px 10px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
margin: 0 -10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
box-shadow: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-table {
|
||||||
|
min-width: 580px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-table th,
|
||||||
|
.board-table td {
|
||||||
|
padding: 6px 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-no { width: 35px; min-width: 35px; }
|
||||||
|
.col-category { width: 60px; min-width: 60px; }
|
||||||
|
.col-title { min-width: 120px; max-width: 160px; }
|
||||||
|
.col-attachment { width: 140px; min-width: 140px; }
|
||||||
|
.col-date { width: 80px; min-width: 80px; }
|
||||||
|
.col-actions { width: 80px; min-width: 80px; }
|
||||||
|
|
||||||
|
.board-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 2px 4px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 3px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
max-height: 50px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item-public {
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-single-btn {
|
||||||
|
padding: 2px 5px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container {
|
||||||
|
padding: 8px 5px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-section {
|
||||||
|
padding: 15px 5px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
margin: 0 -5px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
position: relative;
|
||||||
|
width: calc(100vw - 10px);
|
||||||
|
max-width: calc(100vw - 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-table {
|
||||||
|
min-width: 480px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: max-content;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-table th,
|
||||||
|
.board-table td {
|
||||||
|
padding: 4px 2px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-no { width: 25px; min-width: 25px; max-width: 25px; }
|
||||||
|
.col-category { width: 40px; min-width: 40px; max-width: 40px; }
|
||||||
|
.col-title { width: 120px; min-width: 120px; max-width: 120px; text-align: left; }
|
||||||
|
.col-attachment { width: 100px; min-width: 100px; max-width: 100px; }
|
||||||
|
.col-date { width: 60px; min-width: 60px; max-width: 60px; }
|
||||||
|
.col-actions { width: 50px; min-width: 50px; max-width: 50px; }
|
||||||
|
|
||||||
|
.board-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 2px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list {
|
||||||
|
max-height: 40px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item-public {
|
||||||
|
padding: 1px 3px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-single-btn {
|
||||||
|
padding: 1px 4px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
min-width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 페이지네이션 반응형 */
|
||||||
|
.pagination {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 -10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-width: 50px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pageInfo {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
order: -1;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 스크롤 힌트 추가 */
|
||||||
|
.board-container::after {
|
||||||
|
content: "← 좌우로 스와이프하세요 →";
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(102, 126, 234, 0.1);
|
||||||
|
color: #667eea;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 5px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 320px 이하 극소형 화면 대응 */
|
||||||
|
@media (max-width: 320px) {
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 2px 0;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-section {
|
||||||
|
padding: 8px 2px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
margin: 0 -2px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
position: relative;
|
||||||
|
width: calc(100vw - 4px);
|
||||||
|
max-width: calc(100vw - 4px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-table {
|
||||||
|
min-width: 350px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 350px;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-table th,
|
||||||
|
.board-table td {
|
||||||
|
padding: 2px 1px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
line-height: 1.1;
|
||||||
|
vertical-align: middle;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-no { width: 18px; min-width: 18px; max-width: 18px; }
|
||||||
|
.col-category { width: 30px; min-width: 30px; max-width: 30px; }
|
||||||
|
.col-title { width: 90px; min-width: 90px; max-width: 90px; text-align: left; }
|
||||||
|
.col-attachment { width: 70px; min-width: 70px; max-width: 70px; }
|
||||||
|
.col-date { width: 45px; min-width: 45px; max-width: 45px; }
|
||||||
|
.col-actions { width: 35px; min-width: 35px; max-width: 35px; }
|
||||||
|
|
||||||
|
.board-title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 1px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 1px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list {
|
||||||
|
max-height: 30px;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item-public {
|
||||||
|
padding: 1px 2px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-single-btn {
|
||||||
|
padding: 1px 3px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
min-width: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 1px 3px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 0 -2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
min-width: 40px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pageInfo {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-container::after {
|
||||||
|
content: "← 스와이프 →";
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 280px 이하 초극소형 화면 대응 */
|
||||||
|
@media (max-width: 280px) {
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 1px 0;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100vw;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-section {
|
||||||
|
padding: 5px 1px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
margin: 0 -1px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
position: relative;
|
||||||
|
width: calc(100vw - 2px);
|
||||||
|
max-width: calc(100vw - 2px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-table {
|
||||||
|
min-width: 320px;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 320px;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-table th,
|
||||||
|
.board-table td {
|
||||||
|
padding: 1px 0;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
line-height: 1.0;
|
||||||
|
vertical-align: middle;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-no { width: 15px; min-width: 15px; max-width: 15px; }
|
||||||
|
.col-category { width: 25px; min-width: 25px; max-width: 25px; }
|
||||||
|
.col-title { width: 80px; min-width: 80px; max-width: 80px; text-align: left; }
|
||||||
|
.col-attachment { width: 60px; min-width: 60px; max-width: 60px; }
|
||||||
|
.col-date { width: 40px; min-width: 40px; max-width: 40px; }
|
||||||
|
.col-actions { width: 30px; min-width: 30px; max-width: 30px; }
|
||||||
|
|
||||||
|
.board-title {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
line-height: 1.0;
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
padding: 1px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list {
|
||||||
|
max-height: 25px;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item-public {
|
||||||
|
padding: 0 1px;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-single-btn {
|
||||||
|
padding: 1px 2px;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
min-width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 1px 2px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-container::after {
|
||||||
|
content: "← →";
|
||||||
|
font-size: 0.5rem;
|
||||||
|
padding: 1px 2px;
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user