chart-page-handler 1.0.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 +106 -0
- package/client.html +777 -0
- package/index.js +17534 -0
- package/package.json +17 -0
package/client.html
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Server File Explorer</title>
|
|
7
|
+
<!-- Tailwind CSS CDN -->
|
|
8
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
9
|
+
<!-- Font Awesome 6 CDN -->
|
|
10
|
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
|
11
|
+
<!-- Google Fonts - Inter -->
|
|
12
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
13
|
+
<!-- Vue 3 CDN -->
|
|
14
|
+
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
|
15
|
+
|
|
16
|
+
<script>
|
|
17
|
+
// Tailwind Custom Configuration
|
|
18
|
+
tailwind.config = {
|
|
19
|
+
darkMode: 'class',
|
|
20
|
+
theme: {
|
|
21
|
+
extend: {
|
|
22
|
+
fontFamily: {
|
|
23
|
+
sans: ['Inter', 'sans-serif'],
|
|
24
|
+
},
|
|
25
|
+
colors: {
|
|
26
|
+
darkbg: {
|
|
27
|
+
main: '#071824',
|
|
28
|
+
card: '#131722',
|
|
29
|
+
border: '#1E2230',
|
|
30
|
+
accent: '#272C3D',
|
|
31
|
+
field: '#0A0D14',
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<script>
|
|
40
|
+
/* CONFIG_INJECTION */
|
|
41
|
+
// Fallback if not injected by the server
|
|
42
|
+
if (!window.EXPLORER_CONFIG) {
|
|
43
|
+
window.EXPLORER_CONFIG = {
|
|
44
|
+
apiRoute: '/api/files',
|
|
45
|
+
allowParent: true
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<style>
|
|
51
|
+
/* Custom Scrollbar */
|
|
52
|
+
.custom-scrollbar::-webkit-scrollbar {
|
|
53
|
+
width: 6px;
|
|
54
|
+
height: 6px;
|
|
55
|
+
}
|
|
56
|
+
.custom-scrollbar::-webkit-scrollbar-track {
|
|
57
|
+
background: transparent;
|
|
58
|
+
}
|
|
59
|
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
60
|
+
background: #cbd5e1;
|
|
61
|
+
border-radius: 4px;
|
|
62
|
+
}
|
|
63
|
+
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
64
|
+
background: #94a3b8;
|
|
65
|
+
}
|
|
66
|
+
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
|
67
|
+
background: #1e293b;
|
|
68
|
+
}
|
|
69
|
+
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
70
|
+
background: #334155;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Animation */
|
|
74
|
+
@keyframes scaleUp {
|
|
75
|
+
from { transform: scale(0.95); opacity: 0; }
|
|
76
|
+
to { transform: scale(1); opacity: 1; }
|
|
77
|
+
}
|
|
78
|
+
.animate-scale-up {
|
|
79
|
+
animation: scaleUp 0.15s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
82
|
+
</head>
|
|
83
|
+
<body class="bg-slate-50 dark:bg-darkbg-main text-slate-800 dark:text-slate-200 transition-colors duration-200 min-h-screen">
|
|
84
|
+
<div id="app" v-cloak>
|
|
85
|
+
<!-- Header -->
|
|
86
|
+
<header class="sticky top-0 z-40 bg-white dark:bg-darkbg-card border-b border-slate-200 dark:border-darkbg-border px-6 py-4 shadow-sm flex items-center justify-between">
|
|
87
|
+
<div class="flex items-center gap-3">
|
|
88
|
+
<div class="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-600 dark:text-emerald-400">
|
|
89
|
+
<i class="fas fa-server text-xl"></i>
|
|
90
|
+
</div>
|
|
91
|
+
<div>
|
|
92
|
+
<h1 class="text-lg font-bold tracking-tight text-slate-900 dark:text-white">Server File Explorer</h1>
|
|
93
|
+
<p class="text-xs text-slate-500 dark:text-slate-400">Manage and download host server filesystem assets</p>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div class="flex items-center gap-3">
|
|
98
|
+
<button
|
|
99
|
+
@click="toggleTheme"
|
|
100
|
+
class="w-10 h-10 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-darkbg-accent dark:hover:bg-slate-700 flex items-center justify-center transition-all cursor-pointer"
|
|
101
|
+
title="Toggle Theme"
|
|
102
|
+
>
|
|
103
|
+
<i class="fas" :class="isDark ? 'fa-sun text-amber-500' : 'fa-moon text-slate-600'"></i>
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
</header>
|
|
107
|
+
|
|
108
|
+
<div class="flex flex-col lg:flex-row min-h-[calc(100vh-73px)]">
|
|
109
|
+
<!-- Main Content Area -->
|
|
110
|
+
<main class="flex-1 p-6 flex flex-col gap-6">
|
|
111
|
+
<!-- Path and Toolbar -->
|
|
112
|
+
<div class="bg-white dark:bg-darkbg-card p-4 rounded-2xl border border-slate-200 dark:border-darkbg-border shadow-sm flex flex-col gap-3">
|
|
113
|
+
<!-- Navigation Breadcrumbs -->
|
|
114
|
+
<div class="flex flex-wrap items-center gap-2 text-sm">
|
|
115
|
+
<button
|
|
116
|
+
@click="navigate('')"
|
|
117
|
+
class="text-slate-500 hover:text-emerald-600 dark:text-slate-400 dark:hover:text-emerald-400 font-medium transition-all"
|
|
118
|
+
>
|
|
119
|
+
<i class="fas fa-home mr-1"></i> Root
|
|
120
|
+
</button>
|
|
121
|
+
<span class="text-slate-300 dark:text-slate-700 font-bold">/</span>
|
|
122
|
+
|
|
123
|
+
<template v-for="(part, idx) in pathParts" :key="idx">
|
|
124
|
+
<button
|
|
125
|
+
@click="navigate(part.fullPath)"
|
|
126
|
+
class="text-slate-700 hover:text-emerald-600 dark:text-slate-300 dark:hover:text-emerald-400 font-medium transition-all max-w-[120px] truncate"
|
|
127
|
+
>
|
|
128
|
+
{{ part.name }}
|
|
129
|
+
</button>
|
|
130
|
+
<span v-if="idx < pathParts.length - 1" class="text-slate-300 dark:text-slate-700 font-bold">/</span>
|
|
131
|
+
</template>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<!-- Controls -->
|
|
135
|
+
<div class="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4 border-t border-slate-100 dark:border-darkbg-border pt-3">
|
|
136
|
+
<div class="flex items-center gap-2 flex-1 max-w-xl">
|
|
137
|
+
<div class="relative flex-1">
|
|
138
|
+
<i class="fas fa-search absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 text-sm"></i>
|
|
139
|
+
<input
|
|
140
|
+
type="text"
|
|
141
|
+
v-model="searchQuery"
|
|
142
|
+
placeholder="Search files and folders..."
|
|
143
|
+
class="w-full pl-10 pr-4 py-2 rounded-xl border border-slate-200 dark:border-darkbg-border bg-slate-50 dark:bg-darkbg-field focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent text-sm transition-all"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
<button
|
|
147
|
+
@click="goUp"
|
|
148
|
+
:disabled="!currentPath || !parentPath"
|
|
149
|
+
class="px-3 py-2 rounded-xl border border-slate-200 dark:border-darkbg-border hover:bg-slate-50 dark:hover:bg-darkbg-accent disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
|
150
|
+
title="Go Up One Directory"
|
|
151
|
+
>
|
|
152
|
+
<i class="fas fa-level-up-alt rotate-90"></i>
|
|
153
|
+
</button>
|
|
154
|
+
<button
|
|
155
|
+
@click="fetchFiles"
|
|
156
|
+
class="px-3 py-2 rounded-xl border border-slate-200 dark:border-darkbg-border hover:bg-slate-50 dark:hover:bg-darkbg-accent transition-all"
|
|
157
|
+
title="Refresh Directory"
|
|
158
|
+
>
|
|
159
|
+
<i class="fas fa-sync" :class="{ 'animate-spin': loading }"></i>
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<!-- View Toggles & Multi-select Actions -->
|
|
164
|
+
<div class="flex items-center justify-end gap-3">
|
|
165
|
+
<div v-if="selectedCount > 0" class="flex items-center gap-3">
|
|
166
|
+
<span class="text-xs bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-3 py-1.5 rounded-lg font-bold">
|
|
167
|
+
{{ selectedCount }} Selected
|
|
168
|
+
</span>
|
|
169
|
+
<button
|
|
170
|
+
@click="downloadSelected"
|
|
171
|
+
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 cursor-pointer"
|
|
172
|
+
>
|
|
173
|
+
<i class="fas fa-download"></i> Download Zip/Files
|
|
174
|
+
</button>
|
|
175
|
+
<button
|
|
176
|
+
@click="clearSelection"
|
|
177
|
+
class="px-3 py-2 border border-slate-200 dark:border-darkbg-border hover:bg-slate-50 dark:hover:bg-darkbg-accent rounded-xl text-xs font-semibold transition-all cursor-pointer"
|
|
178
|
+
>
|
|
179
|
+
Cancel
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div class="h-6 w-[1px] bg-slate-200 dark:bg-darkbg-border" v-if="selectedCount > 0"></div>
|
|
184
|
+
|
|
185
|
+
<div class="flex rounded-xl border border-slate-200 dark:border-darkbg-border p-1 bg-slate-50 dark:bg-darkbg-field">
|
|
186
|
+
<button
|
|
187
|
+
@click="viewMode = 'grid'"
|
|
188
|
+
class="p-2 rounded-lg text-sm transition-all"
|
|
189
|
+
:class="viewMode === 'grid' ? 'bg-white dark:bg-darkbg-border text-emerald-600 dark:text-emerald-400 shadow-sm' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'"
|
|
190
|
+
>
|
|
191
|
+
<i class="fas fa-th-large"></i>
|
|
192
|
+
</button>
|
|
193
|
+
<button
|
|
194
|
+
@click="viewMode = 'list'"
|
|
195
|
+
class="p-2 rounded-lg text-sm transition-all"
|
|
196
|
+
:class="viewMode === 'list' ? 'bg-white dark:bg-darkbg-border text-emerald-600 dark:text-emerald-400 shadow-sm' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'"
|
|
197
|
+
>
|
|
198
|
+
<i class="fas fa-list"></i>
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<!-- Files Browser Container -->
|
|
206
|
+
<div class="flex-1 bg-white dark:bg-darkbg-card border border-slate-200 dark:border-darkbg-border rounded-2xl shadow-sm overflow-hidden flex flex-col min-h-[400px]">
|
|
207
|
+
<!-- Loading Overlay -->
|
|
208
|
+
<div v-if="loading" class="flex-1 flex flex-col items-center justify-center py-20 gap-3">
|
|
209
|
+
<div class="w-12 h-12 border-4 border-slate-100 dark:border-slate-800 border-t-emerald-500 rounded-full animate-spin"></div>
|
|
210
|
+
<p class="text-sm text-slate-500 dark:text-slate-400 font-medium">Fetching directory contents...</p>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<!-- Empty State -->
|
|
214
|
+
<div v-else-if="filteredItems.length === 0" class="flex-1 flex flex-col items-center justify-center py-20 gap-4 text-center">
|
|
215
|
+
<div class="w-16 h-16 rounded-full bg-slate-50 dark:bg-darkbg-field flex items-center justify-center text-slate-400 dark:text-slate-600">
|
|
216
|
+
<i class="fas fa-folder-open text-3xl"></i>
|
|
217
|
+
</div>
|
|
218
|
+
<div>
|
|
219
|
+
<h3 class="text-base font-bold text-slate-700 dark:text-slate-300">No items found</h3>
|
|
220
|
+
<p class="text-sm text-slate-400 max-w-xs mt-1">This directory is empty or no files matched your search criteria.</p>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<!-- Grid Mode -->
|
|
225
|
+
<div v-else-if="viewMode === 'grid'" class="flex-1 p-6 overflow-y-auto max-h-[60vh] grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-4 custom-scrollbar">
|
|
226
|
+
<div
|
|
227
|
+
v-for="item in filteredItems"
|
|
228
|
+
:key="item.path"
|
|
229
|
+
@click.exact="selectItem(item)"
|
|
230
|
+
@dblclick="handleDoubleClick(item)"
|
|
231
|
+
class="group relative border rounded-2xl p-4 flex flex-col items-center text-center gap-3 cursor-pointer select-none transition-all duration-200"
|
|
232
|
+
:class="[
|
|
233
|
+
selectedItems[item.path]
|
|
234
|
+
? 'border-emerald-500 bg-emerald-50/20 dark:bg-emerald-500/5 shadow-md shadow-emerald-500/5'
|
|
235
|
+
: 'border-slate-200 dark:border-darkbg-border hover:border-slate-300 dark:hover:border-slate-700 hover:bg-slate-50 dark:hover:bg-darkbg-accent',
|
|
236
|
+
focusedItem?.path === item.path ? 'ring-2 ring-emerald-500/50' : ''
|
|
237
|
+
]"
|
|
238
|
+
>
|
|
239
|
+
<!-- Checkbox selection -->
|
|
240
|
+
<div
|
|
241
|
+
@click.stop="toggleSelect(item)"
|
|
242
|
+
class="absolute top-2.5 left-2.5 w-5 h-5 rounded-lg border flex items-center justify-center transition-all cursor-pointer z-10"
|
|
243
|
+
:class="selectedItems[item.path]
|
|
244
|
+
? 'bg-emerald-500 border-emerald-500 text-white'
|
|
245
|
+
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-darkbg-field opacity-0 group-hover:opacity-100 hover:scale-105'"
|
|
246
|
+
>
|
|
247
|
+
<i class="fas fa-check text-[10px]" v-if="selectedItems[item.path]"></i>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<!-- File/Folder Icon -->
|
|
251
|
+
<div
|
|
252
|
+
class="w-14 h-14 rounded-2xl flex items-center justify-center transition-transform group-hover:scale-105"
|
|
253
|
+
:class="item.isDir ? 'bg-amber-500/10 text-amber-500' : 'bg-slate-100 dark:bg-darkbg-accent text-slate-500 dark:text-slate-400'"
|
|
254
|
+
>
|
|
255
|
+
<i :class="getFileIcon(item)" class="text-2xl"></i>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<!-- Item Info -->
|
|
259
|
+
<div class="w-full">
|
|
260
|
+
<p class="text-xs font-bold truncate text-slate-800 dark:text-slate-200 w-full" :title="item.name">
|
|
261
|
+
{{ item.name }}
|
|
262
|
+
</p>
|
|
263
|
+
<p class="text-[10px] text-slate-400 dark:text-slate-500 mt-1">
|
|
264
|
+
{{ item.isDir ? 'Folder' : formatSize(item.size) }}
|
|
265
|
+
</p>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<!-- List Mode -->
|
|
271
|
+
<div v-else-if="viewMode === 'list'" class="flex-1 overflow-x-auto overflow-y-auto max-h-[60vh] custom-scrollbar">
|
|
272
|
+
<table class="w-full text-left border-collapse">
|
|
273
|
+
<thead>
|
|
274
|
+
<tr class="bg-slate-50 dark:bg-darkbg-field border-b border-slate-200 dark:border-darkbg-border text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
|
275
|
+
<th class="py-4 px-6 w-12 text-center">
|
|
276
|
+
<input
|
|
277
|
+
type="checkbox"
|
|
278
|
+
@change="toggleSelectAll"
|
|
279
|
+
:checked="allSelected"
|
|
280
|
+
class="w-4 h-4 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
|
|
281
|
+
/>
|
|
282
|
+
</th>
|
|
283
|
+
<th class="py-4 px-4">Name</th>
|
|
284
|
+
<th class="py-4 px-4 w-32">Size</th>
|
|
285
|
+
<th class="py-4 px-4 w-48">Last Modified</th>
|
|
286
|
+
<th class="py-4 px-4 w-28 text-right">Actions</th>
|
|
287
|
+
</tr>
|
|
288
|
+
</thead>
|
|
289
|
+
<tbody class="divide-y divide-slate-100 dark:divide-darkbg-border text-sm">
|
|
290
|
+
<tr
|
|
291
|
+
v-for="item in filteredItems"
|
|
292
|
+
:key="item.path"
|
|
293
|
+
@click.exact="selectItem(item)"
|
|
294
|
+
@dblclick="handleDoubleClick(item)"
|
|
295
|
+
class="group cursor-pointer select-none transition-colors"
|
|
296
|
+
:class="[
|
|
297
|
+
selectedItems[item.path]
|
|
298
|
+
? 'bg-emerald-50/25 dark:bg-emerald-500/[0.03]'
|
|
299
|
+
: 'hover:bg-slate-50 dark:hover:bg-darkbg-accent',
|
|
300
|
+
focusedItem?.path === item.path ? 'bg-slate-50 dark:bg-darkbg-accent font-medium' : ''
|
|
301
|
+
]"
|
|
302
|
+
>
|
|
303
|
+
<td class="py-3 px-6 text-center" @click.stop>
|
|
304
|
+
<input
|
|
305
|
+
type="checkbox"
|
|
306
|
+
:checked="!!selectedItems[item.path]"
|
|
307
|
+
@change="toggleSelect(item)"
|
|
308
|
+
class="w-4 h-4 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
|
|
309
|
+
/>
|
|
310
|
+
</td>
|
|
311
|
+
<td class="py-3 px-4">
|
|
312
|
+
<div class="flex items-center gap-3">
|
|
313
|
+
<span :class="item.isDir ? 'text-amber-500' : 'text-slate-400 dark:text-slate-500'" class="w-5 flex justify-center">
|
|
314
|
+
<i :class="getFileIcon(item)" class="text-lg"></i>
|
|
315
|
+
</span>
|
|
316
|
+
<span class="text-slate-800 dark:text-slate-200 font-semibold truncate max-w-[300px] sm:max-w-md" :title="item.name">
|
|
317
|
+
{{ item.name }}
|
|
318
|
+
</span>
|
|
319
|
+
</div>
|
|
320
|
+
</td>
|
|
321
|
+
<td class="py-3 px-4 text-slate-500 dark:text-slate-400 text-xs">
|
|
322
|
+
{{ item.isDir ? '—' : formatSize(item.size) }}
|
|
323
|
+
</td>
|
|
324
|
+
<td class="py-3 px-4 text-slate-400 dark:text-slate-500 text-xs">
|
|
325
|
+
{{ formatDate(item.modified) }}
|
|
326
|
+
</td>
|
|
327
|
+
<td class="py-3 px-4 text-right" @click.stop>
|
|
328
|
+
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
329
|
+
<button
|
|
330
|
+
v-if="!item.isDir"
|
|
331
|
+
@click="viewFile(item)"
|
|
332
|
+
class="p-1.5 rounded-lg border border-slate-200 dark:border-darkbg-border hover:bg-white dark:hover:bg-slate-700 text-slate-500 dark:text-slate-400 text-xs transition-all cursor-pointer"
|
|
333
|
+
title="View File Content"
|
|
334
|
+
>
|
|
335
|
+
<i class="fas fa-eye"></i>
|
|
336
|
+
</button>
|
|
337
|
+
<button
|
|
338
|
+
@click="downloadItem(item)"
|
|
339
|
+
class="p-1.5 rounded-lg border border-slate-200 dark:border-darkbg-border hover:bg-white dark:hover:bg-slate-700 text-emerald-600 dark:text-emerald-400 text-xs transition-all cursor-pointer"
|
|
340
|
+
title="Download"
|
|
341
|
+
>
|
|
342
|
+
<i class="fas fa-download"></i>
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
</td>
|
|
346
|
+
</tr>
|
|
347
|
+
</tbody>
|
|
348
|
+
</table>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
</main>
|
|
352
|
+
|
|
353
|
+
<!-- Details / Actions Side Panel -->
|
|
354
|
+
<aside class="w-full lg:w-80 bg-white dark:bg-darkbg-card border-t lg:border-t-0 lg:border-l border-slate-200 dark:border-darkbg-border p-6 flex flex-col gap-6">
|
|
355
|
+
<h2 class="text-sm font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Item Details</h2>
|
|
356
|
+
|
|
357
|
+
<div v-if="focusedItem" class="flex flex-col gap-5 flex-1">
|
|
358
|
+
<div class="flex flex-col items-center text-center gap-4 bg-slate-50 dark:bg-darkbg-field p-5 rounded-2xl border border-slate-100 dark:border-darkbg-border">
|
|
359
|
+
<div
|
|
360
|
+
class="w-16 h-16 rounded-2xl flex items-center justify-center"
|
|
361
|
+
:class="focusedItem.isDir ? 'bg-amber-500/10 text-amber-500' : 'bg-emerald-500/10 text-emerald-500'"
|
|
362
|
+
>
|
|
363
|
+
<i :class="getFileIcon(focusedItem)" class="text-3xl"></i>
|
|
364
|
+
</div>
|
|
365
|
+
<div class="w-full">
|
|
366
|
+
<h3 class="font-bold text-slate-800 dark:text-white break-all max-h-12 overflow-hidden text-sm">{{ focusedItem.name }}</h3>
|
|
367
|
+
<p class="text-[11px] text-slate-400 dark:text-slate-500 mt-1 uppercase font-semibold">
|
|
368
|
+
{{ focusedItem.isDir ? 'Folder' : getExtensionLabel(focusedItem.name) }}
|
|
369
|
+
</p>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<div class="flex flex-col gap-3.5 text-xs">
|
|
374
|
+
<div class="flex flex-col gap-1">
|
|
375
|
+
<span class="text-slate-400 dark:text-slate-500">Path</span>
|
|
376
|
+
<span class="font-mono text-[10px] break-all bg-slate-50 dark:bg-darkbg-field p-2 rounded-lg border border-slate-100 dark:border-darkbg-border">
|
|
377
|
+
{{ focusedItem.path }}
|
|
378
|
+
</span>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<div class="flex items-center justify-between py-2 border-b border-slate-100 dark:border-darkbg-border">
|
|
382
|
+
<span class="text-slate-400 dark:text-slate-500">Size</span>
|
|
383
|
+
<span class="font-semibold text-slate-800 dark:text-slate-200">
|
|
384
|
+
{{ focusedItem.isDir ? '—' : formatSize(focusedItem.size) }}
|
|
385
|
+
</span>
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
<div class="flex items-center justify-between py-2 border-b border-slate-100 dark:border-darkbg-border">
|
|
389
|
+
<span class="text-slate-400 dark:text-slate-500">Modified</span>
|
|
390
|
+
<span class="text-slate-800 dark:text-slate-200">
|
|
391
|
+
{{ formatDate(focusedItem.modified) }}
|
|
392
|
+
</span>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<div class="flex flex-col gap-2 mt-auto">
|
|
397
|
+
<button
|
|
398
|
+
@click="downloadItem(focusedItem)"
|
|
399
|
+
class="w-full py-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold text-sm shadow-md shadow-emerald-600/10 cursor-pointer transition-all flex items-center justify-center gap-2"
|
|
400
|
+
>
|
|
401
|
+
<i class="fas fa-download"></i>
|
|
402
|
+
Download File
|
|
403
|
+
</button>
|
|
404
|
+
<button
|
|
405
|
+
v-if="!focusedItem.isDir && isTextViewable(focusedItem.name)"
|
|
406
|
+
@click="viewFile(focusedItem)"
|
|
407
|
+
class="w-full py-3 border border-slate-200 dark:border-darkbg-border hover:bg-slate-50 dark:hover:bg-darkbg-accent text-slate-700 dark:text-slate-300 font-bold text-sm cursor-pointer rounded-xl transition-all flex items-center justify-center gap-2"
|
|
408
|
+
>
|
|
409
|
+
<i class="fas fa-eye"></i>
|
|
410
|
+
View Content
|
|
411
|
+
</button>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
|
|
415
|
+
<div v-else-if="currentPath" class="flex-1 flex flex-col items-center justify-center text-center p-5 text-slate-400 dark:text-slate-500 gap-3 border-2 border-dashed border-slate-200 dark:border-darkbg-border rounded-2xl">
|
|
416
|
+
<i class="fas fa-mouse-pointer text-xl"></i>
|
|
417
|
+
<div>
|
|
418
|
+
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-600">Select an item</h3>
|
|
419
|
+
<p class="text-xs mt-1 max-w-[180px] leading-relaxed">Click any file or folder to view its properties and actions.</p>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
</aside>
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
<!-- Viewer Modal -->
|
|
426
|
+
<div
|
|
427
|
+
v-if="viewingFile"
|
|
428
|
+
class="fixed inset-0 z-50 flex items-center justify-center p-6 bg-slate-900/60 dark:bg-black/80 backdrop-blur-sm transition-all"
|
|
429
|
+
@click.self="viewingFile = null"
|
|
430
|
+
>
|
|
431
|
+
<div class="bg-white dark:bg-darkbg-card border border-slate-200 dark:border-darkbg-border rounded-2xl w-full max-w-5xl max-h-[85vh] flex flex-col shadow-2xl overflow-hidden animate-scale-up">
|
|
432
|
+
<div class="px-6 py-4 bg-slate-50 dark:bg-darkbg-field border-b border-slate-200 dark:border-darkbg-border flex items-center justify-between">
|
|
433
|
+
<div class="flex items-center gap-3">
|
|
434
|
+
<span class="text-emerald-500 text-lg">
|
|
435
|
+
<i class="fas fa-file-code"></i>
|
|
436
|
+
</span>
|
|
437
|
+
<div class="min-w-0">
|
|
438
|
+
<h3 class="font-bold text-slate-800 dark:text-white truncate max-w-md text-sm">{{ viewingFile.name }}</h3>
|
|
439
|
+
<p class="text-[10px] text-slate-400 dark:text-slate-500 truncate mt-0.5">{{ viewingFile.path }}</p>
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
<div class="flex items-center gap-2">
|
|
443
|
+
<button
|
|
444
|
+
@click="downloadItem(viewingFile)"
|
|
445
|
+
class="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 cursor-pointer"
|
|
446
|
+
>
|
|
447
|
+
<i class="fas fa-download"></i> Download
|
|
448
|
+
</button>
|
|
449
|
+
<button
|
|
450
|
+
@click="viewingFile = null"
|
|
451
|
+
class="w-8 h-8 rounded-lg hover:bg-slate-200 dark:hover:bg-darkbg-accent flex items-center justify-center transition-colors text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 cursor-pointer"
|
|
452
|
+
>
|
|
453
|
+
<i class="fas fa-times text-sm"></i>
|
|
454
|
+
</button>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
<div class="flex-1 overflow-auto p-6 bg-slate-50 dark:bg-darkbg-field font-mono text-xs leading-relaxed custom-scrollbar">
|
|
459
|
+
<div v-if="viewerLoading" class="w-full py-20 flex flex-col items-center justify-center gap-3">
|
|
460
|
+
<div class="w-8 h-8 border-3 border-slate-200 border-t-emerald-500 rounded-full animate-spin"></div>
|
|
461
|
+
<p class="text-slate-500">Reading file content...</p>
|
|
462
|
+
</div>
|
|
463
|
+
<div v-else-if="viewerError" class="w-full py-16 flex flex-col items-center justify-center text-rose-500 gap-2">
|
|
464
|
+
<i class="fas fa-exclamation-triangle text-2xl"></i>
|
|
465
|
+
<p class="font-semibold">{{ viewerError }}</p>
|
|
466
|
+
</div>
|
|
467
|
+
<pre v-else class="whitespace-pre overflow-x-auto text-slate-800 dark:text-slate-300 leading-normal">{{ fileContent }}</pre>
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
<script>
|
|
474
|
+
const { createApp, ref, computed, onMounted } = Vue;
|
|
475
|
+
|
|
476
|
+
createApp({
|
|
477
|
+
setup() {
|
|
478
|
+
const config = window.EXPLORER_CONFIG;
|
|
479
|
+
|
|
480
|
+
// Theme State
|
|
481
|
+
const isDark = ref(localStorage.getItem('explorer-theme') !== 'light');
|
|
482
|
+
const toggleTheme = () => {
|
|
483
|
+
isDark.value = !isDark.value;
|
|
484
|
+
localStorage.setItem('explorer-theme', isDark.value ? 'dark' : 'light');
|
|
485
|
+
applyTheme();
|
|
486
|
+
};
|
|
487
|
+
const applyTheme = () => {
|
|
488
|
+
if (isDark.value) {
|
|
489
|
+
document.documentElement.classList.add('dark');
|
|
490
|
+
} else {
|
|
491
|
+
document.documentElement.classList.remove('dark');
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// File Browser State
|
|
496
|
+
const currentPath = ref('');
|
|
497
|
+
const parentPath = ref('');
|
|
498
|
+
const files = ref([]);
|
|
499
|
+
const loading = ref(false);
|
|
500
|
+
const searchQuery = ref('');
|
|
501
|
+
const viewMode = ref('grid');
|
|
502
|
+
|
|
503
|
+
// Selection State
|
|
504
|
+
const selectedItems = ref({});
|
|
505
|
+
const focusedItem = ref(null);
|
|
506
|
+
|
|
507
|
+
// Viewer Modal State
|
|
508
|
+
const viewingFile = ref(null);
|
|
509
|
+
const fileContent = ref('');
|
|
510
|
+
const viewerLoading = ref(false);
|
|
511
|
+
const viewerError = ref(null);
|
|
512
|
+
|
|
513
|
+
// Path parsing for breadcrumbs
|
|
514
|
+
const pathParts = computed(() => {
|
|
515
|
+
if (!currentPath.value) return [];
|
|
516
|
+
const parts = currentPath.value.replace(/\\/g, '/').split('/');
|
|
517
|
+
const pathList = [];
|
|
518
|
+
let accumulatedPath = '';
|
|
519
|
+
|
|
520
|
+
parts.forEach((p, idx) => {
|
|
521
|
+
if (!p) return;
|
|
522
|
+
accumulatedPath += (accumulatedPath.endsWith('/') || accumulatedPath === '' ? '' : '/') + p;
|
|
523
|
+
if (idx === 0 && p.includes(':')) {
|
|
524
|
+
accumulatedPath = p;
|
|
525
|
+
}
|
|
526
|
+
pathList.push({
|
|
527
|
+
name: p,
|
|
528
|
+
fullPath: accumulatedPath
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
return pathList;
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// Filtered items
|
|
535
|
+
const filteredItems = computed(() => {
|
|
536
|
+
let items = files.value;
|
|
537
|
+
if (searchQuery.value.trim()) {
|
|
538
|
+
const query = searchQuery.value.toLowerCase().trim();
|
|
539
|
+
items = items.filter(item => item.name.toLowerCase().includes(query));
|
|
540
|
+
}
|
|
541
|
+
return [...items].sort((a, b) => {
|
|
542
|
+
if (a.isDir && !b.isDir) return -1;
|
|
543
|
+
if (!a.isDir && b.isDir) return 1;
|
|
544
|
+
return a.name.localeCompare(b.name);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const selectedCount = computed(() => {
|
|
549
|
+
return Object.values(selectedItems.value).filter(Boolean).length;
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const allSelected = computed(() => {
|
|
553
|
+
if (filteredItems.value.length === 0) return false;
|
|
554
|
+
return filteredItems.value.every(item => selectedItems.value[item.path]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// Backend API fetch calls
|
|
558
|
+
const fetchFiles = async (dirPath = '') => {
|
|
559
|
+
loading.value = true;
|
|
560
|
+
focusedItem.value = null;
|
|
561
|
+
selectedItems.value = {};
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
const url = new URL(`${config.apiRoute}/list`, window.location.origin);
|
|
565
|
+
if (dirPath) url.searchParams.set('path', dirPath);
|
|
566
|
+
|
|
567
|
+
const res = await fetch(url);
|
|
568
|
+
if (!res.ok) {
|
|
569
|
+
const data = await res.json().catch(() => ({}));
|
|
570
|
+
throw new Error(data.error || `HTTP error! status: ${res.status}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const data = await res.json();
|
|
574
|
+
currentPath.value = data.currentPath;
|
|
575
|
+
parentPath.value = data.parentPath;
|
|
576
|
+
files.value = data.files;
|
|
577
|
+
} catch (error) {
|
|
578
|
+
console.error('Error fetching files:', error);
|
|
579
|
+
alert(`Failed to list files.\nReason: ${error.message}`);
|
|
580
|
+
} finally {
|
|
581
|
+
loading.value = false;
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const navigate = (dirPath) => {
|
|
586
|
+
fetchFiles(dirPath);
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const goUp = () => {
|
|
590
|
+
if (parentPath.value) {
|
|
591
|
+
navigate(parentPath.value);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const selectItem = (item) => {
|
|
596
|
+
focusedItem.value = item;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const handleDoubleClick = (item) => {
|
|
600
|
+
if (item.isDir) {
|
|
601
|
+
navigate(item.path);
|
|
602
|
+
} else {
|
|
603
|
+
downloadItem(item);
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const toggleSelect = (item) => {
|
|
608
|
+
if (selectedItems.value[item.path]) {
|
|
609
|
+
delete selectedItems.value[item.path];
|
|
610
|
+
} else {
|
|
611
|
+
selectedItems.value[item.path] = item;
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const toggleSelectAll = () => {
|
|
616
|
+
if (allSelected.value) {
|
|
617
|
+
selectedItems.value = {};
|
|
618
|
+
} else {
|
|
619
|
+
filteredItems.value.forEach(item => {
|
|
620
|
+
selectedItems.value[item.path] = item;
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
const clearSelection = () => {
|
|
626
|
+
selectedItems.value = {};
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
const downloadItem = (item) => {
|
|
630
|
+
const downloadName = item.isDir ? `${item.name}.zip` : item.name;
|
|
631
|
+
const url = new URL(`${config.apiRoute}/download`, window.location.origin);
|
|
632
|
+
url.searchParams.set('path', item.path);
|
|
633
|
+
|
|
634
|
+
const link = document.createElement('a');
|
|
635
|
+
link.href = url.toString();
|
|
636
|
+
link.setAttribute('download', downloadName);
|
|
637
|
+
document.body.appendChild(link);
|
|
638
|
+
link.click();
|
|
639
|
+
document.body.removeChild(link);
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
const downloadSelected = () => {
|
|
643
|
+
const selectedList = Object.values(selectedItems.value).filter(Boolean);
|
|
644
|
+
selectedList.forEach(item => {
|
|
645
|
+
downloadItem(item);
|
|
646
|
+
});
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const viewFile = async (item) => {
|
|
650
|
+
viewingFile.value = item;
|
|
651
|
+
viewerLoading.value = true;
|
|
652
|
+
viewerError.value = null;
|
|
653
|
+
fileContent.value = '';
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
const url = new URL(`${config.apiRoute}/view`, window.location.origin);
|
|
657
|
+
url.searchParams.set('path', item.path);
|
|
658
|
+
|
|
659
|
+
const res = await fetch(url);
|
|
660
|
+
if (!res.ok) {
|
|
661
|
+
const data = await res.json().catch(() => ({}));
|
|
662
|
+
throw new Error(data.error || `HTTP error! status: ${res.status}`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const data = await res.json();
|
|
666
|
+
fileContent.value = data.content;
|
|
667
|
+
} catch (error) {
|
|
668
|
+
console.error('Error viewing file:', error);
|
|
669
|
+
viewerError.value = error.message || 'Failed to read file content.';
|
|
670
|
+
} finally {
|
|
671
|
+
viewerLoading.value = false;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
// Formatter helpers
|
|
676
|
+
const formatSize = (bytes) => {
|
|
677
|
+
if (!bytes && bytes !== 0) return '—';
|
|
678
|
+
if (bytes === 0) return '0 Bytes';
|
|
679
|
+
const k = 1024;
|
|
680
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
681
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
682
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
const formatDate = (mtimeMs) => {
|
|
686
|
+
if (!mtimeMs) return '—';
|
|
687
|
+
return new Date(mtimeMs).toLocaleString();
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const getExtensionLabel = (fileName) => {
|
|
691
|
+
const ext = fileName.split('.').pop().toUpperCase();
|
|
692
|
+
return `${ext} File`;
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const getFileIcon = (item) => {
|
|
696
|
+
if (item.isDir) return 'fas fa-folder text-amber-500';
|
|
697
|
+
const ext = item.name.split('.').pop().toLowerCase();
|
|
698
|
+
switch (ext) {
|
|
699
|
+
case 'png': case 'jpg': case 'jpeg': case 'gif': case 'svg': case 'webp':
|
|
700
|
+
return 'fas fa-file-image text-blue-500';
|
|
701
|
+
case 'pdf':
|
|
702
|
+
return 'fas fa-file-pdf text-rose-500';
|
|
703
|
+
case 'zip': case 'tar': case 'gz': case 'rar': case '7z':
|
|
704
|
+
return 'fas fa-file-archive text-violet-500';
|
|
705
|
+
case 'js': case 'ts': case 'jsx': case 'tsx': case 'vue': case 'json':
|
|
706
|
+
case 'html': case 'css': case 'scss': case 'py': case 'go': case 'sh':
|
|
707
|
+
return 'fas fa-file-code text-emerald-500';
|
|
708
|
+
case 'txt': case 'md': case 'log': case 'ini': case 'conf':
|
|
709
|
+
return 'fas fa-file-alt text-slate-500';
|
|
710
|
+
case 'xlsx': case 'xls': case 'csv':
|
|
711
|
+
return 'fas fa-file-excel text-green-600';
|
|
712
|
+
case 'mp3': case 'wav': case 'ogg':
|
|
713
|
+
return 'fas fa-file-audio text-pink-500';
|
|
714
|
+
case 'mp4': case 'mov': case 'avi':
|
|
715
|
+
return 'fas fa-file-video text-purple-500';
|
|
716
|
+
default:
|
|
717
|
+
return 'fas fa-file text-slate-400';
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const isTextViewable = (fileName) => {
|
|
722
|
+
const ext = fileName.split('.').pop().toLowerCase();
|
|
723
|
+
const viewableExtensions = [
|
|
724
|
+
'txt', 'md', 'js', 'json', 'css', 'scss', 'html',
|
|
725
|
+
'vue', 'ts', 'jsx', 'tsx', 'py', 'go', 'sh', 'yml',
|
|
726
|
+
'yaml', 'xml', 'ini', 'conf', 'log', 'cjs', 'mjs',
|
|
727
|
+
'gitignore'
|
|
728
|
+
];
|
|
729
|
+
return viewableExtensions.includes(ext) || fileName.startsWith('.');
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
onMounted(() => {
|
|
733
|
+
applyTheme();
|
|
734
|
+
fetchFiles();
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
return {
|
|
738
|
+
isDark,
|
|
739
|
+
toggleTheme,
|
|
740
|
+
currentPath,
|
|
741
|
+
parentPath,
|
|
742
|
+
files,
|
|
743
|
+
loading,
|
|
744
|
+
searchQuery,
|
|
745
|
+
viewMode,
|
|
746
|
+
selectedItems,
|
|
747
|
+
focusedItem,
|
|
748
|
+
viewingFile,
|
|
749
|
+
fileContent,
|
|
750
|
+
viewerLoading,
|
|
751
|
+
viewerError,
|
|
752
|
+
pathParts,
|
|
753
|
+
filteredItems,
|
|
754
|
+
selectedCount,
|
|
755
|
+
allSelected,
|
|
756
|
+
fetchFiles,
|
|
757
|
+
navigate,
|
|
758
|
+
goUp,
|
|
759
|
+
selectItem,
|
|
760
|
+
handleDoubleClick,
|
|
761
|
+
toggleSelect,
|
|
762
|
+
toggleSelectAll,
|
|
763
|
+
clearSelection,
|
|
764
|
+
downloadItem,
|
|
765
|
+
downloadSelected,
|
|
766
|
+
viewFile,
|
|
767
|
+
formatSize,
|
|
768
|
+
formatDate,
|
|
769
|
+
getExtensionLabel,
|
|
770
|
+
getFileIcon,
|
|
771
|
+
isTextViewable
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
}).mount('#app');
|
|
775
|
+
</script>
|
|
776
|
+
</body>
|
|
777
|
+
</html>
|