// files.js - File browser module for tile images
// Persistent selection across folders using a shared SelectionStore on window.
(function(){
// --- Shared selection store (created once) ---
(function initSelectionStore(){
if (window.SelectionStore) return;
const KEY = 'tile_selection_v1';
const listeners = new Set();
let items = [];
try {
const raw = localStorage.getItem(KEY);
if (raw) items = JSON.parse(raw) || [];
} catch {}
function save(){ try { localStorage.setItem(KEY, JSON.stringify(items)); } catch {} }
function notify(){ listeners.forEach(fn => { try { fn(getAll()); } catch {} }); }
function getAll(){ return items.slice(); }
function indexOf(url){ return items.findIndex(i => i.url === url); }
function has(url){ return indexOf(url) !== -1; }
function add(entry){
if (!entry || !entry.url) return false;
if (has(entry.url)) return false;
items.push({ url: entry.url, name: entry.name || 'image' });
save(); notify(); return true;
}
function remove(url){
const i = indexOf(url);
if (i === -1) return false;
items.splice(i, 1); save(); notify(); return true;
}
function clear(){
if (!items.length) return false;
items = []; save(); notify(); return true;
}
function subscribe(fn){ listeners.add(fn); return ()=>listeners.delete(fn); }
window.SelectionStore = { getAll, add, remove, clear, has, indexOf, subscribe };
})();
let currentSub = '';
let currentFolder = '';
// --- Data loading ---
async function loadMedia(sub = '') {
try {
const url = sub ? `media.php?sub=${encodeURIComponent(sub)}` : 'media.php';
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw new Error('Failed to load media');
return await res.json();
} catch (err) {
console.error('Media load error:', err);
return { breadcrumb: [], folders: [], images: [], error: err.message };
}
}
// --- Render helpers ---
function renderBreadcrumb(crumbs) {
if (!crumbs || crumbs.length === 0) return '';
return `
<nav id="files-breadcrumbs" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1rem;flex-wrap:wrap;">
${crumbs.map((c, i) => `
<button class="breadcrumb-btn" data-sub="${c.sub || ''}"
style="background:rgba(30,41,59,0.6);border:1px solid rgba(71,85,105,0.4);color:#f1f5f9;
padding:.375rem .75rem;border-radius:.5rem;cursor:pointer;font-size:.875rem;font-weight:500;">
${c.label}
</button>
${i < crumbs.length - 1 ? '<span style="color:#94a3b8;">›</span>' : ''}
`).join('')}
</nav>
`;
}
function renderSelectionTank(){
return `
<div id="selection-wrap" style="margin-bottom:.75rem;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-bottom:.5rem;">
<h3 style="color:#94a3b8;font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin:0;">Selected</h3>
<div style="display:flex;align-items:center;gap:.5rem;">
<span id="files-selection-count"
style="display:none;background:rgba(59,130,246,0.15);border:1px solid rgba(59,130,246,0.35);color:#dbeafe;font-size:.75rem;padding:.35rem .6rem;border-radius:.5rem;">
0 selected
</span>
<button id="files-clear-selection" type="button"
style="display:none;background:rgba(148,163,184,0.12);border:1px solid rgba(148,163,184,0.25);color:#e2e8f0;font-size:.75rem;padding:.35rem .6rem;border-radius:.5rem;cursor:pointer;">
Clear all
</button>
</div>
</div>
<div id="selection-tank"
style="display:flex;gap:.5rem;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;
padding:.35rem;border:1px solid rgba(71,85,105,0.35);border-radius:.75rem;
background:linear-gradient(135deg, rgba(30,41,59,0.4), rgba(15,23,42,0.4));scrollbar-width:thin;"
aria-label="Selected images"></div>
</div>
`;
}
function chipEl({url, name}){
const chip = document.createElement('div');
chip.className = 'tank-chip';
chip.dataset.url = url;
chip.title = (name || 'image');
chip.style.cssText = `
flex:0 0 auto; position:relative; display:flex; align-items:center; gap:.25rem;
padding:.2rem; border:1px solid rgba(148,163,184,0.25); border-radius:.5rem; background:rgba(30,41,59,0.6);
`;
const thumbWrap = document.createElement('div');
thumbWrap.style.cssText = `
width:36px; height:36px; border-radius:.35rem; overflow:hidden; background:#0a0f1c;
display:flex; align-items:center; justify-content:center;
`;
const img = document.createElement('img');
img.src = url; img.alt = name || 'image';
img.decoding = 'async'; img.loading = 'lazy'; img.referrerPolicy = 'no-referrer';
img.width = 36; img.height = 36;
img.style.cssText = 'width:100%; height:100%; object-fit:cover;';
thumbWrap.appendChild(img);
const x = document.createElement('button');
x.type = 'button'; x.className = 'tank-remove'; x.setAttribute('aria-label','Remove'); x.textContent = '✕';
x.style.cssText = 'border:none;background:transparent;color:#94a3b8;cursor:pointer;font-size:1rem;line-height:1;';
chip.appendChild(thumbWrap);
chip.appendChild(x);
return chip;
}
function rebuildTank(){
const tank = document.getElementById('selection-tank');
if (!tank) return;
tank.textContent = '';
window.SelectionStore.getAll().forEach(item => tank.appendChild(chipEl(item)));
updateSelectionCount();
}
function updateSelectionCount(){
const countEl = document.getElementById('files-selection-count');
const clearBtn = document.getElementById('files-clear-selection');
const len = window.SelectionStore.getAll().length;
if (countEl) {
countEl.textContent = `${len} selected`;
countEl.style.display = len ? 'inline-block' : 'none';
}
if (clearBtn) clearBtn.style.display = len ? 'inline-flex' : 'none';
}
function renderFolders(folders) {
if (!folders || folders.length === 0) return '';
return `
<div style="margin-bottom: .75rem;">
<h3 style="color:#94a3b8;font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin:0 0 .5rem 0;">Folders</h3>
<div id="files-folders" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:.75rem;">
${folders.map(f => `
<button class="folder-btn" data-sub="${f.sub}"
style="background:linear-gradient(135deg, rgba(30,41,59,0.8), rgba(51,65,85,0.8));
border:1px solid rgba(71,85,105,0.4);border-radius:.75rem;padding:1rem;cursor:pointer;transition:.2s;text-align:center;">
<div style="font-size:2rem;margin-bottom:.5rem;">📁</div>
<div style="color:#f1f5f9;font-size:.875rem;font-weight:500;word-break:break-word;">${f.name}</div>
</button>
`).join('')}
</div>
</div>
`;
}
function renderImages(images) {
if (!images || images.length === 0) {
return `
<div style="display:flex; align-items:center; justify-content:space-between; margin-top:1rem;">
<h3 style="color:#94a3b8;font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;">Images</h3>
</div>
<p style="color:#94a3b8;text-align:center;padding:2rem;">No images found in this folder</p>`;
}
return `
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:1rem;">
<h3 style="color:#94a3b8;font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;">Images</h3>
</div>
<div id="files-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:1rem;">
${images.map(img => `
<div class="image-card" data-url="${img.url}" data-name="${img.name || ''}"
style="background:rgba(30,41,59,0.6);border:1px solid rgba(71,85,105,0.4);border-radius:.75rem;overflow:hidden;transition:.2s;cursor:pointer;user-select:none;">
<div style="aspect-ratio:1;background:#0a0f1c;display:flex;align-items:center;justify-content:center;overflow:hidden;">
<img src="${img.url}" alt="${img.name}" loading="lazy" decoding="async" referrerpolicy="no-referrer"
style="width:100%;height:100%;object-fit:contain;">
</div>
<div style="padding:.75rem;display:flex;align-items:center;justify-content:space-between;gap:.5rem;">
<div style="color:#f1f5f9;font-size:.75rem;font-weight:500;word-break:break-word;">${img.name}</div>
<div class="check" aria-hidden="true" style="display:none;font-size:.9rem;">✔︎</div>
</div>
</div>
`).join('')}
</div>
`;
}
async function renderContent(sub = '') {
const data = await loadMedia(sub);
currentSub = sub;
// DO NOT clear selection on folder change (persist across folders)
// Current folder name (last path segment)
const parts = sub.split('/').filter(Boolean);
currentFolder = parts.length ? parts[parts.length - 1] : '';
if (data.error) {
return `<div style="color:#ef4444;padding:1rem;text-align:center;">Error: ${data.error}</div>`;
}
return `
<div id="files-content" style="height:100%;overflow-y:auto;padding:.5rem;">
${renderBreadcrumb(data.breadcrumb)}
${renderSelectionTank()}
${renderFolders(data.folders)}
${renderImages(data.images)}
</div>
`;
}
function openCutWithSelection(list, index){
window.dispatchEvent(new CustomEvent('imageSelected', {
detail: { images: list, index, folder: currentFolder }
}));
if (window.AppOverlay) {
AppOverlay.close();
setTimeout(() => {
const cutBtn = Array.from(document.querySelectorAll('.chip')).find(b => b.textContent.includes('✂️'));
if (cutBtn) cutBtn.click();
}, 200);
}
}
// --- Event delegation (bind once) ---
function bindDelegates(){
const root = document.body;
// React to store changes globally (tank + badges + grid highlights)
window.SelectionStore.subscribe(() => {
rebuildTank();
// Refresh grid highlights (efficiently)
document.querySelectorAll('.image-card').forEach(card=>{
const sel = window.SelectionStore.has(card.dataset.url);
if (sel) {
card.classList.add('is-selected');
card.style.borderColor = 'rgba(59,130,246,0.9)';
card.style.boxShadow = '0 0 0 2px rgba(59,130,246,0.35) inset';
} else {
card.classList.remove('is-selected');
card.style.borderColor = 'rgba(71,85,105,0.4)';
card.style.boxShadow = 'none';
}
});
});
// Clear selection - ENHANCED to clear everything
root.addEventListener('click', (e)=>{
const btn = e.target.closest('#files-clear-selection');
if (!btn) return;
if (confirm('Clear all data? This will remove selected files AND all sprite data from Cut and Objects.')) {
// Clear file selection
window.SelectionStore.clear();
// Clear all cutout data (all versions)
try {
localStorage.removeItem('tile_holders_v4');
localStorage.removeItem('tile_holders_v3');
localStorage.removeItem('tile_holders_v1');
} catch (e) {
console.warn('Could not clear cutout data:', e);
}
// Trigger storage event to update other modules
window.dispatchEvent(new StorageEvent('storage', {
key: 'tile_holders_v4',
oldValue: 'dummy',
newValue: null,
storageArea: localStorage
}));
showMiniToast('All data cleared');
}
});
// Breadcrumb nav
root.addEventListener('click', async (e)=>{
const btn = e.target.closest('.breadcrumb-btn');
if (!btn) return;
const sub = btn.dataset.sub || '';
const container = document.getElementById('files-content');
if (!container) return;
container.innerHTML = '<div style="padding:2rem;color:#94a3b8;text-align:center;">Loading…</div>';
const newHtml = await renderContent(sub);
container.outerHTML = newHtml;
// rebuild tank for this view
rebuildTank();
});
// Folder nav
root.addEventListener('click', async (e)=>{
const btn = e.target.closest('.folder-btn');
if (!btn) return;
const sub = btn.dataset.sub || '';
const container = document.getElementById('files-content');
if (!container) return;
container.innerHTML = '<div style="padding:2rem;color:#94a3b8;text-align:center;">Loading…</div>';
const newHtml = await renderContent(sub);
container.outerHTML = newHtml;
rebuildTank();
});
// Tank: remove / dblclick open / click = show name
root.addEventListener('click', (e)=>{
const x = e.target.closest('.tank-remove');
if (!x) return;
const chip = x.closest('.tank-chip');
const url = chip?.dataset.url;
if (!url) return;
window.SelectionStore.remove(url);
});
root.addEventListener('dblclick', (e)=>{
const chip = e.target.closest('.tank-chip');
if (!chip) return;
const url = chip.dataset.url;
const list = window.SelectionStore.getAll();
const idx = Math.max(0, list.findIndex(i => i.url === url));
openCutWithSelection(list, idx);
});
root.addEventListener('click', (e)=>{
const chip = e.target.closest('.tank-chip');
if (!chip || e.target.closest('.tank-remove')) return;
showMiniToast(chip.title || 'image');
});
// Grid: select / dblclick
root.addEventListener('click', (e)=>{
const card = e.target.closest('.image-card');
if (!card) return;
const grid = document.getElementById('files-grid');
if (!grid || !grid.contains(card)) return;
const url = card.dataset.url;
const name = card.dataset.name || 'image';
if (!window.SelectionStore.has(url)) {
window.SelectionStore.add({ url, name });
} else {
window.SelectionStore.remove(url);
}
});
root.addEventListener('dblclick', (e)=>{
const card = e.target.closest('.image-card');
if (!card) return;
const grid = document.getElementById('files-grid');
if (!grid || !grid.contains(card)) return;
const url = card.dataset.url;
const name = card.dataset.name || 'image';
if (!window.SelectionStore.has(url)) {
window.SelectionStore.add({ url, name });
}
const list = window.SelectionStore.getAll();
const index = Math.max(0, list.findIndex(i => i.url === url));
openCutWithSelection(list, index);
});
// Keyboard: Enter toggles, Shift+Enter opens Cut
root.addEventListener('keydown', (e)=>{
const card = e.target.closest('.image-card');
if (!card) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
card.click();
} else if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
const evt = new MouseEvent('dblclick', { bubbles: true });
card.dispatchEvent(evt);
}
});
}
// Simple toast for names
function showMiniToast(text){
let toast = document.getElementById('files-mini-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'files-mini-toast';
toast.style.position = 'fixed';
toast.style.left = '50%';
toast.style.bottom = '16px';
toast.style.transform = 'translateX(-50%)';
toast.style.background = 'rgba(15,23,42,0.95)';
toast.style.border = '1px solid rgba(148,163,184,0.3)';
toast.style.color = '#e2e8f0';
toast.style.padding = '.5rem .75rem';
toast.style.borderRadius = '.5rem';
toast.style.fontSize = '.85rem';
toast.style.zIndex = '99999';
toast.style.boxShadow = '0 10px 20px rgba(0,0,0,.25)';
document.body.appendChild(toast);
}
toast.textContent = text;
toast.style.opacity = '1';
clearTimeout(showMiniToast._t);
showMiniToast._t = setTimeout(()=> toast.style.opacity = '0', 900);
}
// --- Boot ---
window.AppItems = window.AppItems || [];
const filesIndex = window.AppItems.length;
window.AppItems.push({
title: '📁 Files',
html: '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#94a3b8;">Loading files...</div>'
});
(async function init() {
const initialHtml = await renderContent('');
window.AppItems[filesIndex].html = initialHtml;
// Bind delegates ONCE
bindDelegates();
// Build tank from any saved selection
rebuildTank();
})();
})();