// files.js - File Management and Display
(function() {
console.log("[files] Loading File Management module...");
// --- Utility Functions ---
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- Local Storage Functions ---
function getFilesFromLocalStorage() {
try {
return JSON.parse(localStorage.getItem('sftp_active_files') || '[]');
} catch {
return [];
}
}
function saveFilesToLocalStorage(files) {
try {
localStorage.setItem('sftp_active_files', JSON.stringify(files));
} catch (err) {
console.error('[files] Failed to save files:', err);
}
}
// --- File State Management ---
function setFileState(fileName, state) {
const files = getFilesFromLocalStorage();
const file = files.find(f => f.name === fileName);
if (!file) return;
if (state === 'active') {
files.forEach(f => {
if (f.name === fileName) {
f.active = true;
f.read = false;
} else {
f.active = false;
}
});
} else if (state === 'read') {
file.read = true;
file.active = false;
} else if (state === 'inactive') {
file.read = false;
file.active = false;
}
saveFilesToLocalStorage(files);
}
// --- Version Management ---
function restoreVersion(fileName, versionIndex) {
const files = getFilesFromLocalStorage();
const file = files.find(f => f.name === fileName);
if (!file || !file.versions || !file.versions[versionIndex]) return;
// Restore the selected version to current content
file.content = file.versions[versionIndex].content;
saveFilesToLocalStorage(files);
// Trigger update event
window.dispatchEvent(new Event('activeFilesUpdated'));
}
function deleteVersion(fileName, versionIndex) {
const files = getFilesFromLocalStorage();
const file = files.find(f => f.name === fileName);
if (!file || !file.versions || !file.versions[versionIndex]) return;
// Remove the version
file.versions.splice(versionIndex, 1);
// Relabel remaining versions
file.versions.forEach((version, idx) => {
version.label = `v${idx + 1}`;
});
saveFilesToLocalStorage(files);
// Trigger update event
window.dispatchEvent(new Event('activeFilesUpdated'));
}
// --- New File Dialog ---
async function showNewFileDialog(container, onFileChange) {
// Fetch templates from server via templates.php
let templates = [];
try {
const response = await fetch('templates.php?action=list');
const data = await response.json();
if (data.success && Array.isArray(data.templates)) {
templates = data.templates.map(name => ({
name: name,
path: name
}));
console.log('[files] Found templates:', templates);
} else {
console.error('[files] Failed to load templates:', data.error || 'Unknown error');
}
} catch (error) {
console.error('[files] Failed to load templates:', error);
}
// Create dialog overlay
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 2147483647;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background: #1a1a1a;
border: 2px solid #3a3a3a;
border-radius: 12px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
`;
dialog.innerHTML = `
<h2 style="margin: 0 0 20px 0; color: #e6edf3; font-size: 20px; font-weight: 700;">
📄 Create New File
</h2>
<div style="margin-bottom: 16px;">
<label style="display: block; color: #9ca3af; font-size: 13px; font-weight: 600; margin-bottom: 8px;">
File Name
</label>
<input
id="newFileName"
type="text"
placeholder="my-file.php"
style="
width: 100%;
padding: 10px 12px;
background: #0a0a0a;
border: 1px solid #3a3a3a;
border-radius: 6px;
color: #e0e0e0;
font-size: 14px;
font-family: monospace;
outline: none;
"
/>
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; color: #9ca3af; font-size: 13px; font-weight: 600; margin-bottom: 8px;">
Start from Template
</label>
<div id="templateList" style="
max-height: 300px;
overflow-y: auto;
border: 1px solid #3a3a3a;
border-radius: 6px;
background: #0a0a0a;
">
<div class="template-option" data-template="" style="
padding: 12px;
border-bottom: 1px solid #2a2a2a;
cursor: pointer;
transition: background 0.2s;
">
<div style="font-weight: 600; color: #e6edf3;">📝 Empty File</div>
<div style="font-size: 12px; color: #666; margin-top: 4px;">Start with a blank file</div>
</div>
${templates.length === 0 ? `
<div style="padding: 12px; color: #666; font-size: 13px; text-align: center;">
No templates found in /templates folder
</div>
` : ''}
</div>
</div>
<div style="display: flex; gap: 12px;">
<button id="createFileBtn" style="
flex: 1;
padding: 12px;
background: #16a34a;
border: 1px solid #15803d;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 14px;
font-weight: 700;
transition: all 0.2s;
">Create File</button>
<button id="cancelFileBtn" style="
flex: 1;
padding: 12px;
background: #374151;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e0e0e0;
cursor: pointer;
font-size: 14px;
font-weight: 700;
transition: all 0.2s;
">Cancel</button>
</div>
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
const fileNameInput = dialog.querySelector('#newFileName');
const templateList = dialog.querySelector('#templateList');
const createBtn = dialog.querySelector('#createFileBtn');
const cancelBtn = dialog.querySelector('#cancelFileBtn');
let selectedTemplate = '';
// Add template options
templates.forEach(template => {
const option = document.createElement('div');
option.className = 'template-option';
option.dataset.template = template.path;
option.style.cssText = `
padding: 12px;
border-bottom: 1px solid #2a2a2a;
cursor: pointer;
transition: background 0.2s;
`;
option.innerHTML = `
<div style="font-weight: 600; color: #e6edf3;">📄 ${escapeHtml(template.name)}</div>
<div style="font-size: 11px; color: #666; margin-top: 4px; font-family: monospace;">
${escapeHtml(template.path)}
</div>
`;
templateList.appendChild(option);
});
// Template selection
templateList.addEventListener('click', (e) => {
const option = e.target.closest('.template-option');
if (!option) return;
templateList.querySelectorAll('.template-option').forEach(opt => {
opt.style.background = '#0a0a0a';
opt.style.borderLeftWidth = '0';
});
option.style.background = '#1a1a1a';
option.style.borderLeft = '3px solid #16a34a';
selectedTemplate = option.dataset.template;
});
// Hover effects for templates
templateList.addEventListener('mouseover', (e) => {
const option = e.target.closest('.template-option');
if (option && option.style.background !== 'rgb(26, 26, 26)') {
option.style.background = '#151515';
}
});
templateList.addEventListener('mouseout', (e) => {
const option = e.target.closest('.template-option');
if (option && option.style.background !== 'rgb(26, 26, 26)') {
option.style.background = '#0a0a0a';
}
});
// Create file handler
const createFile = async () => {
const fileName = fileNameInput.value.trim();
if (!fileName) {
alert('Please enter a file name');
return;
}
const files = getFilesFromLocalStorage();
if (files.find(f => f.name === fileName)) {
alert('A file with this name already exists');
return;
}
let content = '';
// Load template content if selected
if (selectedTemplate) {
try {
const response = await fetch('templates.php?action=read&file=' + encodeURIComponent(selectedTemplate));
const data = await response.json();
if (data.success) {
content = data.content;
console.log('[files] Loaded template content from:', selectedTemplate);
} else {
console.error('[files] Failed to load template:', data.error);
alert('Failed to load template. Starting with empty file.');
}
} catch (error) {
console.error('[files] Failed to load template:', error);
alert('Failed to load template. Starting with empty file.');
}
}
// Add new file
files.push({
name: fileName,
path: fileName,
content: content,
active: false,
read: false,
versions: []
});
saveFilesToLocalStorage(files);
window.dispatchEvent(new Event('activeFilesUpdated'));
// Close dialog
document.body.removeChild(overlay);
// Refresh file list
renderFilesList(container, onFileChange);
if (onFileChange) onFileChange();
};
createBtn.addEventListener('click', createFile);
cancelBtn.addEventListener('click', () => {
document.body.removeChild(overlay);
});
// Enter to create
fileNameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
createFile();
}
});
// Close on overlay click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
document.body.removeChild(overlay);
}
});
// Focus input
fileNameInput.focus();
}
// --- Files List Rendering ---
function renderFilesList(container, onFileChange) {
const files = getFilesFromLocalStorage();
container.innerHTML = '';
if (files.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.style.cssText = `
color: #666;
text-align: center;
padding: 40px;
font-size: 14px;
`;
emptyMsg.textContent = '📁 No files open - open files from Storage Editor';
container.appendChild(emptyMsg);
return;
}
// Header with New File button
const header = document.createElement('div');
header.style.cssText = `
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid #2a2a2a;
display: flex;
justify-content: space-between;
align-items: center;
`;
const headerLeft = document.createElement('div');
headerLeft.innerHTML = `
<h2 style="margin: 0; color: #e6edf3; font-size: 18px; font-weight: 700;">
📁 All Files (${files.length})
</h2>
<p style="margin: 8px 0 0 0; color: #64748b; font-size: 13px;">
Click file name to set state | Click versions to view history
</p>
`;
const newFileBtn = document.createElement('button');
newFileBtn.style.cssText = `
padding: 10px 20px;
background: #16a34a;
border: 1px solid #15803d;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.2s;
`;
newFileBtn.textContent = '+ New File';
newFileBtn.addEventListener('click', async () => {
await showNewFileDialog(container, onFileChange);
});
newFileBtn.addEventListener('mouseenter', () => {
newFileBtn.style.background = '#15803d';
});
newFileBtn.addEventListener('mouseleave', () => {
newFileBtn.style.background = '#16a34a';
});
header.appendChild(headerLeft);
header.appendChild(newFileBtn);
container.appendChild(header);
// Files grid
files.forEach(file => {
const fileCard = document.createElement('div');
fileCard.style.cssText = `
background: #1a1a1a;
border: 2px solid #2a2a2a;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
transition: all 0.2s;
`;
// Style based on state
if (file.active) {
fileCard.style.borderColor = '#16a34a';
fileCard.style.background = 'rgba(22, 163, 74, 0.1)';
} else if (file.read) {
fileCard.style.borderColor = '#3b82f6';
fileCard.style.background = 'rgba(59, 130, 246, 0.1)';
}
const statusBadge = file.active ? '🟢 ACTIVE' : file.read ? '🔵 READ' : '⚪ INACTIVE';
const statusColor = file.active ? '#16a34a' : file.read ? '#3b82f6' : '#666';
const versionCount = file.versions ? file.versions.length : 0;
const fileHeader = document.createElement('div');
fileHeader.style.cssText = `
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 8px;
cursor: pointer;
`;
fileHeader.innerHTML = `
<div style="
font-weight: 700;
color: #e6edf3;
font-size: 15px;
font-family: monospace;
">${escapeHtml(file.name)}</div>
<div style="
color: ${statusColor};
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
background: rgba(0,0,0,0.3);
border-radius: 4px;
">${statusBadge}</div>
`;
// Click file name to cycle states
fileHeader.addEventListener('click', () => {
if (!file.active && !file.read) {
setFileState(file.name, 'read');
} else if (file.read) {
setFileState(file.name, 'active');
} else if (file.active) {
setFileState(file.name, 'inactive');
}
renderFilesList(container, onFileChange);
if (onFileChange) onFileChange();
});
fileCard.appendChild(fileHeader);
// Path
const pathDiv = document.createElement('div');
pathDiv.style.cssText = `
color: #64748b;
font-size: 12px;
margin-bottom: 8px;
`;
pathDiv.textContent = file.path || 'No path';
fileCard.appendChild(pathDiv);
// Preview
const previewDiv = document.createElement('div');
previewDiv.style.cssText = `
color: #888;
font-size: 11px;
font-family: monospace;
background: #0a0a0a;
padding: 8px;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 8px;
`;
previewDiv.textContent = file.content ? file.content.substring(0, 80) + '...' : '(empty)';
fileCard.appendChild(previewDiv);
// Versions section
if (versionCount > 0) {
const versionsHeader = document.createElement('div');
versionsHeader.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-top: 1px solid #2a2a2a;
margin-top: 8px;
cursor: pointer;
user-select: none;
`;
versionsHeader.innerHTML = `
<div style="color: #3b82f6; font-size: 12px; font-weight: 600;">
<span class="version-toggle">▶</span> 💾 ${versionCount} Version${versionCount > 1 ? 's' : ''}
</div>
`;
const versionsBody = document.createElement('div');
versionsBody.style.cssText = `
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
margin-top: 8px;
`;
// Render versions (newest first)
const reversedVersions = [...file.versions].reverse();
reversedVersions.forEach((version, idx) => {
const realIndex = file.versions.length - 1 - idx;
const date = new Date(version.timestamp);
const isCurrent = file.content === version.content;
const versionItem = document.createElement('div');
versionItem.style.cssText = `
display: flex;
align-items: stretch;
margin-bottom: 6px;
gap: 4px;
`;
const versionBtn = document.createElement('button');
versionBtn.style.cssText = `
flex: 1;
text-align: left;
padding: 8px 12px;
background: ${isCurrent ? '#1e3a5f' : '#0a0a0a'};
border: 1px solid ${isCurrent ? '#3b82f6' : '#2a2a2a'};
border-radius: 4px;
color: #e0e0e0;
cursor: ${isCurrent ? 'default' : 'pointer'};
font-size: 12px;
transition: all 0.2s;
`;
versionBtn.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<span style="font-weight: 700; color: ${isCurrent ? '#3b82f6' : '#fff'};">
${version.label} ${isCurrent ? '(Current)' : ''}
</span>
<span style="color: #666; font-size: 10px;">${date.toLocaleString()}</span>
</div>
<div style="color: #888; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
${version.content.substring(0, 60)}${version.content.length > 60 ? '...' : ''}
</div>
`;
if (!isCurrent) {
versionBtn.addEventListener('click', () => {
if (confirm(`Restore ${version.label}? This will replace the current content.`)) {
restoreVersion(file.name, realIndex);
renderFilesList(container, onFileChange);
if (onFileChange) onFileChange();
}
});
versionBtn.addEventListener('mouseenter', () => {
versionBtn.style.background = '#1a1a1a';
versionBtn.style.borderColor = '#3a3a3a';
});
versionBtn.addEventListener('mouseleave', () => {
versionBtn.style.background = '#0a0a0a';
versionBtn.style.borderColor = '#2a2a2a';
});
}
// Delete button
const deleteBtn = document.createElement('button');
deleteBtn.style.cssText = `
width: 36px;
padding: 8px;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 4px;
color: #ef4444;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
`;
deleteBtn.innerHTML = '🗑️';
deleteBtn.title = `Delete ${version.label}`;
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (confirm(`Delete ${version.label}? This cannot be undone.`)) {
deleteVersion(file.name, realIndex);
renderFilesList(container, onFileChange);
if (onFileChange) onFileChange();
}
});
deleteBtn.addEventListener('mouseenter', () => {
deleteBtn.style.background = '#ef4444';
deleteBtn.style.borderColor = '#dc2626';
deleteBtn.style.color = '#fff';
});
deleteBtn.addEventListener('mouseleave', () => {
deleteBtn.style.background = '#1a1a1a';
deleteBtn.style.borderColor = '#2a2a2a';
deleteBtn.style.color = '#ef4444';
});
versionItem.appendChild(versionBtn);
versionItem.appendChild(deleteBtn);
versionsBody.appendChild(versionItem);
});
// Toggle versions
let versionsExpanded = false;
versionsHeader.addEventListener('click', () => {
versionsExpanded = !versionsExpanded;
const toggle = versionsHeader.querySelector('.version-toggle');
if (versionsExpanded) {
versionsBody.style.maxHeight = versionsBody.scrollHeight + 'px';
toggle.textContent = '▼';
} else {
versionsBody.style.maxHeight = '0';
toggle.textContent = '▶';
}
});
fileCard.appendChild(versionsHeader);
fileCard.appendChild(versionsBody);
}
container.appendChild(fileCard);
});
}
// --- Expose API ---
window.FilesManager = {
getFiles: getFilesFromLocalStorage,
saveFiles: saveFilesToLocalStorage,
setFileState: setFileState,
render: renderFilesList,
restoreVersion: restoreVersion,
deleteVersion: deleteVersion
};
console.log('[files] File Management module loaded');
})();