274 lines
10 KiB
JavaScript
274 lines
10 KiB
JavaScript
import { state } from './state.js';
|
|
import * as pinchZoom from './handlers/pinchZoom.js';
|
|
import * as selection from './handlers/selection.js';
|
|
import * as dragDrop from './handlers/dragDrop.js';
|
|
|
|
// ── DOM ──────────────────────────────────────────────
|
|
const grid = document.getElementById('grid');
|
|
const emptyState = document.getElementById('empty-state');
|
|
const btnOpen = document.getElementById('btn-open');
|
|
const btnSortName = document.getElementById('btn-sort-name');
|
|
const btnSortDate = document.getElementById('btn-sort-date');
|
|
const btnRename = document.getElementById('btn-rename');
|
|
const btnReveal = document.getElementById('btn-reveal');
|
|
const inputPrefix = document.getElementById('input-prefix');
|
|
const inputDigits = document.getElementById('input-digits');
|
|
const previewName = document.getElementById('preview-name');
|
|
const fileCount = document.getElementById('file-count');
|
|
const selCount = document.getElementById('sel-count');
|
|
const dirPath = document.getElementById('dir-path');
|
|
const statusDot = document.getElementById('status-dot');
|
|
const statusText = document.getElementById('status-text');
|
|
const loading = document.getElementById('loading');
|
|
const loadingText = document.getElementById('loading-text');
|
|
const toast = document.getElementById('toast');
|
|
const rubberBand = document.getElementById('rubber-band');
|
|
const gridContainer = document.getElementById('grid-container');
|
|
const dragGhostEl = document.getElementById('drag-ghost');
|
|
const thumbSizeHint = document.getElementById('thumb-size-hint');
|
|
|
|
// ── 핸들러 초기화 ─────────────────────────────────
|
|
const callbacks = {
|
|
refreshCardSelection,
|
|
updateSelCount,
|
|
updateNumberBadges,
|
|
updateNewNameLabels,
|
|
};
|
|
|
|
pinchZoom.init({ gridContainer, thumbSizeHint });
|
|
selection.init({ gridContainer, rubberBand, grid, state, callbacks });
|
|
dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks });
|
|
|
|
// ── 버튼 이벤트 ──────────────────────────────────
|
|
inputPrefix.addEventListener('input', updatePreview);
|
|
inputDigits.addEventListener('input', updatePreview);
|
|
btnOpen.addEventListener('click', openFolder);
|
|
btnSortName.addEventListener('click', () => sortFiles('name'));
|
|
btnSortDate.addEventListener('click', () => sortFiles('date'));
|
|
btnRename.addEventListener('click', doRename);
|
|
btnReveal.addEventListener('click', () => {
|
|
if (state.currentFolder) window.api.revealFolder(state.currentFolder);
|
|
});
|
|
|
|
// ── 폴더 열기 ────────────────────────────────────
|
|
async function openFolder() {
|
|
const folder = await window.api.selectFolder();
|
|
if (!folder) return;
|
|
state.currentFolder = folder;
|
|
dirPath.textContent = folder;
|
|
showLoading('이미지 목록 불러오는 중...');
|
|
const list = await window.api.listImages(folder);
|
|
hideLoading();
|
|
if (!list.length) {
|
|
setStatus('이미지 파일 없음', false);
|
|
showToast('⚠️ 이미지 파일이 없습니다', '#f76a6a', '#0a0a0a');
|
|
return;
|
|
}
|
|
state.files.length = 0;
|
|
state.files.push(...list);
|
|
state.selectedIdxs.clear();
|
|
setStatus(`${state.files.length}개 파일 로드됨`, true);
|
|
await renderGrid();
|
|
updatePreview();
|
|
btnRename.disabled = false;
|
|
btnReveal.disabled = false;
|
|
}
|
|
|
|
// ── 그리드 렌더링 ────────────────────────────────
|
|
async function renderGrid() {
|
|
grid.innerHTML = '';
|
|
emptyState.style.display = 'none';
|
|
grid.style.display = 'grid';
|
|
fileCount.textContent = `${state.files.length}개 파일`;
|
|
updateSelCount();
|
|
|
|
showLoading(`썸네일 로딩 중... (0/${state.files.length})`);
|
|
|
|
const BATCH = 10;
|
|
for (let i = 0; i < state.files.length; i += BATCH) {
|
|
const batch = state.files.slice(i, i + BATCH);
|
|
const results = await Promise.all(batch.map(f => window.api.readImage(f.path)));
|
|
results.forEach((dataUrl, bi) => {
|
|
grid.appendChild(createCard(state.files[i + bi], i + bi, dataUrl));
|
|
});
|
|
loadingText.textContent = `썸네일 로딩 중... (${Math.min(i + BATCH, state.files.length)}/${state.files.length})`;
|
|
}
|
|
|
|
hideLoading();
|
|
refreshCardSelection();
|
|
updateNumberBadges();
|
|
updateNewNameLabels();
|
|
}
|
|
|
|
// ── 카드 생성 ────────────────────────────────────
|
|
function createCard(file, idx, dataUrl) {
|
|
const card = document.createElement('div');
|
|
card.className = 'card';
|
|
card.dataset.idx = idx;
|
|
card.dataset.filepath = file.path;
|
|
card.draggable = true;
|
|
|
|
const numBadge = document.createElement('div');
|
|
numBadge.className = 'card-num';
|
|
numBadge.textContent = `#${idx + 1}`;
|
|
|
|
const checkBadge = document.createElement('div');
|
|
checkBadge.className = 'card-check';
|
|
checkBadge.textContent = '✓';
|
|
|
|
let thumb;
|
|
if (dataUrl) {
|
|
thumb = document.createElement('img');
|
|
thumb.className = 'card-thumb';
|
|
thumb.src = dataUrl;
|
|
thumb.alt = file.name;
|
|
thumb.draggable = false;
|
|
} else {
|
|
thumb = document.createElement('div');
|
|
thumb.className = 'card-thumb-placeholder';
|
|
thumb.textContent = '🖼️';
|
|
}
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'card-body';
|
|
const nameEl = document.createElement('div');
|
|
nameEl.className = 'card-name';
|
|
nameEl.title = file.name;
|
|
nameEl.textContent = file.name;
|
|
const newNameEl = document.createElement('div');
|
|
newNameEl.className = 'card-new-name';
|
|
|
|
body.appendChild(nameEl);
|
|
body.appendChild(newNameEl);
|
|
card.appendChild(numBadge);
|
|
card.appendChild(checkBadge);
|
|
card.appendChild(thumb);
|
|
card.appendChild(body);
|
|
|
|
dragDrop.setupCard(card);
|
|
card.addEventListener('click', selection.onCardClick);
|
|
|
|
return card;
|
|
}
|
|
|
|
// ── 정렬 ─────────────────────────────────────────
|
|
async function sortFiles(mode) {
|
|
if (!state.files.length) return;
|
|
if (mode === 'name') state.files.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
|
|
else state.files.sort((a, b) => a.mtime - b.mtime);
|
|
state.selectedIdxs.clear();
|
|
await renderGrid();
|
|
setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true);
|
|
}
|
|
|
|
// ── 미리보기 ─────────────────────────────────────
|
|
function updatePreview() {
|
|
const prefix = inputPrefix.value.trim() || 'photo';
|
|
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
|
|
const ext = state.files.length ? state.files[0].name.split('.').pop().toLowerCase() : 'jpg';
|
|
previewName.textContent = `${prefix}_${'1'.padStart(digits, '0')}.${ext}`;
|
|
updateNewNameLabels();
|
|
}
|
|
|
|
// ── 이름 변경 ────────────────────────────────────
|
|
async function doRename() {
|
|
if (!state.files.length || !state.currentFolder) return;
|
|
const prefix = inputPrefix.value.trim() || 'photo';
|
|
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
|
|
|
|
const examples = state.files.slice(0, 3).map((f, i) => {
|
|
const ext = f.name.split('.').pop().toLowerCase();
|
|
return `${f.name} → ${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
|
}).join('\n');
|
|
const more = state.files.length > 3 ? `\n... 외 ${state.files.length - 3}개` : '';
|
|
|
|
if (!confirm(`📂 ${state.files.length}개 파일을 이름 변경합니다:\n\n${examples}${more}\n\n계속하시겠습니까?`)) return;
|
|
|
|
showLoading('파일 이름 변경 중...');
|
|
setStatus('이름 변경 중...', false);
|
|
|
|
try {
|
|
const result = await window.api.renameFiles({
|
|
folderPath: state.currentFolder,
|
|
files: state.files,
|
|
prefix,
|
|
digits,
|
|
});
|
|
hideLoading();
|
|
if (result.errors.length) {
|
|
alert('일부 오류:\n' + result.errors.join('\n'));
|
|
} else {
|
|
showToast(`✅ ${result.renamed.length}개 파일 이름 변경 완료!`);
|
|
setStatus(`${result.renamed.length}개 파일 이름 변경 완료`, true);
|
|
}
|
|
const newList = await window.api.listImages(state.currentFolder);
|
|
state.files.length = 0;
|
|
state.files.push(...newList);
|
|
state.selectedIdxs.clear();
|
|
await renderGrid();
|
|
} catch (e) {
|
|
hideLoading();
|
|
alert('오류 발생: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// ── UI 업데이트 ──────────────────────────────────
|
|
|
|
function refreshCardSelection() {
|
|
Array.from(grid.children).forEach((card, i) => {
|
|
card.classList.toggle('selected', state.selectedIdxs.has(i));
|
|
});
|
|
}
|
|
|
|
function updateNumberBadges() {
|
|
Array.from(grid.children).forEach((card, i) => {
|
|
const badge = card.querySelector('.card-num');
|
|
if (badge) badge.textContent = `#${i + 1}`;
|
|
card.dataset.idx = i;
|
|
});
|
|
}
|
|
|
|
function updateNewNameLabels() {
|
|
const prefix = inputPrefix.value.trim() || 'photo';
|
|
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
|
|
Array.from(grid.children).forEach((card, i) => {
|
|
const el = card.querySelector('.card-new-name');
|
|
const file = state.files[i];
|
|
if (!el || !file) return;
|
|
const ext = file.name.split('.').pop().toLowerCase();
|
|
el.textContent = `→ ${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
|
});
|
|
}
|
|
|
|
function updateSelCount() {
|
|
const n = state.selectedIdxs.size;
|
|
if (n > 0) {
|
|
selCount.textContent = `${n}개 선택됨`;
|
|
selCount.classList.add('visible');
|
|
} else {
|
|
selCount.textContent = '';
|
|
selCount.classList.remove('visible');
|
|
}
|
|
}
|
|
|
|
// ── 공통 UI 헬퍼 ─────────────────────────────────
|
|
|
|
function showLoading(msg = '처리 중...') {
|
|
loadingText.textContent = msg;
|
|
loading.classList.add('show');
|
|
}
|
|
function hideLoading() {
|
|
loading.classList.remove('show');
|
|
}
|
|
function setStatus(msg, ready) {
|
|
statusText.textContent = msg;
|
|
statusDot.className = 'status-dot' + (ready ? ' ready' : '');
|
|
}
|
|
function showToast(msg, bg = '#6af7a0', color = '#0a2a1a') {
|
|
toast.textContent = msg;
|
|
toast.style.background = bg;
|
|
toast.style.color = color;
|
|
toast.classList.add('show');
|
|
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
}
|