summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--main.ts20
-rw-r--r--templates/browse.html20
-rw-r--r--templates/error.html14
-rw-r--r--templates/gallery.html34
-rw-r--r--templates/layout.html25
-rw-r--r--templates/scripts/chunked-upload.js114
-rw-r--r--templates/scripts/simple-upload.js70
-rw-r--r--templates/scripts/slideshow.js66
-rw-r--r--templates/styles/gallery.css165
-rw-r--r--templates/upload.html43
-rw-r--r--views.ts705
11 files changed, 669 insertions, 607 deletions
diff --git a/main.ts b/main.ts
index f8bf32b..af02c52 100644
--- a/main.ts
+++ b/main.ts
@@ -36,30 +36,30 @@ router.get("/css/:filename", async (ctx) => {
});
// Routes
-router.get("/", (ctx) => {
+router.get("/", async (ctx) => {
logger.info("Page visit", { page: "/", ip: getClientIP(ctx) });
ctx.response.headers.set("Content-Type", "text/html");
- ctx.response.body = renderUploadPage(false);
+ ctx.response.body = await renderUploadPage(false);
});
-router.get("/chunked", (ctx) => {
+router.get("/chunked", async (ctx) => {
logger.info("Page visit", { page: "/chunked", ip: getClientIP(ctx) });
ctx.response.headers.set("Content-Type", "text/html");
- ctx.response.body = renderUploadPage(true);
+ ctx.response.body = await renderUploadPage(true);
});
router.get("/browse", async (ctx) => {
logger.info("Page visit", { page: "/browse", ip: getClientIP(ctx) });
const files = await listUploadedFiles();
ctx.response.headers.set("Content-Type", "text/html");
- ctx.response.body = renderBrowsePage(files);
+ ctx.response.body = await renderBrowsePage(files);
});
router.get("/gallery", async (ctx) => {
logger.info("Page visit", { page: "/gallery", ip: getClientIP(ctx) });
const files = await listUploadedFiles();
ctx.response.headers.set("Content-Type", "text/html");
- ctx.response.body = renderGalleryPage(files);
+ ctx.response.body = await renderGalleryPage(files);
});
router.post("/upload", async (ctx) => {
@@ -212,7 +212,7 @@ router.get("/download/:filename", async (ctx) => {
if (!filename) {
ctx.response.status = Status.BadRequest;
ctx.response.headers.set("Content-Type", "text/html");
- ctx.response.body = renderError("Filename is required", 400);
+ ctx.response.body = await renderError("Filename is required", 400);
return;
}
@@ -224,7 +224,7 @@ router.get("/download/:filename", async (ctx) => {
if (!fileInfo.isFile) {
ctx.response.status = Status.NotFound;
ctx.response.headers.set("Content-Type", "text/html");
- ctx.response.body = renderError("File not found", 404);
+ ctx.response.body = await renderError("File not found", 404);
return;
}
@@ -248,12 +248,12 @@ router.get("/download/:filename", async (ctx) => {
logger.warn("File not found", { filename, ip });
ctx.response.status = Status.NotFound;
ctx.response.headers.set("Content-Type", "text/html");
- ctx.response.body = renderError("File not found", 404);
+ ctx.response.body = await renderError("File not found", 404);
} else {
logger.error("Download error", { filename, error: String(error), ip });
ctx.response.status = Status.InternalServerError;
ctx.response.headers.set("Content-Type", "text/html");
- ctx.response.body = renderError("Failed to download file", 500);
+ ctx.response.body = await renderError("Failed to download file", 500);
}
}
});
diff --git a/templates/browse.html b/templates/browse.html
new file mode 100644
index 0000000..b22fcbb
--- /dev/null
+++ b/templates/browse.html
@@ -0,0 +1,20 @@
+<section>
+ <h6><a href="/">Home</a></h6>
+ <h5>Browse Files</h5>
+ <h6>
+ <a href="/">Simple Upload</a> |
+ <a href="/chunked">Chunked Upload</a> |
+ <a href="/browse">Browse Files</a> |
+ <a href="/gallery">Gallery</a>
+ </h6>
+</section>
+
+<section>
+ <div class="widget">
+ {{files-content}}
+ </div>
+</section>
+
+<footer>
+ <p>Last updated December 28th, 2025</p>
+</footer>
diff --git a/templates/error.html b/templates/error.html
new file mode 100644
index 0000000..a5d8cd0
--- /dev/null
+++ b/templates/error.html
@@ -0,0 +1,14 @@
+<section>
+ <div class="widget" style="text-align: center;">
+ <div style="font-size: 64px; margin-bottom: 20px;">⚠️</div>
+ <h3>Error {{status}}</h3>
+ <p>{{error-message}}</p>
+ <p style="margin-top: 30px;">
+ <a href="/">← Back to Home</a>
+ </p>
+ </div>
+</section>
+
+<footer>
+ <p>Last updated December 28th, 2025</p>
+</footer>
diff --git a/templates/gallery.html b/templates/gallery.html
new file mode 100644
index 0000000..550ed4f
--- /dev/null
+++ b/templates/gallery.html
@@ -0,0 +1,34 @@
+<section>
+ <h6><a href="/">Home</a></h6>
+ <h5>Gallery</h5>
+ <h6>
+ <a href="/">Simple Upload</a> |
+ <a href="/chunked">Chunked Upload</a> |
+ <a href="/browse">Browse Files</a> |
+ <a href="/gallery">Gallery</a>
+ </h6>
+</section>
+
+<section>
+ <div class="widget">
+ {{gallery-content}}
+ </div>
+</section>
+
+<!-- Slideshow Modal -->
+<div id="slideshow-modal" class="slideshow-modal">
+ <span class="slideshow-close" onclick="closeSlideshow()">&times;</span>
+ <button class="slideshow-nav slideshow-prev" onclick="changeSlide(-1)">❮</button>
+ <div class="slideshow-content">
+ <div id="slideshow-media-container"></div>
+ <div class="slideshow-info">
+ <div id="slideshow-filename"></div>
+ <div id="slideshow-counter"></div>
+ </div>
+ </div>
+ <button class="slideshow-nav slideshow-next" onclick="changeSlide(1)">❯</button>
+</div>
+
+<footer>
+ <p>Last updated December 28th, 2025</p>
+</footer>
diff --git a/templates/layout.html b/templates/layout.html
new file mode 100644
index 0000000..a228982
--- /dev/null
+++ b/templates/layout.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{title}}</title>
+ <link rel="stylesheet" href="/css/skeleton.css">
+ <link rel="stylesheet" href="/css/home.css">
+ {{styles}}
+</head>
+<body>
+ <header>
+ <h2>File Upload</h2>
+ <section class="status">
+ <p> </p>
+ </section>
+ </header>
+ <main>
+ <center>- - -</center>
+ {{content}}
+ <center>- - -</center>
+ </main>
+ {{scripts}}
+</body>
+</html>
diff --git a/templates/scripts/chunked-upload.js b/templates/scripts/chunked-upload.js
new file mode 100644
index 0000000..980c61c
--- /dev/null
+++ b/templates/scripts/chunked-upload.js
@@ -0,0 +1,114 @@
+const fileInput = document.getElementById('fileInput');
+const fileInfo = document.getElementById('fileInfo');
+const form = document.getElementById('uploadForm');
+const result = document.getElementById('result');
+const progress = document.getElementById('progress');
+const progressFill = document.getElementById('progressFill');
+const uploadBtn = document.getElementById('uploadBtn');
+const CHUNK_SIZE = {{chunk-size}};
+
+fileInput.addEventListener('change', (e) => {
+ const files = e.target.files;
+ if (files.length > 0) {
+ const names = Array.from(files).map(f => f.name).join(', ');
+ const totalSize = Array.from(files).reduce((sum, f) => sum + f.size, 0);
+ fileInfo.textContent = `Selected: ${files.length} file(s) - ${formatBytes(totalSize)}`;
+ }
+});
+
+form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const files = fileInput.files;
+
+ if (files.length === 0) {
+ showResult('Please select at least one file', 'error');
+ return;
+ }
+
+ uploadBtn.disabled = true;
+ uploadBtn.textContent = 'Uploading...';
+ progress.style.display = 'block';
+ result.style.display = 'none';
+
+ try {
+ const uploadedFiles = [];
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ const filename = await uploadFileChunked(file, i, files.length);
+ uploadedFiles.push(filename);
+ }
+
+ const links = uploadedFiles.map(f =>
+ `<a href="/download/${f}" target="_blank" style="display: block; margin: 5px 0;">${window.location.origin}/download/${f}</a>`
+ ).join('');
+ showResult('Upload successful!<br>' + links, 'success');
+ form.reset();
+ fileInfo.textContent = 'Max size: {{max-size}}MB per file';
+ } catch (error) {
+ showResult('Upload failed: ' + error.message, 'error');
+ } finally {
+ uploadBtn.disabled = false;
+ uploadBtn.textContent = 'Upload';
+ progress.style.display = 'none';
+ }
+});
+
+async function uploadFileChunked(file, fileIndex, totalFiles) {
+ const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
+
+ for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
+ const start = chunkIndex * CHUNK_SIZE;
+ const end = Math.min(start + CHUNK_SIZE, file.size);
+ const chunk = file.slice(start, end);
+
+ const formData = new FormData();
+ formData.append('chunk', chunk);
+ formData.append('filename', file.name);
+ formData.append('chunkIndex', chunkIndex);
+ formData.append('totalChunks', totalChunks);
+
+ const response = await fetch('/upload-chunk', {
+ method: 'POST',
+ body: formData
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Upload failed');
+ }
+
+ const overallProgress = ((fileIndex * totalChunks + chunkIndex + 1) / (totalFiles * totalChunks)) * 100;
+ updateProgress(overallProgress);
+ }
+
+ return file.name;
+}
+
+function updateProgress(percent) {
+ const rounded = Math.round(percent);
+ progressFill.style.width = rounded + '%';
+ progressFill.textContent = rounded + '%';
+}
+
+function showResult(message, type) {
+ result.innerHTML = message;
+ result.style.display = 'block';
+ if (type === 'success') {
+ result.style.background = '#d4edda';
+ result.style.color = '#155724';
+ result.style.border = '1px solid #c3e6cb';
+ } else {
+ result.style.background = '#f8d7da';
+ result.style.color = '#721c24';
+ result.style.border = '1px solid #f5c6cb';
+ }
+}
+
+function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
+}
diff --git a/templates/scripts/simple-upload.js b/templates/scripts/simple-upload.js
new file mode 100644
index 0000000..c3a0def
--- /dev/null
+++ b/templates/scripts/simple-upload.js
@@ -0,0 +1,70 @@
+const fileInput = document.getElementById('fileInput');
+const fileInfo = document.getElementById('fileInfo');
+const form = document.getElementById('uploadForm');
+const result = document.getElementById('result');
+const uploadBtn = document.getElementById('uploadBtn');
+
+fileInput.addEventListener('change', (e) => {
+ const files = e.target.files;
+ if (files.length > 0) {
+ const names = Array.from(files).map(f => f.name).join(', ');
+ fileInfo.textContent = `Selected: ${files.length} file(s) - ${names}`;
+ }
+});
+
+form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const files = fileInput.files;
+
+ if (files.length === 0) {
+ showResult('Please select at least one file', 'error');
+ return;
+ }
+
+ uploadBtn.disabled = true;
+ uploadBtn.textContent = 'Uploading...';
+
+ const formData = new FormData();
+ for (let i = 0; i < files.length; i++) {
+ formData.append('files', files[i]);
+ }
+
+ try {
+ const response = await fetch('/upload', {
+ method: 'POST',
+ body: formData
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ const links = data.files.map(f =>
+ `<a href="/download/${f}" target="_blank" style="display: block; margin: 5px 0;">${window.location.origin}/download/${f}</a>`
+ ).join('');
+ showResult('Upload successful!<br>' + links, 'success');
+ form.reset();
+ fileInfo.textContent = 'Max size: {{max-size}}MB per file';
+ } else {
+ showResult('Error: ' + data.error, 'error');
+ }
+ } catch (error) {
+ showResult('Upload failed: ' + error.message, 'error');
+ } finally {
+ uploadBtn.disabled = false;
+ uploadBtn.textContent = 'Upload';
+ }
+});
+
+function showResult(message, type) {
+ result.innerHTML = message;
+ result.style.display = 'block';
+ if (type === 'success') {
+ result.style.background = '#d4edda';
+ result.style.color = '#155724';
+ result.style.border = '1px solid #c3e6cb';
+ } else {
+ result.style.background = '#f8d7da';
+ result.style.color = '#721c24';
+ result.style.border = '1px solid #f5c6cb';
+ }
+}
diff --git a/templates/scripts/slideshow.js b/templates/scripts/slideshow.js
new file mode 100644
index 0000000..ccf7c32
--- /dev/null
+++ b/templates/scripts/slideshow.js
@@ -0,0 +1,66 @@
+const mediaFiles = {{media-files-json}};
+let currentSlide = 0;
+
+function openSlideshow(index) {
+ currentSlide = index;
+ showSlide(currentSlide);
+ document.getElementById('slideshow-modal').classList.add('active');
+ document.body.style.overflow = 'hidden';
+}
+
+function closeSlideshow() {
+ document.getElementById('slideshow-modal').classList.remove('active');
+ document.body.style.overflow = 'auto';
+
+ // Pause any playing videos
+ const videos = document.querySelectorAll('#slideshow-media-container video');
+ videos.forEach(v => v.pause());
+}
+
+function changeSlide(direction) {
+ currentSlide += direction;
+
+ if (currentSlide >= mediaFiles.length) {
+ currentSlide = 0;
+ }
+ if (currentSlide < 0) {
+ currentSlide = mediaFiles.length - 1;
+ }
+
+ showSlide(currentSlide);
+}
+
+function showSlide(index) {
+ const file = mediaFiles[index];
+ const container = document.getElementById('slideshow-media-container');
+
+ if (file.isVideo) {
+ container.innerHTML = `<video src="/download/${file.name}" controls autoplay style="max-width: 100%; max-height: 80vh;"></video>`;
+ } else {
+ container.innerHTML = `<img src="/download/${file.name}" alt="${file.name}" style="max-width: 100%; max-height: 80vh;">`;
+ }
+
+ document.getElementById('slideshow-filename').textContent = file.name;
+ document.getElementById('slideshow-counter').textContent = `${index + 1} / ${mediaFiles.length}`;
+}
+
+// Keyboard navigation
+document.addEventListener('keydown', (e) => {
+ const modal = document.getElementById('slideshow-modal');
+ if (!modal.classList.contains('active')) return;
+
+ if (e.key === 'ArrowLeft') {
+ changeSlide(-1);
+ } else if (e.key === 'ArrowRight') {
+ changeSlide(1);
+ } else if (e.key === 'Escape') {
+ closeSlideshow();
+ }
+});
+
+// Close on background click
+document.getElementById('slideshow-modal').addEventListener('click', (e) => {
+ if (e.target.id === 'slideshow-modal') {
+ closeSlideshow();
+ }
+});
diff --git a/templates/styles/gallery.css b/templates/styles/gallery.css
new file mode 100644
index 0000000..5ae5615
--- /dev/null
+++ b/templates/styles/gallery.css
@@ -0,0 +1,165 @@
+.gallery-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 20px;
+ padding: 10px 0;
+}
+.gallery-item {
+ position: relative;
+ background: #f5f5f5;
+ border-radius: 4px;
+ overflow: hidden;
+ transition: transform 0.2s, box-shadow 0.2s;
+ cursor: pointer;
+}
+.gallery-item:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+}
+.gallery-item a {
+ display: block;
+ position: relative;
+ text-decoration: none;
+}
+.video-overlay {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 48px;
+ color: white;
+ text-shadow: 0 2px 4px rgba(0,0,0,0.5);
+ pointer-events: none;
+}
+.gallery-info {
+ padding: 10px;
+ background: white;
+}
+.gallery-filename {
+ font-size: 14px;
+ font-weight: 600;
+ color: #333;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-bottom: 4px;
+}
+.gallery-meta {
+ font-size: 12px;
+ color: #666;
+}
+
+/* Slideshow Styles */
+.slideshow-modal {
+ display: none;
+ position: fixed;
+ z-index: 9999;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.95);
+}
+.slideshow-modal.active {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.slideshow-close {
+ position: absolute;
+ top: 20px;
+ right: 40px;
+ color: #fff;
+ font-size: 50px;
+ font-weight: bold;
+ cursor: pointer;
+ z-index: 10001;
+ transition: color 0.3s;
+}
+.slideshow-close:hover {
+ color: #bbb;
+}
+.slideshow-content {
+ max-width: 90%;
+ max-height: 90%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+#slideshow-media-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ max-width: 100%;
+ max-height: 80vh;
+}
+#slideshow-media-container img,
+#slideshow-media-container video {
+ max-width: 100%;
+ max-height: 80vh;
+ object-fit: contain;
+ border-radius: 4px;
+}
+.slideshow-info {
+ color: white;
+ text-align: center;
+ margin-top: 20px;
+ font-size: 16px;
+}
+#slideshow-filename {
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+#slideshow-counter {
+ font-size: 14px;
+ color: #ccc;
+}
+.slideshow-nav {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ background-color: rgba(255, 255, 255, 0.2);
+ color: white;
+ border: none;
+ font-size: 40px;
+ padding: 20px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+ z-index: 10001;
+}
+.slideshow-nav:hover {
+ background-color: rgba(255, 255, 255, 0.4);
+}
+.slideshow-prev {
+ left: 20px;
+}
+.slideshow-next {
+ right: 20px;
+}
+
+@media (max-width: 550px) {
+ .gallery-grid {
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 10px;
+ }
+ .gallery-item a img,
+ .gallery-item a video {
+ height: 150px !important;
+ }
+ .slideshow-close {
+ top: 10px;
+ right: 20px;
+ font-size: 40px;
+ }
+ .slideshow-nav {
+ font-size: 30px;
+ padding: 15px;
+ }
+ .slideshow-prev {
+ left: 10px;
+ }
+ .slideshow-next {
+ right: 10px;
+ }
+}
diff --git a/templates/upload.html b/templates/upload.html
new file mode 100644
index 0000000..e3b972a
--- /dev/null
+++ b/templates/upload.html
@@ -0,0 +1,43 @@
+<section>
+ <h5>Shared Files</h5>
+ <p>Welcome to the file sharing service</p>
+ <center>
+ <a href="/browse">Browse All Files</a> |
+ <a href="/gallery">View Gallery</a>
+ </center>
+</section>
+
+<section id="fileupload{{section-suffix}}">
+ <div class="widget">
+ <h4>File Upload {{upload-type}}</h4>
+ <h6>
+ {{nav-links}}
+ | <a href="/browse">Browse Files</a>
+ | <a href="/gallery">Gallery</a>
+ </h6>
+ <form id="uploadForm" {{form-attrs}}>
+ <input type="file" id="fileInput" name="files" multiple {{input-attrs}}>
+ <div class="file-info" id="fileInfo" style="margin: 10px 0; color: #666;">
+ Max size: {{max-size}}MB per file
+ </div>
+ <button type="submit" id="uploadBtn">Upload</button>
+ </form>
+
+ <div class="progress" id="progress" style="display: none; margin-top: 15px;">
+ <div style="width: 100%; height: 30px; background: #f0f0f0; border-radius: 4px; overflow: hidden;">
+ <div id="progressFill" style="height: 100%; background: #33C3F0; width: 0%; display: flex; align-items: center; justify-content: center; color: white; font-size: 12px; font-weight: 600; transition: width 0.3s;">0%</div>
+ </div>
+ </div>
+
+ <div class="result" id="result" style="display: none; margin-top: 15px; padding: 15px; border-radius: 4px;"></div>
+
+ <div style="background: #e7f3ff; padding: 15px; border-radius: 4px; margin-top: 15px; font-size: 14px; color: #004085;">
+ <strong>Info:</strong> Files expire after {{expiration-days}} days.
+ Rate limit: {{rate-limit-max}} uploads per {{rate-limit-minutes}} minutes.
+ </div>
+ </div>
+</section>
+
+<footer>
+ <p>Last updated December 28th, 2025</p>
+</footer>
diff --git a/views.ts b/views.ts
index bfd089c..4db06ce 100644
--- a/views.ts
+++ b/views.ts
@@ -1,282 +1,73 @@
import { config } from "./config.ts";
import { formatFileSize, getMimeType, isViewableInBrowser } from "./fileUtils.ts";
-function renderTemplate(title: string, content: string, scripts = "") {
- return `<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>${title}</title>
- <link rel="stylesheet" href="/css/skeleton.css">
- <link rel="stylesheet" href="/css/home.css">
-</head>
-<body>
- <header>
- <h2>File Upload</h2>
- <section class="status">
- <p> </p>
- </section>
- </header>
- <main>
- <center>- - -</center>
- ${content}
- <center>- - -</center>
- </main>
- ${scripts}
-</body>
-</html>`;
-}
-
-export function renderUploadPage(chunked = false) {
- const maxSizeMB = Math.floor(config.maxFileSize / (1024 * 1024));
- const expirationDays = Math.floor(config.fileExpiration / (24 * 60 * 60 * 1000));
-
- const content = `
-<section>
- <h5>Shared Files</h5>
- <p>Welcome to the file sharing service</p>
- <center>
- <a href="/browse">Browse All Files</a> |
- <a href="/gallery">View Gallery</a>
- </center>
-</section>
-
-<section id="fileupload${chunked ? '-js' : '-nojs'}">
- <div class="widget">
- <h4>File Upload ${chunked ? '(Chunked)' : ''}</h4>
- <h6>
- ${chunked ? '<a href="/">Simple Upload</a>' : '<a href="/chunked">Chunked Upload</a>'}
- | <a href="/browse">Browse Files</a>
- | <a href="/gallery">Gallery</a>
- </h6>
- <form id="uploadForm" ${chunked ? '' : 'action="/upload" method="POST" enctype="multipart/form-data"'}>
- <input type="file" id="fileInput" name="files" multiple ${chunked ? '' : 'required'}>
- <div class="file-info" id="fileInfo" style="margin: 10px 0; color: #666;">
- Max size: ${maxSizeMB}MB per file
- </div>
- <button type="submit" id="uploadBtn">Upload</button>
- </form>
-
- <div class="progress" id="progress" style="display: none; margin-top: 15px;">
- <div style="width: 100%; height: 30px; background: #f0f0f0; border-radius: 4px; overflow: hidden;">
- <div id="progressFill" style="height: 100%; background: #33C3F0; width: 0%; display: flex; align-items: center; justify-content: center; color: white; font-size: 12px; font-weight: 600; transition: width 0.3s;">0%</div>
- </div>
- </div>
-
- <div class="result" id="result" style="display: none; margin-top: 15px; padding: 15px; border-radius: 4px;"></div>
-
- <div style="background: #e7f3ff; padding: 15px; border-radius: 4px; margin-top: 15px; font-size: 14px; color: #004085;">
- <strong>Info:</strong> Files expire after ${expirationDays} days.
- Rate limit: ${config.rateLimit.maxUploads} uploads per ${config.rateLimit.windowMs / 60000} minutes.
- </div>
- </div>
-</section>
-
-<footer>
- <p>Last updated December 28th, 2025</p>
-</footer>`;
+const templateCache = new Map<string, string>();
- const scripts = chunked ? getChunkedUploadScript() : getSimpleUploadScript();
+async function loadTemplate(path: string): Promise<string> {
+ if (templateCache.has(path)) {
+ return templateCache.get(path)!;
+ }
- return renderTemplate("File Upload", content, scripts);
+ const content = await Deno.readTextFile(path);
+ templateCache.set(path, content);
+ return content;
}
-function getSimpleUploadScript() {
- return `<script>
- const fileInput = document.getElementById('fileInput');
- const fileInfo = document.getElementById('fileInfo');
- const form = document.getElementById('uploadForm');
- const result = document.getElementById('result');
- const uploadBtn = document.getElementById('uploadBtn');
-
- fileInput.addEventListener('change', (e) => {
- const files = e.target.files;
- if (files.length > 0) {
- const names = Array.from(files).map(f => f.name).join(', ');
- fileInfo.textContent = \`Selected: \${files.length} file(s) - \${names}\`;
- }
- });
-
- form.addEventListener('submit', async (e) => {
- e.preventDefault();
- const files = fileInput.files;
-
- if (files.length === 0) {
- showResult('Please select at least one file', 'error');
- return;
- }
-
- uploadBtn.disabled = true;
- uploadBtn.textContent = 'Uploading...';
-
- const formData = new FormData();
- for (let i = 0; i < files.length; i++) {
- formData.append('files', files[i]);
- }
-
- try {
- const response = await fetch('/upload', {
- method: 'POST',
- body: formData
- });
-
- const data = await response.json();
-
- if (response.ok) {
- const links = data.files.map(f =>
- \`<a href="/download/\${f}" target="_blank" style="display: block; margin: 5px 0;">\${window.location.origin}/download/\${f}</a>\`
- ).join('');
- showResult('Upload successful!<br>' + links, 'success');
- form.reset();
- fileInfo.textContent = 'Max size: ${Math.floor(config.maxFileSize / (1024 * 1024))}MB per file';
- } else {
- showResult('Error: ' + data.error, 'error');
- }
- } catch (error) {
- showResult('Upload failed: ' + error.message, 'error');
- } finally {
- uploadBtn.disabled = false;
- uploadBtn.textContent = 'Upload';
- }
- });
-
- function showResult(message, type) {
- result.innerHTML = message;
- result.style.display = 'block';
- if (type === 'success') {
- result.style.background = '#d4edda';
- result.style.color = '#155724';
- result.style.border = '1px solid #c3e6cb';
- } else {
- result.style.background = '#f8d7da';
- result.style.color = '#721c24';
- result.style.border = '1px solid #f5c6cb';
- }
- }
- </script>`;
+function replaceVariables(template: string, vars: Record<string, string>): string {
+ let result = template;
+ for (const [key, value] of Object.entries(vars)) {
+ result = result.replaceAll(`{{${key}}}`, value);
+ }
+ return result;
}
-function getChunkedUploadScript() {
- return `<script>
- const fileInput = document.getElementById('fileInput');
- const fileInfo = document.getElementById('fileInfo');
- const form = document.getElementById('uploadForm');
- const result = document.getElementById('result');
- const progress = document.getElementById('progress');
- const progressFill = document.getElementById('progressFill');
- const uploadBtn = document.getElementById('uploadBtn');
- const CHUNK_SIZE = ${config.chunkSize};
-
- fileInput.addEventListener('change', (e) => {
- const files = e.target.files;
- if (files.length > 0) {
- const names = Array.from(files).map(f => f.name).join(', ');
- const totalSize = Array.from(files).reduce((sum, f) => sum + f.size, 0);
- fileInfo.textContent = \`Selected: \${files.length} file(s) - \${formatBytes(totalSize)}\`;
- }
- });
-
- form.addEventListener('submit', async (e) => {
- e.preventDefault();
- const files = fileInput.files;
-
- if (files.length === 0) {
- showResult('Please select at least one file', 'error');
- return;
- }
-
- uploadBtn.disabled = true;
- uploadBtn.textContent = 'Uploading...';
- progress.style.display = 'block';
- result.style.display = 'none';
-
- try {
- const uploadedFiles = [];
-
- for (let i = 0; i < files.length; i++) {
- const file = files[i];
- const filename = await uploadFileChunked(file, i, files.length);
- uploadedFiles.push(filename);
- }
-
- const links = uploadedFiles.map(f =>
- \`<a href="/download/\${f}" target="_blank" style="display: block; margin: 5px 0;">\${window.location.origin}/download/\${f}</a>\`
- ).join('');
- showResult('Upload successful!<br>' + links, 'success');
- form.reset();
- fileInfo.textContent = 'Max size: ${Math.floor(config.maxFileSize / (1024 * 1024))}MB per file';
- } catch (error) {
- showResult('Upload failed: ' + error.message, 'error');
- } finally {
- uploadBtn.disabled = false;
- uploadBtn.textContent = 'Upload';
- progress.style.display = 'none';
- }
- });
-
- async function uploadFileChunked(file, fileIndex, totalFiles) {
- const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
-
- for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
- const start = chunkIndex * CHUNK_SIZE;
- const end = Math.min(start + CHUNK_SIZE, file.size);
- const chunk = file.slice(start, end);
-
- const formData = new FormData();
- formData.append('chunk', chunk);
- formData.append('filename', file.name);
- formData.append('chunkIndex', chunkIndex);
- formData.append('totalChunks', totalChunks);
+async function renderLayout(title: string, content: string, scripts = "", styles = ""): Promise<string> {
+ const layout = await loadTemplate("./templates/layout.html");
+ return replaceVariables(layout, {
+ title,
+ content,
+ scripts,
+ styles,
+ });
+}
- const response = await fetch('/upload-chunk', {
- method: 'POST',
- body: formData
- });
+export async function renderUploadPage(chunked = false): Promise<string> {
+ const maxSizeMB = Math.floor(config.maxFileSize / (1024 * 1024));
+ const expirationDays = Math.floor(config.fileExpiration / (24 * 60 * 60 * 1000));
- if (!response.ok) {
- const data = await response.json();
- throw new Error(data.error || 'Upload failed');
- }
+ const uploadTemplate = await loadTemplate("./templates/upload.html");
- const overallProgress = ((fileIndex * totalChunks + chunkIndex + 1) / (totalFiles * totalChunks)) * 100;
- updateProgress(overallProgress);
- }
+ const scriptTemplate = chunked
+ ? await loadTemplate("./templates/scripts/chunked-upload.js")
+ : await loadTemplate("./templates/scripts/simple-upload.js");
- return file.name;
- }
+ const scriptContent = replaceVariables(scriptTemplate, {
+ "max-size": String(maxSizeMB),
+ "chunk-size": String(config.chunkSize),
+ });
- function updateProgress(percent) {
- const rounded = Math.round(percent);
- progressFill.style.width = rounded + '%';
- progressFill.textContent = rounded + '%';
- }
+ const content = replaceVariables(uploadTemplate, {
+ "section-suffix": chunked ? "-js" : "-nojs",
+ "upload-type": chunked ? "(Chunked)" : "",
+ "nav-links": chunked
+ ? '<a href="/">Simple Upload</a>'
+ : '<a href="/chunked">Chunked Upload</a>',
+ "form-attrs": chunked ? "" : 'action="/upload" method="POST" enctype="multipart/form-data"',
+ "input-attrs": chunked ? "" : "required",
+ "max-size": String(maxSizeMB),
+ "expiration-days": String(expirationDays),
+ "rate-limit-max": String(config.rateLimit.maxUploads),
+ "rate-limit-minutes": String(config.rateLimit.windowMs / 60000),
+ });
- function showResult(message, type) {
- result.innerHTML = message;
- result.style.display = 'block';
- if (type === 'success') {
- result.style.background = '#d4edda';
- result.style.color = '#155724';
- result.style.border = '1px solid #c3e6cb';
- } else {
- result.style.background = '#f8d7da';
- result.style.color = '#721c24';
- result.style.border = '1px solid #f5c6cb';
- }
- }
+ const scripts = `<script>\n${scriptContent}\n</script>`;
- function formatBytes(bytes) {
- if (bytes === 0) return '0 Bytes';
- const k = 1024;
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
- }
- </script>`;
+ return renderLayout("File Upload", content, scripts);
}
-export function renderBrowsePage(files: Array<{ name: string; size: number; uploaded: Date }>) {
+export async function renderBrowsePage(files: Array<{ name: string; size: number; uploaded: Date }>): Promise<string> {
+ const browseTemplate = await loadTemplate("./templates/browse.html");
+
const fileRows = files.map(file => {
const mimeType = getMimeType(file.name);
const viewable = isViewableInBrowser(mimeType);
@@ -294,51 +85,39 @@ export function renderBrowsePage(files: Array<{ name: string; size: number; uplo
`;
}).join('');
- const content = `
-<section>
- <h5>Browse Files</h5>
- <h6>
- <a href="/">Simple Upload</a> |
- <a href="/chunked">Chunked Upload</a> |
- <a href="/browse">Browse Files</a> |
- <a href="/gallery">Gallery</a>
- </h6>
-</section>
-
-<section>
- <div class="widget">
- ${files.length > 0 ? `
- <table style="width: 100%; border-collapse: collapse;">
- <thead>
- <tr>
- <th>Filename</th>
- <th>Size</th>
- <th>Uploaded</th>
- <th>Actions</th>
- </tr>
- </thead>
- <tbody>
- ${fileRows}
- </tbody>
- </table>
- ` : `
- <div style="text-align: center; padding: 60px 20px; color: #666;">
- <div style="font-size: 64px; margin-bottom: 20px;">📭</div>
- <h3>No files uploaded yet</h3>
- <p>Upload some files to see them here!</p>
- </div>
- `}
- </div>
-</section>
+ const filesContent = files.length > 0 ? `
+ <table style="width: 100%; border-collapse: collapse;">
+ <thead>
+ <tr>
+ <th>Filename</th>
+ <th>Size</th>
+ <th>Uploaded</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${fileRows}
+ </tbody>
+ </table>
+ ` : `
+ <div style="text-align: center; padding: 60px 20px; color: #666;">
+ <div style="font-size: 64px; margin-bottom: 20px;">📭</div>
+ <h3>No files uploaded yet</h3>
+ <p>Upload some files to see them here!</p>
+ </div>
+ `;
-<footer>
- <p>Last updated December 28th, 2025</p>
-</footer>`;
+ const content = replaceVariables(browseTemplate, {
+ "files-content": filesContent,
+ });
- return renderTemplate("Browse Files", content);
+ return renderLayout("Browse Files", content);
}
-export function renderGalleryPage(files: Array<{ name: string; size: number; uploaded: Date }>) {
+export async function renderGalleryPage(files: Array<{ name: string; size: number; uploaded: Date }>): Promise<string> {
+ const galleryTemplate = await loadTemplate("./templates/gallery.html");
+ const galleryCss = await loadTemplate("./templates/styles/gallery.css");
+
// Filter only images and videos
const mediaFiles = files.filter(file => {
const mimeType = getMimeType(file.name);
@@ -367,50 +146,21 @@ export function renderGalleryPage(files: Array<{ name: string; size: number; upl
`;
}).join('');
- const content = `
-<section>
- <h5>Gallery</h5>
- <h6>
- <a href="/">Simple Upload</a> |
- <a href="/chunked">Chunked Upload</a> |
- <a href="/browse">Browse Files</a> |
- <a href="/gallery">Gallery</a>
- </h6>
-</section>
-
-<section>
- <div class="widget">
- ${mediaFiles.length > 0 ? `
- <div class="gallery-grid">
- ${galleryItems}
- </div>
- ` : `
- <div style="text-align: center; padding: 60px 20px; color: #666;">
- <div style="font-size: 64px; margin-bottom: 20px;">🖼️</div>
- <h3>No images or videos yet</h3>
- <p>Upload some media files to see them here!</p>
- </div>
- `}
- </div>
-</section>
-
-<!-- Slideshow Modal -->
-<div id="slideshow-modal" class="slideshow-modal">
- <span class="slideshow-close" onclick="closeSlideshow()">&times;</span>
- <button class="slideshow-nav slideshow-prev" onclick="changeSlide(-1)">❮</button>
- <div class="slideshow-content">
- <div id="slideshow-media-container"></div>
- <div class="slideshow-info">
- <div id="slideshow-filename"></div>
- <div id="slideshow-counter"></div>
+ const galleryContent = mediaFiles.length > 0 ? `
+ <div class="gallery-grid">
+ ${galleryItems}
</div>
- </div>
- <button class="slideshow-nav slideshow-next" onclick="changeSlide(1)">❯</button>
-</div>
+ ` : `
+ <div style="text-align: center; padding: 60px 20px; color: #666;">
+ <div style="font-size: 64px; margin-bottom: 20px;">🖼️</div>
+ <h3>No images or videos yet</h3>
+ <p>Upload some media files to see them here!</p>
+ </div>
+ `;
-<footer>
- <p>Last updated December 28th, 2025</p>
-</footer>`;
+ const content = replaceVariables(galleryTemplate, {
+ "gallery-content": galleryContent,
+ });
const mediaFilesJson = JSON.stringify(mediaFiles.map(file => ({
name: file.name,
@@ -419,263 +169,24 @@ export function renderGalleryPage(files: Array<{ name: string; size: number; upl
uploaded: file.uploaded.toLocaleDateString()
})));
- const styles = `
- <style>
- .gallery-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
- gap: 20px;
- padding: 10px 0;
- }
- .gallery-item {
- position: relative;
- background: #f5f5f5;
- border-radius: 4px;
- overflow: hidden;
- transition: transform 0.2s, box-shadow 0.2s;
- cursor: pointer;
- }
- .gallery-item:hover {
- transform: translateY(-4px);
- box-shadow: 0 4px 12px rgba(0,0,0,0.15);
- }
- .gallery-item a {
- display: block;
- position: relative;
- text-decoration: none;
- }
- .video-overlay {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- font-size: 48px;
- color: white;
- text-shadow: 0 2px 4px rgba(0,0,0,0.5);
- pointer-events: none;
- }
- .gallery-info {
- padding: 10px;
- background: white;
- }
- .gallery-filename {
- font-size: 14px;
- font-weight: 600;
- color: #333;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- margin-bottom: 4px;
- }
- .gallery-meta {
- font-size: 12px;
- color: #666;
- }
-
- /* Slideshow Styles */
- .slideshow-modal {
- display: none;
- position: fixed;
- z-index: 9999;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
- background-color: rgba(0, 0, 0, 0.95);
- }
- .slideshow-modal.active {
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .slideshow-close {
- position: absolute;
- top: 20px;
- right: 40px;
- color: #fff;
- font-size: 50px;
- font-weight: bold;
- cursor: pointer;
- z-index: 10001;
- transition: color 0.3s;
- }
- .slideshow-close:hover {
- color: #bbb;
- }
- .slideshow-content {
- max-width: 90%;
- max-height: 90%;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- }
- #slideshow-media-container {
- display: flex;
- align-items: center;
- justify-content: center;
- max-width: 100%;
- max-height: 80vh;
- }
- #slideshow-media-container img,
- #slideshow-media-container video {
- max-width: 100%;
- max-height: 80vh;
- object-fit: contain;
- border-radius: 4px;
- }
- .slideshow-info {
- color: white;
- text-align: center;
- margin-top: 20px;
- font-size: 16px;
- }
- #slideshow-filename {
- font-weight: 600;
- margin-bottom: 8px;
- }
- #slideshow-counter {
- font-size: 14px;
- color: #ccc;
- }
- .slideshow-nav {
- position: absolute;
- top: 50%;
- transform: translateY(-50%);
- background-color: rgba(255, 255, 255, 0.2);
- color: white;
- border: none;
- font-size: 40px;
- padding: 20px;
- cursor: pointer;
- transition: background-color 0.3s;
- z-index: 10001;
- }
- .slideshow-nav:hover {
- background-color: rgba(255, 255, 255, 0.4);
- }
- .slideshow-prev {
- left: 20px;
- }
- .slideshow-next {
- right: 20px;
- }
-
- @media (max-width: 550px) {
- .gallery-grid {
- grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
- gap: 10px;
- }
- .gallery-item a img,
- .gallery-item a video {
- height: 150px !important;
- }
- .slideshow-close {
- top: 10px;
- right: 20px;
- font-size: 40px;
- }
- .slideshow-nav {
- font-size: 30px;
- padding: 15px;
- }
- .slideshow-prev {
- left: 10px;
- }
- .slideshow-next {
- right: 10px;
- }
- }
- </style>
-
- <script>
- const mediaFiles = ${mediaFilesJson};
- let currentSlide = 0;
-
- function openSlideshow(index) {
- currentSlide = index;
- showSlide(currentSlide);
- document.getElementById('slideshow-modal').classList.add('active');
- document.body.style.overflow = 'hidden';
- }
-
- function closeSlideshow() {
- document.getElementById('slideshow-modal').classList.remove('active');
- document.body.style.overflow = 'auto';
-
- // Pause any playing videos
- const videos = document.querySelectorAll('#slideshow-media-container video');
- videos.forEach(v => v.pause());
- }
-
- function changeSlide(direction) {
- currentSlide += direction;
-
- if (currentSlide >= mediaFiles.length) {
- currentSlide = 0;
- }
- if (currentSlide < 0) {
- currentSlide = mediaFiles.length - 1;
- }
-
- showSlide(currentSlide);
- }
-
- function showSlide(index) {
- const file = mediaFiles[index];
- const container = document.getElementById('slideshow-media-container');
-
- if (file.isVideo) {
- container.innerHTML = \`<video src="/download/\${file.name}" controls autoplay style="max-width: 100%; max-height: 80vh;"></video>\`;
- } else {
- container.innerHTML = \`<img src="/download/\${file.name}" alt="\${file.name}" style="max-width: 100%; max-height: 80vh;">\`;
- }
-
- document.getElementById('slideshow-filename').textContent = file.name;
- document.getElementById('slideshow-counter').textContent = \`\${index + 1} / \${mediaFiles.length}\`;
- }
-
- // Keyboard navigation
- document.addEventListener('keydown', (e) => {
- const modal = document.getElementById('slideshow-modal');
- if (!modal.classList.contains('active')) return;
-
- if (e.key === 'ArrowLeft') {
- changeSlide(-1);
- } else if (e.key === 'ArrowRight') {
- changeSlide(1);
- } else if (e.key === 'Escape') {
- closeSlideshow();
- }
- });
+ const slideshowScript = await loadTemplate("./templates/scripts/slideshow.js");
+ const scriptContent = replaceVariables(slideshowScript, {
+ "media-files-json": mediaFilesJson,
+ });
- // Close on background click
- document.getElementById('slideshow-modal').addEventListener('click', (e) => {
- if (e.target.id === 'slideshow-modal') {
- closeSlideshow();
- }
- });
- </script>`;
+ const styles = `<style>\n${galleryCss}\n</style>`;
+ const scripts = `<script>\n${scriptContent}\n</script>`;
- return renderTemplate("Gallery", content, styles);
+ return renderLayout("Gallery", content, scripts, styles);
}
-export function renderError(error: string, status = 400) {
- const content = `
-<section>
- <div class="widget" style="text-align: center;">
- <div style="font-size: 64px; margin-bottom: 20px;">⚠️</div>
- <h3>Error ${status}</h3>
- <p>${error}</p>
- <p style="margin-top: 30px;">
- <a href="/">← Back to Home</a>
- </p>
- </div>
-</section>
+export async function renderError(error: string, status = 400): Promise<string> {
+ const errorTemplate = await loadTemplate("./templates/error.html");
-<footer>
- <p>Last updated December 28th, 2025</p>
-</footer>`;
+ const content = replaceVariables(errorTemplate, {
+ status: String(status),
+ "error-message": error,
+ });
- return renderTemplate("Error", content);
+ return renderLayout("Error", content);
}