static 폴더 및 하위 파일 업로드

This commit is contained in:
sotam0316
2026-04-22 12:05:03 +09:00
commit 514b209a5a
203 changed files with 29494 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
import { studioState } from './state.js';
import * as processor from './processor.js';
import { renderStudioUI } from './renderer.js';
import { logger } from '../utils/logger.js';
import { t } from '../i18n.js';
/**
* Handle individual asset vectorization
*/
export async function startVectorization(id) {
const src = studioState.sources.find(s => s.id === id);
if (!src || src.status === 'processing') return;
src.status = 'processing';
renderStudioUI();
try {
const img = new Image();
img.src = src.previewUrl;
await new Promise(r => img.onload = r);
const pathData = await processor.traceImage(img, {
threshold: src.threshold || 128,
turdsize: 2
});
const svgData = processor.wrapAsSVG(pathData);
studioState.updateSource(id, {
svgData,
status: 'done',
choice: 'svg'
});
} catch (err) {
logger.critical("Trace Error:", err);
src.status = 'error';
}
renderStudioUI();
}
/**
* Bulk update properties for selected assets
*/
export function bulkUpdateProperties(sources, updates) {
sources.forEach(src => {
if (updates.category) src.category = updates.category;
if (updates.vendor) src.vendor = updates.vendor;
});
}
/**
* Final Build and Publish to Server
*/
export async function buildPackage() {
const packIdElement = document.getElementById('pack-id');
const packNameElement = document.getElementById('pack-name');
if (!packIdElement) return;
const packId = packIdElement.value.trim();
const packName = packNameElement ? packNameElement.value.trim() : packId;
if (!packId || studioState.sources.length === 0) {
alert(t('please_enter_pack_id'));
return;
}
const buildBtn = document.getElementById('build-btn');
const originalText = buildBtn.innerHTML;
buildBtn.disabled = true;
buildBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t('building')}`;
try {
const formData = new FormData();
formData.append('packId', packId);
formData.append('packName', packName);
const assetsMetadata = studioState.sources.map(src => ({
id: src.id,
label: src.label,
category: src.category,
choice: src.choice,
svgData: src.choice === 'svg' ? src.svgData : null
}));
formData.append('assets', JSON.stringify(assetsMetadata));
studioState.sources.forEach(src => {
if (src.choice === 'png' || (src.type === 'svg' && src.choice === 'svg' && !src.svgData)) {
formData.append(`file_${src.id}`, src.file);
}
});
const response = await fetch('/api/studio/save-pack', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
alert(result.message || t('build_success'));
window.location.href = '/';
} else {
throw new Error(result.message || t('build_failed').replace('{message}', ''));
}
} catch (err) {
alert(t('build_failed').replace('{message}', err.message));
buildBtn.disabled = false;
buildBtn.innerHTML = originalText;
}
}
/**
* Load an existing package from the server
*/
export async function loadExistingPack(packId) {
if (!packId) return;
try {
const response = await fetch(`/api/studio/load-pack/${packId}`);
const result = await response.json();
if (result.success) {
studioState.loadFromMetadata(result.data);
// UI에 Pack ID와 Name 입력값 동기화
const packIdInput = document.getElementById('pack-id');
const packNameInput = document.getElementById('pack-name');
const vendorInput = document.getElementById('pack-vendor');
if (packIdInput) packIdInput.value = studioState.packInfo.id;
if (packNameInput) packNameInput.value = studioState.packInfo.name;
if (vendorInput) vendorInput.value = studioState.packInfo.vendor;
renderStudioUI();
logger.info(`drawNET Studio: Package "${packId}" loaded.`);
} else {
alert(t('load_failed').replace('{message}', result.message));
}
} catch (err) {
alert(t('load_error').replace('{message}', err.message));
}
}
+29
View File
@@ -0,0 +1,29 @@
import { initIngester } from './ingester.js';
import { renderStudioUI } from './renderer.js';
import { buildPackage } from './actions.js';
import { logger } from '../utils/logger.js';
/**
* Object Studio Main Entry Point
*/
document.addEventListener('DOMContentLoaded', () => {
initStudio();
});
function initStudio() {
logger.info("DrawNET Studio Initializing...");
// Initialize file ingestion module
initIngester();
// Listen for state updates to trigger re-renders
window.addEventListener('studio-sources-updated', renderStudioUI);
window.addEventListener('studio-selection-updated', renderStudioUI);
// Bind Global Actions
const buildBtn = document.getElementById('build-btn');
if (buildBtn) buildBtn.onclick = buildPackage;
// Initial render
renderStudioUI();
}
+52
View File
@@ -0,0 +1,52 @@
import { studioState } from './state.js';
export function initIngester() {
const dropZone = document.getElementById('drop-zone');
const folderInput = document.getElementById('folder-input');
const fileInput = document.getElementById('file-input');
if (!dropZone || !folderInput) return;
// Handle clicks
dropZone.addEventListener('click', () => folderInput.click());
// Handle folder selection
folderInput.addEventListener('change', (e) => handleFiles(e.target.files));
fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
// Handle drag & drop
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.style.borderColor = '#38bdf8';
dropZone.style.background = 'rgba(56, 189, 248, 0.05)';
});
dropZone.addEventListener('dragleave', () => {
dropZone.style.borderColor = 'rgba(255, 255, 255, 0.1)';
dropZone.style.background = 'transparent';
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.borderColor = 'rgba(255, 255, 255, 0.1)';
dropZone.style.background = 'transparent';
handleFiles(e.dataTransfer.files);
});
}
function handleFiles(files) {
const validTypes = ['image/png', 'image/jpeg', 'image/svg+xml'];
let addedCount = 0;
Array.from(files).forEach(file => {
if (validTypes.includes(file.type)) {
studioState.addSource(file);
addedCount++;
}
});
if (addedCount > 0) {
// Trigger UI update (to be implemented in index.js)
window.dispatchEvent(new CustomEvent('studio-sources-updated'));
}
}
+78
View File
@@ -0,0 +1,78 @@
import { potrace, init } from 'https://cdn.jsdelivr.net/npm/esm-potrace-wasm@0.4.1/dist/index.js';
import { logger } from '../utils/logger.js';
let isWasmInitialized = false;
/**
* Image Tracing using WASM Potrace (High performance & quality)
* @param {HTMLImageElement} imageElement
* @param {Object} options
*/
export async function traceImage(imageElement, options = {}) {
if (!isWasmInitialized) {
await init();
isWasmInitialized = true;
}
// Ensure image is loaded
if (imageElement instanceof HTMLImageElement && !imageElement.complete) {
await new Promise(r => imageElement.onload = r);
}
// --- Pre-process with Canvas for Thresholding ---
const canvas = document.createElement('canvas');
canvas.width = imageElement.naturalWidth || imageElement.width;
canvas.height = imageElement.naturalHeight || imageElement.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(imageElement, 0, 0);
const threshold = options.threshold !== undefined ? options.threshold : 128;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
// Luminance calculation
const brightness = 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
const v = brightness < threshold ? 0 : 255;
data[i] = data[i+1] = data[i+2] = v;
// Keep alpha as is or flatten to white?
// For icons, if alpha is low, treat as white
if (data[i+3] < 128) {
data[i] = data[i+1] = data[i+2] = 255;
data[i+3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
// Potrace Options (WASM version)
const potraceOptions = {
turdsize: options.turdsize || 2,
turnpolicy: options.turnpolicy || 4,
alphamax: options.alphamax !== undefined ? options.alphamax : 1,
opticurve: options.opticurve !== undefined ? options.opticurve : 1,
opttolerance: options.opttolerance || 0.2
};
try {
// Pass the thresholded canvas instead of the original image
const svgStr = await potrace(canvas, potraceOptions);
return svgStr;
} catch (err) {
logger.critical("WASM Potrace Error:", err);
throw err;
}
}
/**
* Creates a clean SVG string from path data
* (Maintained for API compatibility, though WASM potrace returns full SVG)
*/
export function wrapAsSVG(svgData, width = 64, height = 64) {
if (typeof svgData === 'string' && svgData.trim().startsWith('<svg')) {
return svgData;
}
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
${svgData}
</svg>`;
}
+269
View File
@@ -0,0 +1,269 @@
import { studioState } from './state.js';
import * as actions from './actions.js';
import { t } from '../i18n.js';
/**
* Main Studio UI rendering orchestrator
*/
export function renderStudioUI() {
renderSourceList();
renderAssetGrid();
renderPropertyPanel();
}
/**
* Left Panel: Source File List
*/
function renderSourceList() {
const list = document.getElementById('source-list');
const loadArea = document.getElementById('load-package-area');
if (!list) return;
// 1. Render Load Area (only once or update if needed)
if (loadArea && loadArea.innerHTML === "") {
fetch('/assets').then(res => res.json()).then(data => {
const packs = data.packs || [];
loadArea.innerHTML = `
<div style="padding: 15px; border-bottom: 1px solid rgba(255,255,255,0.05); background: rgba(56, 189, 248, 0.03);">
<label style="display: block; font-size: 10px; color: #64748b; margin-bottom: 8px;">${t('studio_edit_existing')}</label>
<div style="display: flex; gap: 8px;">
<select id="exist-pack-select" style="flex: 1; background: #0f172a; border: 1px solid rgba(255,255,255,0.1); color: white; padding: 6px; border-radius: 4px; font-size: 11px;">
<option value="">${t('studio_select_package')}</option>
${packs.map(p => `<option value="${p.id}">${p.name}</option>`).join('')}
</select>
<button id="load-pack-btn" style="background: #38bdf8; color: #0f172a; border: none; padding: 0 10px; border-radius: 4px; cursor: pointer; font-weight: 800; font-size: 11px;">${t('studio_open')}</button>
</div>
</div>
`;
document.getElementById('load-pack-btn').onclick = () => {
const packId = document.getElementById('exist-pack-select').value;
if (packId && confirm(t('confirm_load_package').replace('{id}', packId))) {
actions.loadExistingPack(packId);
}
};
});
}
// 2. Render Source List
list.innerHTML = studioState.sources.map(src => `
<li class="source-item ${studioState.selectedIds.has(src.id) ? 'active' : ''}" data-id="${src.id}">
<i class="fas ${src.type === 'svg' ? 'fa-vector-square' : 'fa-image'}"></i>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${src.name}</span>
<i class="fas fa-times delete-btn" style="font-size: 10px; cursor: pointer;" title="${t('remove')}"></i>
</li>
`).join('');
// Add click listeners
list.querySelectorAll('.source-item').forEach(item => {
const id = item.dataset.id;
item.addEventListener('click', (e) => {
if (e.target.classList.contains('delete-btn')) {
studioState.removeSource(id);
renderStudioUI();
} else {
studioState.toggleSelection(id, e.ctrlKey || e.metaKey);
renderStudioUI();
}
});
});
}
/**
* Center Panel: Asset Preview Grid
*/
function renderAssetGrid() {
const grid = document.getElementById('asset-grid');
if (!grid) return;
if (studioState.sources.length === 0) {
grid.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; color: #475569; margin-top: 100px;">
<i class="fas fa-layer-group" style="font-size: 40px; margin-bottom: 20px; display: block;"></i>
<p>${t('studio_no_assets')}</p>
</div>
`;
return;
}
grid.innerHTML = studioState.sources.map(src => `
<div class="asset-card ${studioState.selectedIds.has(src.id) ? 'selected' : ''}" data-id="${src.id}">
<div class="asset-preview">
<img src="${src.previewUrl}" alt="${src.name}">
</div>
<div class="asset-name">${src.label}</div>
<div style="font-size: 9px; color: #64748b; margin-top: 4px;">${src.choice.toUpperCase()}</div>
</div>
`).join('');
// Add click listeners
grid.querySelectorAll('.asset-card').forEach(card => {
card.addEventListener('click', (e) => {
studioState.toggleSelection(card.dataset.id, e.ctrlKey || e.metaKey);
renderStudioUI();
});
});
}
/**
* Right Panel: Property Settings & Comparison
*/
function renderPropertyPanel() {
const panel = document.querySelector('.property-panel .panel-content');
if (!panel) return;
const selectedSources = studioState.sources.filter(s => studioState.selectedIds.has(s.id));
if (selectedSources.length === 0) {
panel.innerHTML = `
<div style="text-align: center; color: #475569; margin-top: 50px;">
<i class="fas fa-info-circle" style="font-size: 30px; margin-bottom: 15px; display: block;"></i>
<p style="font-size: 13px;">${t('studio_select_prompt')}</p>
</div>
`;
return;
}
if (selectedSources.length === 1) {
const src = selectedSources[0];
panel.innerHTML = renderSingleAssetUI(src);
setupSingleAssetListeners(panel, src);
} else {
panel.innerHTML = renderBulkEditUI(selectedSources.length);
setupBulkEditListeners(panel, selectedSources);
}
}
function renderSingleAssetUI(src) {
return `
<div class="settings-group">
<h3 style="font-size: 13px; color: #38bdf8; margin-bottom: 15px;">${t('studio_asset_detail')}</h3>
<div style="margin-bottom: 12px;">
<label style="display: block; font-size: 10px; color: #64748b; margin-bottom: 4px;">${t('display_name_label')}</label>
<input type="text" id="prop-label" value="${src.label}" style="width: 100%; background: #1e293b; border: 1px solid rgba(255,255,255,0.1); color: white; padding: 8px; border-radius: 4px; font-size: 12px;">
</div>
<div style="margin-bottom: 12px;">
<label style="display: block; font-size: 10px; color: #64748b; margin-bottom: 4px;">${t('category')}</label>
<div style="display: flex; gap: 8px;">
<select id="prop-category" style="flex: 1; background: #1e293b; border: 1px solid rgba(255,255,255,0.1); color: white; padding: 8px; border-radius: 4px; font-size: 12px;">
${studioState.categories.map(cat => `<option value="${cat}">${cat}</option>`).join('')}
</select>
<button id="add-cat-btn" style="background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #38bdf8; width: 32px; border-radius: 4px; cursor: pointer;" title="${t('add_category_tooltip')}">+</button>
</div>
<!-- Hidden Add Form -->
<div id="new-cat-area" style="display: none; margin-top: 8px; gap: 8px; align-items: center;">
<input type="text" id="new-cat-name" placeholder="${t('new_category_placeholder')}" style="flex: 1; background: #0f172a; border: 1px solid #38bdf8; color: white; padding: 6px; border-radius: 4px; font-size: 11px;">
<button id="save-cat-btn" style="background: #38bdf8; color: #0f172a; border: none; padding: 6px 10px; border-radius: 4px; font-size: 11px; font-weight: 700;">${t('add')}</button>
</div>
</div>
<div style="margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label style="font-size: 10px; color: #64748b;">${t('trace_threshold')}</label>
<span id="threshold-val" style="font-size: 10px; color: #38bdf8; font-weight: 800;">${src.threshold || 128}</span>
</div>
<input type="range" id="prop-threshold" min="0" max="255" value="${src.threshold || 128}" style="width: 100%; height: 4px; background: #1e293b; border-radius: 2px; cursor: pointer;">
<p style="font-size: 9px; color: #475569; margin-top: 6px;">${t('trace_hint')}</p>
</div>
</div>
<div class="comparison-view">
<div class="comp-box">
<span class="comp-label">${t('original')}</span>
<div class="comp-preview">
<img src="${src.previewUrl}">
</div>
<button class="choice-btn ${src.choice === 'png' ? 'active' : ''}" data-choice="png">${t('keep_png')}</button>
</div>
<div class="comp-box">
<span class="comp-label">${t('vector_svg')}</span>
<div class="comp-preview" id="svg-preview">
${src.svgData ? src.svgData : '<i class="fas fa-magic" style="opacity:0.2"></i>'}
</div>
<button class="choice-btn ${src.choice === 'svg' ? 'active' : ''}"
data-choice="svg"
${!src.svgData ? 'disabled style="opacity:0.3"' : ''}>
${t('use_svg')}
</button>
</div>
</div>
<div style="margin-top: 20px;">
<button id="convert-btn" class="btn-primary" style="width: 100%; font-size: 11px; background: #6366f1;" ${src.status === 'processing' ? 'disabled' : ''}>
${src.status === 'processing' ? `<i class="fas fa-spinner fa-spin"></i> ${t('tracing')}` : t('convert_to_svg')}
</button>
</div>
`;
}
let thresholdTimer = null;
function setupSingleAssetListeners(panel, src) {
panel.querySelector('#prop-label').oninput = (e) => src.label = e.target.value;
panel.querySelector('#prop-category').onchange = (e) => src.category = e.target.value;
panel.querySelector('#prop-category').value = src.category;
panel.querySelector('#add-cat-btn').onclick = () => {
const area = panel.querySelector('#new-cat-area');
area.style.display = area.style.display === 'none' ? 'flex' : 'none';
if (area.style.display === 'flex') panel.querySelector('#new-cat-name').focus();
};
panel.querySelector('#save-cat-btn').onclick = () => {
const input = panel.querySelector('#new-cat-name');
const name = input.value.trim();
if (studioState.addCategory(name)) {
src.category = name;
renderStudioUI();
} else {
panel.querySelector('#new-cat-area').style.display = 'none';
}
};
const thresholdInput = panel.querySelector('#prop-threshold');
const thresholdVal = panel.querySelector('#threshold-val');
thresholdInput.oninput = (e) => {
const val = parseInt(e.target.value);
src.threshold = val;
thresholdVal.textContent = val;
// Debounced Auto-retrace
clearTimeout(thresholdTimer);
thresholdTimer = setTimeout(() => {
actions.startVectorization(src.id);
}, 400);
};
panel.querySelectorAll('.choice-btn').forEach(btn => {
btn.onclick = () => {
if (btn.disabled) return;
studioState.setChoice(src.id, btn.dataset.choice);
renderStudioUI();
};
});
panel.querySelector('#convert-btn').onclick = () => actions.startVectorization(src.id);
}
function renderBulkEditUI(count) {
return `
<div class="settings-group">
<h3 style="font-size: 13px; color: #fbbf24; margin-bottom: 20px;">${t('bulk_edit').replace('{count}', count)}</h3>
<div style="margin-bottom: 15px;">
<label style="display: block; font-size: 10px; color: #64748b; margin-bottom: 5px;">${t('common_category')}</label>
<select id="bulk-category" style="width: 100%; background: #1e293b; border: 1px solid rgba(255,255,255,0.1); color: white; padding: 8px; border-radius: 4px; font-size: 12px;">
<option value="">${t('no_change')}</option>
${studioState.categories.map(cat => `<option value="${cat}">${cat}</option>`).join('')}
</select>
</div>
</div>
<button id="bulk-apply" class="btn-primary" style="width: 100%; font-size: 11px;">${t('apply_to_selected')}</button>
`;
}
function setupBulkEditListeners(panel, selectedSources) {
panel.querySelector('#bulk-apply').onclick = () => {
const cat = panel.querySelector('#bulk-category').value;
actions.bulkUpdateProperties(selectedSources, { category: cat });
renderStudioUI();
};
}
+118
View File
@@ -0,0 +1,118 @@
export const studioState = {
sources: [], // Original files [{file, id, previewUrl, type}]
selectedIds: new Set(),
packInfo: {
id: '',
name: '',
vendor: '',
version: '1.0.0'
},
categories: ['Network', 'Security', 'Compute', 'Cloud', 'Device', 'Other'],
addCategory(name) {
if (name && !this.categories.includes(name)) {
this.categories.push(name);
return true;
}
return false;
},
addSource(file) {
const baseId = file.name.replace(/\.[^/.]+$/, "").toLowerCase().replace(/[^a-z0-9]/g, '_');
let id = baseId;
let counter = 1;
// Ensure ID is unique
while (this.sources.some(s => s.id === id)) {
id = `${baseId}_${counter++}`;
}
const previewUrl = URL.createObjectURL(file);
this.sources.push({
id,
file,
name: file.name,
label: id.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '),
previewUrl,
category: 'Other',
type: file.type.includes('svg') ? 'svg' : 'png',
choice: file.type.includes('svg') ? 'svg' : 'png', // png, svg
svgData: null,
threshold: 128,
status: 'pending' // pending, processing, done, error
});
},
updateSource(id, data) {
const src = this.sources.find(s => s.id === id);
if (src) Object.assign(src, data);
},
setChoice(id, choice) {
const src = this.sources.find(s => s.id === id);
if (src) src.choice = choice;
},
removeSource(id) {
const index = this.sources.findIndex(s => s.id === id);
if (index > -1) {
URL.revokeObjectURL(this.sources[index].previewUrl);
this.sources.splice(index, 1);
this.selectedIds.delete(id);
}
},
clearSources() {
this.sources.forEach(s => URL.revokeObjectURL(s.previewUrl));
this.sources = [];
this.selectedIds.clear();
},
toggleSelection(id, multi = false) {
if (!multi) {
const wasSelected = this.selectedIds.has(id);
this.selectedIds.clear();
if (!wasSelected) this.selectedIds.add(id);
} else {
if (this.selectedIds.has(id)) this.selectedIds.delete(id);
else this.selectedIds.add(id);
}
},
loadFromMetadata(metadata) {
this.clearSources();
this.packInfo = {
id: metadata.id || '',
name: metadata.name || metadata.id || '',
vendor: metadata.vendor || '',
version: metadata.version || '1.0.0'
};
if (metadata.assets) {
metadata.assets.forEach(asset => {
const choice = asset.views && asset.views.icon && asset.views.icon.endsWith('.svg') ? 'svg' : 'png';
const filename = asset.views ? asset.views.icon : '';
const previewUrl = `/static/assets/packs/${metadata.id}/${filename}`;
this.sources.push({
id: asset.id,
file: null, // No local file object for loaded assets
name: filename,
label: asset.label || asset.id,
previewUrl,
category: asset.category || 'Other',
type: choice,
choice: choice,
svgData: null,
threshold: 128,
status: 'done'
});
// Ensure category exists in the list
if (asset.category && !this.categories.includes(asset.category)) {
this.categories.push(asset.category);
}
});
}
}
};