Update.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
class AdminBilling extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.pricingConfig = [];
|
||||
this.stats = null;
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.addEventListener('click', this.boundHandleClick);
|
||||
await this.loadData();
|
||||
this.render();
|
||||
this.attachEventListeners();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('click', this.boundHandleClick);
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
const [pricing, stats] = await Promise.all([
|
||||
this.fetchPricing(),
|
||||
this.fetchStats()
|
||||
]);
|
||||
|
||||
this.pricingConfig = pricing;
|
||||
this.stats = stats;
|
||||
} catch (error) {
|
||||
console.error('Failed to load admin billing data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchPricing() {
|
||||
const response = await fetch('/api/admin/billing/pricing', {
|
||||
headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`}
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async fetchStats() {
|
||||
const response = await fetch('/api/admin/billing/stats', {
|
||||
headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`}
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="admin-billing">
|
||||
<h2>Billing Administration</h2>
|
||||
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
<h3>Total Revenue</h3>
|
||||
<div class="stat-value">${this.formatCurrency(this.stats?.total_revenue || 0)}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Total Invoices</h3>
|
||||
<div class="stat-value">${this.stats?.total_invoices || 0}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Pending Invoices</h3>
|
||||
<div class="stat-value">${this.stats?.pending_invoices || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pricing-config-section">
|
||||
<h3>Pricing Configuration</h3>
|
||||
<table class="pricing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Configuration</th>
|
||||
<th>Current Value</th>
|
||||
<th>Unit</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.pricingConfig.map(config => `
|
||||
<tr data-config-id="${config.id}">
|
||||
<td>${config.description || config.config_key}</td>
|
||||
<td class="config-value">${config.config_value}</td>
|
||||
<td>${config.unit || '-'}</td>
|
||||
<td>
|
||||
<button class="btn-edit" data-config-id="${config.id}">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="invoice-generation-section">
|
||||
<h3>Generate Invoices</h3>
|
||||
<div class="invoice-gen-form">
|
||||
<label>
|
||||
Year:
|
||||
<input type="number" id="invoiceYear" value="${new Date().getFullYear()}" min="2020">
|
||||
</label>
|
||||
<label>
|
||||
Month:
|
||||
<input type="number" id="invoiceMonth" value="${new Date().getMonth() + 1}" min="1" max="12">
|
||||
</label>
|
||||
<button class="btn-primary" id="generateInvoices">Generate All Invoices</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
const target = e.target;
|
||||
|
||||
if (target.classList.contains('btn-edit')) {
|
||||
const configId = target.dataset.configId;
|
||||
this.editPricing(configId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === 'generateInvoices') {
|
||||
this.generateInvoices();
|
||||
}
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
}
|
||||
|
||||
async editPricing(configId) {
|
||||
const config = this.pricingConfig.find(c => c.id === parseInt(configId));
|
||||
if (!config) return;
|
||||
|
||||
const newValue = prompt(`Enter new value for ${config.config_key}:`, config.config_value);
|
||||
if (newValue === null) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/billing/pricing/${configId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
config_key: config.config_key,
|
||||
config_value: parseFloat(newValue)
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Pricing updated successfully');
|
||||
await this.loadData();
|
||||
this.render();
|
||||
this.attachEventListeners();
|
||||
} else {
|
||||
alert('Failed to update pricing');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating pricing:', error);
|
||||
alert('Error updating pricing');
|
||||
}
|
||||
}
|
||||
|
||||
async generateInvoices() {
|
||||
const year = parseInt(this.querySelector('#invoiceYear').value);
|
||||
const month = parseInt(this.querySelector('#invoiceMonth').value);
|
||||
|
||||
if (!confirm(`Generate invoices for ${month}/${year}?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/billing/generate-invoices/${year}/${month}`, {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
alert(`Generated ${result.generated} invoices, skipped ${result.skipped} users`);
|
||||
} catch (error) {
|
||||
console.error('Error generating invoices:', error);
|
||||
alert('Failed to generate invoices');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('admin-billing', AdminBilling);
|
||||
|
||||
export default AdminBilling;
|
||||
@@ -4,12 +4,21 @@ export class AdminDashboard extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.users = [];
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
this.boundHandleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.addEventListener('click', this.boundHandleClick);
|
||||
this.addEventListener('submit', this.boundHandleSubmit);
|
||||
await this.loadUsers();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('click', this.boundHandleClick);
|
||||
this.removeEventListener('submit', this.boundHandleSubmit);
|
||||
}
|
||||
|
||||
async loadUsers() {
|
||||
try {
|
||||
this.users = await api.listUsers();
|
||||
@@ -61,24 +70,35 @@ export class AdminDashboard extends HTMLElement {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.querySelector('#createUserButton').addEventListener('click', () => this._showUserModal());
|
||||
this.querySelector('.user-list').addEventListener('click', this._handleUserAction.bind(this));
|
||||
this.querySelector('.close-button').addEventListener('click', () => this.querySelector('#userModal').style.display = 'none');
|
||||
this.querySelector('#userForm').addEventListener('submit', this._handleUserFormSubmit.bind(this));
|
||||
}
|
||||
|
||||
_handleUserAction(event) {
|
||||
const target = event.target;
|
||||
handleClick(e) {
|
||||
const target = e.target;
|
||||
|
||||
if (target.id === 'createUserButton') {
|
||||
this._showUserModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('close-button')) {
|
||||
this.querySelector('#userModal').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const userItem = target.closest('.user-item');
|
||||
if (!userItem) return;
|
||||
if (userItem) {
|
||||
const userId = userItem.dataset.userId;
|
||||
if (target.classList.contains('button-danger')) {
|
||||
this._deleteUser(userId);
|
||||
} else if (target.classList.contains('button')) {
|
||||
this._showUserModal(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userId = userItem.dataset.userId; // Assuming user ID will be stored in data-userId attribute
|
||||
|
||||
if (target.classList.contains('button-danger')) {
|
||||
this._deleteUser(userId);
|
||||
} else if (target.classList.contains('button')) { // Edit button
|
||||
this._showUserModal(userId);
|
||||
handleSubmit(e) {
|
||||
if (e.target.id === 'userForm') {
|
||||
this._handleUserFormSubmit(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
export class BaseFileList extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.files = [];
|
||||
this.folders = [];
|
||||
this.selectedFiles = new Set();
|
||||
this.selectedFolders = new Set();
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
this.boundHandleDblClick = this.handleDblClick.bind(this);
|
||||
this.boundHandleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener('click', this.boundHandleClick);
|
||||
this.addEventListener('dblclick', this.boundHandleDblClick);
|
||||
this.addEventListener('change', this.boundHandleChange);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('click', this.boundHandleClick);
|
||||
this.removeEventListener('dblclick', this.boundHandleDblClick);
|
||||
this.removeEventListener('change', this.boundHandleChange);
|
||||
}
|
||||
|
||||
isEditableFile(filename, mimeType) {
|
||||
if (mimeType && mimeType.startsWith('text/')) return true;
|
||||
|
||||
const editableExtensions = [
|
||||
'txt', 'md', 'log', 'json', 'js', 'py', 'html', 'css',
|
||||
'xml', 'yaml', 'yml', 'sh', 'bat', 'ini', 'conf', 'cfg'
|
||||
];
|
||||
const extension = filename.split('.').pop().toLowerCase();
|
||||
return editableExtensions.includes(extension);
|
||||
}
|
||||
|
||||
getFileIcon(mimeType) {
|
||||
if (mimeType.startsWith('image/')) return '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
|
||||
renderFolder(folder) {
|
||||
const isSelected = this.selectedFolders.has(folder.id);
|
||||
const starIcon = folder.is_starred ? '★' : '☆';
|
||||
const starAction = folder.is_starred ? 'unstar-folder' : 'star-folder';
|
||||
const actions = this.getFolderActions(folder);
|
||||
|
||||
return `
|
||||
<div class="file-item folder-item ${isSelected ? 'selected' : ''}" data-folder-id="${folder.id}">
|
||||
<input type="checkbox" class="select-item" data-type="folder" data-id="${folder.id}" ${isSelected ? 'checked' : ''}>
|
||||
<div class="file-icon">📁</div>
|
||||
<div class="file-name">${folder.name}</div>
|
||||
<div class="file-actions-menu">
|
||||
${actions}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFile(file) {
|
||||
const isSelected = this.selectedFiles.has(file.id);
|
||||
const icon = this.getFileIcon(file.mime_type);
|
||||
const size = this.formatFileSize(file.size);
|
||||
const starIcon = file.is_starred ? '★' : '☆';
|
||||
const starAction = file.is_starred ? 'unstar-file' : 'star-file';
|
||||
const actions = this.getFileActions(file);
|
||||
|
||||
return `
|
||||
<div class="file-item ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
|
||||
<input type="checkbox" class="select-item" data-type="file" data-id="${file.id}" ${isSelected ? 'checked' : ''}>
|
||||
<div class="file-icon">${icon}</div>
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-size">${size}</div>
|
||||
<div class="file-actions-menu">
|
||||
${actions}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
getFolderActions(folder) {
|
||||
return '';
|
||||
}
|
||||
|
||||
getFileActions(file) {
|
||||
return '';
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
const target = e.target;
|
||||
|
||||
if (target.id === 'clear-selection-btn') {
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('action-btn')) {
|
||||
e.stopPropagation();
|
||||
const action = target.dataset.action;
|
||||
const id = parseInt(target.dataset.id);
|
||||
this.handleAction(action, id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('select-item')) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
const fileItem = target.closest('.file-item:not(.folder-item)');
|
||||
if (fileItem) {
|
||||
const fileId = parseInt(fileItem.dataset.fileId);
|
||||
const file = this.files.find(f => f.id === fileId);
|
||||
|
||||
if (this.isEditableFile(file.name, file.mime_type)) {
|
||||
this.dispatchEvent(new CustomEvent('edit-file', {
|
||||
detail: { file: file },
|
||||
bubbles: true
|
||||
}));
|
||||
} else {
|
||||
this.dispatchEvent(new CustomEvent('photo-click', {
|
||||
detail: { photo: file },
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleDblClick(e) {
|
||||
const folderItem = e.target.closest('.folder-item');
|
||||
if (folderItem) {
|
||||
const folderId = parseInt(folderItem.dataset.folderId);
|
||||
this.dispatchEvent(new CustomEvent('folder-open', { detail: { folderId } }));
|
||||
}
|
||||
}
|
||||
|
||||
handleChange(e) {
|
||||
const target = e.target;
|
||||
|
||||
if (target.id === 'select-all') {
|
||||
this.toggleSelectAll(target.checked);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('select-item')) {
|
||||
const type = target.dataset.type;
|
||||
const id = parseInt(target.dataset.id);
|
||||
this.toggleSelectItem(type, id, target.checked);
|
||||
}
|
||||
}
|
||||
|
||||
toggleSelectItem(type, id, checked) {
|
||||
if (type === 'file') {
|
||||
if (checked) {
|
||||
this.selectedFiles.add(id);
|
||||
} else {
|
||||
this.selectedFiles.delete(id);
|
||||
}
|
||||
} else if (type === 'folder') {
|
||||
if (checked) {
|
||||
this.selectedFolders.add(id);
|
||||
} else {
|
||||
this.selectedFolders.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const item = this.querySelector(`[data-${type}-id="${id}"]`);
|
||||
if (item) {
|
||||
if (checked) {
|
||||
item.classList.add('selected');
|
||||
} else {
|
||||
item.classList.remove('selected');
|
||||
}
|
||||
}
|
||||
|
||||
this.updateSelectionUI();
|
||||
}
|
||||
|
||||
toggleSelectAll(checked) {
|
||||
this.selectedFiles.clear();
|
||||
this.selectedFolders.clear();
|
||||
|
||||
if (checked) {
|
||||
this.files.forEach(file => this.selectedFiles.add(file.id));
|
||||
this.folders.forEach(folder => this.selectedFolders.add(folder.id));
|
||||
}
|
||||
|
||||
this.querySelectorAll('.select-item').forEach(checkbox => {
|
||||
checkbox.checked = checked;
|
||||
});
|
||||
|
||||
this.querySelectorAll('.file-item').forEach(item => {
|
||||
if (checked) {
|
||||
item.classList.add('selected');
|
||||
} else {
|
||||
item.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
|
||||
this.updateSelectionUI();
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selectedFiles.clear();
|
||||
this.selectedFolders.clear();
|
||||
this.querySelectorAll('.select-item').forEach(checkbox => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
this.querySelectorAll('.file-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
this.updateSelectionUI();
|
||||
}
|
||||
|
||||
updateSelectionUI() {
|
||||
const hasSelected = this.selectedFiles.size > 0 || this.selectedFolders.size > 0;
|
||||
const totalItems = this.files.length + this.folders.length;
|
||||
const totalSelected = this.selectedFiles.size + this.selectedFolders.size;
|
||||
const allSelected = totalItems > 0 && totalSelected === totalItems;
|
||||
|
||||
const selectAllCheckbox = this.querySelector('#select-all');
|
||||
const selectAllLabel = this.querySelector('label[for="select-all"]');
|
||||
const batchActionsDiv = this.querySelector('.batch-actions');
|
||||
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = allSelected;
|
||||
this.updateIndeterminateState();
|
||||
}
|
||||
|
||||
if (selectAllLabel) {
|
||||
selectAllLabel.textContent = hasSelected ? `${totalSelected} selected` : 'Select all';
|
||||
}
|
||||
|
||||
if (hasSelected && !batchActionsDiv) {
|
||||
this.createBatchActionsBar();
|
||||
} else if (!hasSelected && batchActionsDiv) {
|
||||
batchActionsDiv.remove();
|
||||
}
|
||||
}
|
||||
|
||||
createBatchActionsBar() {
|
||||
}
|
||||
|
||||
updateIndeterminateState() {
|
||||
const selectAllCheckbox = this.querySelector('#select-all');
|
||||
if (selectAllCheckbox) {
|
||||
const totalItems = this.files.length + this.folders.length;
|
||||
const totalSelected = this.selectedFiles.size + this.selectedFolders.size;
|
||||
const hasSelected = totalSelected > 0;
|
||||
const allSelected = totalItems > 0 && totalSelected === totalItems;
|
||||
|
||||
selectAllCheckbox.indeterminate = hasSelected && !allSelected;
|
||||
}
|
||||
}
|
||||
|
||||
async handleAction(action, id) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
class BillingDashboard extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.currentUsage = null;
|
||||
this.subscription = null;
|
||||
this.pricing = null;
|
||||
this.invoices = [];
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.addEventListener('click', this.boundHandleClick);
|
||||
await this.loadData();
|
||||
this.render();
|
||||
this.attachEventListeners();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('click', this.boundHandleClick);
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
const [usage, subscription, pricing, invoices] = await Promise.all([
|
||||
this.fetchCurrentUsage(),
|
||||
this.fetchSubscription(),
|
||||
this.fetchPricing(),
|
||||
this.fetchInvoices()
|
||||
]);
|
||||
|
||||
this.currentUsage = usage;
|
||||
this.subscription = subscription;
|
||||
this.pricing = pricing;
|
||||
this.invoices = invoices;
|
||||
} catch (error) {
|
||||
console.error('Failed to load billing data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchCurrentUsage() {
|
||||
const response = await fetch('/api/billing/usage/current', {
|
||||
headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async fetchSubscription() {
|
||||
const response = await fetch('/api/billing/subscription', {
|
||||
headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async fetchPricing() {
|
||||
const response = await fetch('/api/billing/pricing');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async fetchInvoices() {
|
||||
const response = await fetch('/api/billing/invoices?limit=10', {
|
||||
headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
formatGB(gb) {
|
||||
if (gb >= 1024) {
|
||||
return `${(gb / 1024).toFixed(2)} TB`;
|
||||
}
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
calculateEstimatedCost() {
|
||||
if (!this.currentUsage || !this.pricing) return 0;
|
||||
|
||||
const storagePrice = parseFloat(this.pricing.storage_per_gb_month?.value || 0);
|
||||
const bandwidthPrice = parseFloat(this.pricing.bandwidth_egress_per_gb?.value || 0);
|
||||
const freeStorage = parseFloat(this.pricing.free_tier_storage_gb?.value || 0);
|
||||
const freeBandwidth = parseFloat(this.pricing.free_tier_bandwidth_gb?.value || 0);
|
||||
|
||||
const storageGB = this.currentUsage.storage_gb;
|
||||
const bandwidthGB = this.currentUsage.bandwidth_down_gb_today * 30;
|
||||
|
||||
const billableStorage = Math.max(0, Math.ceil(storageGB - freeStorage));
|
||||
const billableBandwidth = Math.max(0, Math.ceil(bandwidthGB - freeBandwidth));
|
||||
|
||||
return (billableStorage * storagePrice) + (billableBandwidth * bandwidthPrice);
|
||||
}
|
||||
|
||||
render() {
|
||||
const estimatedCost = this.calculateEstimatedCost();
|
||||
const storageUsed = this.currentUsage?.storage_gb || 0;
|
||||
const freeStorage = parseFloat(this.pricing?.free_tier_storage_gb?.value || 15);
|
||||
const storagePercentage = Math.min(100, (storageUsed / freeStorage) * 100);
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="billing-dashboard">
|
||||
<div class="billing-header">
|
||||
<h2>Billing & Usage</h2>
|
||||
<div class="subscription-badge ${this.subscription?.status === 'active' ? 'active' : 'inactive'}">
|
||||
${this.subscription?.billing_type === 'pay_as_you_go' ? 'Pay As You Go' : this.subscription?.plan_name || 'Free'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="billing-cards">
|
||||
<div class="billing-card usage-card">
|
||||
<h3>Current Usage</h3>
|
||||
<div class="usage-details">
|
||||
<div class="usage-item">
|
||||
<span class="usage-label">Storage</span>
|
||||
<span class="usage-value">${this.formatGB(storageUsed)}</span>
|
||||
</div>
|
||||
<div class="usage-progress">
|
||||
<div class="usage-progress-bar" style="width: ${storagePercentage}%"></div>
|
||||
</div>
|
||||
<div class="usage-info">${this.formatGB(freeStorage)} included free</div>
|
||||
|
||||
<div class="usage-item">
|
||||
<span class="usage-label">Bandwidth (Today)</span>
|
||||
<span class="usage-value">${this.formatGB(this.currentUsage?.bandwidth_down_gb_today || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="billing-card cost-card">
|
||||
<h3>Estimated Monthly Cost</h3>
|
||||
<div class="estimated-cost">${this.formatCurrency(estimatedCost)}</div>
|
||||
<div class="cost-breakdown">
|
||||
<div class="cost-item">
|
||||
<span>Storage</span>
|
||||
<span>${this.formatCurrency(Math.max(0, Math.ceil(storageUsed - freeStorage)) * parseFloat(this.pricing?.storage_per_gb_month?.value || 0))}</span>
|
||||
</div>
|
||||
<div class="cost-item">
|
||||
<span>Bandwidth</span>
|
||||
<span>${this.formatCurrency(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="billing-card pricing-card">
|
||||
<h3>Current Pricing</h3>
|
||||
<div class="pricing-details">
|
||||
<div class="pricing-item">
|
||||
<span>Storage</span>
|
||||
<span>${this.formatCurrency(parseFloat(this.pricing?.storage_per_gb_month?.value || 0))}/GB/month</span>
|
||||
</div>
|
||||
<div class="pricing-item">
|
||||
<span>Bandwidth</span>
|
||||
<span>${this.formatCurrency(parseFloat(this.pricing?.bandwidth_egress_per_gb?.value || 0))}/GB</span>
|
||||
</div>
|
||||
<div class="pricing-item">
|
||||
<span>Free Tier</span>
|
||||
<span>${this.formatGB(freeStorage)} storage, ${this.formatGB(parseFloat(this.pricing?.free_tier_bandwidth_gb?.value || 15))} bandwidth/month</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="invoices-section">
|
||||
<h3>Recent Invoices</h3>
|
||||
<div class="invoices-table">
|
||||
${this.renderInvoicesTable()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="payment-methods-section">
|
||||
<h3>Payment Methods</h3>
|
||||
<button class="btn-primary" id="addPaymentMethod">Add Payment Method</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderInvoicesTable() {
|
||||
if (!this.invoices || this.invoices.length === 0) {
|
||||
return '<p class="no-invoices">No invoices yet</p>';
|
||||
}
|
||||
|
||||
return `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice #</th>
|
||||
<th>Period</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Due Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.invoices.map(invoice => `
|
||||
<tr>
|
||||
<td>${invoice.invoice_number}</td>
|
||||
<td>${this.formatDate(invoice.period_start)} - ${this.formatDate(invoice.period_end)}</td>
|
||||
<td>${this.formatCurrency(invoice.total)}</td>
|
||||
<td><span class="invoice-status ${invoice.status}">${invoice.status}</span></td>
|
||||
<td>${invoice.due_date ? this.formatDate(invoice.due_date) : '-'}</td>
|
||||
<td>
|
||||
<button class="btn-link" data-invoice-id="${invoice.id}">View</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
const target = e.target;
|
||||
|
||||
if (target.id === 'addPaymentMethod') {
|
||||
this.showPaymentMethodModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.dataset.invoiceId) {
|
||||
const invoiceId = target.dataset.invoiceId;
|
||||
this.showInvoiceDetail(invoiceId);
|
||||
}
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
}
|
||||
|
||||
async showPaymentMethodModal() {
|
||||
alert('Payment method modal will be implemented with Stripe Elements');
|
||||
}
|
||||
|
||||
async showInvoiceDetail(invoiceId) {
|
||||
const response = await fetch(`/api/billing/invoices/${invoiceId}`, {
|
||||
headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`}
|
||||
});
|
||||
const invoice = await response.json();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<h2>Invoice ${invoice.invoice_number}</h2>
|
||||
<div class="invoice-details">
|
||||
<p><strong>Period:</strong> ${this.formatDate(invoice.period_start)} - ${this.formatDate(invoice.period_end)}</p>
|
||||
<p><strong>Status:</strong> ${invoice.status}</p>
|
||||
<h3>Line Items</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Quantity</th>
|
||||
<th>Unit Price</th>
|
||||
<th>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${invoice.line_items.map(item => `
|
||||
<tr>
|
||||
<td>${item.description}</td>
|
||||
<td>${item.quantity.toFixed(2)}</td>
|
||||
<td>${this.formatCurrency(item.unit_price)}</td>
|
||||
<td>${this.formatCurrency(item.amount)}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="invoice-total">
|
||||
<div><strong>Subtotal:</strong> ${this.formatCurrency(invoice.subtotal)}</div>
|
||||
<div><strong>Tax:</strong> ${this.formatCurrency(invoice.tax)}</div>
|
||||
<div><strong>Total:</strong> ${this.formatCurrency(invoice.total)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-secondary" onclick="this.closest('.modal').remove()">Close</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('billing-dashboard', BillingDashboard);
|
||||
|
||||
export default BillingDashboard;
|
||||
@@ -0,0 +1,153 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
class CodeEditorView extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.editor = null;
|
||||
this.file = null;
|
||||
this.previousView = null;
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
this.boundHandleEscape = this.handleEscape.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener('click', this.boundHandleClick);
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('click', this.boundHandleClick);
|
||||
document.removeEventListener('keydown', this.boundHandleEscape);
|
||||
if (this.editor) {
|
||||
this.editor.toTextArea();
|
||||
this.editor = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleEscape(e) {
|
||||
if (e.key === 'Escape') {
|
||||
this.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
async setFile(file, previousView = 'files') {
|
||||
this.file = file;
|
||||
this.previousView = previousView;
|
||||
await this.loadAndRender();
|
||||
}
|
||||
|
||||
async loadAndRender() {
|
||||
try {
|
||||
const blob = await api.downloadFile(this.file.id);
|
||||
const content = await blob.text();
|
||||
this.render(content);
|
||||
this.initializeEditor(content);
|
||||
} catch (error) {
|
||||
console.error('Failed to load file:', error);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to load file: ' + error.message, type: 'error' }
|
||||
}));
|
||||
this.render('');
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
getMimeType(filename) {
|
||||
const extension = filename.split('.').pop().toLowerCase();
|
||||
const mimeMap = {
|
||||
'js': 'text/javascript',
|
||||
'json': 'application/json',
|
||||
'py': 'text/x-python',
|
||||
'md': 'text/x-markdown',
|
||||
'html': 'text/html',
|
||||
'xml': 'application/xml',
|
||||
'css': 'text/css',
|
||||
'txt': 'text/plain',
|
||||
'log': 'text/plain',
|
||||
'sh': 'text/x-sh',
|
||||
'yaml': 'text/x-yaml',
|
||||
'yml': 'text/x-yaml'
|
||||
};
|
||||
return mimeMap[extension] || 'text/plain';
|
||||
}
|
||||
|
||||
render(content) {
|
||||
this.innerHTML = `
|
||||
<div class="code-editor-view">
|
||||
<div class="code-editor-header">
|
||||
<div class="header-left">
|
||||
<button class="button" id="back-btn">Back</button>
|
||||
<h2 class="editor-filename">${this.file.name}</h2>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="button button-primary" id="save-btn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="code-editor-body">
|
||||
<textarea id="code-editor-textarea">${content}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
initializeEditor(content) {
|
||||
const textarea = this.querySelector('#code-editor-textarea');
|
||||
if (!textarea) return;
|
||||
|
||||
this.editor = CodeMirror.fromTextArea(textarea, {
|
||||
value: content,
|
||||
mode: this.getMimeType(this.file.name),
|
||||
lineNumbers: true,
|
||||
theme: 'default',
|
||||
lineWrapping: true,
|
||||
indentUnit: 4,
|
||||
indentWithTabs: false,
|
||||
extraKeys: {
|
||||
'Ctrl-S': () => this.save(),
|
||||
'Cmd-S': () => this.save()
|
||||
}
|
||||
});
|
||||
|
||||
this.editor.setSize('100%', '100%');
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
if (e.target.id === 'back-btn') {
|
||||
this.goBack();
|
||||
} else if (e.target.id === 'save-btn') {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
if (!this.editor) return;
|
||||
|
||||
try {
|
||||
const content = this.editor.getValue();
|
||||
await api.updateFile(this.file.id, content);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'File saved successfully!', type: 'success' }
|
||||
}));
|
||||
} catch (error) {
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to save file: ' + error.message, type: 'error' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
goBack() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
hide() {
|
||||
document.removeEventListener('keydown', this.boundHandleEscape);
|
||||
if (this.editor) {
|
||||
this.editor.toTextArea();
|
||||
this.editor = null;
|
||||
}
|
||||
this.remove();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('code-editor-view', CodeEditorView);
|
||||
export { CodeEditorView };
|
||||
@@ -1,18 +1,22 @@
|
||||
import { api } from '../api.js';
|
||||
import { BaseFileList } from './base-file-list.js';
|
||||
|
||||
export class DeletedFiles extends HTMLElement {
|
||||
export class DeletedFiles extends BaseFileList {
|
||||
constructor() {
|
||||
super();
|
||||
this.deletedFiles = [];
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadDeletedFiles();
|
||||
}
|
||||
|
||||
async loadDeletedFiles() {
|
||||
try {
|
||||
this.deletedFiles = await api.listDeletedFiles();
|
||||
this.files = await api.listDeletedFiles();
|
||||
this.folders = [];
|
||||
this.selectedFiles.clear();
|
||||
this.selectedFolders.clear();
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Failed to load deleted files:', error);
|
||||
@@ -21,10 +25,17 @@ export class DeletedFiles extends HTMLElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.deletedFiles.length === 0) {
|
||||
const hasSelected = this.selectedFiles.size > 0;
|
||||
const totalSelected = this.selectedFiles.size;
|
||||
const allSelected = this.files.length > 0 && this.selectedFiles.size === this.files.length;
|
||||
const someSelected = hasSelected && !allSelected;
|
||||
|
||||
if (this.files.length === 0) {
|
||||
this.innerHTML = `
|
||||
<div class="deleted-files-container">
|
||||
<h2>Deleted Files</h2>
|
||||
<div class="file-list-container">
|
||||
<div class="file-list-header">
|
||||
<h2>Deleted Files</h2>
|
||||
</div>
|
||||
<p class="empty-state">No deleted files found.</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -32,76 +43,110 @@ export class DeletedFiles extends HTMLElement {
|
||||
}
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="deleted-files-container">
|
||||
<h2>Deleted Files</h2>
|
||||
<div class="file-list-container">
|
||||
<div class="file-list-header">
|
||||
<div class="header-left">
|
||||
<h2>Deleted Files</h2>
|
||||
${this.files.length > 0 ? `
|
||||
<div class="selection-controls">
|
||||
<input type="checkbox" id="select-all" ${allSelected ? 'checked' : ''} ${someSelected ? 'data-indeterminate="true"' : ''}>
|
||||
<label for="select-all">
|
||||
${hasSelected ? `${totalSelected} selected` : 'Select all'}
|
||||
</label>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${hasSelected ? `
|
||||
<div class="batch-actions">
|
||||
<button class="button button-small button-primary" id="batch-restore-btn">Restore Selected</button>
|
||||
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="file-grid">
|
||||
${this.deletedFiles.map(file => this.renderDeletedFile(file)).join('')}
|
||||
${this.files.map(file => this.renderDeletedFile(file)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.attachListeners();
|
||||
this.updateIndeterminateState();
|
||||
}
|
||||
|
||||
renderDeletedFile(file) {
|
||||
const isSelected = this.selectedFiles.has(file.id);
|
||||
const icon = this.getFileIcon(file.mime_type);
|
||||
const size = this.formatFileSize(file.size);
|
||||
const deletedDate = new Date(file.deleted_at).toLocaleDateString();
|
||||
const deletedAt = file.deleted_at ? new Date(file.deleted_at).toLocaleString() : 'N/A';
|
||||
|
||||
return `
|
||||
<div class="file-item deleted-item" data-file-id="${file.id}">
|
||||
<div class="file-item ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
|
||||
<input type="checkbox" class="select-item" data-type="file" data-id="${file.id}" ${isSelected ? 'checked' : ''}>
|
||||
<div class="file-icon">${icon}</div>
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-size">${size}</div>
|
||||
<div class="file-deleted-date">Deleted: ${deletedDate}</div>
|
||||
<div class="file-deleted-at">Deleted: ${deletedAt}</div>
|
||||
<div class="file-actions-menu">
|
||||
<button class="button button-danger action-btn" data-action="restore" data-id="${file.id}">Restore</button>
|
||||
<button class="action-btn" data-action="restore" data-id="${file.id}">Restore</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
getFileIcon(mimeType) {
|
||||
if (mimeType.startsWith('image/')) return '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
createBatchActionsBar() {
|
||||
const container = this.querySelector('.file-list-container');
|
||||
const header = container.querySelector('.file-list-header');
|
||||
const batchBar = document.createElement('div');
|
||||
batchBar.className = 'batch-actions';
|
||||
batchBar.innerHTML = `
|
||||
<button class="button button-small button-primary" id="batch-restore-btn">Restore Selected</button>
|
||||
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
|
||||
`;
|
||||
header.insertAdjacentElement('afterend', batchBar);
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
this.querySelectorAll('.action-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const action = btn.dataset.action;
|
||||
const id = parseInt(btn.dataset.id);
|
||||
if (action === 'restore') {
|
||||
await this.handleRestore(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
const batchRestoreBtn = this.querySelector('#batch-restore-btn');
|
||||
if (batchRestoreBtn) {
|
||||
batchRestoreBtn.addEventListener('click', () => this.handleBatchRestore());
|
||||
}
|
||||
}
|
||||
|
||||
async handleRestore(fileId) {
|
||||
if (confirm('Are you sure you want to restore this file?')) {
|
||||
try {
|
||||
async handleBatchRestore() {
|
||||
const totalSelected = this.selectedFiles.size;
|
||||
if (totalSelected === 0) return;
|
||||
|
||||
if (!confirm(`Restore ${totalSelected} files?`)) return;
|
||||
|
||||
try {
|
||||
for (const fileId of this.selectedFiles) {
|
||||
await api.restoreFile(fileId);
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Files restored successfully!', type: 'success' }
|
||||
}));
|
||||
await this.loadDeletedFiles();
|
||||
} catch (error) {
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to restore files: ' + error.message, type: 'error' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async handleAction(action, id) {
|
||||
try {
|
||||
if (action === 'restore') {
|
||||
await api.restoreFile(id);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'File restored successfully!', type: 'success' }
|
||||
}));
|
||||
await this.loadDeletedFiles(); // Reload the list
|
||||
} catch (error) {
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to restore file: ' + error.message, type: 'error' }
|
||||
}));
|
||||
await this.loadDeletedFiles();
|
||||
}
|
||||
} catch (error) {
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Action failed: ' + error.message, type: 'error' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,29 +4,54 @@ export class FileList extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.currentFolderId = null;
|
||||
this.folderPath = [];
|
||||
this.files = [];
|
||||
this.folders = [];
|
||||
this.selectedFiles = new Set();
|
||||
this.selectedFolders = new Set();
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
this.boundHandleDblClick = this.handleDblClick.bind(this);
|
||||
this.boundHandleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.addEventListener('click', this.boundHandleClick);
|
||||
this.addEventListener('dblclick', this.boundHandleDblClick);
|
||||
this.addEventListener('change', this.boundHandleChange);
|
||||
await this.loadContents(null);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('click', this.boundHandleClick);
|
||||
this.removeEventListener('dblclick', this.boundHandleDblClick);
|
||||
this.removeEventListener('change', this.boundHandleChange);
|
||||
}
|
||||
|
||||
async loadContents(folderId) {
|
||||
this.currentFolderId = folderId;
|
||||
try {
|
||||
if (folderId) {
|
||||
try {
|
||||
this.folderPath = await api.getFolderPath(folderId);
|
||||
} catch (pathError) {
|
||||
this.folderPath = [];
|
||||
}
|
||||
} else {
|
||||
this.folderPath = [];
|
||||
}
|
||||
this.folders = await api.listFolders(folderId);
|
||||
this.files = await api.listFiles(folderId);
|
||||
this.selectedFiles.clear();
|
||||
this.selectedFolders.clear();
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Failed to load contents:', error);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to load contents: ' + error.message, type: 'error' }
|
||||
detail: { message: 'Failed to load folder contents', type: 'error' }
|
||||
}));
|
||||
this.folderPath = [];
|
||||
this.folders = [];
|
||||
this.files = [];
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,29 +65,52 @@ export class FileList extends HTMLElement {
|
||||
|
||||
render() {
|
||||
const hasSelected = this.selectedFiles.size > 0 || this.selectedFolders.size > 0;
|
||||
const totalItems = this.files.length + this.folders.length;
|
||||
const totalSelected = this.selectedFiles.size + this.selectedFolders.size;
|
||||
const allFilesSelected = this.files.length > 0 && this.selectedFiles.size === this.files.length;
|
||||
const allFoldersSelected = this.folders.length > 0 && this.selectedFolders.size === this.folders.length;
|
||||
const allSelected = (this.files.length + this.folders.length) > 0 && allFilesSelected && allFoldersSelected;
|
||||
const allSelected = totalItems > 0 && allFilesSelected && allFoldersSelected;
|
||||
const someSelected = hasSelected && !allSelected;
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="file-list-container">
|
||||
${this.folderPath.length > 0 ? `
|
||||
<div class="breadcrumb-nav">
|
||||
<span class="breadcrumb-item" data-folder-id="null">Home</span>
|
||||
${this.folderPath.map((folder, index) => `
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<span class="breadcrumb-item ${index === this.folderPath.length - 1 ? 'breadcrumb-current' : ''}" data-folder-id="${folder.id}">${folder.name}</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="file-list-header">
|
||||
<h2>Files</h2>
|
||||
<div class="header-left">
|
||||
<h2>Files</h2>
|
||||
${totalItems > 0 ? `
|
||||
<div class="selection-controls">
|
||||
<input type="checkbox" id="select-all" ${allSelected ? 'checked' : ''} ${someSelected ? 'data-indeterminate="true"' : ''}>
|
||||
<label for="select-all">
|
||||
${hasSelected ? `${totalSelected} selected` : 'Select all'}
|
||||
</label>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<button class="button" id="create-folder-btn">New Folder</button>
|
||||
<button class="button button-primary" id="upload-btn">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="batch-actions" style="display: ${hasSelected ? 'flex' : 'none'};">
|
||||
<input type="checkbox" id="select-all" ${allSelected ? 'checked' : ''}>
|
||||
<label for="select-all">Select All</label>
|
||||
<button class="button button-small button-danger" id="batch-delete-btn">Delete Selected</button>
|
||||
<button class="button button-small" id="batch-move-btn">Move Selected</button>
|
||||
<button class="button button-small" id="batch-copy-btn">Copy Selected</button>
|
||||
<button class="button button-small" id="batch-star-btn">Star Selected</button>
|
||||
<button class="button button-small" id="batch-unstar-btn">Unstar Selected</button>
|
||||
</div>
|
||||
${hasSelected ? `
|
||||
<div class="batch-actions">
|
||||
<button class="button button-small button-danger" id="batch-delete-btn">Delete</button>
|
||||
<button class="button button-small" id="batch-move-btn">Move</button>
|
||||
<button class="button button-small" id="batch-copy-btn">Copy</button>
|
||||
<button class="button button-small" id="batch-star-btn">Star</button>
|
||||
<button class="button button-small" id="batch-unstar-btn">Unstar</button>
|
||||
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="file-grid">
|
||||
${this.folders.map(folder => this.renderFolder(folder)).join('')}
|
||||
@@ -72,6 +120,7 @@ export class FileList extends HTMLElement {
|
||||
`;
|
||||
|
||||
this.attachListeners();
|
||||
this.updateIndeterminateState();
|
||||
}
|
||||
|
||||
renderFolder(folder) {
|
||||
@@ -124,6 +173,17 @@ export class FileList extends HTMLElement {
|
||||
return '📄';
|
||||
}
|
||||
|
||||
isEditableFile(filename, mimeType) {
|
||||
if (mimeType && mimeType.startsWith('text/')) return true;
|
||||
|
||||
const editableExtensions = [
|
||||
'txt', 'md', 'log', 'json', 'js', 'py', 'html', 'css',
|
||||
'xml', 'yaml', 'yml', 'sh', 'bat', 'ini', 'conf', 'cfg'
|
||||
];
|
||||
const extension = filename.split('.').pop().toLowerCase();
|
||||
return editableExtensions.includes(extension);
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
@@ -131,62 +191,116 @@ export class FileList extends HTMLElement {
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
const target = e.target;
|
||||
|
||||
if (target.classList.contains('breadcrumb-item') && !target.classList.contains('breadcrumb-current')) {
|
||||
const folderId = target.dataset.folderId;
|
||||
const targetFolderId = folderId === 'null' ? null : parseInt(folderId);
|
||||
this.loadContents(targetFolderId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === 'upload-btn') {
|
||||
this.dispatchEvent(new CustomEvent('upload-request', {
|
||||
detail: { folderId: this.currentFolderId }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === 'create-folder-btn') {
|
||||
this.handleCreateFolder();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === 'clear-selection-btn') {
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === 'batch-delete-btn') {
|
||||
this.handleBatchAction('delete');
|
||||
return;
|
||||
}
|
||||
if (target.id === 'batch-move-btn') {
|
||||
this.handleBatchAction('move');
|
||||
return;
|
||||
}
|
||||
if (target.id === 'batch-copy-btn') {
|
||||
this.handleBatchAction('copy');
|
||||
return;
|
||||
}
|
||||
if (target.id === 'batch-star-btn') {
|
||||
this.handleBatchAction('star');
|
||||
return;
|
||||
}
|
||||
if (target.id === 'batch-unstar-btn') {
|
||||
this.handleBatchAction('unstar');
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('action-btn')) {
|
||||
e.stopPropagation();
|
||||
const action = target.dataset.action;
|
||||
const id = parseInt(target.dataset.id);
|
||||
this.handleAction(action, id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('select-item')) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
const fileItem = target.closest('.file-item:not(.folder-item)');
|
||||
if (fileItem) {
|
||||
const fileId = parseInt(fileItem.dataset.fileId);
|
||||
const file = this.files.find(f => f.id === fileId);
|
||||
|
||||
if (this.isEditableFile(file.name, file.mime_type)) {
|
||||
this.dispatchEvent(new CustomEvent('edit-file', {
|
||||
detail: { file: file },
|
||||
bubbles: true
|
||||
}));
|
||||
} else {
|
||||
this.dispatchEvent(new CustomEvent('photo-click', {
|
||||
detail: { photo: file },
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleDblClick(e) {
|
||||
if (e.target.classList.contains('select-item') ||
|
||||
e.target.classList.contains('action-btn') ||
|
||||
e.target.classList.contains('breadcrumb-item')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const folderItem = e.target.closest('.folder-item');
|
||||
if (folderItem) {
|
||||
const folderId = parseInt(folderItem.dataset.folderId);
|
||||
this.loadContents(folderId);
|
||||
}
|
||||
}
|
||||
|
||||
handleChange(e) {
|
||||
const target = e.target;
|
||||
|
||||
if (target.id === 'select-all') {
|
||||
this.toggleSelectAll(target.checked);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('select-item')) {
|
||||
const type = target.dataset.type;
|
||||
const id = parseInt(target.dataset.id);
|
||||
this.toggleSelectItem(type, id, target.checked);
|
||||
}
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
this.querySelector('#upload-btn')?.addEventListener('click', () => {
|
||||
this.dispatchEvent(new CustomEvent('upload-request'));
|
||||
});
|
||||
|
||||
this.querySelector('#create-folder-btn')?.addEventListener('click', async () => {
|
||||
await this.handleCreateFolder();
|
||||
});
|
||||
|
||||
this.querySelectorAll('.folder-item').forEach(item => {
|
||||
item.addEventListener('dblclick', () => {
|
||||
const folderId = parseInt(item.dataset.folderId);
|
||||
this.dispatchEvent(new CustomEvent('folder-open', { detail: { folderId } }));
|
||||
});
|
||||
});
|
||||
|
||||
this.querySelectorAll('.file-item:not(.folder-item)').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
if (!e.target.classList.contains('action-btn') && !e.target.classList.contains('select-item')) {
|
||||
const fileId = parseInt(item.dataset.fileId);
|
||||
const file = this.files.find(f => f.id === fileId);
|
||||
this.dispatchEvent(new CustomEvent('photo-click', {
|
||||
detail: { photo: file },
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.querySelectorAll('.action-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const action = btn.dataset.action;
|
||||
const id = parseInt(btn.dataset.id);
|
||||
await this.handleAction(action, id);
|
||||
});
|
||||
});
|
||||
|
||||
this.querySelectorAll('.select-item').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
const type = e.target.dataset.type;
|
||||
const id = parseInt(e.target.dataset.id);
|
||||
this.toggleSelectItem(type, id, e.target.checked);
|
||||
});
|
||||
});
|
||||
|
||||
this.querySelector('#select-all')?.addEventListener('change', (e) => {
|
||||
this.toggleSelectAll(e.target.checked);
|
||||
});
|
||||
|
||||
this.querySelector('#batch-delete-btn')?.addEventListener('click', () => this.handleBatchAction('delete'));
|
||||
this.querySelector('#batch-move-btn')?.addEventListener('click', () => this.handleBatchAction('move'));
|
||||
this.querySelector('#batch-copy-btn')?.addEventListener('click', () => this.handleBatchAction('copy'));
|
||||
this.querySelector('#batch-star-btn')?.addEventListener('click', () => this.handleBatchAction('star'));
|
||||
this.querySelector('#batch-unstar-btn')?.addEventListener('click', () => this.handleBatchAction('unstar'));
|
||||
|
||||
this.updateBatchActionVisibility();
|
||||
}
|
||||
|
||||
@@ -204,7 +318,7 @@ export class FileList extends HTMLElement {
|
||||
this.selectedFolders.delete(id);
|
||||
}
|
||||
}
|
||||
this.updateBatchActionVisibility();
|
||||
this.updateSelectionUI();
|
||||
}
|
||||
|
||||
toggleSelectAll(checked) {
|
||||
@@ -215,18 +329,75 @@ export class FileList extends HTMLElement {
|
||||
this.files.forEach(file => this.selectedFiles.add(file.id));
|
||||
this.folders.forEach(folder => this.selectedFolders.add(folder.id));
|
||||
}
|
||||
this.render(); // Re-render to update checkboxes
|
||||
|
||||
this.querySelectorAll('.select-item').forEach(checkbox => {
|
||||
checkbox.checked = checked;
|
||||
});
|
||||
|
||||
this.updateSelectionUI();
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selectedFiles.clear();
|
||||
this.selectedFolders.clear();
|
||||
this.querySelectorAll('.select-item').forEach(checkbox => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
this.updateSelectionUI();
|
||||
}
|
||||
|
||||
updateSelectionUI() {
|
||||
const hasSelected = this.selectedFiles.size > 0 || this.selectedFolders.size > 0;
|
||||
const totalItems = this.files.length + this.folders.length;
|
||||
const totalSelected = this.selectedFiles.size + this.selectedFolders.size;
|
||||
const allSelected = totalItems > 0 && totalSelected === totalItems;
|
||||
|
||||
const selectAllCheckbox = this.querySelector('#select-all');
|
||||
const selectAllLabel = this.querySelector('label[for="select-all"]');
|
||||
const batchActionsDiv = this.querySelector('.batch-actions');
|
||||
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = allSelected;
|
||||
this.updateIndeterminateState();
|
||||
}
|
||||
|
||||
if (selectAllLabel) {
|
||||
selectAllLabel.textContent = hasSelected ? `${totalSelected} selected` : 'Select all';
|
||||
}
|
||||
|
||||
if (hasSelected && !batchActionsDiv) {
|
||||
const container = this.querySelector('.file-list-container');
|
||||
const header = container.querySelector('.file-list-header');
|
||||
const batchBar = document.createElement('div');
|
||||
batchBar.className = 'batch-actions';
|
||||
batchBar.innerHTML = `
|
||||
<button class="button button-small button-danger" id="batch-delete-btn">Delete</button>
|
||||
<button class="button button-small" id="batch-move-btn">Move</button>
|
||||
<button class="button button-small" id="batch-copy-btn">Copy</button>
|
||||
<button class="button button-small" id="batch-star-btn">Star</button>
|
||||
<button class="button button-small" id="batch-unstar-btn">Unstar</button>
|
||||
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
|
||||
`;
|
||||
header.insertAdjacentElement('afterend', batchBar);
|
||||
} else if (!hasSelected && batchActionsDiv) {
|
||||
batchActionsDiv.remove();
|
||||
}
|
||||
}
|
||||
|
||||
updateIndeterminateState() {
|
||||
const selectAllCheckbox = this.querySelector('#select-all');
|
||||
if (selectAllCheckbox) {
|
||||
const totalItems = this.files.length + this.folders.length;
|
||||
const totalSelected = this.selectedFiles.size + this.selectedFolders.size;
|
||||
const hasSelected = totalSelected > 0;
|
||||
const allSelected = totalItems > 0 && totalSelected === totalItems;
|
||||
|
||||
selectAllCheckbox.indeterminate = hasSelected && !allSelected;
|
||||
}
|
||||
}
|
||||
|
||||
updateBatchActionVisibility() {
|
||||
const batchActionsDiv = this.querySelector('.batch-actions');
|
||||
if (batchActionsDiv) {
|
||||
if (this.selectedFiles.size > 0 || this.selectedFolders.size > 0) {
|
||||
batchActionsDiv.style.display = 'flex';
|
||||
} else {
|
||||
batchActionsDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
this.updateSelectionUI();
|
||||
}
|
||||
|
||||
async handleBatchAction(action) {
|
||||
|
||||
@@ -14,17 +14,20 @@ class FilePreview extends HTMLElement {
|
||||
|
||||
setupEventListeners() {
|
||||
const closeBtn = this.querySelector('.close-preview');
|
||||
const modal = this.querySelector('.preview-modal');
|
||||
const downloadBtn = this.querySelector('.download-btn');
|
||||
const shareBtn = this.querySelector('.share-btn');
|
||||
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) this.close();
|
||||
});
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
downloadBtn.addEventListener('click', () => this.downloadFile());
|
||||
shareBtn.addEventListener('click', () => this.shareFile());
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', () => this.downloadFile());
|
||||
}
|
||||
|
||||
if (shareBtn) {
|
||||
shareBtn.addEventListener('click', () => this.shareFile());
|
||||
}
|
||||
}
|
||||
|
||||
handleEscape(e) {
|
||||
@@ -33,17 +36,30 @@ class FilePreview extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
async show(file) {
|
||||
async show(file, pushState = true) {
|
||||
this.file = file;
|
||||
this.style.display = 'block';
|
||||
document.addEventListener('keydown', this.handleEscape);
|
||||
this.renderPreview();
|
||||
|
||||
if (pushState) {
|
||||
window.history.pushState(
|
||||
{ view: 'file-preview', file: file },
|
||||
'',
|
||||
`#preview/${file.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.style.display = 'none';
|
||||
this.file = null;
|
||||
document.removeEventListener('keydown', this.handleEscape);
|
||||
this.remove();
|
||||
}
|
||||
|
||||
async renderPreview() {
|
||||
@@ -149,21 +165,21 @@ class FilePreview extends HTMLElement {
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="preview-modal">
|
||||
<div class="preview-container">
|
||||
<div class="preview-header">
|
||||
<div class="file-preview-overlay">
|
||||
<div class="file-preview-header">
|
||||
<div class="header-left">
|
||||
<button class="button close-preview">Back</button>
|
||||
<div class="preview-info">
|
||||
<h3 class="preview-file-name"></h3>
|
||||
<h2 class="preview-file-name"></h2>
|
||||
<p class="preview-file-info"></p>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button class="btn btn-icon download-btn" title="Download">⬇</button>
|
||||
<button class="btn btn-icon share-btn" title="Share">🔗</button>
|
||||
<button class="btn btn-icon close-preview" title="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-content"></div>
|
||||
<div class="preview-actions">
|
||||
<button class="button download-btn">Download</button>
|
||||
<button class="button share-btn">Share</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-content"></div>
|
||||
</div>
|
||||
`;
|
||||
this.style.display = 'none';
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
export class FileUploadView extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.folderId = null;
|
||||
this.handleEscape = this.handleEscape.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
document.addEventListener('keydown', this.handleEscape);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener('keydown', this.handleEscape);
|
||||
}
|
||||
|
||||
setFolder(folderId) {
|
||||
this.folderId = folderId;
|
||||
this.render();
|
||||
this.attachListeners();
|
||||
this.openFileSelector();
|
||||
}
|
||||
|
||||
render() {
|
||||
const folderInfo = this.folderId ? `(Folder ID: ${this.folderId})` : '(Root)';
|
||||
this.innerHTML = `
|
||||
<div class="file-upload-view" style="display: none;">
|
||||
<div class="file-upload-header">
|
||||
<div class="header-left">
|
||||
<button class="button" id="upload-back-btn">Back</button>
|
||||
<h2>Uploading Files ${folderInfo}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-upload-body">
|
||||
<div class="upload-list" id="upload-list"></div>
|
||||
</div>
|
||||
<input type="file" id="file-input" multiple style="display: none;">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
openFileSelector() {
|
||||
const fileInput = this.querySelector('#file-input');
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
const fileInput = this.querySelector('#file-input');
|
||||
const backBtn = this.querySelector('#upload-back-btn');
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
const view = this.querySelector('.file-upload-view');
|
||||
if (view) {
|
||||
view.style.display = 'flex';
|
||||
}
|
||||
this.handleFiles(e.target.files);
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('cancel', () => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleEscape(e) {
|
||||
if (e.key === 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
hide() {
|
||||
document.removeEventListener('keydown', this.handleEscape);
|
||||
this.remove();
|
||||
}
|
||||
|
||||
async handleFiles(files) {
|
||||
const uploadList = this.querySelector('#upload-list');
|
||||
if (!uploadList) return;
|
||||
|
||||
for (const file of files) {
|
||||
const itemId = `upload-${Date.now()}-${Math.random()}`;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'upload-item';
|
||||
item.id = itemId;
|
||||
item.innerHTML = `
|
||||
<div class="upload-item-info">
|
||||
<div class="upload-item-name">${file.name}</div>
|
||||
<div class="upload-item-size">${this.formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
<div class="upload-item-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="upload-item-status">Uploading...</div>
|
||||
</div>
|
||||
`;
|
||||
uploadList.appendChild(item);
|
||||
|
||||
try {
|
||||
await this.uploadFile(file, itemId);
|
||||
const statusEl = item.querySelector('.upload-item-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'Complete';
|
||||
statusEl.classList.add('success');
|
||||
}
|
||||
} catch (error) {
|
||||
const statusEl = item.querySelector('.upload-item-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'Failed: ' + error.message;
|
||||
statusEl.classList.add('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent('upload-complete', { bubbles: true }));
|
||||
|
||||
setTimeout(() => {
|
||||
this.close();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async uploadFile(file, itemId) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (this.folderId !== null && this.folderId !== undefined) {
|
||||
formData.append('folder_id', String(this.folderId));
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = (e.loaded / e.total) * 100;
|
||||
const item = this.querySelector(`#${itemId}`);
|
||||
if (item) {
|
||||
const progressFill = item.querySelector('.progress-fill');
|
||||
if (progressFill) {
|
||||
progressFill.style.width = percentComplete + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} else {
|
||||
reject(new Error(xhr.statusText));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
|
||||
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));
|
||||
|
||||
xhr.open('POST', '/files/upload');
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${api.getToken()}`);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('file-upload-view', FileUploadView);
|
||||
@@ -16,7 +16,7 @@ export class LoginView extends HTMLElement {
|
||||
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab active" data-tab="login">Login</button>
|
||||
<button class="auth-tab" data-tab="register">Register</button>
|
||||
<button class="auth-tab" data-tab="register">Sign Up</button>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="auth-form">
|
||||
|
||||
@@ -4,13 +4,19 @@ class PhotoGallery extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.photos = [];
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener('click', this.boundHandleClick);
|
||||
this.render();
|
||||
this.loadPhotos();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('click', this.boundHandleClick);
|
||||
}
|
||||
|
||||
async loadPhotos() {
|
||||
try {
|
||||
this.photos = await api.getPhotos();
|
||||
@@ -62,16 +68,22 @@ class PhotoGallery extends HTMLElement {
|
||||
}
|
||||
});
|
||||
|
||||
grid.querySelectorAll('.photo-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const fileId = item.dataset.fileId;
|
||||
const photo = this.photos.find(p => p.id === parseInt(fileId));
|
||||
this.dispatchEvent(new CustomEvent('photo-click', {
|
||||
detail: { photo },
|
||||
bubbles: true
|
||||
}));
|
||||
});
|
||||
});
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
const photoItem = e.target.closest('.photo-item');
|
||||
if (photoItem) {
|
||||
const fileId = photoItem.dataset.fileId;
|
||||
const photo = this.photos.find(p => p.id === parseInt(fileId));
|
||||
this.dispatchEvent(new CustomEvent('photo-click', {
|
||||
detail: { photo },
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { api } from '../api.js';
|
||||
import './login-view.js';
|
||||
import './file-list.js';
|
||||
import './file-upload.js';
|
||||
import './file-upload-view.js';
|
||||
import './share-modal.js';
|
||||
import './photo-gallery.js';
|
||||
import './file-preview.js';
|
||||
@@ -10,20 +10,24 @@ import './admin-dashboard.js';
|
||||
import './toast-notification.js';
|
||||
import './starred-items.js';
|
||||
import './recent-files.js';
|
||||
import './shared-items.js'; // Import the new component
|
||||
import './shared-items.js';
|
||||
import './billing-dashboard.js';
|
||||
import './admin-billing.js';
|
||||
import './code-editor-view.js';
|
||||
import { shortcuts } from '../shortcuts.js';
|
||||
|
||||
export class RBoxApp extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.currentView = 'files';
|
||||
this.currentFolderId = null;
|
||||
this.user = null;
|
||||
this.navigationStack = [];
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await this.init();
|
||||
this.addEventListener('show-toast', this.handleShowToast);
|
||||
window.addEventListener('popstate', this.handlePopState.bind(this));
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -85,7 +89,9 @@ export class RBoxApp extends HTMLElement {
|
||||
<li><a href="#" class="nav-link" data-view="photos">Photo Gallery</a></li>
|
||||
<li><a href="#" class="nav-link" data-view="shared">Shared Items</a></li>
|
||||
<li><a href="#" class="nav-link" data-view="deleted">Deleted Files</a></li>
|
||||
<li><a href="#" class="nav-link" data-view="billing">Billing</a></li>
|
||||
${this.user && this.user.is_superuser ? `<li><a href="#" class="nav-link" data-view="admin">Admin Dashboard</a></li>` : ''}
|
||||
${this.user && this.user.is_superuser ? `<li><a href="#" class="nav-link" data-view="admin-billing">Admin Billing</a></li>` : ''}
|
||||
</ul>
|
||||
<h3 class="nav-title">Quick Access</h3>
|
||||
<ul class="nav-list">
|
||||
@@ -102,23 +108,38 @@ export class RBoxApp extends HTMLElement {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<file-upload></file-upload>
|
||||
<share-modal></share-modal>
|
||||
<file-preview></file-preview>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.initializeNavigation();
|
||||
this.attachListeners();
|
||||
this.registerShortcuts();
|
||||
}
|
||||
|
||||
initializeNavigation() {
|
||||
if (!window.history.state) {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash && hash !== '') {
|
||||
const view = hash.split('/')[0];
|
||||
const validViews = ['files', 'photos', 'shared', 'deleted', 'starred', 'recent', 'admin', 'billing', 'admin-billing'];
|
||||
if (validViews.includes(view)) {
|
||||
window.history.replaceState({ view: view }, '', `#${hash}`);
|
||||
this.currentView = view;
|
||||
} else {
|
||||
window.history.replaceState({ view: 'files' }, '', '#files');
|
||||
}
|
||||
} else {
|
||||
window.history.replaceState({ view: 'files' }, '', '#files');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerShortcuts() {
|
||||
shortcuts.register('ctrl+u', () => {
|
||||
const upload = this.querySelector('file-upload');
|
||||
if (upload) {
|
||||
upload.setFolder(this.currentFolderId);
|
||||
upload.show();
|
||||
}
|
||||
const fileList = this.querySelector('file-list');
|
||||
const folderId = fileList ? fileList.currentFolderId : null;
|
||||
this.showUpload(folderId);
|
||||
});
|
||||
|
||||
shortcuts.register('ctrl+f', () => {
|
||||
@@ -164,7 +185,44 @@ export class RBoxApp extends HTMLElement {
|
||||
});
|
||||
|
||||
shortcuts.register('f2', () => {
|
||||
console.log('Rename shortcut - to be implemented');
|
||||
const fileListComponent = document.querySelector('file-list');
|
||||
if (fileListComponent && fileListComponent.selectedFiles && fileListComponent.selectedFiles.size === 1) {
|
||||
const fileId = Array.from(fileListComponent.selectedFiles)[0];
|
||||
const file = fileListComponent.files.find(f => f.id === fileId);
|
||||
if (file) {
|
||||
const newName = prompt('Enter new name:', file.name);
|
||||
if (newName && newName !== file.name) {
|
||||
api.renameFile(fileId, newName).then(() => {
|
||||
fileListComponent.loadContents(fileListComponent.currentFolderId);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'File renamed successfully', type: 'success' }
|
||||
}));
|
||||
}).catch(error => {
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to rename file', type: 'error' }
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (fileListComponent && fileListComponent.selectedFolders && fileListComponent.selectedFolders.size === 1) {
|
||||
const folderId = Array.from(fileListComponent.selectedFolders)[0];
|
||||
const folder = fileListComponent.folders.find(f => f.id === folderId);
|
||||
if (folder) {
|
||||
const newName = prompt('Enter new name:', folder.name);
|
||||
if (newName && newName !== folder.name) {
|
||||
api.updateFolder(folderId, { name: newName }).then(() => {
|
||||
fileListComponent.loadContents(fileListComponent.currentFolderId);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Folder renamed successfully', type: 'success' }
|
||||
}));
|
||||
}).catch(error => {
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to rename folder', type: 'error' }
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -277,15 +335,12 @@ export class RBoxApp extends HTMLElement {
|
||||
|
||||
const fileList = this.querySelector('file-list');
|
||||
if (fileList) {
|
||||
fileList.addEventListener('upload-request', () => {
|
||||
const upload = this.querySelector('file-upload');
|
||||
upload.setFolder(this.currentFolderId);
|
||||
upload.show();
|
||||
fileList.addEventListener('upload-request', (e) => {
|
||||
this.showUpload(e.detail.folderId);
|
||||
});
|
||||
|
||||
fileList.addEventListener('folder-open', (e) => {
|
||||
this.currentFolderId = e.detail.folderId;
|
||||
fileList.loadContents(this.currentFolderId);
|
||||
fileList.loadContents(e.detail.folderId);
|
||||
});
|
||||
|
||||
fileList.addEventListener('share-request', (e) => {
|
||||
@@ -294,13 +349,12 @@ export class RBoxApp extends HTMLElement {
|
||||
});
|
||||
}
|
||||
|
||||
const upload = this.querySelector('file-upload');
|
||||
if (upload) {
|
||||
upload.addEventListener('upload-complete', () => {
|
||||
const fileList = this.querySelector('file-list');
|
||||
fileList.loadContents(this.currentFolderId);
|
||||
});
|
||||
}
|
||||
this.addEventListener('upload-complete', () => {
|
||||
const fileList = this.querySelector('file-list');
|
||||
if (fileList) {
|
||||
fileList.loadContents(fileList.currentFolderId);
|
||||
}
|
||||
});
|
||||
|
||||
const searchInput = this.querySelector('#search-input');
|
||||
if (searchInput) {
|
||||
@@ -315,14 +369,109 @@ export class RBoxApp extends HTMLElement {
|
||||
}
|
||||
|
||||
this.addEventListener('photo-click', (e) => {
|
||||
const preview = this.querySelector('file-preview');
|
||||
preview.show(e.detail.photo);
|
||||
this.showFilePreview(e.detail.photo);
|
||||
});
|
||||
|
||||
this.addEventListener('share-file', (e) => {
|
||||
const modal = this.querySelector('share-modal');
|
||||
modal.show(e.detail.file.id);
|
||||
});
|
||||
|
||||
this.addEventListener('edit-file', (e) => {
|
||||
this.showCodeEditor(e.detail.file);
|
||||
});
|
||||
}
|
||||
|
||||
handlePopState(e) {
|
||||
this.closeAllOverlays();
|
||||
|
||||
if (e.state && e.state.view) {
|
||||
if (e.state.view === 'code-editor' && e.state.file) {
|
||||
this.showCodeEditor(e.state.file, false);
|
||||
} else if (e.state.view === 'file-preview' && e.state.file) {
|
||||
this.showFilePreview(e.state.file, false);
|
||||
} else if (e.state.view === 'upload') {
|
||||
const folderId = e.state.folderId !== undefined ? e.state.folderId : null;
|
||||
this.showUpload(folderId, false);
|
||||
} else {
|
||||
this.switchView(e.state.view, false);
|
||||
}
|
||||
} else {
|
||||
this.switchView('files', false);
|
||||
}
|
||||
}
|
||||
|
||||
closeAllOverlays() {
|
||||
const existingEditor = this.querySelector('code-editor-view');
|
||||
if (existingEditor) {
|
||||
existingEditor.hide();
|
||||
}
|
||||
|
||||
const existingPreview = this.querySelector('file-preview');
|
||||
if (existingPreview) {
|
||||
existingPreview.hide();
|
||||
}
|
||||
|
||||
const existingUpload = this.querySelector('file-upload-view');
|
||||
if (existingUpload) {
|
||||
existingUpload.hide();
|
||||
}
|
||||
|
||||
const shareModal = this.querySelector('share-modal');
|
||||
if (shareModal && shareModal.style.display !== 'none') {
|
||||
shareModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
showCodeEditor(file, pushState = true) {
|
||||
this.closeAllOverlays();
|
||||
|
||||
const mainElement = this.querySelector('.app-main');
|
||||
const editorView = document.createElement('code-editor-view');
|
||||
mainElement.appendChild(editorView);
|
||||
editorView.setFile(file, this.currentView);
|
||||
|
||||
if (pushState) {
|
||||
window.history.pushState(
|
||||
{ view: 'code-editor', file: file },
|
||||
'',
|
||||
`#editor/${file.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
showFilePreview(file, pushState = true) {
|
||||
this.closeAllOverlays();
|
||||
|
||||
const mainElement = this.querySelector('.app-main');
|
||||
const preview = document.createElement('file-preview');
|
||||
mainElement.appendChild(preview);
|
||||
preview.show(file, false);
|
||||
|
||||
if (pushState) {
|
||||
window.history.pushState(
|
||||
{ view: 'file-preview', file: file },
|
||||
'',
|
||||
`#preview/${file.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
showUpload(folderId = null, pushState = true) {
|
||||
this.closeAllOverlays();
|
||||
|
||||
const mainElement = this.querySelector('.app-main');
|
||||
const uploadView = document.createElement('file-upload-view');
|
||||
mainElement.appendChild(uploadView);
|
||||
uploadView.setFolder(folderId);
|
||||
|
||||
if (pushState) {
|
||||
window.history.pushState(
|
||||
{ view: 'upload', folderId: folderId },
|
||||
'',
|
||||
'#upload'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async performSearch(query) {
|
||||
@@ -343,7 +492,10 @@ export class RBoxApp extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
switchView(view) {
|
||||
switchView(view, pushState = true) {
|
||||
this.closeAllOverlays();
|
||||
|
||||
if(this.currentView === view) return;
|
||||
this.currentView = view;
|
||||
|
||||
this.querySelectorAll('.nav-link').forEach(link => {
|
||||
@@ -353,6 +505,10 @@ export class RBoxApp extends HTMLElement {
|
||||
|
||||
const mainContent = this.querySelector('#main-content');
|
||||
|
||||
if (pushState) {
|
||||
window.history.pushState({ view: view }, '', `#${view}`);
|
||||
}
|
||||
|
||||
switch (view) {
|
||||
case 'files':
|
||||
mainContent.innerHTML = '<file-list></file-list>';
|
||||
@@ -368,7 +524,7 @@ export class RBoxApp extends HTMLElement {
|
||||
break;
|
||||
case 'deleted':
|
||||
mainContent.innerHTML = '<deleted-files></deleted-files>';
|
||||
this.attachListeners(); // Re-attach listeners for the new component
|
||||
this.attachListeners();
|
||||
break;
|
||||
case 'starred':
|
||||
mainContent.innerHTML = '<starred-items></starred-items>';
|
||||
@@ -382,6 +538,14 @@ export class RBoxApp extends HTMLElement {
|
||||
mainContent.innerHTML = '<admin-dashboard></admin-dashboard>';
|
||||
this.attachListeners();
|
||||
break;
|
||||
case 'billing':
|
||||
mainContent.innerHTML = '<billing-dashboard></billing-dashboard>';
|
||||
this.attachListeners();
|
||||
break;
|
||||
case 'admin-billing':
|
||||
mainContent.innerHTML = '<admin-billing></admin-billing>';
|
||||
this.attachListeners();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { api } from '../api.js';
|
||||
import { BaseFileList } from './base-file-list.js';
|
||||
|
||||
export class RecentFiles extends HTMLElement {
|
||||
export class RecentFiles extends BaseFileList {
|
||||
constructor() {
|
||||
super();
|
||||
this.recentFiles = [];
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadRecentFiles();
|
||||
}
|
||||
|
||||
async loadRecentFiles() {
|
||||
try {
|
||||
this.recentFiles = await api.listRecentFiles();
|
||||
this.files = await api.listRecentFiles();
|
||||
this.folders = [];
|
||||
this.selectedFiles.clear();
|
||||
this.selectedFolders.clear();
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent files:', error);
|
||||
@@ -23,10 +27,17 @@ export class RecentFiles extends HTMLElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.recentFiles.length === 0) {
|
||||
const hasSelected = this.selectedFiles.size > 0;
|
||||
const totalSelected = this.selectedFiles.size;
|
||||
const allSelected = this.files.length > 0 && this.selectedFiles.size === this.files.length;
|
||||
const someSelected = hasSelected && !allSelected;
|
||||
|
||||
if (this.files.length === 0) {
|
||||
this.innerHTML = `
|
||||
<div class="recent-files-container">
|
||||
<h2>Recent Files</h2>
|
||||
<div class="file-list-container">
|
||||
<div class="file-list-header">
|
||||
<h2>Recent Files</h2>
|
||||
</div>
|
||||
<p class="empty-state">No recent files found.</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -34,23 +45,45 @@ export class RecentFiles extends HTMLElement {
|
||||
}
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="recent-files-container">
|
||||
<h2>Recent Files</h2>
|
||||
<div class="file-list-container">
|
||||
<div class="file-list-header">
|
||||
<div class="header-left">
|
||||
<h2>Recent Files</h2>
|
||||
${this.files.length > 0 ? `
|
||||
<div class="selection-controls">
|
||||
<input type="checkbox" id="select-all" ${allSelected ? 'checked' : ''} ${someSelected ? 'data-indeterminate="true"' : ''}>
|
||||
<label for="select-all">
|
||||
${hasSelected ? `${totalSelected} selected` : 'Select all'}
|
||||
</label>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${hasSelected ? `
|
||||
<div class="batch-actions">
|
||||
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="file-grid">
|
||||
${this.recentFiles.map(file => this.renderFile(file)).join('')}
|
||||
${this.files.map(file => this.renderRecentFile(file)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.attachListeners();
|
||||
this.updateIndeterminateState();
|
||||
}
|
||||
|
||||
renderFile(file) {
|
||||
renderRecentFile(file) {
|
||||
const isSelected = this.selectedFiles.has(file.id);
|
||||
const icon = this.getFileIcon(file.mime_type);
|
||||
const size = this.formatFileSize(file.size);
|
||||
const lastAccessed = file.last_accessed_at ? new Date(file.last_accessed_at).toLocaleString() : 'N/A';
|
||||
|
||||
return `
|
||||
<div class="file-item" data-file-id="${file.id}">
|
||||
<div class="file-item ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
|
||||
<input type="checkbox" class="select-item" data-type="file" data-id="${file.id}" ${isSelected ? 'checked' : ''}>
|
||||
<div class="file-icon">${icon}</div>
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-size">${size}</div>
|
||||
@@ -62,51 +95,24 @@ export class RecentFiles extends HTMLElement {
|
||||
`;
|
||||
}
|
||||
|
||||
getFileIcon(mimeType) {
|
||||
if (mimeType.startsWith('image/')) return '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
this.querySelectorAll('.action-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const action = btn.dataset.action;
|
||||
const id = parseInt(btn.dataset.id);
|
||||
if (action === 'download') {
|
||||
await this.handleDownload(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async handleDownload(fileId) {
|
||||
async handleAction(action, id) {
|
||||
try {
|
||||
const blob = await api.downloadFile(fileId);
|
||||
const file = this.recentFiles.find(f => f.id === fileId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'File downloaded successfully!', type: 'success' }
|
||||
}));
|
||||
if (action === 'download') {
|
||||
const blob = await api.downloadFile(id);
|
||||
const file = this.files.find(f => f.id === id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'File downloaded successfully!', type: 'success' }
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to download file: ' + error.message, type: 'error' }
|
||||
detail: { message: 'Action failed: ' + error.message, type: 'error' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,18 @@ export class SharedItems extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.myShares = [];
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.addEventListener('click', this.boundHandleClick);
|
||||
await this.loadMyShares();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('click', this.boundHandleClick);
|
||||
}
|
||||
|
||||
async loadMyShares() {
|
||||
try {
|
||||
this.myShares = await api.listMyShares();
|
||||
@@ -69,23 +75,25 @@ export class SharedItems extends HTMLElement {
|
||||
`;
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
this.querySelectorAll('.share-actions .button').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const action = btn.dataset.action;
|
||||
const id = parseInt(btn.dataset.id);
|
||||
const link = btn.dataset.link;
|
||||
handleClick(e) {
|
||||
const target = e.target;
|
||||
if (target.closest('.share-actions') && target.classList.contains('button')) {
|
||||
e.stopPropagation();
|
||||
const action = target.dataset.action;
|
||||
const id = parseInt(target.dataset.id);
|
||||
const link = target.dataset.link;
|
||||
|
||||
if (action === 'copy-link') {
|
||||
this.copyLink(link);
|
||||
} else if (action === 'edit-share') {
|
||||
this.handleEditShare(id);
|
||||
} else if (action === 'delete-share') {
|
||||
await this.handleDeleteShare(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (action === 'copy-link') {
|
||||
this.copyLink(link);
|
||||
} else if (action === 'edit-share') {
|
||||
this.handleEditShare(id);
|
||||
} else if (action === 'delete-share') {
|
||||
this.handleDeleteShare(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
}
|
||||
|
||||
copyLink(link) {
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { api } from '../api.js';
|
||||
import { BaseFileList } from './base-file-list.js';
|
||||
|
||||
export class StarredItems extends HTMLElement {
|
||||
export class StarredItems extends BaseFileList {
|
||||
constructor() {
|
||||
super();
|
||||
this.starredFiles = [];
|
||||
this.starredFolders = [];
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadStarredItems();
|
||||
}
|
||||
|
||||
async loadStarredItems() {
|
||||
try {
|
||||
this.starredFiles = await api.listStarredFiles();
|
||||
this.starredFolders = await api.listStarredFolders();
|
||||
this.files = await api.listStarredFiles();
|
||||
this.folders = await api.listStarredFolders();
|
||||
this.selectedFiles.clear();
|
||||
this.selectedFolders.clear();
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Failed to load starred items:', error);
|
||||
@@ -25,12 +27,21 @@ export class StarredItems extends HTMLElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
const allStarred = [...this.starredFolders, ...this.starredFiles];
|
||||
const allStarred = [...this.folders, ...this.files];
|
||||
const hasSelected = this.selectedFiles.size > 0 || this.selectedFolders.size > 0;
|
||||
const totalItems = this.files.length + this.folders.length;
|
||||
const totalSelected = this.selectedFiles.size + this.selectedFolders.size;
|
||||
const allFilesSelected = this.files.length > 0 && this.selectedFiles.size === this.files.length;
|
||||
const allFoldersSelected = this.folders.length > 0 && this.selectedFolders.size === this.folders.length;
|
||||
const allSelected = totalItems > 0 && allFilesSelected && allFoldersSelected;
|
||||
const someSelected = hasSelected && !allSelected;
|
||||
|
||||
if (allStarred.length === 0) {
|
||||
this.innerHTML = `
|
||||
<div class="starred-items-container">
|
||||
<h2>Starred Items</h2>
|
||||
<div class="file-list-container">
|
||||
<div class="file-list-header">
|
||||
<h2>Starred Items</h2>
|
||||
</div>
|
||||
<p class="empty-state">No starred items found.</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -38,71 +49,90 @@ export class StarredItems extends HTMLElement {
|
||||
}
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="starred-items-container">
|
||||
<h2>Starred Items</h2>
|
||||
<div class="file-list-container">
|
||||
<div class="file-list-header">
|
||||
<div class="header-left">
|
||||
<h2>Starred Items</h2>
|
||||
${totalItems > 0 ? `
|
||||
<div class="selection-controls">
|
||||
<input type="checkbox" id="select-all" ${allSelected ? 'checked' : ''} ${someSelected ? 'data-indeterminate="true"' : ''}>
|
||||
<label for="select-all">
|
||||
${hasSelected ? `${totalSelected} selected` : 'Select all'}
|
||||
</label>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${hasSelected ? `
|
||||
<div class="batch-actions">
|
||||
<button class="button button-small" id="batch-unstar-btn">Unstar Selected</button>
|
||||
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="file-grid">
|
||||
${this.starredFolders.map(folder => this.renderFolder(folder)).join('')}
|
||||
${this.starredFiles.map(file => this.renderFile(file)).join('')}
|
||||
${this.folders.map(folder => this.renderFolder(folder)).join('')}
|
||||
${this.files.map(file => this.renderFile(file)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.attachListeners();
|
||||
this.updateIndeterminateState();
|
||||
}
|
||||
|
||||
renderFolder(folder) {
|
||||
getFolderActions(folder) {
|
||||
return `<button class="action-btn star-btn" data-action="unstar-folder" data-id="${folder.id}">★</button>`;
|
||||
}
|
||||
|
||||
getFileActions(file) {
|
||||
return `
|
||||
<div class="file-item folder-item" data-folder-id="${folder.id}">
|
||||
<div class="file-icon">📁</div>
|
||||
<div class="file-name">${folder.name}</div>
|
||||
<div class="file-actions-menu">
|
||||
<button class="action-btn star-btn" data-action="unstar-folder" data-id="${folder.id}">★</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-btn" data-action="download" data-id="${file.id}">Download</button>
|
||||
<button class="action-btn star-btn" data-action="unstar-file" data-id="${file.id}">★</button>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFile(file) {
|
||||
const icon = this.getFileIcon(file.mime_type);
|
||||
const size = this.formatFileSize(file.size);
|
||||
|
||||
return `
|
||||
<div class="file-item" data-file-id="${file.id}">
|
||||
<div class="file-icon">${icon}</div>
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-size">${size}</div>
|
||||
<div class="file-actions-menu">
|
||||
<button class="action-btn" data-action="download" data-id="${file.id}">Download</button>
|
||||
<button class="action-btn star-btn" data-action="unstar-file" data-id="${file.id}">★</button>
|
||||
</div>
|
||||
</div>
|
||||
createBatchActionsBar() {
|
||||
const container = this.querySelector('.file-list-container');
|
||||
const header = container.querySelector('.file-list-header');
|
||||
const batchBar = document.createElement('div');
|
||||
batchBar.className = 'batch-actions';
|
||||
batchBar.innerHTML = `
|
||||
<button class="button button-small" id="batch-unstar-btn">Unstar Selected</button>
|
||||
<button class="button button-small" id="clear-selection-btn">Clear Selection</button>
|
||||
`;
|
||||
}
|
||||
|
||||
getFileIcon(mimeType) {
|
||||
if (mimeType.startsWith('image/')) return '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
header.insertAdjacentElement('afterend', batchBar);
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
this.querySelectorAll('.action-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const action = btn.dataset.action;
|
||||
const id = parseInt(btn.dataset.id);
|
||||
await this.handleAction(action, id);
|
||||
});
|
||||
});
|
||||
const batchUnstarBtn = this.querySelector('#batch-unstar-btn');
|
||||
if (batchUnstarBtn) {
|
||||
batchUnstarBtn.addEventListener('click', () => this.handleBatchUnstar());
|
||||
}
|
||||
}
|
||||
|
||||
async handleBatchUnstar() {
|
||||
const totalSelected = this.selectedFiles.size + this.selectedFolders.size;
|
||||
if (totalSelected === 0) return;
|
||||
|
||||
if (!confirm(`Unstar ${totalSelected} items?`)) return;
|
||||
|
||||
try {
|
||||
for (const fileId of this.selectedFiles) {
|
||||
await api.unstarFile(fileId);
|
||||
}
|
||||
for (const folderId of this.selectedFolders) {
|
||||
await api.unstarFolder(folderId);
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Items unstarred successfully!', type: 'success' }
|
||||
}));
|
||||
await this.loadStarredItems();
|
||||
} catch (error) {
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to unstar items: ' + error.message, type: 'error' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async handleAction(action, id) {
|
||||
@@ -110,7 +140,7 @@ export class StarredItems extends HTMLElement {
|
||||
switch (action) {
|
||||
case 'download':
|
||||
const blob = await api.downloadFile(id);
|
||||
const file = this.starredFiles.find(f => f.id === id);
|
||||
const file = this.files.find(f => f.id === id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
Reference in New Issue
Block a user