Compare commits
12 Commits
92391aa2c0
...
master
Author | SHA1 | Date | |
---|---|---|---|
9422439a51 | |||
7796d9b7d5 | |||
d6a0656f12 | |||
04d92b7842 | |||
d3d8aa48b6 | |||
80f147731e | |||
ed5fa15814 | |||
896e42d9cc | |||
bda299a6c3 | |||
7be1f2ed07 | |||
ced3fd03e4 | |||
1a053ca047 |
@@ -47,7 +47,39 @@
|
||||
"Bash(npm install)",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(schtasks:*)",
|
||||
"Bash(cmd //c:*)"
|
||||
"Bash(cmd //c:*)",
|
||||
"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": [],
|
||||
"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
|
||||
|
||||
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
|
||||
## Purpose
|
||||
Systematically clean up code, remove dead code, optimize imports, and improve project structure.
|
||||
|
||||
## Usage
|
||||
```
|
||||
자료실/
|
||||
├── 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
|
||||
/sc:cleanup [target] [--type code|imports|files|all] [--safe|--aggressive] [--dry-run]
|
||||
```
|
||||
|
||||
## 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`)
|
||||
- Main application controller with hybrid storage support
|
||||
- Handles all CRUD operations (Supabase + localStorage fallback)
|
||||
- Manages user authentication and session state
|
||||
- Contains event handling and UI updates
|
||||
- Real-time synchronization capabilities
|
||||
|
||||
2. **Supabase Integration** (`supabase-config.js`)
|
||||
- Database configuration and connection management
|
||||
- Authentication helper functions (signup, login, logout)
|
||||
- CRUD helper functions for files and attachments
|
||||
- Storage helper functions for file uploads/downloads
|
||||
- Real-time subscription management
|
||||
|
||||
3. **Data Model**
|
||||
```javascript
|
||||
// Files table
|
||||
{
|
||||
id: UUID, // Primary key
|
||||
title: string, // File title (required)
|
||||
description: string, // Optional description
|
||||
category: string, // Required category
|
||||
tags: string[], // Array of tags
|
||||
user_id: UUID, // Foreign key to auth.users
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp
|
||||
}
|
||||
|
||||
// File attachments table
|
||||
{
|
||||
id: UUID,
|
||||
file_id: UUID, // Foreign key to files
|
||||
original_name: string,
|
||||
storage_path: string, // Supabase Storage path
|
||||
file_size: integer,
|
||||
mime_type: string,
|
||||
created_at: timestamp
|
||||
}
|
||||
```
|
||||
|
||||
4. **UI Components**
|
||||
- Authentication section (login/signup/logout)
|
||||
- Sync status indicator
|
||||
- Search and filter section
|
||||
- Add new file form with cloud upload
|
||||
- File list display with sorting
|
||||
- Edit modal for updates
|
||||
- Responsive card-based layout
|
||||
- Offline mode notifications
|
||||
|
||||
### Development Commands
|
||||
|
||||
This is a hybrid web application supporting both online and offline modes:
|
||||
|
||||
1. **Local Development**
|
||||
```bash
|
||||
# Open index.html in a web browser
|
||||
# OR use a simple HTTP server:
|
||||
python -m http.server 8000
|
||||
# OR
|
||||
npx serve .
|
||||
```
|
||||
|
||||
2. **Supabase Setup (Required for online features)**
|
||||
```bash
|
||||
# 1. Follow setup-guide.md for complete setup
|
||||
# 2. Create Supabase project and database
|
||||
# 3. Run supabase-schema.sql in SQL Editor
|
||||
# 4. Create Storage bucket named 'files'
|
||||
# 5. Update supabase-config.js with your credentials
|
||||
```
|
||||
|
||||
3. **File Access**
|
||||
- Open `index.html` directly in browser
|
||||
- Works offline with localStorage (limited functionality)
|
||||
- Full features available with Supabase configuration
|
||||
|
||||
### Technical Implementation
|
||||
|
||||
- **Database**: Supabase PostgreSQL with Row Level Security (RLS)
|
||||
- **Storage**: Supabase Storage for files + localStorage fallback
|
||||
- **Authentication**: Supabase Auth with email/password
|
||||
- **Real-time**: Supabase Realtime for live synchronization
|
||||
- **File Handling**: FileReader API + Supabase Storage API
|
||||
- **UI Updates**: Vanilla JavaScript DOM manipulation
|
||||
- **Styling**: CSS Grid and Flexbox for responsive layouts
|
||||
- **Animations**: CSS transitions and keyframe animations
|
||||
- **Offline Support**: Automatic fallback to localStorage when offline
|
||||
|
||||
### Data Management
|
||||
|
||||
- **Online Mode**: Files stored in Supabase PostgreSQL + Storage
|
||||
- **Offline Mode**: Files stored as base64 strings in localStorage
|
||||
- **Hybrid Sync**: Automatic synchronization when connection restored
|
||||
- User-specific data isolation with RLS policies
|
||||
- Search works across title, description, and tags
|
||||
- Sorting available by date, title, or category
|
||||
- Categories are predefined but can be extended
|
||||
- Real-time updates across devices for same user
|
||||
|
||||
### Browser Compatibility
|
||||
|
||||
- Modern browsers with ES6+ support
|
||||
- localStorage API support required
|
||||
- FileReader API for file uploads
|
||||
- Fetch API for Supabase communication
|
||||
- WebSocket support for real-time features
|
||||
- External dependency: Supabase JavaScript client library
|
||||
## Claude Code Integration
|
||||
- Uses Glob for systematic file discovery
|
||||
- Leverages Grep for dead code detection
|
||||
- Applies MultiEdit for batch cleanup operations
|
||||
- Maintains backup and rollback capabilities
|
@@ -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() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/session');
|
||||
const response = await fetch('/api/auth/session', { credentials: 'include' });
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.user) {
|
||||
@@ -494,20 +494,21 @@ class AdminFileManager {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
this.currentUser = data.user;
|
||||
this.currentUser = data.user || data.data || null;
|
||||
this.isLoggedIn = true;
|
||||
this.showNotification('로그인되었습니다!', 'success');
|
||||
|
||||
await this.loadData();
|
||||
this.updateUI();
|
||||
} else {
|
||||
throw new Error(data.message || '로그인에 실패했습니다.');
|
||||
throw new Error(data.error || data.message || '로그인에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그인 오류:', error);
|
||||
@@ -520,7 +521,7 @@ class AdminFileManager {
|
||||
|
||||
async handleLogout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
|
||||
this.currentUser = null;
|
||||
this.isLoggedIn = false;
|
||||
@@ -1329,7 +1330,6 @@ class AdminFileManager {
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1367,6 +1367,22 @@ class AdminFileManager {
|
||||
|
||||
resetCategoryForm() {
|
||||
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() {
|
||||
|
@@ -4,7 +4,9 @@ const fs = require('fs');
|
||||
|
||||
class DatabaseHelper {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -16,13 +18,26 @@ class DatabaseHelper {
|
||||
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) {
|
||||
console.error('데이터베이스 연결 오류:', err.message);
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('✅ SQLite 데이터베이스 연결됨');
|
||||
resolve(this.db);
|
||||
console.log('✅ SQLite 데이터베이스 연결됨:', this.dbPath);
|
||||
this.initializeTables().then(() => {
|
||||
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) {
|
||||
await this.connect();
|
||||
@@ -277,8 +364,8 @@ class DatabaseHelper {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const query = `
|
||||
INSERT INTO file_attachments (file_id, original_name, file_name, file_path, file_size, mime_type, file_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO file_attachments (file_id, original_name, file_name, file_path, file_size, mime_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
const params = [
|
||||
@@ -287,8 +374,7 @@ class DatabaseHelper {
|
||||
attachmentData.file_name || attachmentData.original_name,
|
||||
attachmentData.file_path || '',
|
||||
attachmentData.file_size || 0,
|
||||
attachmentData.mime_type || '',
|
||||
attachmentData.file_data || null
|
||||
attachmentData.mime_type || ''
|
||||
];
|
||||
|
||||
this.db.run(query, params, function(err) {
|
||||
@@ -323,7 +409,7 @@ class DatabaseHelper {
|
||||
await this.connect();
|
||||
|
||||
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) => {
|
||||
if (err) {
|
||||
|
@@ -1,211 +0,0 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
class MariaDBHelper {
|
||||
constructor() {
|
||||
this.connection = null;
|
||||
this.config = {
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
user: 'jaryo_user',
|
||||
password: 'JaryoPass2024!@#',
|
||||
database: 'jaryo',
|
||||
charset: 'utf8mb4',
|
||||
timezone: '+09:00',
|
||||
acquireTimeout: 60000,
|
||||
timeout: 60000
|
||||
};
|
||||
}
|
||||
|
||||
async connect() {
|
||||
try {
|
||||
if (!this.connection || this.connection.connection._closing) {
|
||||
this.connection = await mysql.createConnection(this.config);
|
||||
console.log('✅ MariaDB 연결 성공');
|
||||
}
|
||||
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}"
|
||||
PROJECT_NAME="${2:-jaryo}"
|
||||
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"
|
||||
SERVICE_PORT="3005"
|
||||
GITEA_URL="http://$NAS_IP:3000/vibsin9322/jaryo.git"
|
||||
|
||||
# SSH 명령어 준비
|
||||
SSH_CMD="ssh -p 2222 -o ConnectTimeout=10 -o StrictHostKeyChecking=no $NAS_USER@$NAS_IP"
|
||||
# SSH 명령어 준비 (NAS_PASS가 있으면 plink로 비대화식, 없으면 ssh 프롬프트)
|
||||
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 "🚀 시놀로지 NAS 자료실 배포 시작"
|
||||
@@ -31,8 +40,12 @@ echo "=========================================="
|
||||
echo "📋 1단계: 사전 요구사항 확인"
|
||||
|
||||
# SSH 방식 확인
|
||||
echo "🔧 SSH 접속 방식: 비밀번호 프롬프트 방식"
|
||||
echo "📝 SSH 연결 시 비밀번호 입력이 필요합니다."
|
||||
if [ -n "$NAS_PASS" ]; then
|
||||
echo "🔧 SSH 접속 방식: 비밀번호 비대화식(plink)"
|
||||
else
|
||||
echo "🔧 SSH 접속 방식: 비밀번호 프롬프트 방식"
|
||||
echo "📝 SSH 연결 시 비밀번호 입력이 필요합니다."
|
||||
fi
|
||||
|
||||
# SSH 연결 테스트 (포트 2222)
|
||||
echo "🔗 SSH 연결 테스트 중... (사용자: $NAS_USER, 포트: 2222)"
|
||||
@@ -135,27 +148,22 @@ if [ \$? -ne 0 ]; then
|
||||
fi
|
||||
echo '✅ 의존성 설치 완료'
|
||||
|
||||
# 데이터베이스 백업 및 초기화
|
||||
if [ -f 'scripts/init-database.js' ]; then
|
||||
# 기존 데이터베이스 백업
|
||||
DB_FILE='data/database.db'
|
||||
BACKUP_FILE='data/database_backup_$(date +%Y%m%d_%H%M%S).db'
|
||||
# 데이터베이스 초기화 (선택사항)
|
||||
if [ "\$INIT_DB" = "true" ] && [ -f 'scripts/init-database.js' ]; then
|
||||
echo '🗄️ SQLite 데이터베이스 초기화 중...'
|
||||
echo 'ℹ️ SQLite 데이터베이스: data/jaryo.db'
|
||||
|
||||
if [ -f '\$DB_FILE' ]; then
|
||||
echo '💾 기존 데이터베이스 백업 중...'
|
||||
cp '\$DB_FILE' '\$BACKUP_FILE'
|
||||
echo '✅ 백업 완료: \$BACKUP_FILE'
|
||||
|
||||
# 기존 데이터 유지 - 초기화 건너뛰기
|
||||
echo 'ℹ️ 기존 데이터베이스 발견 - 초기화 건너뛰기'
|
||||
echo '💡 새 데이터베이스가 필요하면 수동으로 실행: npm run init-db'
|
||||
export PATH='$NODE_PATH':\$PATH
|
||||
if '$NODE_PATH'/npm run init-db; then
|
||||
echo '✅ SQLite 초기화 완료'
|
||||
else
|
||||
# 새 설치 - 데이터베이스 초기화
|
||||
echo '🗄️ 새 데이터베이스 초기화 중...'
|
||||
export PATH='$NODE_PATH':\$PATH
|
||||
'$NODE_PATH'/npm run init-db
|
||||
echo '✅ 데이터베이스 초기화 완료'
|
||||
echo '❌ SQLite 초기화 실패'
|
||||
echo '💡 수동으로 초기화하려면:'
|
||||
echo ' npm run init-db'
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo 'ℹ️ 데이터베이스 초기화 건너뜀 (INIT_DB=true로 설정시 초기화)'
|
||||
fi
|
||||
"
|
||||
|
||||
@@ -201,7 +209,11 @@ fi
|
||||
# 서비스 시작
|
||||
echo '🚀 자료실 서비스 시작 중...'
|
||||
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'
|
||||
|
||||
sleep 2
|
||||
@@ -266,7 +278,7 @@ chmod +x '$DEPLOY_DIR/stop-nas-service.sh'
|
||||
echo ""
|
||||
echo "🎬 5단계: 서비스 시작"
|
||||
|
||||
eval "$SSH_CMD '$DEPLOY_DIR/start-nas-service.sh"
|
||||
eval "$SSH_CMD '$DEPLOY_DIR/start-nas-service.sh'"
|
||||
|
||||
# 6단계: 접속 테스트
|
||||
echo ""
|
||||
@@ -295,5 +307,5 @@ if curl -s "http://$NAS_IP:$SERVICE_PORT" >/dev/null; then
|
||||
else
|
||||
echo "❌ 서비스 접속 실패"
|
||||
echo "로그 확인:"
|
||||
eval "$SSH_CMD 'tail -20 $DEPLOY_DIR/logs/app.log"
|
||||
eval "$SSH_CMD 'tail -20 $DEPLOY_DIR/logs/app.log'"
|
||||
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"
|
2465
package-lock.json
generated
2465
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -6,21 +6,22 @@
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js",
|
||||
"init-db": "node scripts/init-database.js",
|
||||
"build": "echo 'Build complete'"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"uuid": "^9.0.1",
|
||||
"sqlite3": "^5.1.6"
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sqlite3": "^5.1.6",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vercel": "^32.0.0"
|
||||
},
|
||||
"keywords": ["file-manager", "admin"],
|
||||
"keywords": [
|
||||
"file-manager",
|
||||
"admin"
|
||||
],
|
||||
"author": "Claude Code",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
64
reset-admin.js
Normal file
64
reset-admin.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const DatabaseHelper = require('./database/db-helper');
|
||||
|
||||
async function resetAdminPassword() {
|
||||
const dbHelper = new DatabaseHelper();
|
||||
|
||||
try {
|
||||
console.log('🔄 관리자 비밀번호 초기화 시작...');
|
||||
|
||||
const password = 'Hee150603!';
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
console.log('🔐 해시된 비밀번호:', hashedPassword);
|
||||
|
||||
// 관리자 사용자 확인
|
||||
const existingUser = await dbHelper.getUserByEmail('admin@jaryo.com');
|
||||
|
||||
if (existingUser) {
|
||||
// 기존 사용자 비밀번호 업데이트 (SQLite 용)
|
||||
await dbHelper.connect();
|
||||
const query = 'UPDATE users SET password_hash = ? WHERE email = ?';
|
||||
dbHelper.db.run(query, [hashedPassword, 'admin@jaryo.com'], function(err) {
|
||||
if (err) {
|
||||
console.error('비밀번호 업데이트 실패:', err);
|
||||
} else {
|
||||
console.log('✅ 기존 관리자 비밀번호가 업데이트되었습니다.');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 새 관리자 사용자 생성
|
||||
const adminData = {
|
||||
email: 'admin@jaryo.com',
|
||||
password_hash: hashedPassword,
|
||||
name: '관리자',
|
||||
role: 'admin'
|
||||
};
|
||||
|
||||
const result = await dbHelper.createUser(adminData);
|
||||
console.log('✅ 새 관리자 사용자가 생성되었습니다.');
|
||||
}
|
||||
|
||||
// 로그인 테스트
|
||||
const user = await dbHelper.getUserByEmail('admin@jaryo.com');
|
||||
const isValid = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
console.log('🧪 로그인 테스트 결과:', isValid ? '성공' : '실패');
|
||||
|
||||
if (isValid) {
|
||||
console.log('🎉 관리자 계정 설정 완료!');
|
||||
console.log('📧 이메일: admin@jaryo.com');
|
||||
console.log('🔑 비밀번호: Hee150603!');
|
||||
}
|
||||
|
||||
await dbHelper.close();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error);
|
||||
await dbHelper.close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
resetAdminPassword();
|
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;
|
124
server.js
124
server.js
@@ -6,7 +6,9 @@ const fs = require('fs');
|
||||
const bcrypt = require('bcrypt');
|
||||
const session = require('express-session');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
// 모든 환경에서 SQLite 사용
|
||||
const DatabaseHelper = require('./database/db-helper');
|
||||
console.log('🗄️ SQLite 데이터베이스 사용');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3005;
|
||||
@@ -26,9 +28,9 @@ app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
app.use(session({
|
||||
secret: 'jaryo-file-manager-secret-key-2024',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
saveUninitialized: true, // 세션 초기화 허용
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production', // Vercel에서는 HTTPS
|
||||
secure: false, // 개발 환경에서도 HTTP로 작동하도록 수정
|
||||
httpOnly: true,
|
||||
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) => {
|
||||
try {
|
||||
const { id, attachmentId } = req.params;
|
||||
|
||||
// 첨부파일 정보 조회 (간단한 쿼리로 대체)
|
||||
// SQLite에서 첨부파일 정보 조회
|
||||
await db.connect();
|
||||
const query = 'SELECT * FROM file_attachments WHERE id = ? AND file_id = ?';
|
||||
|
||||
@@ -728,64 +730,64 @@ app.get('/api/download/:id/:attachmentId', async (req, res) => {
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
// 한글 파일명을 위한 개선된 헤더 설정
|
||||
console.log('📁 다운로드 파일 정보:', {
|
||||
original_name: row.original_name,
|
||||
file_path: row.file_path,
|
||||
storage_path: filePath
|
||||
});
|
||||
console.log('📁 다운로드 파일 정보:', {
|
||||
original_name: row.original_name,
|
||||
file_path: row.file_path,
|
||||
storage_path: filePath
|
||||
});
|
||||
|
||||
const originalName = row.original_name || 'download';
|
||||
const encodedName = encodeURIComponent(originalName);
|
||||
const originalName = row.original_name || 'download';
|
||||
const encodedName = encodeURIComponent(originalName);
|
||||
|
||||
// RFC 5987을 준수하는 헤더 설정 (한글 파일명 지원)
|
||||
const stat = fs.statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
// RFC 5987을 준수하는 헤더 설정 (한글 파일명 지원)
|
||||
const stat = fs.statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
|
||||
// Range 요청 처리
|
||||
const range = req.headers.range;
|
||||
let start = 0;
|
||||
let end = fileSize - 1;
|
||||
let statusCode = 200;
|
||||
// Range 요청 처리
|
||||
const range = req.headers.range;
|
||||
let start = 0;
|
||||
let end = fileSize - 1;
|
||||
let statusCode = 200;
|
||||
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
start = parseInt(parts[0], 10);
|
||||
end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
statusCode = 206; // Partial Content
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
start = parseInt(parts[0], 10);
|
||||
end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
statusCode = 206; // Partial Content
|
||||
|
||||
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
||||
res.setHeader('Content-Length', (end - start + 1));
|
||||
} else {
|
||||
res.setHeader('Content-Length', fileSize);
|
||||
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
||||
res.setHeader('Content-Length', (end - start + 1));
|
||||
} else {
|
||||
res.setHeader('Content-Length', fileSize);
|
||||
}
|
||||
|
||||
res.status(statusCode);
|
||||
res.setHeader('Content-Disposition',
|
||||
`attachment; filename*=UTF-8''${encodedName}`);
|
||||
res.setHeader('Content-Type', row.mime_type || 'application/octet-stream');
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Cache-Control', 'public, max-age=0');
|
||||
|
||||
// 클라이언트 연결 끊김 감지
|
||||
res.on('close', () => {
|
||||
if (!res.headersSent) {
|
||||
console.log('📁 다운로드 취소됨:', originalName);
|
||||
}
|
||||
});
|
||||
|
||||
res.status(statusCode);
|
||||
res.setHeader('Content-Disposition',
|
||||
`attachment; filename*=UTF-8''${encodedName}`);
|
||||
res.setHeader('Content-Type', row.mime_type || 'application/octet-stream');
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Cache-Control', 'public, max-age=0');
|
||||
// 스트림 기반 다운로드로 대용량 파일 지원 (Range 요청 지원)
|
||||
const readStream = fs.createReadStream(filePath, { start, end });
|
||||
|
||||
// 클라이언트 연결 끊김 감지
|
||||
res.on('close', () => {
|
||||
if (!res.headersSent) {
|
||||
console.log('📁 다운로드 취소됨:', originalName);
|
||||
}
|
||||
});
|
||||
readStream.on('error', (err) => {
|
||||
console.error('📁 파일 읽기 오류:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: '파일 읽기 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 스트림 기반 다운로드로 대용량 파일 지원 (Range 요청 지원)
|
||||
const readStream = fs.createReadStream(filePath, { start, end });
|
||||
|
||||
readStream.on('error', (err) => {
|
||||
console.error('📁 파일 읽기 오류:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: '파일 읽기 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
readStream.on('end', () => {
|
||||
console.log('📁 다운로드 완료:', originalName);
|
||||
});
|
||||
readStream.on('end', () => {
|
||||
console.log('📁 다운로드 완료:', originalName);
|
||||
});
|
||||
|
||||
// 스트림을 응답에 연결
|
||||
readStream.pipe(res);
|
||||
@@ -827,11 +829,19 @@ module.exports = app;
|
||||
|
||||
// 로컬 개발 환경에서만 서버 시작
|
||||
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(`📱 Admin 페이지: http://99.1.110.50:${PORT}/admin/index.html`);
|
||||
console.log(`🌐 Main 페이지: http://99.1.110.50:${PORT}/index.html`);
|
||||
console.log(`📊 API: http://99.1.110.50:${PORT}/api/files`);
|
||||
console.log(`📍 서버 주소: ${HOST}:${PORT}`);
|
||||
console.log(`📱 Admin 페이지: http://${host}:${PORT}/admin/index.html`);
|
||||
console.log(`🌐 Main 페이지: http://${host}:${PORT}/index.html`);
|
||||
console.log(`📊 API: http://${host}:${PORT}/api/files`);
|
||||
console.log(`🔧 NAS 접속: http://[NAS-IP]:${PORT}`);
|
||||
});
|
||||
|
||||
// 대용량 파일 다운로드를 위해 서버 타임아웃을 30분으로 설정
|
||||
|
@@ -40,9 +40,17 @@ if [ ! -d "node_modules" ]; then
|
||||
$NPM_PATH install
|
||||
fi
|
||||
|
||||
# 데이터베이스 초기화
|
||||
echo "데이터베이스 초기화 중..."
|
||||
$NODE_PATH scripts/init-database.js
|
||||
# SQLite 데이터베이스 초기화 (선택적)
|
||||
if [ "$INIT_DB" = "true" ] && [ -f "scripts/init-database.js" ]; then
|
||||
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
|
||||
@@ -57,6 +65,11 @@ fi
|
||||
|
||||
# 서비스 시작
|
||||
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 &
|
||||
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;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
header {
|
||||
@@ -559,6 +561,8 @@ header p {
|
||||
margin-top: 20px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.board-table {
|
||||
@@ -1469,3 +1473,481 @@ header p {
|
||||
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