Initial commit: drawNET Alpha v1.0 - Professional Topology Designer with Full i18n and Performance Optimizations

This commit is contained in:
leeyj
2026-03-22 22:37:24 +09:00
commit 5cea93e317
192 changed files with 14449 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
import { state } from '../state.js';
import { logger } from '../utils/logger.js';
/**
* fetchAssets - Fetches the complete asset and pack list from the server
*/
export async function fetchAssets() {
try {
const response = await fetch('/assets');
const data = await response.json();
state.assetsData = data.assets || [];
return data;
} catch (e) {
logger.critical("Failed to fetch assets:", e);
return { assets: [], packs: [] };
}
}
+110
View File
@@ -0,0 +1,110 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
export const FIXED_ASSETS = [
{ id: 'fixed-group', type: 'group', label: 'Group Container', icon: 'group.png', is_img: true },
{ id: 'fixed-rect', type: 'rect', label: 'Rectangle', icon: 'far fa-square', is_img: false },
{ id: 'fixed-rounded-rect', type: 'rounded-rect', label: 'Rounded Rectangle', icon: 'fas fa-vector-square', is_img: false },
{ id: 'fixed-circle', type: 'circle', label: 'Circle', icon: 'far fa-circle', is_img: false },
{ id: 'fixed-text', type: 'text-box', label: 'Text Box', icon: 'fas fa-font', is_img: false },
{ id: 'fixed-label', type: 'label', label: 'Label', icon: 'fas fa-tag', is_img: false },
{ id: 'fixed-blank', type: 'blank', label: 'Blank Point (Invisible)', icon: 'fas fa-crosshairs', is_img: false, extra_style: 'opacity: 0.5;' },
{ id: 'fixed-dot', type: 'dot', label: 'Dot (Small visible point)', icon: 'fas fa-circle', is_img: false, extra_style: 'font-size: 6px;' },
{ id: 'fixed-ellipse', type: 'ellipse', label: 'Ellipse', icon: 'fas fa-egg', is_img: false, extra_style: 'transform: rotate(-45deg);' },
{ id: 'fixed-polyline', type: 'polyline', label: 'Polyline', icon: 'fas fa-chart-line', is_img: false },
{ id: 'fixed-browser', type: 'browser', label: 'Browser', icon: 'fas fa-window-maximize', is_img: false },
{ id: 'fixed-user', type: 'user', label: 'User', icon: 'fas fa-user', is_img: false },
{ id: 'fixed-admin', type: 'admin', label: 'Admin', icon: 'fas fa-user-shield', is_img: false },
{ id: 'fixed-triangle', type: 'triangle', label: 'Triangle', icon: 'fas fa-caret-up', is_img: false, extra_style: 'font-size: 22px; margin-top: -2px;' },
{ id: 'fixed-diamond', type: 'diamond', label: 'Diamond', icon: 'fas fa-square', is_img: false, extra_style: 'transform: rotate(45deg) scale(0.8);' },
{ id: 'fixed-parallelogram', type: 'parallelogram', label: 'Parallelogram', icon: 'fas fa-square', is_img: false, extra_style: 'transform: skewX(-20deg) scaleX(1.2);' },
{ id: 'fixed-cylinder', type: 'cylinder', label: 'Cylinder', icon: 'fas fa-database', is_img: false },
{ id: 'fixed-document', type: 'document', label: 'Document', icon: 'fas fa-file-alt', is_img: false },
{ id: 'fixed-manual-input', type: 'manual-input', label: 'Manual Input', icon: 'fas fa-keyboard', is_img: false },
{ id: 'fixed-table', type: 'table', label: 'Table', icon: 'fas fa-table', is_img: false },
{ id: 'fixed-rack', type: 'rack', label: 'Rack', icon: 'fas fa-columns', is_img: false },
{ id: 'fixed-hourglass', type: 'hourglass', label: 'Hourglass', icon: 'fas fa-hourglass-half', is_img: false },
{ id: 'fixed-arrow-up', type: 'arrow-up', label: 'Arrow Up', icon: 'fas fa-arrow-up', is_img: false },
{ id: 'fixed-arrow-down', type: 'arrow-down', label: 'Arrow Down', icon: 'fas fa-arrow-down', is_img: false },
{ id: 'fixed-arrow-left', type: 'arrow-left', label: 'Arrow Left', icon: 'fas fa-arrow-left', is_img: false },
{ id: 'fixed-arrow-right', type: 'arrow-right', label: 'Arrow Right', icon: 'fas fa-arrow-right', is_img: false },
{ id: 'fixed-rich-card', type: 'rich-card', label: t('rich_card') || 'Rich Text Card', icon: 'fas fa-address-card', is_img: false }
];
export function renderFixedSection(container) {
const fixedSection = document.createElement('div');
fixedSection.className = 'asset-category fixed-category';
const title = t('fixed_objects') || 'Fixed Objects';
fixedSection.innerHTML = `
<div class="category-header">
<span data-i18n="fixed_objects">${title}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="category-collapsed-icon" title="${title}">
<i class="fas fa-layer-group" style="font-size: 20px; color: #64748b;"></i>
</div>
<div class="category-content">
<div class="asset-grid"></div>
</div>
<div class="category-divider" style="height: 1px; background: rgba(255,255,255,0.1); margin: 15px 10px;"></div>
`;
// Toggle Logic
const header = fixedSection.querySelector('.category-header');
header.addEventListener('click', () => {
if (!document.getElementById('sidebar').classList.contains('collapsed')) {
fixedSection.classList.toggle('active');
}
});
// Handle Sidebar Collapsed State (Flyout)
const collapsedIcon = fixedSection.querySelector('.category-collapsed-icon');
collapsedIcon.addEventListener('click', (e) => {
if (document.getElementById('sidebar').classList.contains('collapsed')) {
e.stopPropagation();
// Since we don't want to import renderFlyout here to avoid circular dependencies
// we'll dispatch a custom event or check if it's available globally (though not recommended)
// or just let assets.js handle it if we restructure.
// For now, let's trigger a click on a hidden flyout handler or use state.
const event = new CustomEvent('render-fixed-flyout', {
detail: {
container: fixedSection,
assets: FIXED_ASSETS,
meta: { id: 'fixed_objects', label: title }
}
});
document.dispatchEvent(event);
}
});
const grid = fixedSection.querySelector('.asset-grid');
FIXED_ASSETS.forEach(asset => {
const item = document.createElement('div');
item.className = 'asset-item';
item.draggable = true;
item.dataset.type = asset.type;
item.dataset.assetId = asset.id;
item.title = asset.label;
if (asset.is_img) {
item.innerHTML = `<img src="/static/assets/${asset.icon}" width="22" height="22" loading="lazy">`;
} else {
item.innerHTML = `<i class="${asset.icon}" style="font-size: 18px; color: #64748b; ${asset.extra_style || ''}"></i>`;
}
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('assetId', asset.id);
state.assetMap[asset.id] = {
id: asset.id,
type: asset.type,
path: asset.is_img ? asset.icon : '',
label: asset.label
};
});
grid.appendChild(item);
});
container.appendChild(fixedSection);
}
+60
View File
@@ -0,0 +1,60 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
import { fetchAssets } from './api.js';
import { renderLibrary, createAssetElement, renderFlyout } from './renderer.js';
import { renderPackSelector } from './selector.js';
import { initSearch } from './search.js';
import { renderFixedSection } from './fixed_objects.js';
import { logger } from '../utils/logger.js';
/**
* initAssets - Main initialization for the asset library sidebar
*/
export async function initAssets() {
try {
const data = await fetchAssets();
const library = document.querySelector('.asset-library');
if (!library) return;
// 1. Initial Header & Global UI
library.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin: 24px 16px 12px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 8px;">
<h3 style="margin:0; font-size: 10px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--sub-text); font-weight: 800;">
${t('asset_library') || 'Asset Library'}
</h3>
<button id="manage-packs-btn" style="background:none; border:none; color: var(--sub-text); cursor:pointer; font-size: 12px; transition: color 0.2s;" title="Manage Packages">
<i class="fas fa-filter"></i>
</button>
</div>
`;
// 2. Setup Manage Packs Button
document.getElementById('manage-packs-btn').onclick = (e) => {
e.stopPropagation();
renderPackSelector(data.packs || []);
};
// 3. Initialize selection if empty
if (state.selectedPackIds.length === 0 && data.packs && data.packs.length > 0) {
state.selectedPackIds = data.packs.map(p => p.id);
localStorage.setItem('selectedPackIds', JSON.stringify(state.selectedPackIds));
}
// 4. Render Sections
renderFixedSection(library);
renderLibrary({ ...data, state }, library, renderPackSelector);
// 5. Handle Flyouts for Fixed/Custom Sections via Event
document.addEventListener('render-fixed-flyout', (e) => {
const { container, assets, meta } = e.detail;
renderFlyout(container, assets, meta);
});
initSearch();
} catch (e) {
logger.critical("Asset Initialization Error:", e);
}
}
export { createAssetElement, renderFlyout, renderPackSelector };
+197
View File
@@ -0,0 +1,197 @@
import { t } from '../i18n.js';
import { DEFAULTS } from '../constants.js';
/**
* createAssetElement - Creates a single asset item DOM element
* @param {Object} asset
* @returns {HTMLElement}
*/
export function createAssetElement(asset, compositeId = null) {
const item = document.createElement('div');
item.className = 'asset-item';
item.draggable = true;
item.dataset.type = asset.type || asset.id;
item.dataset.assetId = compositeId || asset.id;
item.title = asset.label;
// Support legacy 'path', new 'views.icon' or FontAwesome icon class
const isFontAwesome = asset.is_img === false || (!asset.path && !asset.views && asset.icon && asset.icon.includes('fa-'));
if (isFontAwesome) {
item.innerHTML = `
<i class="${asset.icon}" style="font-size: 18px; color: #64748b; ${asset.extra_style || ''}"></i>
<span>${t(asset.id) || asset.label || asset.id}</span>
`;
} else {
const iconPath = (asset.views && asset.views.icon) ? asset.views.icon : (asset.path || DEFAULTS.DEFAULT_ICON);
item.innerHTML = `
<img src="/static/assets/${iconPath}" width="24" height="24" loading="lazy">
<span>${t(asset.id) || asset.label || asset.id}</span>
`;
}
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('assetId', item.dataset.assetId);
});
return item;
}
/**
* renderFlyout - Renders the floating category window when sidebar is collapsed
* @param {HTMLElement} container
* @param {Array} assets
* @param {Object} meta
*/
export function renderFlyout(container, assets, meta) {
document.querySelectorAll('.category-flyout').forEach(f => f.remove());
const flyout = document.createElement('div');
flyout.className = 'category-flyout glass-panel active';
const rect = container.getBoundingClientRect();
flyout.style.top = `${Math.max(10, Math.min(window.innerHeight - 400, rect.top))}px`;
flyout.style.left = '90px';
flyout.innerHTML = `
<div class="flyout-header">
<strong>${t(meta.id) || meta.name || meta.label}</strong>
<small>${assets.length} items</small>
</div>
<div class="flyout-content" style="max-height: 400px; overflow-y: auto;"></div>
`;
const content = flyout.querySelector('.flyout-content');
// Group by category for flyout as well
const groups = assets.reduce((acc, a) => {
const cat = a.category || 'Other';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(a);
return acc;
}, {});
Object.keys(groups).sort().forEach(catName => {
if (Object.keys(groups).length > 1 || catName !== 'Other') {
const label = document.createElement('div');
label.className = 'asset-group-label';
label.textContent = catName;
content.appendChild(label);
}
const grid = document.createElement('div');
grid.className = 'asset-grid flyout-grid';
groups[catName].forEach(asset => grid.appendChild(createAssetElement(asset)));
content.appendChild(grid);
});
document.body.appendChild(flyout);
const closeFlyout = (e) => {
if (!flyout.contains(e.target)) {
flyout.remove();
document.removeEventListener('click', closeFlyout);
}
};
setTimeout(() => document.addEventListener('click', closeFlyout), 10);
}
/**
* renderLibrary - The main loop that renders the entire asset library
*/
export function renderLibrary(data, library, renderPackSelector) {
const { state } = data; // We expect state to be passed or accessible
const packs = data.packs || [];
const filteredPacks = packs.filter(p => state.selectedPackIds.includes(p.id));
// 1. Clear Library (except fixed categories and header)
const dynamicPacks = library.querySelectorAll('.asset-category:not(.fixed-category)');
dynamicPacks.forEach(p => p.remove());
// 2. Group Custom Assets by Pack -> Category
const packGroups = state.assetsData.reduce((acc, asset) => {
const packId = asset.pack_id || 'legacy';
if (packId !== 'legacy' && !state.selectedPackIds.includes(packId)) return acc;
const category = asset.category || 'Other';
if (!acc[packId]) acc[packId] = {};
if (!acc[packId][category]) acc[packId][category] = [];
acc[packId][category].push(asset);
return acc;
}, {});
// 3. Render each visible Pack
Object.keys(packGroups).forEach((packId, index) => {
const packMeta = packs.find(p => p.id === packId) || { id: packId, name: (packId === 'legacy' ? t('other_assets') : packId) };
const categories = packGroups[packId];
const allAssetsInPack = Object.values(categories).flat();
// Ensure proper path is set for pack-based assets
if (packId !== 'legacy') {
allAssetsInPack.forEach(asset => {
if (asset.views && asset.views.icon) {
const icon = asset.views.icon;
// Only prepend packs/ if not already present
if (!icon.startsWith('packs/')) {
asset.path = `packs/${packId}/${icon}`;
} else {
asset.path = icon;
}
}
});
}
const firstAsset = allAssetsInPack[0] || {};
const iconPath = (firstAsset.views && firstAsset.views.icon) ? firstAsset.views.icon : (firstAsset.path || DEFAULTS.DEFAULT_ICON);
const catContainer = document.createElement('div');
catContainer.className = `asset-category shadow-sm`;
const header = document.createElement('div');
header.className = 'category-header';
header.innerHTML = `<span>${packMeta.name || packMeta.id}</span><i class="fas fa-chevron-down"></i>`;
header.onclick = () => {
if (!document.getElementById('sidebar').classList.contains('collapsed')) {
catContainer.classList.toggle('active');
}
};
const collapsedIcon = document.createElement('div');
collapsedIcon.className = 'category-collapsed-icon';
collapsedIcon.title = packMeta.name || packMeta.id;
collapsedIcon.innerHTML = `<img src="/static/assets/${iconPath}" width="28" height="28">`;
collapsedIcon.onclick = (e) => {
if (document.getElementById('sidebar').classList.contains('collapsed')) {
e.stopPropagation();
renderFlyout(catContainer, allAssetsInPack, packMeta);
}
};
const content = document.createElement('div');
content.className = 'category-content';
// Render each Category within the Pack
Object.keys(categories).sort().forEach(catName => {
const assets = categories[catName];
if (Object.keys(categories).length > 1 || catName !== 'Other') {
const subHeader = document.createElement('div');
subHeader.className = 'asset-group-label';
subHeader.textContent = catName;
content.appendChild(subHeader);
}
const itemGrid = document.createElement('div');
itemGrid.className = 'asset-grid';
assets.forEach(asset => {
const compositeId = `${packId}|${asset.id}`;
state.assetMap[compositeId] = asset;
itemGrid.appendChild(createAssetElement(asset, compositeId));
});
content.appendChild(itemGrid);
});
catContainer.append(header, collapsedIcon, content);
library.appendChild(catContainer);
});
}
+35
View File
@@ -0,0 +1,35 @@
import { state } from '../state.js';
/**
* initSearch - Initializes the asset search input listener
*/
export function initSearch() {
const searchInput = document.getElementById('asset-search');
if (!searchInput) return;
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase().trim();
const categories = document.querySelectorAll('.asset-category');
categories.forEach(cat => {
const items = cat.querySelectorAll('.asset-item');
let hasVisibleItem = false;
items.forEach(item => {
const assetId = item.dataset.assetId;
const asset = state.assetMap[assetId];
if (!asset) return;
const matches = (asset.type && asset.type.toLowerCase().includes(query)) ||
(asset.label && asset.label.toLowerCase().includes(query)) ||
(asset.vendor && asset.vendor.toLowerCase().includes(query));
item.style.display = matches ? 'flex' : 'none';
if (matches) hasVisibleItem = true;
});
cat.style.display = (hasVisibleItem || query === '') ? 'block' : 'none';
if (query !== '' && hasVisibleItem) cat.classList.add('active');
});
});
}
+64
View File
@@ -0,0 +1,64 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
/**
* renderPackSelector - Show a modal to enable/disable asset packages
*/
export function renderPackSelector(packs) {
let modal = document.getElementById('pack-selector-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'pack-selector-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
const selectedIds = state.selectedPackIds;
modal.innerHTML = `
<div class="modal-content" style="width: 400px; max-height: 80vh; overflow-y: auto;">
<div class="modal-header">
<h3>${t('manage_packages') || 'Manage Packages'}</h3>
<i class="fas fa-times close-modal"></i>
</div>
<div class="modal-body" style="padding: 15px;">
<p style="font-size: 12px; color: #64748b; margin-bottom: 15px;">Select which asset packages to load in your sidebar.</p>
<div class="pack-list">
${packs.map(p => `
<div class="pack-item" style="display: flex; align-items: center; justify-content: space-between; padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.05);">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-box" style="color: #38bdf8; font-size: 14px;"></i>
<span style="font-size: 13px; font-weight: 600;">${p.name || p.id}</span>
</div>
<input type="checkbox" data-id="${p.id}" ${selectedIds.includes(p.id) ? 'checked' : ''} style="width: 18px; height: 18px; cursor: pointer;">
</div>
`).join('')}
</div>
</div>
<div class="modal-footer" style="padding: 15px; border-top: 1px solid rgba(255,255,255,0.05); text-align: right;">
<button class="btn-primary apply-packs" style="background: #38bdf8; color: #0f172a; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 700; cursor: pointer;">Apply & Refresh</button>
</div>
</div>
`;
modal.classList.add('active');
// Close handlers
modal.querySelector('.close-modal').onclick = () => modal.classList.remove('active');
modal.onclick = (e) => { if (e.target === modal) modal.classList.remove('active'); };
// Apply handler
modal.querySelector('.apply-packs').onclick = () => {
const checkboxes = modal.querySelectorAll('input[type="checkbox"]');
const newSelectedIds = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.dataset.id);
state.selectedPackIds = newSelectedIds;
localStorage.setItem('selectedPackIds', JSON.stringify(newSelectedIds));
// This is a global refresh to re-init everything with new filters
// For a more seamless experience, we could re-call initAssets()
window.location.reload();
};
}