diff options
| -rw-r--r-- | main.ts | 20 | ||||
| -rw-r--r-- | templates/browse.html | 20 | ||||
| -rw-r--r-- | templates/error.html | 14 | ||||
| -rw-r--r-- | templates/gallery.html | 34 | ||||
| -rw-r--r-- | templates/layout.html | 25 | ||||
| -rw-r--r-- | templates/scripts/chunked-upload.js | 114 | ||||
| -rw-r--r-- | templates/scripts/simple-upload.js | 70 | ||||
| -rw-r--r-- | templates/scripts/slideshow.js | 66 | ||||
| -rw-r--r-- | templates/styles/gallery.css | 165 | ||||
| -rw-r--r-- | templates/upload.html | 43 | ||||
| -rw-r--r-- | views.ts | 705 |
11 files changed, 669 insertions, 607 deletions
@@ -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()">×</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> @@ -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()">×</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); } |
