refactor: complete modal class-based architecture, design system integration, and map editor modularization

This commit is contained in:
2026-06-01 14:57:07 +09:00
parent 590ddd0e85
commit 9cd5d59bf8
32 changed files with 1838 additions and 1670 deletions

222
src/views/MapEditor.ts Normal file
View File

@@ -0,0 +1,222 @@
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
export class MapEditor {
private container: HTMLElement;
private wrapper: HTMLElement;
private img: HTMLImageElement;
private boxListEl: HTMLElement;
private pathLabel: HTMLElement;
private statusEl: HTMLElement;
private saveBtn: HTMLButtonElement;
private fileSidebar: HTMLElement;
private allMapConfig: Record<string, any[]> = {};
private boxes: any[] = [];
private isDrawing: boolean = false;
private startX: number = 0;
private startY: number = 0;
private currentBox: HTMLElement | null = null;
private currentPath: string = '';
constructor() {
this.container = document.getElementById('container')!;
this.wrapper = document.getElementById('wrapper')!;
this.img = document.getElementById('target-img') as HTMLImageElement;
this.boxListEl = document.getElementById('box-list')!;
this.pathLabel = document.getElementById('current-path')!;
this.statusEl = document.getElementById('save-status')!;
this.saveBtn = document.getElementById('btn-save-server') as HTMLButtonElement;
this.fileSidebar = document.getElementById('file-sidebar')!;
}
public async init() {
this.renderFileSidebar();
await this.loadConfig();
this.bindEvents();
this.selectFirstFile();
createIcons({ icons: { X, Save, Trash2, ChevronLeft, ChevronRight } });
}
private renderFileSidebar() {
let html = '';
Object.entries(IMAGE_LOCATIONS).forEach(([bldg, details]) => {
html += `<div class="folder-item">${bldg}</div>`;
Object.entries(details).forEach(([detail, paths]) => {
paths.forEach(path => {
const fileName = path.split('/').pop() || path;
html += `<div class="file-item" data-path="${path}">${fileName}</div>`;
});
});
});
this.fileSidebar.innerHTML = html;
this.fileSidebar.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', () => {
this.fileSidebar.querySelectorAll('.file-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
this.renderCurrentFile();
});
});
}
private selectFirstFile() {
const firstItem = this.fileSidebar.querySelector('.file-item') as HTMLElement;
if (firstItem) {
firstItem.classList.add('active');
this.renderCurrentFile();
}
}
private async loadConfig() {
try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
this.allMapConfig = await res.json();
} catch (err) {
console.error('Failed to load config:', err);
}
}
private renderCurrentFile() {
const activeItem = this.fileSidebar.querySelector('.file-item.active') as HTMLElement;
if (!activeItem) return;
this.currentPath = activeItem.dataset.path || '';
this.boxes = this.allMapConfig[this.currentPath] || [];
this.pathLabel.textContent = this.currentPath;
this.img.src = this.currentPath;
this.render();
}
private bindEvents() {
this.wrapper.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
this.isDrawing = true;
const rect = this.wrapper.getBoundingClientRect();
this.startX = e.clientX - rect.left;
this.startY = e.clientY - rect.top;
this.currentBox = document.createElement('div');
this.currentBox.className = 'draw-box';
this.currentBox.style.left = this.startX + 'px';
this.currentBox.style.top = this.startY + 'px';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (this.boxes.length + 1);
this.currentBox.appendChild(label);
this.wrapper.appendChild(this.currentBox);
});
window.addEventListener('mousemove', (e) => {
if (!this.isDrawing || !this.currentBox) return;
const rect = this.wrapper.getBoundingClientRect();
const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
const width = currentX - this.startX;
const height = currentY - this.startY;
this.currentBox.style.width = Math.abs(width) + 'px';
this.currentBox.style.height = Math.abs(height) + 'px';
this.currentBox.style.left = (width > 0 ? this.startX : currentX) + 'px';
this.currentBox.style.top = (height > 0 ? this.startY : currentY) + 'px';
});
window.addEventListener('mouseup', () => {
if (!this.isDrawing || !this.currentBox) return;
this.isDrawing = false;
const width = parseFloat(this.currentBox.style.width);
const height = parseFloat(this.currentBox.style.height);
if (width > 3 && height > 3) {
const rect = this.wrapper.getBoundingClientRect();
const boxData = {
x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2),
y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2),
w: (width / rect.width * 100).toFixed(2),
h: (height / rect.height * 100).toFixed(2)
};
this.boxes.push(boxData);
this.render();
}
this.currentBox.remove();
this.currentBox = null;
});
(window as any).removeBox = (index: number) => {
this.boxes.splice(index, 1);
this.render();
};
(window as any).clearAll = () => {
if(confirm('모든 박스를 삭제할까요?')) {
this.boxes = [];
this.render();
}
};
(window as any).saveToServer = () => this.saveToServer();
}
private async saveToServer() {
if (!this.currentPath) return;
try {
this.saveBtn.disabled = true;
this.saveBtn.textContent = '저장 중...';
const res = await fetch(`http://${location.hostname}:3000/api/maps/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.currentPath, boxes: this.boxes })
});
if (res.ok) {
this.allMapConfig[this.currentPath] = [...this.boxes];
this.statusEl.textContent = '✅ 서버 저장 완료 (' + new Date().toLocaleTimeString() + ')';
setTimeout(() => this.statusEl.textContent = '', 3000);
} else {
alert('저장 실패!');
}
} catch (err) {
alert('서버 연결 오류!');
} finally {
this.saveBtn.disabled = false;
this.saveBtn.textContent = '서버에 즉시 저장';
}
}
private render() {
this.boxListEl.innerHTML = '';
const oldBoxes = this.wrapper.querySelectorAll('.placed-box');
oldBoxes.forEach(b => b.remove());
this.boxes.forEach((box, i) => {
const div = document.createElement('div');
div.className = 'placed-box';
div.style.left = box.x + '%';
div.style.top = box.y + '%';
div.style.width = box.w + '%';
div.style.height = box.h + '%';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (i + 1);
div.appendChild(label);
this.wrapper.appendChild(div);
const item = document.createElement('div');
item.className = 'box-item';
item.innerHTML = `
<span>#${i+1}: [${box.x}, ${box.y}]</span>
<button class="btn-del" onclick="removeBox(${i})">×</button>
`;
this.boxListEl.appendChild(item);
});
}
}