Skip to content

Commit 5452124

Browse files
committed
feat(web):add download all files as zip feature (closes #21)
1 parent 5e6754d commit 5452124

File tree

4 files changed

+183
-66
lines changed

4 files changed

+183
-66
lines changed

app/src/main/assets/index.html

Lines changed: 64 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,85 @@
11
<!DOCTYPE html>
22
<html lang="en">
3+
34
<head>
45
<meta charset="UTF-8">
56
<meta name="viewport" content="width=device-width, initial-scale=1.0">
67
<title>Transfer App</title>
78
<link rel="stylesheet" href="/assets/style.css">
89
</head>
10+
911
<body>
10-
<header>
11-
<h1>Transfer</h1>
12-
<p>Manage your files wirelessly</p>
13-
<button id="theme-toggle" class="theme-toggle-button" aria-label="Toggle theme">
14-
<!-- Sun icon for light mode, Moon for dark mode -->
15-
<span id="theme-icon">🌙</span>
16-
</button>
17-
</header>
12+
<header>
13+
<h1>Transfer</h1>
14+
<p>Manage your files wirelessly</p>
15+
<button id="theme-toggle" class="theme-toggle-button" aria-label="Toggle theme">
16+
<!-- Sun icon for light mode, Moon for dark mode -->
17+
<span id="theme-icon">🌙</span>
18+
</button>
19+
</header>
1820

19-
<main>
20-
<section id="upload-section">
21-
<h2>Upload Files</h2>
22-
<div id="drop-zone">
23-
<p>Drag & drop files here or click to select</p>
24-
<input type="file" id="file-input" multiple>
25-
</div>
26-
<div id="upload-progress-container">
27-
<!-- Upload progress items will be dynamically added here -->
28-
</div>
29-
</section>
21+
<main>
22+
<section id="upload-section">
23+
<h2>Upload Files</h2>
24+
<div id="drop-zone">
25+
<p>Drag & drop files here or click to select</p>
26+
<input type="file" id="file-input" multiple>
27+
</div>
28+
<div id="upload-progress-container">
29+
<!-- Upload progress items will be dynamically added here -->
30+
</div>
31+
</section>
3032

31-
<section id="files-section">
33+
<section id="files-section">
34+
<div id="files-header-container">
3235
<h2>Available Files</h2>
33-
<div class="table-container">
34-
<table id="files-table">
35-
<thead>
36-
<tr>
37-
<th>Name</th>
38-
<th>Size</th>
39-
<th>Last Modified</th>
40-
<th>Type</th>
41-
<th>Actions</th> <!-- Re-added Actions header -->
42-
</tr>
43-
</thead>
44-
<tbody>
45-
<!-- File rows will be dynamically added here -->
46-
</tbody>
47-
</table>
36+
<div id="files-actions">
37+
<button id="download-all-zip-button" style="display: none;">Download All as Zip</button>
4838
</div>
49-
<p id="no-files-message" style="display:none;">No files found in the shared folder.</p>
50-
</section>
51-
</main>
39+
</div>
5240

53-
<footer>
54-
<p>Transfer App</p>
55-
</footer>
5641

57-
<!-- Custom Confirmation Modal HTML -->
58-
<div id="confirmation-modal-overlay" class="modal-overlay">
59-
<div class="modal-content">
60-
<p id="confirmation-modal-message"></p>
61-
<div class="modal-checkbox-container">
62-
<input type="checkbox" id="do-not-ask-again">
63-
<label for="do-not-ask-again">Don't ask me again</label>
64-
</div>
65-
<div class="modal-buttons">
66-
<button id="modal-confirm-button" class="delete-button">Delete</button>
67-
<button id="modal-cancel-button">Cancel</button>
42+
<div class="table-container">
43+
<table id="files-table">
44+
<thead>
45+
<tr>
46+
<th>Name</th>
47+
<th>Size</th>
48+
<th>Last Modified</th>
49+
<th>Type</th>
50+
<th>Actions</th> <!-- Re-added Actions header -->
51+
</tr>
52+
</thead>
53+
<tbody>
54+
<!-- File rows will be dynamically added here -->
55+
</tbody>
56+
</table>
57+
</div>
58+
<p id="no-files-message" style="display:none;">No files found in the shared folder.</p>
59+
</section>
60+
</main>
61+
62+
<footer>
63+
<p>Transfer App</p>
64+
</footer>
65+
66+
<!-- Custom Confirmation Modal HTML -->
67+
<div id="confirmation-modal-overlay" class="modal-overlay">
68+
<div class="modal-content">
69+
<p id="confirmation-modal-message"></p>
70+
<div class="modal-checkbox-container">
71+
<input type="checkbox" id="do-not-ask-again">
72+
<label for="do-not-ask-again">Don't ask me again</label>
73+
</div>
74+
<div class="modal-buttons">
75+
<button id="modal-confirm-button" class="delete-button">Delete</button>
76+
<button id="modal-cancel-button">Cancel</button>
77+
</div>
6878
</div>
6979
</div>
70-
</div>
7180

72-
<script src="/assets/script.js"></script>
81+
<script src="/assets/script.js"></script>
7382

7483
</body>
84+
7585
</html>

app/src/main/assets/script.js

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ document.addEventListener('DOMContentLoaded', () => {
66
const noFilesMessage = document.getElementById('no-files-message');
77
const themeToggleButton = document.getElementById('theme-toggle');
88
const themeIcon = document.getElementById('theme-icon');
9+
const downloadAllZipButton = document.getElementById('download-all-zip-button'); // New
910

1011
// Modal elements
1112
const confirmationModalOverlay = document.getElementById('confirmation-modal-overlay');
@@ -105,6 +106,7 @@ document.addEventListener('DOMContentLoaded', () => {
105106
console.error('Error fetching files:', response.status, errorText);
106107
filesTableBody.innerHTML = `<tr><td colspan="5" style="color: var(--error-color);">Error loading files: ${errorText}</td></tr>`;
107108
noFilesMessage.style.display = 'none';
109+
downloadAllZipButton.style.display = 'none'; // Hide button on error
108110
return;
109111
}
110112
const data = await response.json();
@@ -113,16 +115,20 @@ document.addEventListener('DOMContentLoaded', () => {
113115
console.error('Failed to fetch files:', error);
114116
filesTableBody.innerHTML = `<tr><td colspan="5" style="color: var(--error-color);">Could not connect to server or error fetching files.</td></tr>`;
115117
noFilesMessage.style.display = 'none';
118+
downloadAllZipButton.style.display = 'none';
119+
116120
}
117121
}
118122

119123
function renderFiles(files) {
120124
filesTableBody.innerHTML = ''; // Clear existing files
121125
if (!files || files.length === 0) {
122126
noFilesMessage.style.display = 'block';
127+
downloadAllZipButton.style.display = 'none'; // Hide button if no files
123128
return;
124129
}
125130
noFilesMessage.style.display = 'none';
131+
downloadAllZipButton.style.display = 'block';
126132

127133
files.forEach(file => {
128134
const row = filesTableBody.insertRow();
@@ -186,20 +192,20 @@ document.addEventListener('DOMContentLoaded', () => {
186192
});
187193
}
188194

189-
function confirmDeleteFile(fileName) {
190-
// Check localStorage preference first
191-
const doNotAskAgain = localStorage.getItem('doNotAskAgainDelete') === 'true';
195+
function confirmDeleteFile(fileName) {
196+
// Check localStorage preference first
197+
const doNotAskAgain = localStorage.getItem('doNotAskAgainDelete') === 'true';
192198

193-
if (doNotAskAgain) {
194-
deleteFile(fileName); // Proceed directly if preference is set
195-
} else {
196-
showConfirmModal(`Delete "${fileName}"?`, (confirmed) => { // Shorter message
197-
if (confirmed) {
198-
deleteFile(fileName);
199-
}
200-
});
201-
}
199+
if (doNotAskAgain) {
200+
deleteFile(fileName); // Proceed directly if preference is set
201+
} else {
202+
showConfirmModal(`Delete "${fileName}"?`, (confirmed) => { // Shorter message
203+
if (confirmed) {
204+
deleteFile(fileName);
205+
}
206+
});
202207
}
208+
}
203209

204210
async function deleteFile(fileName) {
205211
try {
@@ -219,6 +225,7 @@ document.addEventListener('DOMContentLoaded', () => {
219225
}
220226
if (filesTableBody.children.length === 0) {
221227
noFilesMessage.style.display = 'block';
228+
downloadAllZipButton.style.display = 'none';
222229
}
223230
// Optionally show a temporary success message
224231
console.log(`Successfully deleted: ${fileName}`);
@@ -356,6 +363,11 @@ document.addEventListener('DOMContentLoaded', () => {
356363
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
357364
}
358365

366+
downloadAllZipButton.addEventListener('click', () => {
367+
window.location.href = '/api/zip';
368+
});
369+
370+
359371
// Initial load of files when the page is ready
360372
fetchFiles();
361373
});

app/src/main/assets/style.css

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,37 @@ background-color: var(--error-color);
330330
/* Delete icon becomes red on hover */
331331
border-color: var(--error-color);
332332
}
333+
#files-header-container {
334+
display: flex; /* Enable Flexbox */
335+
justify-content: space-between; /* Puts space between the h2 and the button div */
336+
align-items: center; /* Vertically aligns items in the middle */
337+
margin-bottom: 1rem; /* Maintains the spacing below the header */
338+
padding: 0 15px; /* Add some padding to the sides */
339+
}
340+
341+
#files-actions {
342+
/* Remove inline styles here, flexbox handles alignment */
343+
margin-bottom: 0; /* Reset margin if inherited from elsewhere */
344+
}
345+
346+
/* Styling for the Download All as Zip button */
347+
#download-all-zip-button {
348+
padding: 0.6rem 1.2rem;
349+
border-radius: 8px;
350+
font-size: 0.9rem;
351+
font-weight: 500;
352+
transition: background-color 0.2s ease, transform 0.1s ease, box-shadow 0.2s ease;
353+
box-shadow: 0 2px 4px var(--shadow-light);
354+
white-space: nowrap; /* Prevent button text from wrapping */
355+
}
356+
357+
#download-all-zip-button:hover {
358+
background-color: var(--primary-gradient-end);
359+
transform: translateY(-1px);
360+
box-shadow: 0 4px 8px var(--shadow-medium);
361+
}
362+
363+
333364

334365
/* SVG icon styling */
335366
.icon-button svg {

app/src/main/java/com/matanh/transfer/TransferServerModule.kt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,23 @@ import io.ktor.util.AttributeKey
4646
import io.ktor.utils.io.ByteReadChannel
4747
import io.ktor.utils.io.ByteWriteChannel
4848
import io.ktor.utils.io.copyTo
49+
import io.ktor.utils.io.jvm.javaio.toOutputStream
4950
import io.ktor.utils.io.writeFully
5051
import kotlinx.coroutines.Dispatchers
5152
import kotlinx.coroutines.withContext
5253
import kotlinx.serialization.Serializable
5354
import org.json.JSONObject
5455
import timber.log.Timber
56+
import java.io.OutputStream
5557
import java.net.URLDecoder
5658
import java.net.URLEncoder
5759
import java.nio.channels.Channels
5860
import java.text.SimpleDateFormat
5961
import java.util.Date
6062
import java.util.Locale
6163
import java.util.TimeZone
64+
import java.util.zip.ZipEntry
65+
import java.util.zip.ZipOutputStream
6266

6367
const val TAG_KTOR_MODULE = "TransferKtorModule"
6468
private val logger = Timber.tag(TAG_KTOR_MODULE)
@@ -379,6 +383,66 @@ fun Application.transferServerModule(
379383
}
380384
handleFileDownload(call, applicationContext, baseDocumentFile, fileNameEncoded)
381385
}
386+
get("/zip") {
387+
try {
388+
val filesToZip = baseDocumentFile.listFiles().filter { it.isFile && it.canRead() }
389+
390+
if (filesToZip.isEmpty()) {
391+
call.respond(HttpStatusCode.NoContent, "No files to zip.")
392+
return@get
393+
}
394+
395+
val zipFileName = "transfer_files_${
396+
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(
397+
Date()
398+
)
399+
}.zip"
400+
401+
call.respond(object : OutgoingContent.WriteChannelContent() {
402+
override val contentType: ContentType = ContentType.Application.Zip
403+
override val headers: Headers = headersOf(
404+
HttpHeaders.ContentDisposition,
405+
ContentDisposition.Attachment.withParameter(
406+
ContentDisposition.Parameters.FileName,
407+
zipFileName
408+
).toString()
409+
)
410+
411+
override suspend fun writeTo(channel: ByteWriteChannel) {
412+
withContext(Dispatchers.IO) {
413+
val outputStream: OutputStream = channel.toOutputStream()
414+
ZipOutputStream(outputStream).use { zipOutputStream ->
415+
val buffer = ByteArray(256 * 1024) // 256 KB chunks
416+
417+
for (file in filesToZip) {
418+
if (file.name == null) {
419+
logger.w("Skipping file with null name: ${file.uri}")
420+
continue
421+
}
422+
val entry = ZipEntry(file.name)
423+
zipOutputStream.putNextEntry(entry)
424+
425+
applicationContext.contentResolver.openInputStream(file.uri)?.use { inputStream ->
426+
var bytesRead: Int
427+
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
428+
zipOutputStream.write(buffer, 0, bytesRead)
429+
}
430+
} ?: logger.e("Could not open input stream for file: ${file.name}")
431+
432+
zipOutputStream.closeEntry()
433+
}
434+
}
435+
}
436+
}
437+
})
438+
} catch (e: Exception) {
439+
logger.e(e, "Error zipping files $e")
440+
call.respond(
441+
HttpStatusCode.InternalServerError,
442+
ErrorResponse("Error creating zip archive: ${e.localizedMessage}")
443+
)
444+
}
445+
}
382446

383447
post("/upload") {
384448
var filesUploadedCount = 0

0 commit comments

Comments
 (0)