리펙토링 - 전체 프로젝트 코드 (Codex)
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.heic', '.heif']);
|
||||
|
||||
function listImages(folderPath) {
|
||||
return fs.readdirSync(folderPath)
|
||||
.filter(fileName => IMAGE_EXTENSIONS.has(path.extname(fileName).toLowerCase()))
|
||||
.map(fileName => {
|
||||
const fullPath = path.join(folderPath, fileName);
|
||||
const stat = fs.statSync(fullPath);
|
||||
return { name: fileName, path: fullPath, mtime: stat.mtimeMs };
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
|
||||
}
|
||||
|
||||
function readImageAsDataUrl(filePath) {
|
||||
const data = fs.readFileSync(filePath);
|
||||
const mime = getImageMime(path.extname(filePath));
|
||||
return `data:image/${mime};base64,${data.toString('base64')}`;
|
||||
}
|
||||
|
||||
function getImageMime(extName) {
|
||||
const ext = extName.toLowerCase().replace('.', '');
|
||||
if (ext === 'jpg' || ext === 'jpeg') return 'jpeg';
|
||||
if (ext === 'png') return 'png';
|
||||
if (ext === 'gif') return 'gif';
|
||||
if (ext === 'webp') return 'webp';
|
||||
if (ext === 'tiff' || ext === 'tif') return 'tiff';
|
||||
if (ext === 'bmp') return 'bmp';
|
||||
if (ext === 'heic' || ext === 'heif') return 'heic';
|
||||
return 'jpeg';
|
||||
}
|
||||
|
||||
function fileExists(filePath) {
|
||||
try {
|
||||
fs.accessSync(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function moveFileWithCopyFallback(src, dest) {
|
||||
try {
|
||||
fs.renameSync(src, dest);
|
||||
} catch (e) {
|
||||
if (e.code !== 'EXDEV') throw e;
|
||||
fs.copyFileSync(src, dest);
|
||||
fs.unlinkSync(src);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fileExists,
|
||||
listImages,
|
||||
moveFileWithCopyFallback,
|
||||
readImageAsDataUrl,
|
||||
};
|
||||
+14
-53
@@ -2,21 +2,17 @@ const { ipcMain, app } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.heic', '.heif']);
|
||||
const {
|
||||
fileExists,
|
||||
listImages,
|
||||
moveFileWithCopyFallback,
|
||||
readImageAsDataUrl,
|
||||
} = require('./file-utils');
|
||||
|
||||
function register() {
|
||||
ipcMain.handle('list-images', async (_event, folderPath) => {
|
||||
try {
|
||||
const files = fs.readdirSync(folderPath)
|
||||
.filter(f => EXTS.has(path.extname(f).toLowerCase()))
|
||||
.map(f => {
|
||||
const fullPath = path.join(folderPath, f);
|
||||
const stat = fs.statSync(fullPath);
|
||||
return { name: f, path: fullPath, mtime: stat.mtimeMs };
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
|
||||
return files;
|
||||
return listImages(folderPath);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
@@ -24,27 +20,14 @@ function register() {
|
||||
|
||||
ipcMain.handle('read-image', async (_event, filePath) => {
|
||||
try {
|
||||
const data = fs.readFileSync(filePath);
|
||||
const ext = path.extname(filePath).toLowerCase().replace('.', '');
|
||||
const mime = (ext === 'jpg' || ext === 'jpeg') ? 'jpeg'
|
||||
: ext === 'png' ? 'png'
|
||||
: ext === 'gif' ? 'gif'
|
||||
: ext === 'webp' ? 'webp'
|
||||
: (ext === 'tiff' || ext === 'tif') ? 'tiff'
|
||||
: ext === 'bmp' ? 'bmp'
|
||||
: (ext === 'heic' || ext === 'heif') ? 'heic'
|
||||
: 'jpeg';
|
||||
return `data:image/${mime};base64,${data.toString('base64')}`;
|
||||
return readImageAsDataUrl(filePath);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('check-files-exist', (_event, folderPath, names) => {
|
||||
return names.filter(name => {
|
||||
try { fs.accessSync(path.join(folderPath, name)); return true; }
|
||||
catch { return false; }
|
||||
});
|
||||
return names.filter(name => fileExists(path.join(folderPath, name)));
|
||||
});
|
||||
|
||||
ipcMain.handle('move-files', async (_event, { targetFolder, files }) => {
|
||||
@@ -54,22 +37,12 @@ function register() {
|
||||
const destName = file.newName || file.name;
|
||||
const dest = path.join(targetFolder, destName);
|
||||
try {
|
||||
fs.renameSync(file.path, dest);
|
||||
moveFileWithCopyFallback(file.path, dest);
|
||||
moved.push({ src: file.path, dest, name: destName });
|
||||
} catch (e) {
|
||||
if (e.code === 'EXDEV') {
|
||||
try {
|
||||
fs.copyFileSync(file.path, dest);
|
||||
fs.unlinkSync(file.path);
|
||||
moved.push({ src: file.path, dest, name: destName });
|
||||
} catch (e2) {
|
||||
errors.push(`${file.name}: ${e2.message}`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`${file.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { moved, errors };
|
||||
});
|
||||
|
||||
@@ -84,16 +57,10 @@ function register() {
|
||||
const name = path.basename(fp);
|
||||
const dest = path.join(batchDir, name);
|
||||
try {
|
||||
fs.renameSync(fp, dest);
|
||||
moveFileWithCopyFallback(fp, dest);
|
||||
staged.push({ name, originalPath: fp, stagingPath: dest });
|
||||
} catch (e) {
|
||||
if (e.code === 'EXDEV') {
|
||||
try {
|
||||
fs.copyFileSync(fp, dest);
|
||||
fs.unlinkSync(fp);
|
||||
staged.push({ name, originalPath: fp, stagingPath: dest });
|
||||
} catch (e2) { errors.push(`${name}: ${e2.message}`); }
|
||||
} else { errors.push(`${name}: ${e.message}`); }
|
||||
errors.push(`${name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (!staged.length) try { fs.rmdirSync(batchDir); } catch {}
|
||||
@@ -111,16 +78,10 @@ function register() {
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(originalPath), { recursive: true });
|
||||
fs.renameSync(stagingPath, originalPath);
|
||||
moveFileWithCopyFallback(stagingPath, originalPath);
|
||||
restored.push(originalPath);
|
||||
} catch (e) {
|
||||
if (e.code === 'EXDEV') {
|
||||
try {
|
||||
fs.copyFileSync(stagingPath, originalPath);
|
||||
fs.unlinkSync(stagingPath);
|
||||
restored.push(originalPath);
|
||||
} catch (e2) { errors.push(`${name}: ${e2.message}`); }
|
||||
} else { errors.push(`${name}: ${e.message}`); }
|
||||
errors.push(`${name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return { restored, errors };
|
||||
|
||||
+3
-10
@@ -3,6 +3,7 @@ import { setStatus, showToast } from './ui.js';
|
||||
import { renderGrid, showEmptyGrid } from './grid.js';
|
||||
import * as history from './history.js';
|
||||
import { openModal, closeModal } from './modal.js';
|
||||
import { renderTextList } from './utils/dom.js';
|
||||
|
||||
const deleteModal = document.getElementById('delete-modal');
|
||||
const deleteFileList = document.getElementById('delete-file-list');
|
||||
@@ -28,11 +29,7 @@ export function deleteSelected() {
|
||||
const filesToDelete = [...state.selectedIdxs].sort((a, b) => a - b)
|
||||
.map(i => state.files[i]).filter(Boolean);
|
||||
if (!filesToDelete.length) return;
|
||||
deleteFileList.replaceChildren(...filesToDelete.map(f => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = f.name;
|
||||
return li;
|
||||
}));
|
||||
renderTextList(deleteFileList, filesToDelete.map(f => f.name));
|
||||
_pendingFiles = filesToDelete;
|
||||
openModal(deleteModal);
|
||||
}
|
||||
@@ -56,11 +53,7 @@ export function init(gridEl) {
|
||||
|
||||
if (!filesToDelete.length) return;
|
||||
|
||||
deleteFileList.replaceChildren(...filesToDelete.map(f => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = f.name;
|
||||
return li;
|
||||
}));
|
||||
renderTextList(deleteFileList, filesToDelete.map(f => f.name));
|
||||
_pendingFiles = filesToDelete;
|
||||
openModal(deleteModal);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { state } from './state.js';
|
||||
import { showLoading, hideLoading, setStatus } from './ui.js';
|
||||
import { renderGrid, showEmptyGrid } from './grid.js';
|
||||
import * as history from './history.js';
|
||||
import * as fileExplorer from './handlers/file-explorer.js';
|
||||
|
||||
export function createFolderController({
|
||||
btnRename,
|
||||
btnSortName,
|
||||
btnSortDate,
|
||||
dirPath,
|
||||
inputPrefix,
|
||||
updatePreview,
|
||||
}) {
|
||||
let homeDir = null;
|
||||
|
||||
async function openFolder() {
|
||||
const folder = await window.api.selectFolder();
|
||||
if (!folder) return;
|
||||
|
||||
if (folder.startsWith(homeDir)) {
|
||||
await fileExplorer.loadTree(homeDir, folder);
|
||||
} else {
|
||||
await fileExplorer.loadTree(folder, folder);
|
||||
}
|
||||
await loadFolder(folder);
|
||||
}
|
||||
|
||||
async function loadFolder(folder) {
|
||||
if (!folder) return;
|
||||
state.currentFolder = folder;
|
||||
dirPath.textContent = folder;
|
||||
await window.api.setLastFolder(folder);
|
||||
await fileExplorer.setSelectedPath(folder);
|
||||
|
||||
showLoading('이미지 목록 불러오는 중...');
|
||||
const list = await window.api.listImages(folder);
|
||||
hideLoading();
|
||||
|
||||
if (!list.length) {
|
||||
state.files.length = 0;
|
||||
state.selectedIdxs.clear();
|
||||
showEmptyGrid();
|
||||
btnRename.disabled = true;
|
||||
setStatus('이미지 파일 없음', false);
|
||||
return;
|
||||
}
|
||||
|
||||
state.files.length = 0;
|
||||
state.files.push(...list);
|
||||
state.selectedIdxs.clear();
|
||||
state.sortMode = 'name';
|
||||
updateSortButtons();
|
||||
setStatus(`${state.files.length}개 파일 로드됨`, true);
|
||||
|
||||
const folderName = folder.replace(/\\/g, '/').split('/').pop();
|
||||
if (folderName) inputPrefix.value = folderName;
|
||||
|
||||
renderGrid();
|
||||
updatePreview();
|
||||
btnRename.disabled = false;
|
||||
history.reset();
|
||||
history.pushReorder(state.files);
|
||||
}
|
||||
|
||||
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.sortMode = mode;
|
||||
state.selectedIdxs.clear();
|
||||
updateSortButtons();
|
||||
await renderGrid();
|
||||
setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true);
|
||||
history.pushReorder(state.files);
|
||||
}
|
||||
|
||||
function updateSortButtons() {
|
||||
btnSortName.classList.toggle('active', state.sortMode === 'name');
|
||||
btnSortDate.classList.toggle('active', state.sortMode === 'date');
|
||||
}
|
||||
|
||||
return {
|
||||
loadFolder,
|
||||
openFolder,
|
||||
setHomeDir: value => { homeDir = value; },
|
||||
sortFiles,
|
||||
updateSortButtons,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { state } from './state.js';
|
||||
import * as dragDrop from './handlers/drag-drop.js';
|
||||
import * as hoverPreview from './handlers/image-preview.js';
|
||||
import * as selection from './handlers/selection.js';
|
||||
import { buildSequentialName, getNameFormat } from './utils/file-names.js';
|
||||
|
||||
const grid = document.getElementById('grid');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
@@ -110,14 +111,12 @@ export function updateNumberBadges() {
|
||||
}
|
||||
|
||||
export function updateNewNameLabels() {
|
||||
const prefix = inputPrefix.value.trim() || 'photo';
|
||||
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
|
||||
const format = getNameFormat(inputPrefix, inputDigits);
|
||||
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}`;
|
||||
el.textContent = `→ ${buildSequentialName(file.name, i, format)}`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
let _grid, _gridContainer, _dragGhostEl, _fileExplorerEl;
|
||||
import { createAutoScroller } from './drag/auto-scroll.js';
|
||||
import { createDragGhost } from './drag/ghost.js';
|
||||
|
||||
let _grid, _gridContainer, _fileExplorerEl;
|
||||
let _state, _cb, _onFolderDrop;
|
||||
let _dropSeparator = null;
|
||||
let _originPlaceholders = [];
|
||||
let _dropTargetIdx = null;
|
||||
let _autoScroller = null;
|
||||
let _ghost = null;
|
||||
|
||||
let dragMode = null;
|
||||
let dragSrcIdx = null;
|
||||
let _hoveredTreeRow = null;
|
||||
|
||||
let autoScrollRAF = null;
|
||||
let autoScrollSpeed = 0;
|
||||
let lastScrollTs = null;
|
||||
|
||||
export function init({ grid, gridContainer, dragGhostEl, state, callbacks, fileExplorerEl, onFolderDrop }) {
|
||||
_grid = grid;
|
||||
_gridContainer = gridContainer;
|
||||
_dragGhostEl = dragGhostEl;
|
||||
_state = state;
|
||||
_cb = callbacks;
|
||||
_fileExplorerEl = fileExplorerEl;
|
||||
_onFolderDrop = onFolderDrop;
|
||||
_autoScroller = createAutoScroller(_gridContainer, () => Boolean(dragMode));
|
||||
_ghost = createDragGhost(dragGhostEl);
|
||||
|
||||
_dropSeparator = document.createElement('div');
|
||||
_dropSeparator.className = 'drop-separator';
|
||||
@@ -49,8 +51,8 @@ export function setupCard(card) {
|
||||
|
||||
function _onWindowDragOver(e) {
|
||||
if (!dragMode) return;
|
||||
_moveGhost(e);
|
||||
_trackScroll(e.clientY);
|
||||
_ghost.move(e);
|
||||
_autoScroller.track(e.clientY);
|
||||
}
|
||||
|
||||
// ── 카드 드래그 핸들러 ─────────────────────────────
|
||||
@@ -68,7 +70,7 @@ function _onDragStart(e) {
|
||||
const img = new Image();
|
||||
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
e.dataTransfer.setDragImage(img, 0, 0);
|
||||
_showGhost(e, `📦 ${count}개 이동 중`);
|
||||
_ghost.show(e, `📦 ${count}개 이동 중`);
|
||||
|
||||
// 브라우저가 드래그 이미지를 캡처한 뒤 카드 숨기고 원본 위치 표시
|
||||
requestAnimationFrame(() => {
|
||||
@@ -136,8 +138,8 @@ function _performDrop() {
|
||||
}
|
||||
|
||||
function _onDragEnd() {
|
||||
_stopScroll();
|
||||
_hideGhost();
|
||||
_autoScroller.stop();
|
||||
_ghost.hide();
|
||||
_hideDropSeparator();
|
||||
_clearTreeHover();
|
||||
_clearOriginPlaceholders();
|
||||
@@ -163,19 +165,19 @@ function _onExplorerDragOver(e) {
|
||||
if (_hoveredTreeRow) _hoveredTreeRow.classList.add('drop-target');
|
||||
}
|
||||
|
||||
if (dragMode === 'single' && row && _dragGhostEl.style.display === 'none') {
|
||||
_showGhost(e, '📁 1개 폴더로 이동');
|
||||
if (dragMode === 'single' && row && _ghost.isHidden()) {
|
||||
_ghost.show(e, '📁 1개 폴더로 이동');
|
||||
}
|
||||
if (dragMode === 'multi' && row) {
|
||||
_dragGhostEl.textContent = `📁 ${_state.selectedIdxs.size}개 폴더로 이동`;
|
||||
_ghost.setText(`📁 ${_state.selectedIdxs.size}개 폴더로 이동`);
|
||||
}
|
||||
}
|
||||
|
||||
function _onExplorerDragLeave(e) {
|
||||
if (!_fileExplorerEl.contains(e.relatedTarget)) {
|
||||
_clearTreeHover();
|
||||
if (dragMode === 'single') _hideGhost();
|
||||
if (dragMode === 'multi') _dragGhostEl.textContent = `📦 ${_state.selectedIdxs.size}개 이동 중`;
|
||||
if (dragMode === 'single') _ghost.hide();
|
||||
if (dragMode === 'multi') _ghost.setText(`📦 ${_state.selectedIdxs.size}개 이동 중`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,65 +289,6 @@ function _moveMulti(targetIdx) {
|
||||
_cb.pushHistory?.(movedFiles.map(f => f.path));
|
||||
}
|
||||
|
||||
// ── 자동 스크롤 ───────────────────────────────────
|
||||
|
||||
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,51 @@
|
||||
export function createAutoScroller(container, isActive) {
|
||||
let frameId = null;
|
||||
let speed = 0;
|
||||
let lastTs = null;
|
||||
|
||||
function track(clientY) {
|
||||
const vh = window.innerHeight;
|
||||
const topLine = vh * 0.1;
|
||||
const botLine = vh * 0.9;
|
||||
|
||||
if (clientY < topLine) {
|
||||
const ratio = clientY / topLine;
|
||||
start(-vh * (1 - 0.8 * ratio));
|
||||
} else if (clientY > botLine) {
|
||||
const ratio = (clientY - botLine) / (vh * 0.1);
|
||||
start(vh * (0.2 + 0.8 * ratio));
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
function start(nextSpeed) {
|
||||
speed = nextSpeed;
|
||||
if (!frameId) {
|
||||
lastTs = performance.now();
|
||||
frameId = requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
speed = 0;
|
||||
if (frameId) {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function tick(now) {
|
||||
if (!isActive()) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = (now - lastTs) / 1000;
|
||||
lastTs = now;
|
||||
container.scrollTop += speed * dt;
|
||||
frameId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
return { track, stop };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function createDragGhost(el) {
|
||||
function show(e, text) {
|
||||
el.textContent = text;
|
||||
el.style.display = 'block';
|
||||
move(e);
|
||||
}
|
||||
|
||||
function move(e) {
|
||||
el.style.left = `${e.clientX + 16}px`;
|
||||
el.style.top = `${e.clientY - 14}px`;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
|
||||
function setText(text) {
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
function isHidden() {
|
||||
return el.style.display === 'none';
|
||||
}
|
||||
|
||||
return { hide, isHidden, move, setText, show };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { state } from './state.js';
|
||||
import { setStatus, showToast } from './ui.js';
|
||||
import { renderGrid, showEmptyGrid } from './grid.js';
|
||||
import * as history from './history.js';
|
||||
|
||||
export function createHistoryController({
|
||||
btnRename,
|
||||
btnUndo,
|
||||
btnRedo,
|
||||
updateSortButtons,
|
||||
}) {
|
||||
let busy = false;
|
||||
|
||||
function updateButtons() {
|
||||
btnUndo.disabled = !history.canUndo();
|
||||
btnRedo.disabled = !history.canRedo();
|
||||
}
|
||||
|
||||
async function performUndo() {
|
||||
if (busy || !history.canUndo()) {
|
||||
if (!history.canUndo()) showToast('더 이상 되돌릴 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
try {
|
||||
const { entry, targetFiles } = history.undo();
|
||||
if (entry.type === 'delete') {
|
||||
const { restored, errors } = await window.api.restoreStagedFiles(entry.deletedEntries);
|
||||
if (errors.length) showToast('일부 파일 복원 실패: ' + errors[0]);
|
||||
applySnapshot(targetFiles, new Set(restored));
|
||||
if (restored.length) setStatus(`${restored.length}개 파일 복원됨`, true);
|
||||
} else {
|
||||
applySnapshot(targetFiles, entry.affectedPaths);
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function performRedo() {
|
||||
if (busy || !history.canRedo()) {
|
||||
if (!history.canRedo()) showToast('마지막 작업 상태입니다');
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
try {
|
||||
const nextEntry = history.peekRedo();
|
||||
if (nextEntry.type === 'delete') {
|
||||
const pathsToRedo = nextEntry.deletedEntries.map(e => e.originalPath);
|
||||
const { staged, errors } = await window.api.stageDeleteFiles(pathsToRedo);
|
||||
if (errors.length) showToast('일부 파일 삭제 실패: ' + errors[0]);
|
||||
updateDeleteStagingPaths(nextEntry, staged);
|
||||
const { targetFiles } = history.redo();
|
||||
applySnapshot(targetFiles, null);
|
||||
if (staged.length) setStatus(`${staged.length}개 파일 삭제됨`, true);
|
||||
} else {
|
||||
const { entry, targetFiles } = history.redo();
|
||||
applySnapshot(targetFiles, entry.affectedPaths);
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applySnapshot(targetFiles, affectedPaths = null) {
|
||||
state.files.length = 0;
|
||||
state.files.push(...targetFiles);
|
||||
state.selectedIdxs.clear();
|
||||
|
||||
if (affectedPaths && affectedPaths.size > 0) {
|
||||
const pathToIdx = new Map(state.files.map((f, i) => [f.path, i]));
|
||||
affectedPaths.forEach(p => {
|
||||
const idx = pathToIdx.get(p);
|
||||
if (idx !== undefined) state.selectedIdxs.add(idx);
|
||||
});
|
||||
}
|
||||
|
||||
state.sortMode = null;
|
||||
updateSortButtons();
|
||||
if (!state.files.length) {
|
||||
showEmptyGrid();
|
||||
btnRename.disabled = true;
|
||||
} else {
|
||||
renderGrid();
|
||||
btnRename.disabled = false;
|
||||
}
|
||||
updateButtons();
|
||||
}
|
||||
|
||||
return { performUndo, performRedo, updateButtons };
|
||||
}
|
||||
|
||||
function updateDeleteStagingPaths(entry, staged) {
|
||||
const stagedByPath = new Map(staged.map(s => [s.originalPath, s.stagingPath]));
|
||||
entry.deletedEntries.forEach(e => {
|
||||
if (stagedByPath.has(e.originalPath)) e.stagingPath = stagedByPath.get(e.originalPath);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export function initKeyboardShortcuts({
|
||||
btnOpen,
|
||||
btnRename,
|
||||
btnPreviewToggle,
|
||||
deleteSelected,
|
||||
performUndo,
|
||||
performRedo,
|
||||
refreshSelection,
|
||||
selectAll,
|
||||
}) {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (hasOpenModal()) return;
|
||||
if (isEditableTarget(e.target)) return;
|
||||
|
||||
if (e.code === 'KeyO') {
|
||||
btnOpen.click();
|
||||
} else if (e.code === 'KeyS' && !btnRename.disabled) {
|
||||
btnRename.click();
|
||||
} else if (e.code === 'KeyA') {
|
||||
selectAll();
|
||||
refreshSelection();
|
||||
} else if (e.metaKey && !e.shiftKey && e.code === 'KeyZ') {
|
||||
e.preventDefault();
|
||||
performUndo();
|
||||
} else if (e.metaKey && e.shiftKey && e.code === 'KeyZ') {
|
||||
e.preventDefault();
|
||||
performRedo();
|
||||
} else if (e.code === 'KeyP') {
|
||||
btnPreviewToggle.click();
|
||||
} else if (e.code === 'Backspace' || e.code === 'Delete') {
|
||||
deleteSelected();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isEditableTarget(target) {
|
||||
return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable;
|
||||
}
|
||||
|
||||
function hasOpenModal() {
|
||||
return [...document.querySelectorAll('.modal-overlay')]
|
||||
.some(modal => modal.style.display === 'flex');
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { showLoading, hideLoading, setStatus, showToast } from './ui.js';
|
||||
import { renderGrid, updateSelCount, showEmptyGrid } from './grid.js';
|
||||
import * as history from './history.js';
|
||||
import { openModal, closeModal } from './modal.js';
|
||||
import { buildSequentialName, getNameFormat } from './utils/file-names.js';
|
||||
|
||||
const btnRename = document.getElementById('btn-rename');
|
||||
const moveModal = document.getElementById('move-modal');
|
||||
@@ -22,15 +23,11 @@ moveModal.addEventListener('modal:closed', () => {
|
||||
moveConfirmBtn.addEventListener('click', async () => {
|
||||
if (!_pendingMoveTarget || !_pendingMoveFiles) return;
|
||||
|
||||
const prefix = movePrefixInput.value.trim() || 'photo';
|
||||
const digits = Math.max(1, Math.min(6, parseInt(moveDigitsInput.value) || 4));
|
||||
const format = getNameFormat(movePrefixInput, moveDigitsInput, 4);
|
||||
const targetPath = _pendingMoveTarget;
|
||||
const pendingFiles = _pendingMoveFiles;
|
||||
|
||||
const newNames = pendingFiles.map((f, i) => {
|
||||
const ext = f.name.split('.').pop().toLowerCase();
|
||||
return `${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
||||
});
|
||||
const newNames = pendingFiles.map((f, i) => buildSequentialName(f.name, i, format));
|
||||
|
||||
const conflicts = await window.api.checkFilesExist(targetPath, newNames);
|
||||
if (conflicts.length) {
|
||||
|
||||
+9
-20
@@ -3,6 +3,8 @@ import { showLoading, hideLoading, setStatus, showToast } from './ui.js';
|
||||
import { renderGrid } from './grid.js';
|
||||
import * as history from './history.js';
|
||||
import { openModal, closeModal } from './modal.js';
|
||||
import { renderTextList } from './utils/dom.js';
|
||||
import { buildSequentialName, getNameFormat } from './utils/file-names.js';
|
||||
|
||||
const inputPrefix = document.getElementById('input-prefix');
|
||||
const inputDigits = document.getElementById('input-digits');
|
||||
@@ -28,8 +30,8 @@ renameConfirmModal.addEventListener('modal:closed', () => {
|
||||
|
||||
export 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 { prefix, digits } = getNameFormat(inputPrefix, inputDigits);
|
||||
const format = { prefix, digits };
|
||||
|
||||
const selectedArr = [...state.selectedIdxs].sort((a, b) => a - b);
|
||||
const filesToRename = selectedArr.length > 0
|
||||
@@ -43,26 +45,17 @@ export async function doRename() {
|
||||
const unselectedNames = new Set(
|
||||
state.files.filter((_, i) => !state.selectedIdxs.has(i)).map(f => f.name)
|
||||
);
|
||||
const newNames = filesToRename.map((f, i) => {
|
||||
const ext = f.name.split('.').pop().toLowerCase();
|
||||
return `${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
||||
});
|
||||
const newNames = filesToRename.map((f, i) => buildSequentialName(f.name, i, format));
|
||||
const conflicting = newNames.filter(n => unselectedNames.has(n));
|
||||
if (conflicting.length) {
|
||||
renameConflictList.replaceChildren(...conflicting.map(n => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = n;
|
||||
return li;
|
||||
}));
|
||||
renderTextList(renameConflictList, conflicting);
|
||||
openModal(renameConflictModal);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const examples = filesToRename.slice(0, 3).map((f, i) => {
|
||||
const ext = f.name.split('.').pop().toLowerCase();
|
||||
return `${f.name} → ${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
||||
});
|
||||
const examples = filesToRename.slice(0, 3)
|
||||
.map((f, i) => `${f.name} → ${buildSequentialName(f.name, i, format)}`);
|
||||
const more = filesToRename.length > 3 ? `... 외 ${filesToRename.length - 3}개` : '';
|
||||
|
||||
const confirmed = await showRenameConfirm({
|
||||
@@ -111,11 +104,7 @@ function showRenameConfirm({ count, examples, more, isPartial }) {
|
||||
|
||||
const items = [...examples];
|
||||
if (more) items.push(more);
|
||||
renameConfirmList.replaceChildren(...items.map(text => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = text;
|
||||
return li;
|
||||
}));
|
||||
renderTextList(renameConfirmList, items);
|
||||
|
||||
openModal(renameConfirmModal);
|
||||
|
||||
|
||||
+42
-178
@@ -1,13 +1,16 @@
|
||||
import { state } from './state.js';
|
||||
import { showLoading, hideLoading, setStatus, showToast } from './ui.js';
|
||||
import * as history from './history.js';
|
||||
import {
|
||||
renderGrid, updateSelCount, updateNumberBadges,
|
||||
updateNewNameLabels, refreshCardSelection, showEmptyGrid,
|
||||
updateSelCount, updateNumberBadges,
|
||||
updateNewNameLabels, refreshCardSelection,
|
||||
} from './grid.js';
|
||||
import { doRename } from './rename.js';
|
||||
import { handleFolderDrop } from './move.js';
|
||||
import { initModalControls } from './modal.js';
|
||||
import { clampDigits, getExtension } from './utils/file-names.js';
|
||||
import { initKeyboardShortcuts } from './keyboard.js';
|
||||
import { createFolderController } from './folder-controller.js';
|
||||
import { createHistoryController } from './history-controller.js';
|
||||
import * as deleteHandler from './delete.js';
|
||||
import * as pinchZoom from './handlers/pinch-zoom.js';
|
||||
import * as selection from './handlers/selection.js';
|
||||
@@ -15,8 +18,6 @@ import * as dragDrop from './handlers/drag-drop.js';
|
||||
import * as hoverPreview from './handlers/image-preview.js';
|
||||
import * as fileExplorer from './handlers/file-explorer.js';
|
||||
|
||||
let _homeDir = null;
|
||||
|
||||
// ── DOM ──────────────────────────────────────────────
|
||||
const btnOpen = document.getElementById('btn-open');
|
||||
const btnSortName = document.getElementById('btn-sort-name');
|
||||
@@ -36,8 +37,23 @@ const btnUndo = document.getElementById('btn-undo');
|
||||
const btnRedo = document.getElementById('btn-redo');
|
||||
const fileExplorerContainer = document.querySelector('.file-explorer');
|
||||
|
||||
const folderController = createFolderController({
|
||||
btnRename,
|
||||
btnSortName,
|
||||
btnSortDate,
|
||||
dirPath,
|
||||
inputPrefix,
|
||||
updatePreview,
|
||||
});
|
||||
const historyController = createHistoryController({
|
||||
btnRename,
|
||||
btnUndo,
|
||||
btnRedo,
|
||||
updateSortButtons: folderController.updateSortButtons,
|
||||
});
|
||||
|
||||
// ── 핸들러 초기화 ─────────────────────────────────
|
||||
history.setOnUpdate(updateHistoryButtons);
|
||||
history.setOnUpdate(historyController.updateButtons);
|
||||
initModalControls();
|
||||
|
||||
const callbacks = {
|
||||
@@ -50,7 +66,7 @@ selection.init({ gridContainer, rubberBand, grid, state, callbacks });
|
||||
dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks, fileExplorerEl: fileExplorerContainer, onFolderDrop: handleFolderDrop });
|
||||
hoverPreview.init();
|
||||
deleteHandler.init(grid);
|
||||
fileExplorer.init(fileExplorerContainer, loadFolder);
|
||||
fileExplorer.init(fileExplorerContainer, folderController.loadFolder);
|
||||
|
||||
window.api.onFullscreenChange((isFullscreen) => {
|
||||
document.body.classList.toggle('fullscreen', isFullscreen);
|
||||
@@ -63,202 +79,50 @@ window.api.onFullscreenChange((isFullscreen) => {
|
||||
window.api.getHomeDir(),
|
||||
window.api.getPreviewOn(),
|
||||
]);
|
||||
_homeDir = homeDir;
|
||||
folderController.setHomeDir(homeDir);
|
||||
|
||||
btnPreviewToggle.classList.toggle('active', previewOn);
|
||||
hoverPreview.setEnabled(previewOn);
|
||||
|
||||
const target = lastFolder || homeDir;
|
||||
await fileExplorer.loadTree(homeDir, target);
|
||||
await loadFolder(target);
|
||||
await folderController.loadFolder(target);
|
||||
})();
|
||||
|
||||
// ── 버튼 / 키보드 이벤트 ─────────────────────────
|
||||
inputPrefix.addEventListener('input', updatePreview);
|
||||
inputDigits.addEventListener('input', updatePreview);
|
||||
btnOpen.addEventListener('click', openFolder);
|
||||
btnSortName.addEventListener('click', () => sortFiles('name'));
|
||||
btnSortDate.addEventListener('click', () => sortFiles('date'));
|
||||
btnOpen.addEventListener('click', folderController.openFolder);
|
||||
btnSortName.addEventListener('click', () => folderController.sortFiles('name'));
|
||||
btnSortDate.addEventListener('click', () => folderController.sortFiles('date'));
|
||||
btnRename.addEventListener('click', doRename);
|
||||
btnUndo.addEventListener('click', performUndo);
|
||||
btnRedo.addEventListener('click', performRedo);
|
||||
btnUndo.addEventListener('click', historyController.performUndo);
|
||||
btnRedo.addEventListener('click', historyController.performRedo);
|
||||
btnPreviewToggle.addEventListener('click', () => {
|
||||
const on = btnPreviewToggle.classList.toggle('active');
|
||||
hoverPreview.setEnabled(on);
|
||||
window.api.setPreviewOn(on);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.tagName === 'INPUT') return;
|
||||
if (e.code === 'KeyO') btnOpen.click();
|
||||
if (e.code === 'KeyS' && !btnRename.disabled) btnRename.click();
|
||||
if (e.code === 'KeyA') {
|
||||
state.files.forEach((_, i) => state.selectedIdxs.add(i));
|
||||
initKeyboardShortcuts({
|
||||
btnOpen,
|
||||
btnRename,
|
||||
btnPreviewToggle,
|
||||
deleteSelected: deleteHandler.deleteSelected,
|
||||
performUndo: historyController.performUndo,
|
||||
performRedo: historyController.performRedo,
|
||||
refreshSelection: () => {
|
||||
refreshCardSelection();
|
||||
updateSelCount();
|
||||
}
|
||||
if (e.metaKey && !e.shiftKey && e.code === 'KeyZ') { e.preventDefault(); performUndo(); }
|
||||
if (e.metaKey && e.shiftKey && e.code === 'KeyZ') { e.preventDefault(); performRedo(); }
|
||||
if (e.code === 'KeyP') btnPreviewToggle.click();
|
||||
if (e.code === 'Backspace' || e.code === 'Delete') deleteHandler.deleteSelected();
|
||||
},
|
||||
selectAll: () => state.files.forEach((_, i) => state.selectedIdxs.add(i)),
|
||||
});
|
||||
|
||||
// ── 폴더 열기 (다이얼로그) ───────────────────────
|
||||
async function openFolder() {
|
||||
const folder = await window.api.selectFolder();
|
||||
if (!folder) return;
|
||||
const homeDir = _homeDir;
|
||||
if (folder.startsWith(homeDir)) {
|
||||
await fileExplorer.loadTree(homeDir, folder);
|
||||
} else {
|
||||
await fileExplorer.loadTree(folder, folder);
|
||||
}
|
||||
await loadFolder(folder);
|
||||
}
|
||||
|
||||
// ── 폴더 로드 (트리 클릭 또는 다이얼로그 공통) ──
|
||||
async function loadFolder(folder) {
|
||||
if (!folder) return;
|
||||
state.currentFolder = folder;
|
||||
dirPath.textContent = folder;
|
||||
await window.api.setLastFolder(folder);
|
||||
await fileExplorer.setSelectedPath(folder);
|
||||
|
||||
showLoading('이미지 목록 불러오는 중...');
|
||||
const list = await window.api.listImages(folder);
|
||||
hideLoading();
|
||||
|
||||
if (!list.length) {
|
||||
state.files.length = 0;
|
||||
state.selectedIdxs.clear();
|
||||
showEmptyGrid();
|
||||
btnRename.disabled = true;
|
||||
setStatus('이미지 파일 없음', false);
|
||||
return;
|
||||
}
|
||||
|
||||
state.files.length = 0;
|
||||
state.files.push(...list);
|
||||
state.selectedIdxs.clear();
|
||||
state.sortMode = 'name';
|
||||
updateSortButtons();
|
||||
setStatus(`${state.files.length}개 파일 로드됨`, true);
|
||||
|
||||
const folderName = folder.replace(/\\/g, '/').split('/').pop();
|
||||
if (folderName) inputPrefix.value = folderName;
|
||||
|
||||
renderGrid();
|
||||
updatePreview();
|
||||
btnRename.disabled = false;
|
||||
history.reset();
|
||||
history.pushReorder(state.files);
|
||||
}
|
||||
|
||||
// ── 정렬 ─────────────────────────────────────────
|
||||
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.sortMode = mode;
|
||||
state.selectedIdxs.clear();
|
||||
updateSortButtons();
|
||||
await renderGrid();
|
||||
setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true);
|
||||
history.pushReorder(state.files);
|
||||
}
|
||||
|
||||
function updateSortButtons() {
|
||||
btnSortName.classList.toggle('active', state.sortMode === 'name');
|
||||
btnSortDate.classList.toggle('active', state.sortMode === 'date');
|
||||
}
|
||||
|
||||
// ── Undo / Redo ───────────────────────────────────
|
||||
let _historyBusy = false;
|
||||
|
||||
function updateHistoryButtons() {
|
||||
btnUndo.disabled = !history.canUndo();
|
||||
btnRedo.disabled = !history.canRedo();
|
||||
}
|
||||
|
||||
async function performUndo() {
|
||||
if (_historyBusy || !history.canUndo()) {
|
||||
if (!history.canUndo()) showToast('더 이상 되돌릴 수 없습니다');
|
||||
return;
|
||||
}
|
||||
_historyBusy = true;
|
||||
try {
|
||||
const { entry, targetFiles } = history.undo();
|
||||
if (entry.type === 'delete') {
|
||||
const { restored, errors } = await window.api.restoreStagedFiles(entry.deletedEntries);
|
||||
if (errors.length) showToast('일부 파일 복원 실패: ' + errors[0]);
|
||||
_applyHistorySnapshot(targetFiles, new Set(restored));
|
||||
if (restored.length) setStatus(`${restored.length}개 파일 복원됨`, true);
|
||||
} else {
|
||||
_applyHistorySnapshot(targetFiles, entry.affectedPaths);
|
||||
}
|
||||
} finally {
|
||||
_historyBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function performRedo() {
|
||||
if (_historyBusy || !history.canRedo()) {
|
||||
if (!history.canRedo()) showToast('마지막 작업 상태입니다');
|
||||
return;
|
||||
}
|
||||
_historyBusy = true;
|
||||
try {
|
||||
const nextEntry = history.peekRedo();
|
||||
if (nextEntry.type === 'delete') {
|
||||
const pathsToRedo = nextEntry.deletedEntries.map(e => e.originalPath);
|
||||
const { staged, errors } = await window.api.stageDeleteFiles(pathsToRedo);
|
||||
if (errors.length) showToast('일부 파일 삭제 실패: ' + errors[0]);
|
||||
const stagedByPath = new Map(staged.map(s => [s.originalPath, s.stagingPath]));
|
||||
nextEntry.deletedEntries.forEach(e => {
|
||||
if (stagedByPath.has(e.originalPath)) e.stagingPath = stagedByPath.get(e.originalPath);
|
||||
});
|
||||
const { targetFiles } = history.redo();
|
||||
_applyHistorySnapshot(targetFiles, null);
|
||||
if (staged.length) setStatus(`${staged.length}개 파일 삭제됨`, true);
|
||||
} else {
|
||||
const { entry, targetFiles } = history.redo();
|
||||
_applyHistorySnapshot(targetFiles, entry.affectedPaths);
|
||||
}
|
||||
} finally {
|
||||
_historyBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _applyHistorySnapshot(targetFiles, affectedPaths = null) {
|
||||
state.files.length = 0;
|
||||
state.files.push(...targetFiles);
|
||||
state.selectedIdxs.clear();
|
||||
|
||||
if (affectedPaths && affectedPaths.size > 0) {
|
||||
const pathToIdx = new Map(state.files.map((f, i) => [f.path, i]));
|
||||
affectedPaths.forEach(p => {
|
||||
const idx = pathToIdx.get(p);
|
||||
if (idx !== undefined) state.selectedIdxs.add(idx);
|
||||
});
|
||||
}
|
||||
|
||||
state.sortMode = null;
|
||||
updateSortButtons();
|
||||
if (!state.files.length) {
|
||||
showEmptyGrid();
|
||||
btnRename.disabled = true;
|
||||
} else {
|
||||
renderGrid();
|
||||
btnRename.disabled = false;
|
||||
}
|
||||
updateHistoryButtons();
|
||||
}
|
||||
|
||||
// ── 포맷 미리보기 ─────────────────────────────────
|
||||
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';
|
||||
const digits = clampDigits(inputDigits.value);
|
||||
const ext = state.files.length ? getExtension(state.files[0].name) : 'jpg';
|
||||
previewName.textContent = `${prefix}_${'1'.padStart(digits, '0')}.${ext}`;
|
||||
updateNewNameLabels();
|
||||
}
|
||||
|
||||
+15
-8
@@ -11,6 +11,13 @@
|
||||
--warning: #f7c26a;
|
||||
--danger: #ff6b7a;
|
||||
--info: #6ac8f7;
|
||||
--success-bg: #1a4a2e;
|
||||
--success-bg-hover: #204a34;
|
||||
--success-border: #2a6a40;
|
||||
--warning-bg: rgba(247,194,106,.12);
|
||||
--warning-border: rgba(247,194,106,.35);
|
||||
--danger-bg: rgba(255,107,122,.12);
|
||||
--danger-border: rgba(255,107,122,.35);
|
||||
--text: #e8e8f0;
|
||||
--muted: #8f8fb8;
|
||||
--drag-over: #3a2e70;
|
||||
@@ -155,8 +162,8 @@ main {
|
||||
}
|
||||
.sort-btn:hover { background: #132e42; color: var(--info); }
|
||||
.sort-btn.active { background: #1a3a4a; color: var(--info); }
|
||||
.btn-success { background: #1a4a2e; color: var(--success); border: 1px solid #2a6a40; }
|
||||
.btn-success:hover { background: #204a34; }
|
||||
.btn-success { background: var(--success-bg); color: var(--success); border: 1px solid var(--success-border); }
|
||||
.btn-success:hover { background: var(--success-bg-hover); }
|
||||
.btn-success:disabled { opacity: .4; cursor: not-allowed; transform: none; }
|
||||
.btn-warning { background: #3a2a10; color: var(--warning); border: 1px solid #5a4020; }
|
||||
.btn-warning:hover { background: #4a3518; }
|
||||
@@ -551,13 +558,13 @@ footer span { font-size: 11px; color: var(--muted); }
|
||||
}
|
||||
.modal-titlebar-warning {
|
||||
color: var(--warning);
|
||||
background: rgba(247,194,106,.12);
|
||||
border-bottom-color: rgba(247,194,106,.35);
|
||||
background: var(--warning-bg);
|
||||
border-bottom-color: var(--warning-border);
|
||||
}
|
||||
.modal-titlebar-danger {
|
||||
color: var(--danger);
|
||||
background: rgba(255,107,122,.12);
|
||||
border-bottom-color: rgba(255,107,122,.35);
|
||||
background: var(--danger-bg);
|
||||
border-bottom-color: var(--danger-border);
|
||||
}
|
||||
.modal-msg {
|
||||
font-size: 14px; color: var(--text); line-height: 1.7;
|
||||
@@ -567,8 +574,8 @@ footer span { font-size: 11px; color: var(--muted); }
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
color: var(--warning);
|
||||
background: rgba(247,194,106,.12);
|
||||
border: 1px solid rgba(247,194,106,.35);
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function renderTextList(listEl, items) {
|
||||
listEl.replaceChildren(...items.map(createTextListItem));
|
||||
}
|
||||
|
||||
function createTextListItem(text) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = text;
|
||||
return li;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function getExtension(fileName, fallback = 'jpg') {
|
||||
const parts = fileName.split('.');
|
||||
return parts.length > 1 ? parts.pop().toLowerCase() : fallback;
|
||||
}
|
||||
|
||||
export function clampDigits(value, fallback = 3) {
|
||||
return Math.max(1, Math.min(6, parseInt(value, 10) || fallback));
|
||||
}
|
||||
|
||||
export function buildSequentialName(fileName, index, { prefix, digits }) {
|
||||
const ext = getExtension(fileName);
|
||||
return `${prefix}_${String(index + 1).padStart(digits, '0')}.${ext}`;
|
||||
}
|
||||
|
||||
export function getNameFormat(prefixInput, digitsInput, digitsFallback = 3) {
|
||||
return {
|
||||
prefix: prefixInput.value.trim() || 'photo',
|
||||
digits: clampDigits(digitsInput.value, digitsFallback),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user