@riligar/elysia-backup 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -12
- package/package.json +7 -5
- package/src/assets/favicon.ico +0 -0
- package/src/assets/logo.png +0 -0
- package/src/core/config.js +70 -0
- package/src/core/scheduler.js +99 -0
- package/src/core/session.js +90 -0
- package/src/index.js +609 -3
- package/src/middleware/auth.middleware.js +41 -0
- package/src/services/backup.service.js +169 -0
- package/src/services/s3-client.js +28 -0
- package/src/views/DashboardPage.js +56 -0
- package/src/views/LoginPage.js +23 -0
- package/src/views/components/ActionArea.js +48 -0
- package/src/views/components/FilesTab.js +111 -0
- package/src/views/components/Head.js +43 -0
- package/src/views/components/Header.js +54 -0
- package/src/views/components/LoginCard.js +114 -0
- package/src/views/components/SecuritySection.js +166 -0
- package/src/views/components/SettingsTab.js +88 -0
- package/src/views/components/StatusCards.js +54 -0
- package/src/views/scripts/backupApp.js +324 -0
- package/src/views/scripts/loginApp.js +60 -0
- package/src/elysia-backup.js +0 -1316
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { readdir, stat, readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { join, relative, dirname } from 'node:path'
|
|
3
|
+
import { createS3Client } from './s3-client.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a backup service instance
|
|
7
|
+
* @param {Function} getConfig - Function to get current configuration
|
|
8
|
+
* @returns {Object} Backup service with all operations
|
|
9
|
+
*/
|
|
10
|
+
export const createBackupService = getConfig => {
|
|
11
|
+
/**
|
|
12
|
+
* Get a configured S3 client
|
|
13
|
+
*/
|
|
14
|
+
const getS3 = () => createS3Client(getConfig())
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Upload a single file to S3
|
|
18
|
+
* @param {string} filePath - Local file path
|
|
19
|
+
* @param {string} rootDir - Root directory for relative path calculation
|
|
20
|
+
* @param {string} timestampPrefix - Timestamp to prepend to filename
|
|
21
|
+
*/
|
|
22
|
+
const uploadFile = async (filePath, rootDir, timestampPrefix) => {
|
|
23
|
+
const config = getConfig()
|
|
24
|
+
const s3 = getS3()
|
|
25
|
+
const fileContent = await readFile(filePath)
|
|
26
|
+
|
|
27
|
+
const relativePath = relative(rootDir, filePath)
|
|
28
|
+
const dir = dirname(relativePath)
|
|
29
|
+
const filename = relativePath.split('/').pop()
|
|
30
|
+
|
|
31
|
+
// Format: YYYY-MM-DD_HH-mm-ss_filename.ext
|
|
32
|
+
const timestamp = timestampPrefix || new Date().toISOString()
|
|
33
|
+
const newFilename = `${timestamp}_${filename}`
|
|
34
|
+
|
|
35
|
+
// Reconstruct path with new filename
|
|
36
|
+
const finalPath = dir === '.' ? newFilename : join(dir, newFilename)
|
|
37
|
+
const key = config.prefix ? join(config.prefix, finalPath) : finalPath
|
|
38
|
+
|
|
39
|
+
console.log(`Uploading ${key}...`)
|
|
40
|
+
await s3.write(key, fileContent)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Process a directory recursively and upload all matching files
|
|
45
|
+
* @param {string} dir - Directory to process
|
|
46
|
+
* @param {string} timestampPrefix - Timestamp for all files in this batch
|
|
47
|
+
*/
|
|
48
|
+
const processDirectory = async (dir, timestampPrefix) => {
|
|
49
|
+
const config = getConfig()
|
|
50
|
+
const files = await readdir(dir)
|
|
51
|
+
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const fullPath = join(dir, file)
|
|
54
|
+
const stats = await stat(fullPath)
|
|
55
|
+
|
|
56
|
+
if (stats.isDirectory()) {
|
|
57
|
+
await processDirectory(fullPath, timestampPrefix)
|
|
58
|
+
} else {
|
|
59
|
+
// Filter by extension
|
|
60
|
+
const allowedExtensions = config.extensions || []
|
|
61
|
+
const hasExtension = allowedExtensions.some(ext => file.endsWith(ext))
|
|
62
|
+
|
|
63
|
+
// Skip files that look like backups to prevent recursion/duplication
|
|
64
|
+
const timestampRegex = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)_/
|
|
65
|
+
if (timestampRegex.test(file)) {
|
|
66
|
+
console.log(`Skipping backup-like file: ${file}`)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (allowedExtensions.length === 0 || hasExtension) {
|
|
71
|
+
await uploadFile(fullPath, config.sourceDir, timestampPrefix)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* List all files in the remote bucket
|
|
79
|
+
* @returns {Array} List of file objects with Key, Size, LastModified
|
|
80
|
+
*/
|
|
81
|
+
const listRemoteFiles = async () => {
|
|
82
|
+
const config = getConfig()
|
|
83
|
+
const s3 = getS3()
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const response = await s3.list({ prefix: config.prefix || '' })
|
|
87
|
+
|
|
88
|
+
// Handle array response
|
|
89
|
+
if (Array.isArray(response)) {
|
|
90
|
+
return response.map(f => ({
|
|
91
|
+
Key: f.key || f.name,
|
|
92
|
+
Size: f.size,
|
|
93
|
+
LastModified: f.lastModified,
|
|
94
|
+
}))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Handle AWS-like response with contents
|
|
98
|
+
if (response.contents) {
|
|
99
|
+
return response.contents.map(f => ({
|
|
100
|
+
Key: f.key,
|
|
101
|
+
Size: f.size,
|
|
102
|
+
LastModified: f.lastModified,
|
|
103
|
+
}))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log('Unknown list response structure:', response)
|
|
107
|
+
return []
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.error('Error listing files with Bun.s3:', e)
|
|
110
|
+
return []
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Restore a file from backup to local filesystem
|
|
116
|
+
* @param {string} key - S3 key of the file to restore
|
|
117
|
+
* @returns {string} Local path where file was restored
|
|
118
|
+
*/
|
|
119
|
+
const restoreFile = async key => {
|
|
120
|
+
const config = getConfig()
|
|
121
|
+
const s3 = getS3()
|
|
122
|
+
const file = s3.file(key)
|
|
123
|
+
|
|
124
|
+
if (!(await file.exists())) {
|
|
125
|
+
throw new Error(`File ${key} not found in bucket`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const arrayBuffer = await file.arrayBuffer()
|
|
129
|
+
const byteArray = new Uint8Array(arrayBuffer)
|
|
130
|
+
|
|
131
|
+
// Remove prefix from key to get relative path
|
|
132
|
+
const relativePath = config.prefix ? key.replace(config.prefix, '') : key
|
|
133
|
+
const cleanRelative = relativePath.replace(/^[\/\\]/, '')
|
|
134
|
+
|
|
135
|
+
// Extract directory and filename
|
|
136
|
+
const dir = dirname(cleanRelative)
|
|
137
|
+
const filename = cleanRelative.split('/').pop()
|
|
138
|
+
|
|
139
|
+
// Strip timestamp prefix to restore original filename
|
|
140
|
+
const timestampRegex = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)_/
|
|
141
|
+
const originalFilename = filename.replace(timestampRegex, '')
|
|
142
|
+
|
|
143
|
+
const finalLocalRelativePath = dir === '.' ? originalFilename : join(dir, originalFilename)
|
|
144
|
+
const localPath = join(config.sourceDir, finalLocalRelativePath)
|
|
145
|
+
|
|
146
|
+
// Ensure directory exists
|
|
147
|
+
await mkdir(dirname(localPath), { recursive: true })
|
|
148
|
+
await writeFile(localPath, byteArray)
|
|
149
|
+
|
|
150
|
+
return localPath
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Delete a file from the remote bucket
|
|
155
|
+
* @param {string} key - S3 key of the file to delete
|
|
156
|
+
*/
|
|
157
|
+
const deleteFile = async key => {
|
|
158
|
+
const s3 = getS3()
|
|
159
|
+
await s3.delete(key)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
uploadFile,
|
|
164
|
+
processDirectory,
|
|
165
|
+
listRemoteFiles,
|
|
166
|
+
restoreFile,
|
|
167
|
+
deleteFile,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { S3Client } from 'bun'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create an S3/R2 client with the given configuration
|
|
5
|
+
* @param {Object} config - Configuration object
|
|
6
|
+
* @param {string} config.bucket - Bucket name
|
|
7
|
+
* @param {string} config.endpoint - S3/R2 endpoint URL
|
|
8
|
+
* @param {string} config.accessKeyId - Access key ID
|
|
9
|
+
* @param {string} config.secretAccessKey - Secret access key
|
|
10
|
+
* @returns {S3Client} Configured S3 client
|
|
11
|
+
*/
|
|
12
|
+
export const createS3Client = config => {
|
|
13
|
+
// Debug config (masked for security)
|
|
14
|
+
console.log('S3 Config:', {
|
|
15
|
+
bucket: config.bucket,
|
|
16
|
+
endpoint: config.endpoint,
|
|
17
|
+
accessKeyId: config.accessKeyId ? '***' + config.accessKeyId.slice(-4) : 'missing',
|
|
18
|
+
hasSecret: !!config.secretAccessKey,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
return new S3Client({
|
|
22
|
+
accessKeyId: config.accessKeyId,
|
|
23
|
+
secretAccessKey: config.secretAccessKey,
|
|
24
|
+
endpoint: config.endpoint,
|
|
25
|
+
bucket: config.bucket,
|
|
26
|
+
region: 'auto',
|
|
27
|
+
})
|
|
28
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard page component - Modular version
|
|
3
|
+
* Composes all dashboard components
|
|
4
|
+
* @param {{ config: object, jobStatus: object, hasAuth: boolean }} props
|
|
5
|
+
* @returns {string} HTML string
|
|
6
|
+
*/
|
|
7
|
+
import { Head } from './components/Head.js'
|
|
8
|
+
import { Header } from './components/Header.js'
|
|
9
|
+
import { StatusCards } from './components/StatusCards.js'
|
|
10
|
+
import { ActionArea } from './components/ActionArea.js'
|
|
11
|
+
import { FilesTab } from './components/FilesTab.js'
|
|
12
|
+
import { SettingsTab } from './components/SettingsTab.js'
|
|
13
|
+
import { SecuritySection } from './components/SecuritySection.js'
|
|
14
|
+
import { backupAppScript } from './scripts/backupApp.js'
|
|
15
|
+
|
|
16
|
+
export const DashboardPage = ({ config, jobStatus, hasAuth }) => `
|
|
17
|
+
<!DOCTYPE html>
|
|
18
|
+
<html lang="en">
|
|
19
|
+
<head>
|
|
20
|
+
${Head({ title: 'R2 Backup Manager' })}
|
|
21
|
+
</head>
|
|
22
|
+
<body class="bg-gray-50 text-gray-900 min-h-screen p-12 antialiased selection:bg-gray-900 selection:text-white">
|
|
23
|
+
<div class="max-w-5xl mx-auto" x-data="backupApp()">
|
|
24
|
+
<!-- Header -->
|
|
25
|
+
${Header({ hasAuth })}
|
|
26
|
+
|
|
27
|
+
<!-- Dashboard Tab -->
|
|
28
|
+
<div x-show="activeTab === 'dashboard'" class="space-y-12"
|
|
29
|
+
x-transition:enter="transition ease-out duration-300"
|
|
30
|
+
x-transition:enter-start="opacity-0 translate-y-2"
|
|
31
|
+
x-transition:enter-end="opacity-100 translate-y-0">
|
|
32
|
+
|
|
33
|
+
<!-- Status Cards -->
|
|
34
|
+
${StatusCards()}
|
|
35
|
+
|
|
36
|
+
<!-- Action Area -->
|
|
37
|
+
${ActionArea()}
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<!-- Files Tab -->
|
|
41
|
+
${FilesTab()}
|
|
42
|
+
|
|
43
|
+
<!-- Settings Tab -->
|
|
44
|
+
<div x-show="activeTab === 'settings'" class="max-w-3xl mx-auto"
|
|
45
|
+
x-transition:enter="transition ease-out duration-300"
|
|
46
|
+
x-transition:enter-start="opacity-0 translate-y-2"
|
|
47
|
+
x-transition:enter-end="opacity-100 translate-y-0">
|
|
48
|
+
${SettingsTab()}
|
|
49
|
+
${SecuritySection()}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
${backupAppScript({ config, jobStatus })}
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
56
|
+
`
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login page component - Modular version
|
|
3
|
+
* Composes login components
|
|
4
|
+
* @param {{ totpEnabled: boolean }} props
|
|
5
|
+
* @returns {string} HTML string
|
|
6
|
+
*/
|
|
7
|
+
import { Head } from './components/Head.js'
|
|
8
|
+
import { LoginCard } from './components/LoginCard.js'
|
|
9
|
+
import { loginAppScript } from './scripts/loginApp.js'
|
|
10
|
+
|
|
11
|
+
export const LoginPage = ({ totpEnabled = false }) => `
|
|
12
|
+
<!DOCTYPE html>
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<head>
|
|
15
|
+
${Head({ title: 'Login - Backup Manager' })}
|
|
16
|
+
</head>
|
|
17
|
+
<body class="bg-gray-50 min-h-screen flex items-center justify-center p-6 antialiased">
|
|
18
|
+
${LoginCard({ totpEnabled })}
|
|
19
|
+
|
|
20
|
+
${loginAppScript({ totpEnabled })}
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
23
|
+
`
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action area component with backup button and activity logs
|
|
3
|
+
* @returns {string} HTML string
|
|
4
|
+
*/
|
|
5
|
+
export const ActionArea = () => `
|
|
6
|
+
<div class="bg-white rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] overflow-hidden">
|
|
7
|
+
<div class="p-10 flex flex-col items-center justify-center text-center">
|
|
8
|
+
<div class="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mb-6 text-gray-400">
|
|
9
|
+
<i data-lucide="save" class="w-8 h-8"></i>
|
|
10
|
+
</div>
|
|
11
|
+
<h3 class="text-xl font-bold text-gray-900 mb-2">Trigger Manual Backup</h3>
|
|
12
|
+
<p class="text-gray-500 max-w-md mb-8 leading-relaxed">Initiate a backup of your local directory to the configured R2 bucket. This will upload all files recursively.</p>
|
|
13
|
+
|
|
14
|
+
<button
|
|
15
|
+
@click="runBackup()"
|
|
16
|
+
:disabled="loading"
|
|
17
|
+
class="group relative inline-flex items-center justify-center px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 bg-gray-900 rounded-xl hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-70 disabled:cursor-not-allowed">
|
|
18
|
+
<span x-show="!loading" class="flex items-center gap-2">
|
|
19
|
+
<i data-lucide="play-circle" class="w-5 h-5"></i>
|
|
20
|
+
Start Backup Process
|
|
21
|
+
</span>
|
|
22
|
+
<span x-show="loading" class="flex items-center gap-2">
|
|
23
|
+
<i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
|
|
24
|
+
Processing...
|
|
25
|
+
</span>
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<!-- Logs -->
|
|
30
|
+
<div class="bg-gray-50 border-t border-gray-100 p-8">
|
|
31
|
+
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">Activity Log</h4>
|
|
32
|
+
<div class="space-y-3">
|
|
33
|
+
<template x-for="log in logs" :key="log.id">
|
|
34
|
+
<div class="flex items-start gap-4 text-sm">
|
|
35
|
+
<span class="font-mono text-xs text-gray-400 mt-0.5" x-text="log.time"></span>
|
|
36
|
+
<div class="flex items-center gap-2">
|
|
37
|
+
<i :data-lucide="log.type === 'error' ? 'alert-circle' : 'info'"
|
|
38
|
+
:class="log.type === 'error' ? 'text-red-600' : 'text-gray-400'"
|
|
39
|
+
class="w-4 h-4"></i>
|
|
40
|
+
<span :class="log.type === 'error' ? 'text-red-600 font-medium' : 'text-gray-600'" x-text="log.message"></span>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
44
|
+
<div x-show="logs.length === 0" class="text-gray-400 text-sm italic">No recent activity recorded.</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
`
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files tab component with remote files table
|
|
3
|
+
* @returns {string} HTML string
|
|
4
|
+
*/
|
|
5
|
+
export const FilesTab = () => `
|
|
6
|
+
<div x-show="activeTab === 'files'" class="bg-white rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] overflow-hidden"
|
|
7
|
+
x-transition:enter="transition ease-out duration-300"
|
|
8
|
+
x-transition:enter-start="opacity-0 translate-y-2"
|
|
9
|
+
x-transition:enter-end="opacity-100 translate-y-0">
|
|
10
|
+
<div class="p-8 border-b border-gray-100 flex justify-between items-center">
|
|
11
|
+
<div>
|
|
12
|
+
<h2 class="text-xl font-bold text-gray-900 flex items-center gap-2">
|
|
13
|
+
<i data-lucide="server" class="w-5 h-5 text-gray-400"></i>
|
|
14
|
+
Remote Files
|
|
15
|
+
</h2>
|
|
16
|
+
<p class="text-gray-500 text-sm mt-1">Files currently stored in your R2 bucket</p>
|
|
17
|
+
</div>
|
|
18
|
+
<button @click="fetchFiles()" class="text-gray-900 hover:text-gray-600 font-semibold text-sm transition-colors flex items-center gap-2">
|
|
19
|
+
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
|
20
|
+
Refresh List
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="overflow-x-auto">
|
|
24
|
+
<table class="w-full text-left">
|
|
25
|
+
<thead>
|
|
26
|
+
<tr class="border-b border-gray-100">
|
|
27
|
+
<th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">File Key</th>
|
|
28
|
+
<th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">Size</th>
|
|
29
|
+
<th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">Last Modified</th>
|
|
30
|
+
<th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider text-right">Action</th>
|
|
31
|
+
</tr>
|
|
32
|
+
</thead>
|
|
33
|
+
<template x-for="group in groups" :key="group.name">
|
|
34
|
+
<tbody class="divide-y divide-gray-50 border-b border-gray-100">
|
|
35
|
+
<!-- Folder Header -->
|
|
36
|
+
<tr class="bg-gray-50 hover:bg-gray-100 cursor-pointer transition-colors select-none"
|
|
37
|
+
@click="group.expanded = !group.expanded; $nextTick(() => lucide.createIcons())">
|
|
38
|
+
<td colspan="4" class="px-8 py-3">
|
|
39
|
+
<div class="flex items-center gap-3">
|
|
40
|
+
<i :data-lucide="group.expanded ? 'folder-open' : 'folder'" class="w-5 h-5 text-gray-400"></i>
|
|
41
|
+
<span class="font-bold text-gray-700 text-sm" x-text="formatDateHeader(group.name)"></span>
|
|
42
|
+
<span class="text-xs font-medium text-gray-500 bg-gray-200 px-2 py-0.5 rounded-full" x-text="group.files.length"></span>
|
|
43
|
+
</div>
|
|
44
|
+
</td>
|
|
45
|
+
</tr>
|
|
46
|
+
|
|
47
|
+
<!-- Files in Group -->
|
|
48
|
+
<template x-for="file in group.files" :key="file.key">
|
|
49
|
+
<tr x-show="group.expanded" class="hover:bg-gray-50 transition-colors duration-150 group bg-white">
|
|
50
|
+
<td class="px-8 py-5 font-medium text-gray-900 text-sm pl-12" x-text="file.key"></td>
|
|
51
|
+
<td class="px-8 py-5 text-gray-500 text-sm" x-text="formatBytes(file.size)"></td>
|
|
52
|
+
<td class="px-8 py-5 text-gray-500 text-sm" x-text="new Date(file.lastModified).toLocaleString()"></td>
|
|
53
|
+
<td class="px-8 py-5 text-right">
|
|
54
|
+
<div class="flex justify-end gap-3 items-center">
|
|
55
|
+
<!-- Restore Button -->
|
|
56
|
+
<button
|
|
57
|
+
x-data="holdButton(() => restoreFile(file.key))"
|
|
58
|
+
@mousedown="start()" @touchstart.prevent="start()"
|
|
59
|
+
@mouseup="stop()" @mouseleave="stop()" @touchend="stop()"
|
|
60
|
+
class="relative overflow-hidden px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider text-blue-600 bg-blue-50 hover:bg-blue-100 transition-colors select-none"
|
|
61
|
+
title="Hold 3s to Restore"
|
|
62
|
+
>
|
|
63
|
+
<div class="absolute inset-0 bg-blue-200/50 origin-left transition-all duration-0 ease-linear" :style="'width: ' + progress + '%'"></div>
|
|
64
|
+
<span class="relative z-10 flex items-center gap-2">
|
|
65
|
+
<i data-lucide="rotate-ccw" class="w-3 h-3"></i>
|
|
66
|
+
<span x-text="progress > 0 ? 'Hold...' : 'Restore'"></span>
|
|
67
|
+
</span>
|
|
68
|
+
</button>
|
|
69
|
+
|
|
70
|
+
<!-- Delete Button -->
|
|
71
|
+
<button
|
|
72
|
+
x-data="holdButton(() => deleteFile(file.key))"
|
|
73
|
+
@mousedown="start()" @touchstart.prevent="start()"
|
|
74
|
+
@mouseup="stop()" @mouseleave="stop()" @touchend="stop()"
|
|
75
|
+
class="relative overflow-hidden px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider text-red-600 bg-red-50 hover:bg-red-100 transition-colors select-none"
|
|
76
|
+
title="Hold 3s to Delete"
|
|
77
|
+
>
|
|
78
|
+
<div class="absolute inset-0 bg-red-200/50 origin-left transition-all duration-0 ease-linear" :style="'width: ' + progress + '%'"></div>
|
|
79
|
+
<span class="relative z-10 flex items-center gap-2">
|
|
80
|
+
<i data-lucide="trash-2" class="w-3 h-3"></i>
|
|
81
|
+
<span x-text="progress > 0 ? 'Hold...' : 'Delete'"></span>
|
|
82
|
+
</span>
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
</template>
|
|
88
|
+
</tbody>
|
|
89
|
+
</template>
|
|
90
|
+
|
|
91
|
+
<!-- Empty State -->
|
|
92
|
+
<tbody x-show="groups.length === 0">
|
|
93
|
+
<tr>
|
|
94
|
+
<td colspan="4" class="px-8 py-16 text-center">
|
|
95
|
+
<div class="flex flex-col items-center justify-center">
|
|
96
|
+
<span x-show="!loadingFiles" class="text-gray-500 font-medium flex flex-col items-center gap-2">
|
|
97
|
+
<i data-lucide="inbox" class="w-8 h-8 text-gray-300"></i>
|
|
98
|
+
No files found in bucket
|
|
99
|
+
</span>
|
|
100
|
+
<span x-show="loadingFiles" class="text-gray-500 font-medium animate-pulse flex flex-col items-center gap-2">
|
|
101
|
+
<i data-lucide="loader" class="w-8 h-8 animate-spin text-gray-300"></i>
|
|
102
|
+
Loading remote files...
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
</td>
|
|
106
|
+
</tr>
|
|
107
|
+
</tbody>
|
|
108
|
+
</table>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
`
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTML head component
|
|
3
|
+
* Contains Tailwind config, Alpine.js, Lucide icons, and fonts
|
|
4
|
+
* @param {{ title: string }} props
|
|
5
|
+
* @returns {string} HTML string for head section
|
|
6
|
+
*/
|
|
7
|
+
export const Head = ({ title = 'Backup Manager' }) => `
|
|
8
|
+
<meta charset="UTF-8">
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
10
|
+
<title>${title}</title>
|
|
11
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
12
|
+
<script>
|
|
13
|
+
tailwind.config = {
|
|
14
|
+
theme: {
|
|
15
|
+
extend: {
|
|
16
|
+
fontFamily: {
|
|
17
|
+
sans: ['Montserrat', 'sans-serif'],
|
|
18
|
+
},
|
|
19
|
+
colors: {
|
|
20
|
+
gray: {
|
|
21
|
+
50: '#F9FAFB',
|
|
22
|
+
100: '#F3F4F6',
|
|
23
|
+
200: '#E5E7EB',
|
|
24
|
+
300: '#D1D5DB',
|
|
25
|
+
400: '#9CA3AF',
|
|
26
|
+
500: '#6B7280',
|
|
27
|
+
600: '#4B5563',
|
|
28
|
+
700: '#374151',
|
|
29
|
+
800: '#1F2937',
|
|
30
|
+
900: '#111827',
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
38
|
+
<script src="https://unpkg.com/lucide@latest"></script>
|
|
39
|
+
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
40
|
+
<style>
|
|
41
|
+
[x-cloak] { display: none !important; }
|
|
42
|
+
</style>
|
|
43
|
+
`
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard header component with tabs
|
|
3
|
+
* @param {{ hasAuth: boolean }} props
|
|
4
|
+
* @returns {string} HTML string
|
|
5
|
+
*/
|
|
6
|
+
export const Header = ({ hasAuth }) => `
|
|
7
|
+
<div class="flex flex-col md:flex-row md:items-end justify-between mb-16 gap-6">
|
|
8
|
+
<div>
|
|
9
|
+
<h6 class="text-xs font-bold tracking-widest text-gray-500 uppercase mb-2 flex items-center gap-2">
|
|
10
|
+
<i data-lucide="shield-check" class="w-4 h-4"></i>
|
|
11
|
+
System Administration
|
|
12
|
+
</h6>
|
|
13
|
+
<h1 class="text-4xl font-bold text-gray-900 tracking-tight flex items-center gap-3">
|
|
14
|
+
Backup Manager
|
|
15
|
+
</h1>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="flex items-center gap-4">
|
|
19
|
+
<!-- Tabs -->
|
|
20
|
+
<div class="flex p-1 bg-gray-200/50 rounded-xl">
|
|
21
|
+
<button @click="activeTab = 'dashboard'; $nextTick(() => lucide.createIcons())"
|
|
22
|
+
:class="activeTab === 'dashboard' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
|
23
|
+
class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
|
|
24
|
+
<i data-lucide="layout-dashboard" class="w-4 h-4"></i>
|
|
25
|
+
Overview
|
|
26
|
+
</button>
|
|
27
|
+
<button @click="activeTab = 'files'; fetchFiles()"
|
|
28
|
+
:class="activeTab === 'files' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
|
29
|
+
class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
|
|
30
|
+
<i data-lucide="folder-open" class="w-4 h-4"></i>
|
|
31
|
+
Files & Restore
|
|
32
|
+
</button>
|
|
33
|
+
<button @click="activeTab = 'settings'; $nextTick(() => lucide.createIcons())"
|
|
34
|
+
:class="activeTab === 'settings' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
|
35
|
+
class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
|
|
36
|
+
<i data-lucide="settings" class="w-4 h-4"></i>
|
|
37
|
+
Settings
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
${
|
|
42
|
+
hasAuth
|
|
43
|
+
? `
|
|
44
|
+
<!-- Logout Button -->
|
|
45
|
+
<button @click="logout" class="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-semibold text-sm rounded-lg transition-all">
|
|
46
|
+
<i data-lucide="log-out" class="w-4 h-4"></i>
|
|
47
|
+
<span>Logout</span>
|
|
48
|
+
</button>
|
|
49
|
+
`
|
|
50
|
+
: ''
|
|
51
|
+
}
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
`
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login card component
|
|
3
|
+
* @param {{ totpEnabled: boolean }} props
|
|
4
|
+
* @returns {string} HTML string
|
|
5
|
+
*/
|
|
6
|
+
export const LoginCard = ({ totpEnabled }) => `
|
|
7
|
+
<div class="w-full max-w-md" x-data="loginApp()">
|
|
8
|
+
<!-- Login Card -->
|
|
9
|
+
<div class="bg-white rounded-2xl border border-gray-100 shadow-[0_8px_30px_rgba(0,0,0,0.08)] overflow-hidden">
|
|
10
|
+
<!-- Header -->
|
|
11
|
+
<div class="p-10 text-center border-b border-gray-100">
|
|
12
|
+
<div class="w-16 h-16 bg-gray-900 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
13
|
+
<i data-lucide="shield-check" class="w-8 h-8 text-white"></i>
|
|
14
|
+
</div>
|
|
15
|
+
<h1 class="text-2xl font-bold text-gray-900 mb-2">Backup Manager</h1>
|
|
16
|
+
<p class="text-sm text-gray-500">Access Control Panel</p>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<!-- Form -->
|
|
20
|
+
<div class="p-10">
|
|
21
|
+
<form @submit.prevent="login" class="space-y-6">
|
|
22
|
+
<!-- Error Message -->
|
|
23
|
+
<div x-show="error" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
24
|
+
<div class="flex items-center gap-3">
|
|
25
|
+
<i data-lucide="alert-circle" class="w-5 h-5 text-red-600"></i>
|
|
26
|
+
<span class="text-sm text-red-800 font-medium" x-text="error"></span>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<!-- Username -->
|
|
31
|
+
<div>
|
|
32
|
+
<label class="block text-sm font-semibold text-gray-700 mb-2">Username</label>
|
|
33
|
+
<div class="relative">
|
|
34
|
+
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
|
35
|
+
<i data-lucide="user" class="w-5 h-5 text-gray-400"></i>
|
|
36
|
+
</div>
|
|
37
|
+
<input
|
|
38
|
+
type="text"
|
|
39
|
+
x-model="username"
|
|
40
|
+
required
|
|
41
|
+
class="w-full bg-gray-50 border border-gray-200 rounded-lg pl-12 pr-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium"
|
|
42
|
+
placeholder="Enter your username"
|
|
43
|
+
autofocus
|
|
44
|
+
>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<!-- Password -->
|
|
49
|
+
<div>
|
|
50
|
+
<label class="block text-sm font-semibold text-gray-700 mb-2">Password</label>
|
|
51
|
+
<div class="relative">
|
|
52
|
+
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
|
53
|
+
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
|
|
54
|
+
</div>
|
|
55
|
+
<input
|
|
56
|
+
type="password"
|
|
57
|
+
x-model="password"
|
|
58
|
+
required
|
|
59
|
+
class="w-full bg-gray-50 border border-gray-200 rounded-lg pl-12 pr-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium"
|
|
60
|
+
placeholder="Enter your password"
|
|
61
|
+
>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<!-- TOTP Code (only shown when TOTP is enabled) -->
|
|
66
|
+
<div x-show="totpEnabled" x-cloak>
|
|
67
|
+
<label class="block text-sm font-semibold text-gray-700 mb-2">Authenticator Code</label>
|
|
68
|
+
<div class="relative">
|
|
69
|
+
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
|
70
|
+
<i data-lucide="smartphone" class="w-5 h-5 text-gray-400"></i>
|
|
71
|
+
</div>
|
|
72
|
+
<input
|
|
73
|
+
type="text"
|
|
74
|
+
x-model="totpCode"
|
|
75
|
+
inputmode="numeric"
|
|
76
|
+
pattern="[0-9]*"
|
|
77
|
+
maxlength="6"
|
|
78
|
+
:required="totpEnabled"
|
|
79
|
+
class="w-full bg-gray-50 border border-gray-200 rounded-lg pl-12 pr-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium tracking-widest text-center text-lg"
|
|
80
|
+
placeholder="000000"
|
|
81
|
+
>
|
|
82
|
+
</div>
|
|
83
|
+
<p class="text-xs text-gray-500 mt-2 flex items-center gap-1">
|
|
84
|
+
<i data-lucide="info" class="w-3 h-3"></i>
|
|
85
|
+
Enter the 6-digit code from your authenticator app
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<!-- Submit Button -->
|
|
90
|
+
<button
|
|
91
|
+
type="submit"
|
|
92
|
+
:disabled="loading"
|
|
93
|
+
class="w-full bg-gray-900 hover:bg-gray-800 text-white font-bold py-3.5 px-6 rounded-xl transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none"
|
|
94
|
+
>
|
|
95
|
+
<span x-show="!loading" class="flex items-center gap-2">
|
|
96
|
+
<span>Sign In</span>
|
|
97
|
+
<i data-lucide="arrow-right" class="w-5 h-5"></i>
|
|
98
|
+
</span>
|
|
99
|
+
<span x-show="loading" class="flex items-center gap-2">
|
|
100
|
+
<i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
|
|
101
|
+
<span>Authenticating...</span>
|
|
102
|
+
</span>
|
|
103
|
+
</button>
|
|
104
|
+
</form>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<!-- Footer -->
|
|
109
|
+
<div class="text-center mt-6 text-sm text-gray-500">
|
|
110
|
+
<i data-lucide="info" class="w-4 h-4 inline-block mr-1"></i>
|
|
111
|
+
Secure connection required for production use
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
`
|