리펙토링: Node.js 프로젝트 폴더 구조 적용
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
let _grid, _gridContainer, _dragGhostEl;
|
||||
let _state, _cb;
|
||||
|
||||
let dragMode = null;
|
||||
let dragSrcIdx = null;
|
||||
|
||||
let autoScrollRAF = null;
|
||||
let autoScrollSpeed = 0;
|
||||
let lastScrollTs = null;
|
||||
|
||||
export function init({ grid, gridContainer, dragGhostEl, state, callbacks }) {
|
||||
_grid = grid;
|
||||
_gridContainer = gridContainer;
|
||||
_dragGhostEl = dragGhostEl;
|
||||
_state = state;
|
||||
_cb = callbacks;
|
||||
|
||||
window.addEventListener('dragover', _onWindowDragOver);
|
||||
}
|
||||
|
||||
// 카드 생성 시 호출해 드래그 이벤트를 부착
|
||||
export function setupCard(card) {
|
||||
card.addEventListener('dragstart', _onDragStart);
|
||||
card.addEventListener('dragover', _onDragOver);
|
||||
card.addEventListener('dragenter', _onDragEnter);
|
||||
card.addEventListener('dragleave', _onDragLeave);
|
||||
card.addEventListener('drop', _onDrop);
|
||||
card.addEventListener('dragend', _onDragEnd);
|
||||
}
|
||||
|
||||
// ── window-level dragover: 고스트 위치 + 자동 스크롤 ──
|
||||
|
||||
function _onWindowDragOver(e) {
|
||||
if (!dragMode) return;
|
||||
if (dragMode === 'multi') _moveGhost(e);
|
||||
_trackScroll(e.clientY);
|
||||
}
|
||||
|
||||
// ── 카드 드래그 핸들러 ─────────────────────────────
|
||||
|
||||
function _onDragStart(e) {
|
||||
const idx = parseInt(this.dataset.idx);
|
||||
|
||||
if (_state.selectedIdxs.has(idx) && _state.selectedIdxs.size > 1) {
|
||||
dragMode = 'multi';
|
||||
dragSrcIdx = idx;
|
||||
_state.selectedIdxs.forEach(i => {
|
||||
const c = _getCardEl(i);
|
||||
if (c) c.classList.add('multi-dragging');
|
||||
});
|
||||
_showGhost(e, `📦 ${_state.selectedIdxs.size}개 이동 중`);
|
||||
const img = new Image();
|
||||
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
e.dataTransfer.setDragImage(img, 0, 0);
|
||||
} else {
|
||||
dragMode = 'single';
|
||||
dragSrcIdx = idx;
|
||||
this.classList.add('dragging');
|
||||
_state.selectedIdxs.clear();
|
||||
_cb.refreshCardSelection();
|
||||
}
|
||||
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(idx));
|
||||
}
|
||||
|
||||
function _onDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
function _onDragEnter(e) {
|
||||
e.preventDefault();
|
||||
const idx = parseInt(this.dataset.idx);
|
||||
const isSrc = dragMode === 'single' ? idx === dragSrcIdx : _state.selectedIdxs.has(idx);
|
||||
if (!isSrc) this.classList.add('drag-over');
|
||||
}
|
||||
|
||||
function _onDragLeave() {
|
||||
this.classList.remove('drag-over');
|
||||
}
|
||||
|
||||
function _onDrop(e) {
|
||||
e.preventDefault();
|
||||
this.classList.remove('drag-over');
|
||||
const targetIdx = parseInt(this.dataset.idx);
|
||||
|
||||
if (dragMode === 'single') {
|
||||
if (dragSrcIdx === null || dragSrcIdx === targetIdx) return;
|
||||
_moveSingle(dragSrcIdx, targetIdx);
|
||||
} else if (dragMode === 'multi') {
|
||||
if (_state.selectedIdxs.has(targetIdx)) return;
|
||||
_moveMulti(targetIdx);
|
||||
}
|
||||
}
|
||||
|
||||
function _onDragEnd() {
|
||||
_stopScroll();
|
||||
_hideGhost();
|
||||
document.querySelectorAll('.card').forEach(c =>
|
||||
c.classList.remove('dragging', 'multi-dragging', 'drag-over')
|
||||
);
|
||||
dragMode = null;
|
||||
dragSrcIdx = null;
|
||||
}
|
||||
|
||||
// ── 이동 로직 ─────────────────────────────────────
|
||||
|
||||
function _moveSingle(srcIdx, targetIdx) {
|
||||
const [moved] = _state.files.splice(srcIdx, 1);
|
||||
_state.files.splice(targetIdx, 0, moved);
|
||||
|
||||
const cards = Array.from(_grid.children);
|
||||
const src = cards[srcIdx];
|
||||
const target = cards[targetIdx];
|
||||
if (srcIdx < targetIdx) {
|
||||
_grid.insertBefore(src, target.nextSibling);
|
||||
} else {
|
||||
_grid.insertBefore(src, target);
|
||||
}
|
||||
Array.from(_grid.children).forEach((c, i) => { c.dataset.idx = i; });
|
||||
|
||||
_cb.updateNumberBadges();
|
||||
_cb.updateNewNameLabels();
|
||||
}
|
||||
|
||||
function _moveMulti(targetIdx) {
|
||||
const sorted = [..._state.selectedIdxs].sort((a, b) => a - b);
|
||||
const movedFiles = sorted.map(i => _state.files[i]);
|
||||
|
||||
const removedBefore = sorted.filter(i => i < targetIdx).length;
|
||||
const adjustedTarget = targetIdx - removedBefore;
|
||||
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
_state.files.splice(sorted[i], 1);
|
||||
}
|
||||
_state.files.splice(adjustedTarget, 0, ...movedFiles);
|
||||
|
||||
const newSel = new Set();
|
||||
for (let i = 0; i < movedFiles.length; i++) newSel.add(adjustedTarget + i);
|
||||
_state.selectedIdxs = newSel;
|
||||
|
||||
// DOM 재정렬 (이미지 재로딩 없이)
|
||||
const byPath = new Map();
|
||||
Array.from(_grid.children).forEach(c => byPath.set(c.dataset.filepath, c));
|
||||
_grid.innerHTML = '';
|
||||
_state.files.forEach((f, idx) => {
|
||||
const c = byPath.get(f.path);
|
||||
if (c) { c.dataset.idx = idx; _grid.appendChild(c); }
|
||||
});
|
||||
|
||||
_cb.updateNumberBadges();
|
||||
_cb.updateNewNameLabels();
|
||||
_cb.refreshCardSelection();
|
||||
_cb.updateSelCount();
|
||||
}
|
||||
|
||||
// ── 자동 스크롤 ───────────────────────────────────
|
||||
|
||||
function _trackScroll(clientY) {
|
||||
const vh = window.innerHeight;
|
||||
const topLine = vh * 0.1;
|
||||
const botLine = vh * 0.9;
|
||||
|
||||
if (clientY < topLine) {
|
||||
const ratio = clientY / topLine;
|
||||
_startScroll(-vh * (1 - 0.8 * ratio));
|
||||
} else if (clientY > botLine) {
|
||||
const ratio = (clientY - botLine) / (vh * 0.1);
|
||||
_startScroll(vh * (0.2 + 0.8 * ratio));
|
||||
} else {
|
||||
_stopScroll();
|
||||
}
|
||||
}
|
||||
|
||||
function _startScroll(speed) {
|
||||
autoScrollSpeed = speed;
|
||||
if (!autoScrollRAF) {
|
||||
lastScrollTs = performance.now();
|
||||
autoScrollRAF = requestAnimationFrame(_tickScroll);
|
||||
}
|
||||
}
|
||||
|
||||
function _stopScroll() {
|
||||
autoScrollSpeed = 0;
|
||||
if (autoScrollRAF) {
|
||||
cancelAnimationFrame(autoScrollRAF);
|
||||
autoScrollRAF = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _tickScroll(now) {
|
||||
if (!dragMode) { _stopScroll(); return; }
|
||||
const dt = (now - lastScrollTs) / 1000;
|
||||
lastScrollTs = now;
|
||||
_gridContainer.scrollTop += autoScrollSpeed * dt;
|
||||
autoScrollRAF = requestAnimationFrame(_tickScroll);
|
||||
}
|
||||
|
||||
// ── 드래그 고스트 ─────────────────────────────────
|
||||
|
||||
function _showGhost(e, text) {
|
||||
_dragGhostEl.textContent = text;
|
||||
_dragGhostEl.style.display = 'block';
|
||||
_moveGhost(e);
|
||||
}
|
||||
|
||||
function _moveGhost(e) {
|
||||
_dragGhostEl.style.left = (e.clientX + 16) + 'px';
|
||||
_dragGhostEl.style.top = (e.clientY - 14) + 'px';
|
||||
}
|
||||
|
||||
function _hideGhost() {
|
||||
_dragGhostEl.style.display = 'none';
|
||||
}
|
||||
|
||||
function _getCardEl(idx) {
|
||||
return _grid.querySelector(`.card[data-idx="${idx}"]`);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
const THUMB_MIN = 150;
|
||||
const THUMB_MAX = 400;
|
||||
|
||||
let _thumbSizeHint;
|
||||
let thumbSize = 250;
|
||||
let thumbHintTimer = null;
|
||||
|
||||
export function init({ gridContainer, thumbSizeHint }) {
|
||||
_thumbSizeHint = thumbSizeHint;
|
||||
|
||||
gridContainer.addEventListener('wheel', (e) => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
setThumbSize(thumbSize + (-e.deltaY * 0.8));
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
export function setThumbSize(size) {
|
||||
thumbSize = Math.round(Math.max(THUMB_MIN, Math.min(THUMB_MAX, size)));
|
||||
document.documentElement.style.setProperty('--thumb-size', thumbSize + 'px');
|
||||
_showHint(thumbSize);
|
||||
}
|
||||
|
||||
function _showHint(size) {
|
||||
_thumbSizeHint.textContent = `${size}px`;
|
||||
_thumbSizeHint.classList.add('show');
|
||||
clearTimeout(thumbHintTimer);
|
||||
thumbHintTimer = setTimeout(() => _thumbSizeHint.classList.remove('show'), 1000);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
let _rubberBand, _grid;
|
||||
let _state, _cb;
|
||||
|
||||
let rbActive = false;
|
||||
let rbStartX = 0;
|
||||
let rbStartY = 0;
|
||||
|
||||
export function init({ gridContainer, rubberBand, grid, state, callbacks }) {
|
||||
_rubberBand = rubberBand;
|
||||
_grid = grid;
|
||||
_state = state;
|
||||
_cb = callbacks;
|
||||
|
||||
gridContainer.addEventListener('mousedown', _onStart);
|
||||
window.addEventListener('mousemove', _onMove);
|
||||
window.addEventListener('mouseup', _onEnd);
|
||||
}
|
||||
|
||||
// 카드에 부착할 cmd+클릭 핸들러 (this = card element)
|
||||
export function onCardClick(e) {
|
||||
if (!e.metaKey) return;
|
||||
e.stopPropagation();
|
||||
const idx = parseInt(this.dataset.idx);
|
||||
if (_state.selectedIdxs.has(idx)) {
|
||||
_state.selectedIdxs.delete(idx);
|
||||
} else {
|
||||
_state.selectedIdxs.add(idx);
|
||||
}
|
||||
_cb.refreshCardSelection();
|
||||
_cb.updateSelCount();
|
||||
}
|
||||
|
||||
function _onStart(e) {
|
||||
if (e.target.closest('.card')) return;
|
||||
if (e.target.closest('button, input, label')) return;
|
||||
if (e.button !== 0) return;
|
||||
|
||||
rbActive = true;
|
||||
rbStartX = e.clientX;
|
||||
rbStartY = e.clientY;
|
||||
|
||||
Object.assign(_rubberBand.style, {
|
||||
display: 'block',
|
||||
left: rbStartX + 'px',
|
||||
top: rbStartY + 'px',
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
});
|
||||
|
||||
if (!e.shiftKey) {
|
||||
_state.selectedIdxs.clear();
|
||||
_cb.refreshCardSelection();
|
||||
}
|
||||
}
|
||||
|
||||
function _onMove(e) {
|
||||
if (!rbActive) return;
|
||||
|
||||
const left = Math.min(rbStartX, e.clientX);
|
||||
const top = Math.min(rbStartY, e.clientY);
|
||||
const width = Math.abs(e.clientX - rbStartX);
|
||||
const height = Math.abs(e.clientY - rbStartY);
|
||||
|
||||
Object.assign(_rubberBand.style, {
|
||||
left: left + 'px', top: top + 'px',
|
||||
width: width + 'px', height: height + 'px',
|
||||
});
|
||||
|
||||
const sel = { left, top, right: left + width, bottom: top + height };
|
||||
Array.from(_grid.children).forEach((card, i) => {
|
||||
const r = card.getBoundingClientRect();
|
||||
const overlaps = !(r.right < sel.left || r.left > sel.right ||
|
||||
r.bottom < sel.top || r.top > sel.bottom);
|
||||
if (overlaps) {
|
||||
_state.selectedIdxs.add(i);
|
||||
} else if (!e.shiftKey) {
|
||||
_state.selectedIdxs.delete(i);
|
||||
}
|
||||
});
|
||||
|
||||
_cb.refreshCardSelection();
|
||||
_cb.updateSelCount();
|
||||
}
|
||||
|
||||
function _onEnd() {
|
||||
if (!rbActive) return;
|
||||
rbActive = false;
|
||||
_rubberBand.style.display = 'none';
|
||||
_cb.updateSelCount();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Photo Renamer</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="titlebar">
|
||||
<h1>📷 Photo Renamer</h1>
|
||||
<span class="dir-path" id="dir-path">폴더를 선택하세요</span>
|
||||
</div>
|
||||
|
||||
<div id="toolbar">
|
||||
<button class="btn btn-primary" id="btn-open">📁 폴더 열기</button>
|
||||
<div class="separator"></div>
|
||||
<button class="btn btn-ghost" id="btn-sort-name">이름순 정렬</button>
|
||||
<button class="btn btn-ghost" id="btn-sort-date">날짜순 정렬</button>
|
||||
<div class="separator"></div>
|
||||
<button class="btn btn-success" id="btn-rename" disabled>✅ 이름 변경 실행</button>
|
||||
<button class="btn btn-ghost" id="btn-reveal" style="margin-left:auto;" disabled>Finder에서 열기</button>
|
||||
</div>
|
||||
|
||||
<div id="format-bar">
|
||||
<label>파일명 포맷</label>
|
||||
<input type="text" id="input-prefix" value="photo" placeholder="접두사" />
|
||||
<span class="format-sep">_</span>
|
||||
<input type="number" id="input-digits" value="3" min="1" max="6" />
|
||||
<span class="format-sep">자리 순번</span>
|
||||
<span style="color:var(--muted);font-size:12px;">→</span>
|
||||
<span id="preview-name">photo_001.jpg</span>
|
||||
<span id="sel-count"></span>
|
||||
<span id="file-count">0개 파일</span>
|
||||
</div>
|
||||
|
||||
<div id="grid-container">
|
||||
<div id="empty-state">
|
||||
<div class="icon">🗂️</div>
|
||||
<p>폴더를 열어 이미지를 불러오세요<br>
|
||||
<span style="font-size:12px;opacity:.6;">JPG · PNG · GIF · BMP · TIFF · WEBP · HEIC</span></p>
|
||||
</div>
|
||||
<div id="grid" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="statusbar">
|
||||
<div class="status-dot" id="status-dot"></div>
|
||||
<span id="status-text">준비</span>
|
||||
</div>
|
||||
|
||||
<div id="rubber-band"></div>
|
||||
<div id="drag-ghost"></div>
|
||||
<div id="thumb-size-hint"></div>
|
||||
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<p id="loading-text">이미지 로딩 중...</p>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script type="module" src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,273 @@
|
||||
import { state } from './state.js';
|
||||
import * as pinchZoom from './handlers/pinch-zoom.js';
|
||||
import * as selection from './handlers/selection.js';
|
||||
import * as dragDrop from './handlers/drag-drop.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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const state = {
|
||||
files: [],
|
||||
currentFolder: null,
|
||||
selectedIdxs: new Set(),
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #0f0f17;
|
||||
--surface: #17172a;
|
||||
--card: #1e1e34;
|
||||
--border: #2e2e50;
|
||||
--accent: #7c6af7;
|
||||
--accent2: #e96af7;
|
||||
--success: #6af7a0;
|
||||
--text: #e8e8f0;
|
||||
--muted: #6b6b8a;
|
||||
--drag-over: #3a2e70;
|
||||
--sel-bg: rgba(124,106,247,0.22);
|
||||
--sel-border: #7c6af7;
|
||||
--thumb-size: 250px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Instrument Sans', -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* ── 타이틀바 ── */
|
||||
#titlebar {
|
||||
height: 44px;
|
||||
background: var(--bg);
|
||||
-webkit-app-region: drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 80px 0 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#titlebar h1 {
|
||||
font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
letter-spacing: .08em; text-transform: uppercase;
|
||||
}
|
||||
#titlebar .dir-path {
|
||||
margin-left: 16px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px; color: var(--muted); opacity: .7;
|
||||
max-width: 600px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── 툴바 ── */
|
||||
#toolbar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: 8px; border: none;
|
||||
font-family: inherit; font-size: 13px; font-weight: 600;
|
||||
cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: #9180ff; transform: translateY(-1px); }
|
||||
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
||||
.btn-ghost:hover { background: var(--card); color: var(--text); }
|
||||
.btn-success { background: #1a4a2e; color: var(--success); border: 1px solid #2a6a40; }
|
||||
.btn-success:hover { background: #204a34; }
|
||||
.btn-success:disabled { opacity: .4; cursor: not-allowed; transform: none; }
|
||||
.separator { width: 1px; height: 24px; background: var(--border); margin: 0 4px; }
|
||||
|
||||
/* ── 포맷 바 ── */
|
||||
#format-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#format-bar label {
|
||||
font-size: 12px; color: var(--muted); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: .06em;
|
||||
}
|
||||
#format-bar input {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
color: var(--text); font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px; padding: 5px 10px; border-radius: 6px; outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
#format-bar input:focus { border-color: var(--accent); }
|
||||
#input-prefix { width: 160px; }
|
||||
#input-digits { width: 50px; text-align: center; }
|
||||
.format-sep { color: var(--muted); font-family: 'JetBrains Mono', monospace; font-size: 14px; }
|
||||
#preview-name {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 13px;
|
||||
color: var(--accent2); background: rgba(124,106,247,.1);
|
||||
padding: 4px 12px; border-radius: 6px; border: 1px solid rgba(124,106,247,.2);
|
||||
}
|
||||
#file-count { margin-left: auto; font-size: 12px; color: var(--muted); }
|
||||
#sel-count { font-size: 12px; color: var(--accent); font-weight: 700; display: none; }
|
||||
#sel-count.visible { display: inline; }
|
||||
|
||||
/* ── 그리드 컨테이너 ── */
|
||||
#grid-container {
|
||||
flex: 1; overflow-y: auto; padding: 20px;
|
||||
position: relative;
|
||||
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
#grid-container::-webkit-scrollbar { width: 6px; }
|
||||
#grid-container::-webkit-scrollbar-track { background: transparent; }
|
||||
#grid-container::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
/* ── 그리드: 고정 열 폭 (--thumb-size) ── */
|
||||
#grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, var(--thumb-size));
|
||||
gap: 14px;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
#empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; height: 100%; gap: 16px; color: var(--muted);
|
||||
}
|
||||
#empty-state .icon { font-size: 64px; opacity: .3; }
|
||||
#empty-state p { font-size: 15px; text-align: center; line-height: 1.6; }
|
||||
|
||||
/* ── 카드 ── */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
transition: border-color .15s, transform .15s, box-shadow .15s, background .15s;
|
||||
position: relative;
|
||||
width: var(--thumb-size);
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--border);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||
}
|
||||
.card.selected {
|
||||
border-color: var(--sel-border) !important;
|
||||
background: var(--sel-bg);
|
||||
box-shadow: 0 0 0 1px var(--sel-border), 0 8px 24px rgba(124,106,247,.2);
|
||||
}
|
||||
.card.dragging,
|
||||
.card.multi-dragging { opacity: .3; transform: scale(.96); cursor: grabbing; }
|
||||
.card.drag-over { border-color: var(--accent) !important; background: var(--drag-over); }
|
||||
|
||||
/* ── 썸네일: 정사각형 + object-fit: contain ── */
|
||||
.card-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: contain;
|
||||
background: #08080f;
|
||||
display: block;
|
||||
}
|
||||
.card-thumb-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: #08080f;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 52px; opacity: .25;
|
||||
}
|
||||
|
||||
.card-body { padding: 8px 10px; }
|
||||
|
||||
.card-num {
|
||||
position: absolute; top: 8px; left: 8px;
|
||||
background: var(--accent); color: #fff;
|
||||
font-size: 11px; font-weight: 700;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
padding: 2px 7px; border-radius: 99px; line-height: 1.6;
|
||||
pointer-events: none; z-index: 2;
|
||||
}
|
||||
.card-check {
|
||||
position: absolute; top: 8px; right: 8px;
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: var(--accent); color: #fff;
|
||||
font-size: 13px; display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; transition: opacity .15s;
|
||||
pointer-events: none; z-index: 2;
|
||||
}
|
||||
.card.selected .card-check { opacity: 1; }
|
||||
|
||||
.card-name {
|
||||
font-size: 11px; color: var(--muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.card-new-name {
|
||||
font-size: 11px; color: var(--accent2);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Rubber-band 선택 사각형 ── */
|
||||
#rubber-band {
|
||||
position: fixed;
|
||||
border: 1.5px solid var(--accent);
|
||||
background: rgba(124,106,247,.1);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
/* ── 다중 드래그 고스트 배지 ── */
|
||||
#drag-ghost {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 13px; font-weight: 700;
|
||||
font-family: 'Instrument Sans', sans-serif;
|
||||
padding: 6px 16px;
|
||||
border-radius: 99px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.5);
|
||||
}
|
||||
|
||||
/* ── 상태바 ── */
|
||||
#statusbar {
|
||||
height: 28px; background: var(--bg);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex; align-items: center; padding: 0 16px; gap: 12px; flex-shrink: 0;
|
||||
}
|
||||
#statusbar span { font-size: 11px; color: var(--muted); }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }
|
||||
.status-dot.ready { background: var(--success); }
|
||||
|
||||
/* ── 핀치 크기 힌트 ── */
|
||||
#thumb-size-hint {
|
||||
position: fixed; bottom: 40px; right: 24px;
|
||||
background: rgba(124,106,247,.9);
|
||||
color: #fff; font-size: 12px; font-weight: 700;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
padding: 5px 12px; border-radius: 8px;
|
||||
opacity: 0; transition: opacity .2s;
|
||||
pointer-events: none; z-index: 500;
|
||||
}
|
||||
#thumb-size-hint.show { opacity: 1; }
|
||||
|
||||
/* ── 로딩 ── */
|
||||
#loading {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(15,15,23,.85); backdrop-filter: blur(4px);
|
||||
z-index: 100; align-items: center; justify-content: center;
|
||||
flex-direction: column; gap: 16px;
|
||||
}
|
||||
#loading.show { display: flex; }
|
||||
.spinner {
|
||||
width: 40px; height: 40px;
|
||||
border: 3px solid var(--border); border-top-color: var(--accent);
|
||||
border-radius: 50%; animation: spin .8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#loading p { color: var(--text); font-size: 14px; }
|
||||
|
||||
/* ── 토스트 ── */
|
||||
#toast {
|
||||
position: fixed; bottom: 40px; left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--success); color: #0a2a1a;
|
||||
font-weight: 700; font-size: 14px;
|
||||
padding: 12px 24px; border-radius: 12px;
|
||||
opacity: 0; transition: all .3s; pointer-events: none; z-index: 200;
|
||||
}
|
||||
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
Reference in New Issue
Block a user