This commit is contained in:
2025-11-10 15:46:40 +01:00
parent 1ddb2c609d
commit 1e5a6dbd5f
63 changed files with 6107 additions and 491 deletions
+371
View File
@@ -0,0 +1,371 @@
.billing-dashboard {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.billing-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.subscription-badge {
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
}
.subscription-badge.active {
background: #10b981;
color: white;
}
.subscription-badge.inactive {
background: #ef4444;
color: white;
}
.billing-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.billing-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.billing-card h3 {
margin: 0 0 1rem 0;
color: #1f2937;
font-size: 1.1rem;
}
.usage-details {
display: flex;
flex-direction: column;
gap: 1rem;
}
.usage-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.usage-label {
color: #6b7280;
font-size: 0.9rem;
}
.usage-value {
font-weight: 600;
font-size: 1.1rem;
color: #1f2937;
}
.usage-progress {
width: 100%;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.usage-progress-bar {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #2563eb);
transition: width 0.3s ease;
}
.usage-info {
font-size: 0.8rem;
color: #6b7280;
}
.estimated-cost {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
margin: 1rem 0;
}
.cost-breakdown {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.cost-item {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
}
.pricing-details {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pricing-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid #f3f4f6;
}
.pricing-item:last-child {
border-bottom: none;
}
.invoices-section {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.invoices-table {
margin-top: 1rem;
}
.invoices-table table {
width: 100%;
border-collapse: collapse;
}
.invoices-table th,
.invoices-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.invoices-table th {
background: #f9fafb;
font-weight: 600;
color: #374151;
}
.invoice-status {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.invoice-status.paid {
background: #d1fae5;
color: #065f46;
}
.invoice-status.open {
background: #fef3c7;
color: #92400e;
}
.invoice-status.draft {
background: #e5e7eb;
color: #374151;
}
.no-invoices {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.payment-methods-section {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
}
.btn-primary {
background: #2563eb;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-secondary {
background: #6b7280;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-secondary:hover {
background: #4b5563;
}
.btn-link {
background: none;
border: none;
color: #2563eb;
cursor: pointer;
text-decoration: underline;
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
}
.invoice-total {
margin-top: 1rem;
padding-top: 1rem;
border-top: 2px solid #1f2937;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 1.1rem;
}
.admin-billing {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-card h3 {
margin: 0 0 0.5rem 0;
color: #6b7280;
font-size: 0.9rem;
font-weight: 500;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
}
.pricing-config-section {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.pricing-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.pricing-table th,
.pricing-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.pricing-table th {
background: #f9fafb;
font-weight: 600;
color: #374151;
}
.btn-edit {
background: #3b82f6;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-edit:hover {
background: #2563eb;
}
.invoice-generation-section {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
}
.invoice-gen-form {
display: flex;
gap: 1rem;
align-items: end;
margin-top: 1rem;
}
.invoice-gen-form label {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-weight: 500;
color: #374151;
}
.invoice-gen-form input {
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 4px;
font-size: 1rem;
}
+80
View File
@@ -0,0 +1,80 @@
.code-editor-view {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: white;
z-index: 100;
}
.code-editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #ddd;
background: white;
}
.code-editor-header .header-left {
display: flex;
align-items: center;
gap: 16px;
}
.code-editor-header .header-right {
display: flex;
gap: 8px;
}
.editor-filename {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.code-editor-body {
flex: 1;
overflow: hidden;
position: relative;
background: white;
}
.code-editor-body .CodeMirror {
height: 100%;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
background: white;
}
.code-editor-body .CodeMirror-gutters {
background: #f9f9f9;
border-right: 1px solid #ddd;
}
.code-editor-body .CodeMirror-linenumber {
color: #999;
padding: 0 8px;
}
.code-editor-body .CodeMirror-cursor {
border-left: 2px solid #333;
}
.code-editor-body .CodeMirror-selected {
background: #d7e8ff;
}
.code-editor-body .CodeMirror-line::selection,
.code-editor-body .CodeMirror-line > span::selection,
.code-editor-body .CodeMirror-line > span > span::selection {
background: #d7e8ff;
}
.code-editor-body textarea {
display: none;
}
+145
View File
@@ -0,0 +1,145 @@
.file-upload-view {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: white;
z-index: 100;
}
.file-upload-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #ddd;
background: white;
}
.file-upload-header .header-left {
display: flex;
align-items: center;
gap: 16px;
}
.file-upload-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.file-upload-body {
flex: 1;
overflow: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.drop-zone {
border: 2px dashed #ddd;
border-radius: 8px;
padding: 48px 24px;
text-align: center;
background: #f9f9f9;
transition: all 0.2s;
cursor: pointer;
}
.drop-zone:hover {
border-color: #003399;
background: #f0f4ff;
}
.drop-zone.drag-over {
border-color: #003399;
background: #e6f0ff;
border-style: solid;
}
.drop-zone-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.drop-zone h3 {
margin: 0 0 8px 0;
color: #333;
font-size: 20px;
}
.drop-zone p {
margin: 8px 0;
color: #666;
}
.upload-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.upload-item {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
.upload-item-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.upload-item-name {
font-weight: 500;
color: #333;
}
.upload-item-size {
color: #666;
font-size: 14px;
}
.upload-item-progress {
display: flex;
flex-direction: column;
gap: 4px;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #003399;
transition: width 0.3s ease;
}
.upload-item-status {
font-size: 14px;
color: #666;
}
.upload-item-status.success {
color: #28a745;
font-weight: 500;
}
.upload-item-status.error {
color: #dc3545;
font-weight: 500;
}
+118 -43
View File
@@ -16,6 +16,12 @@
* {
box-sizing: border-box;
user-select: none;
scrollbar-width: none;
-ms-overflow-style: none;
}
*::-webkit-scrollbar {
display: none;
}
input, textarea {
@@ -29,6 +35,7 @@ body {
background-color: var(--background-color);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
.app-container {
@@ -146,6 +153,7 @@ body {
flex: 1;
overflow-y: auto;
padding: calc(var(--spacing-unit) * 3);
position: relative;
}
.button {
@@ -267,6 +275,41 @@ body {
padding: calc(var(--spacing-unit) * 2);
}
.breadcrumb-nav {
display: flex;
align-items: center;
gap: calc(var(--spacing-unit) * 1);
margin-bottom: calc(var(--spacing-unit) * 2);
font-size: 14px;
color: var(--text-color-light);
}
.breadcrumb-item {
cursor: pointer;
color: var(--primary-color);
transition: color 0.2s;
}
.breadcrumb-item:hover {
color: var(--secondary-color);
text-decoration: underline;
}
.breadcrumb-item.breadcrumb-current {
color: var(--text-color);
cursor: default;
font-weight: 600;
}
.breadcrumb-item.breadcrumb-current:hover {
text-decoration: none;
}
.breadcrumb-separator {
color: var(--text-color-light);
user-select: none;
}
.file-list-header {
display: flex;
justify-content: space-between;
@@ -276,11 +319,36 @@ body {
border-bottom: 1px solid var(--border-color);
}
.file-list-header .header-left {
display: flex;
align-items: center;
gap: calc(var(--spacing-unit) * 3);
}
.file-list-header h2 {
margin: 0;
color: var(--primary-color);
}
.selection-controls {
display: flex;
align-items: center;
gap: var(--spacing-unit);
}
.selection-controls input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.selection-controls label {
font-size: 14px;
color: var(--text-color);
cursor: pointer;
user-select: none;
}
.file-actions {
display: flex;
gap: var(--spacing-unit);
@@ -301,6 +369,7 @@ body {
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.file-item:hover {
@@ -308,6 +377,10 @@ body {
border-color: var(--primary-color);
}
.file-item:hover .select-item {
opacity: 1;
}
.file-icon {
font-size: 3rem;
margin-bottom: var(--spacing-unit);
@@ -690,66 +763,54 @@ body.dark-mode {
color: var(--text-color-light);
}
.preview-modal {
position: fixed;
.file-preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: calc(var(--spacing-unit) * 2);
}
.preview-container {
background: var(--accent-color);
border-radius: 8px;
max-width: 90vw;
max-height: 90vh;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: white;
z-index: 100;
}
.preview-header {
.file-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: calc(var(--spacing-unit) * 2);
border-bottom: 1px solid var(--border-color);
padding: 16px 24px;
border-bottom: 1px solid #ddd;
background: white;
}
.preview-info h3 {
margin: 0 0 calc(var(--spacing-unit) * 0.5) 0;
font-size: 1.25rem;
.file-preview-header .header-left {
display: flex;
align-items: center;
gap: 16px;
}
.preview-info p {
.preview-info {
display: flex;
flex-direction: column;
}
.preview-file-name {
margin: 0;
color: var(--text-color-light);
font-size: 0.875rem;
font-size: 18px;
font-weight: 600;
color: #333;
}
.preview-file-info {
margin: 4px 0 0 0;
color: #666;
font-size: 14px;
}
.preview-actions {
display: flex;
gap: calc(var(--spacing-unit) * 1);
}
.btn-icon {
background: none;
border: 1px solid var(--border-color);
padding: calc(var(--spacing-unit) * 1);
border-radius: 4px;
cursor: pointer;
font-size: 1.2rem;
transition: background 0.2s;
}
.btn-icon:hover {
background: var(--background-color);
gap: 8px;
}
.preview-content {
@@ -759,7 +820,7 @@ body.dark-mode {
align-items: center;
justify-content: center;
padding: calc(var(--spacing-unit) * 2);
background: #000;
background: #f9f9f9;
}
.preview-content img,
@@ -894,4 +955,18 @@ body.dark-mode {
top: var(--spacing-unit);
left: var(--spacing-unit);
z-index: 1;
width: 20px;
height: 20px;
cursor: pointer;
opacity: 0.3;
transition: opacity 0.2s ease;
}
.file-item .select-item:checked {
opacity: 1;
}
.file-item.selected {
border-color: var(--primary-color);
background-color: rgba(0, 51, 153, 0.05);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 157 KiB

+12
View File
@@ -5,7 +5,19 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RBox Cloud Storage</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/billing.css">
<link rel="stylesheet" href="/static/lib/codemirror/codemirror.min.css">
<link rel="stylesheet" href="/static/css/code-editor-view.css">
<link rel="stylesheet" href="/static/css/file-upload-view.css">
<link rel="manifest" href="/static/manifest.json">
<script src="/static/lib/codemirror/codemirror.min.js"></script>
<script src="/static/lib/codemirror/javascript.min.js"></script>
<script src="/static/lib/codemirror/python.min.js"></script>
<script src="/static/lib/codemirror/markdown.min.js"></script>
<script src="/static/lib/codemirror/xml.min.js"></script>
<script src="/static/lib/codemirror/css.min.js"></script>
<link rel="icon" type="image/png" href="/static/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
</head>
<body>
<rbox-app></rbox-app>
+11 -6
View File
@@ -113,6 +113,10 @@ class APIClient {
});
}
async getFolderPath(folderId) {
return this.request(`folders/${folderId}/path`);
}
async deleteFolder(folderId) {
return this.request(`folders/${folderId}`, {
method: 'DELETE'
@@ -294,12 +298,6 @@ class APIClient {
});
}
async deleteShare(shareId) {
return this.request(`shares/${shareId}`, {
method: 'DELETE'
});
}
async batchFileOperations(operation, fileIds, targetFolderId = null) {
const payload = { file_ids: fileIds, operation: operation };
if (targetFolderId !== null) {
@@ -321,6 +319,13 @@ class APIClient {
body: payload
});
}
async updateFile(fileId, content) {
return this.request(`files/${fileId}/content`, {
method: 'PUT',
body: { content }
});
}
}
export const api = new APIClient();
+193
View File
@@ -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;
+34 -14
View File
@@ -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);
}
}
+268
View File
@@ -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 '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
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 ? '&#9733;' : '&#9734;';
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">&#128193;</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 ? '&#9733;' : '&#9734;';
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) {
}
}
+309
View File
@@ -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;
+153
View File
@@ -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 };
+90 -45
View File
@@ -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 '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
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' }
}));
}
}
}
+249 -78
View File
@@ -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 '&#128196;';
}
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) {
+34 -18
View File
@@ -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';
+187
View File
@@ -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);
+1 -1
View File
@@ -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">
+22 -10
View File
@@ -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() {
+192 -28
View File
@@ -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;
}
}
}
+59 -53
View File
@@ -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 '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
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' }
}));
}
}
+24 -16
View File
@@ -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) {
+89 -59
View File
@@ -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}">&#9733;</button>`;
}
getFileActions(file) {
return `
<div class="file-item folder-item" data-folder-id="${folder.id}">
<div class="file-icon">&#128193;</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}">&#9733;</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}">&#9733;</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}">&#9733;</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 '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
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;
+1 -7
View File
@@ -12,12 +12,6 @@ const app = new RBoxApp();
// Register service worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/service-worker.js')
.then(registration => {
console.log('Service Worker registered with scope:', registration.scope);
})
.catch(error => {
console.log('Service Worker registration failed:', error);
});
navigator.serviceWorker.register('/static/service-worker.js');
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'rbox-cache-v1';
const CACHE_NAME = 'rbox-cache-v11';
const urlsToCache = [
'/',
'/static/index.html',