Compare commits

..

10 Commits

Author SHA1 Message Date
9422439a51 fix: improve mobile responsive design for file list table
Some checks failed
Deploy to Vercel / deploy (push) Has been cancelled
Deploy to Railway / deploy (push) Has been cancelled
- Add comprehensive responsive design for multiple screen sizes (1024px, 768px, 480px, 320px, 280px)
- Implement horizontal scrolling with touch support for table overflow
- Optimize column widths and font sizes for each breakpoint
- Add visual scroll hints for mobile users
- Ensure proper viewport utilization with calc() functions
- Fix table layout issues on extreme small screens (280px)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 16:45:31 +09:00
7796d9b7d5 fix: improve category cancel button functionality
Some checks failed
Deploy to Vercel / deploy (push) Has been cancelled
Deploy to Railway / deploy (push) Has been cancelled
- Enhanced resetCategoryForm() to properly reset editing state
- Clear currentEditCategoryId when cancelling
- Reset button text and form title to default state
- Add console logging for better debugging

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-16 14:08:04 +09:00
d6a0656f12 cleanup: remove unnecessary files and dependencies
Some checks failed
Deploy to Vercel / deploy (push) Has been cancelled
Deploy to Railway / deploy (push) Has been cancelled
- Remove duplicate database files (database.sqlite, database/jaryo.db)
- Remove test files (test-login.html)
- Remove redundant deployment scripts (deploy.sh, start-simple.bat)
- Remove vercel dev dependency and fix security vulnerabilities
- Clean up project structure for better maintainability

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-16 13:28:35 +09:00
04d92b7842 feat: update Claude Code settings
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-16 13:21:43 +09:00
d3d8aa48b6 cleanup: remove MariaDB dependencies and files
Some checks failed
Deploy to Vercel / deploy (push) Has been cancelled
Deploy to Railway / deploy (push) Has been cancelled
- Remove MariaDB helper, schema, and initialization script
- Remove mysql2 dependency from package.json
- Update reset-admin.js to use SQLite DatabaseHelper
- Simplify .env.example to remove MariaDB configuration
- Update start-service.sh to use SQLite initialization
- Clean up all MariaDB references across codebase

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-25 14:08:44 +09:00
80f147731e refactor: simplify to use SQLite for all environments
Some checks failed
Deploy to Vercel / deploy (push) Has been cancelled
Deploy to Railway / deploy (push) Has been cancelled
- Change all environments (local and NAS) to use SQLite database
- Remove MariaDB dependency and complexity
- Make database initialization optional in deployment script
- Simplify deployment by using single database type across all environments

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-25 13:54:59 +09:00
ed5fa15814 feat: enhance database environment handling and cleanup project
Some checks failed
Deploy to Vercel / deploy (push) Has been cancelled
Deploy to Railway / deploy (push) Has been cancelled
- Add environment-specific database selection (SQLite for local, MariaDB for NAS)
- Remove edit button from admin detail view modal for cleaner UX
- Clean up project files: remove redundant docs and test files
- Update deployment script with improved SSH handling
- Maintain backward compatibility while supporting both database types

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-25 13:13:19 +09:00
896e42d9cc fix(admin): include credentials on auth requests and adapt login response
Some checks failed
Deploy to Vercel / deploy (push) Has been cancelled
Deploy to Railway / deploy (push) Has been cancelled
2025-08-22 16:42:30 +09:00
bda299a6c3 Fix download functionality and attachment display
Some checks failed
Deploy to Vercel / deploy (push) Has been cancelled
Deploy to Railway / deploy (push) Has been cancelled
- Fixed MariaDB compatible download API for NAS deployment
- Updated SQLite schema to remove deprecated file_data column
- Enhanced attachment display consistency between admin and public pages
- Resolved category ordering issues in SQLite environment
- Added NAS MariaDB remote connection configuration
- Improved file upload and download functionality for both environments

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 15:44:54 +09:00
7be1f2ed07 MariaDB 완전 마이그레이션 및 NAS 배포 최적화
- MariaDB 환경별 자동 감지 (Windows/NAS/Linux)
- Unix Socket 및 TCP 연결 지원
- 완전한 UTF8MB4 스키마 적용
- 자동 초기화 스크립트 개선
- NAS 배포 스크립트 MariaDB 지원
- 환경변수 기반 설정 시스템
- 상세한 배포 가이드 문서화

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 13:38:25 +09:00
17 changed files with 894 additions and 3257 deletions

View File

@@ -48,7 +48,38 @@
"Bash(powershell:*)",
"Bash(schtasks:*)",
"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": [],
"ask": [],

13
.env.example Normal file
View 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
View File

@@ -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

View File

@@ -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 설치 상태

View File

@@ -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() {

View File

@@ -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 데이터베이스 연결됨');
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) {

View File

@@ -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;

View File

@@ -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'
else
# 새 설치 - 데이터베이스 초기화
echo '🗄️ 새 데이터베이스 초기화 중...'
export PATH='$NODE_PATH':\$PATH
'$NODE_PATH'/npm run init-db
echo '✅ 데이터베이스 초기화 완료'
if '$NODE_PATH'/npm run init-db; then
echo '✅ SQLite 초기화 완료'
else
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
View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
"scripts": {
"start": "node server.js",
"dev": "node server.js",
"init-db": "node scripts/init-database.js",
"build": "echo 'Build complete'"
},
"dependencies": {
@@ -14,13 +15,9 @@
"express": "^4.18.2",
"express-session": "^1.17.3",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.14.3",
"sqlite3": "^5.1.6",
"uuid": "^9.0.1"
},
"devDependencies": {
"vercel": "^32.0.0"
},
"keywords": [
"file-manager",
"admin"

View File

@@ -1,8 +1,8 @@
const bcrypt = require('bcrypt');
const MariaDBHelper = require('./database/mariadb-helper');
const DatabaseHelper = require('./database/db-helper');
async function resetAdminPassword() {
const dbHelper = new MariaDBHelper();
const dbHelper = new DatabaseHelper();
try {
console.log('🔄 관리자 비밀번호 초기화 시작...');
@@ -17,13 +17,16 @@ async function resetAdminPassword() {
const existingUser = await dbHelper.getUserByEmail('admin@jaryo.com');
if (existingUser) {
// 기존 사용자 비밀번호 업데이트
const conn = await dbHelper.connect();
const [result] = await conn.execute(
'UPDATE users SET password_hash = ? WHERE email = ?',
[hashedPassword, 'admin@jaryo.com']
);
// 기존 사용자 비밀번호 업데이트 (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 = {

79
scripts/init-database.js Normal file
View 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;

View File

@@ -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 = ?';
@@ -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분으로 설정

View File

@@ -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=$!

View File

@@ -1,4 +0,0 @@
@echo off
cd /d "C:\Users\COMTREE\claude_code\jaryo"
echo Starting Jaryo File Manager...
node server.js

View File

@@ -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;
}
}