aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2025-12-29 11:44:57 -0800
committerPinapelz <yukais@pinapelz.com>2025-12-29 11:44:57 -0800
commite7ef35277dd0b4bba5b3fb675d5eed3cd0f0fcb8 (patch)
tree0fc0bd749cccdc0cf2be986c58c1a0cf98ebf0d3 /src
parent034ed93198bfd9387458489a4af6c240debd7e48 (diff)
refactor webui into templates
Diffstat (limited to 'src')
-rw-r--r--src/main/java/com/pinapelz/frontend/App.kt1016
-rw-r--r--src/main/java/com/pinapelz/frontend/HtmlTemplates.kt111
-rw-r--r--src/main/resources/templates/directory-item.html17
-rw-r--r--src/main/resources/templates/empty-directories.html6
-rw-r--r--src/main/resources/templates/empty-files.html16
-rw-r--r--src/main/resources/templates/empty-search-results.html22
-rw-r--r--src/main/resources/templates/file-row.html17
-rw-r--r--src/main/resources/templates/file-table.html27
-rw-r--r--src/main/resources/templates/index.html573
-rw-r--r--src/main/resources/templates/main.html840
10 files changed, 1061 insertions, 1584 deletions
diff --git a/src/main/java/com/pinapelz/frontend/App.kt b/src/main/java/com/pinapelz/frontend/App.kt
index 26ce243..02da8b7 100644
--- a/src/main/java/com/pinapelz/frontend/App.kt
+++ b/src/main/java/com/pinapelz/frontend/App.kt
@@ -1,5 +1,7 @@
package com.pinapelz.frontend
+import com.pinapelz.frontend.HtmlTemplates
+
import io.javalin.Javalin
import io.javalin.http.staticfiles.Location
import com.pinapelz.Retriever
@@ -229,893 +231,11 @@ fun validateDirectoryName(path: String): String? {
}
fun generateMainHtml(directoryId: Int): String {
- return """
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>nitro-fs</title>
- <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
- <script src="https://unpkg.com/htmx.org@1.9.10"></script>
- <style>
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
-
- body {
- background-color: #36393f;
- color: #dcddde;
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
- font-size: 14px;
- line-height: 1.4;
- }
-
- .app-container {
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- }
-
- .header {
- background-color: #2f3136;
- padding: 12px 20px;
- border-bottom: 1px solid #202225;
- display: flex;
- align-items: center;
- justify-content: space-between;
- position: sticky;
- top: 0;
- z-index: 100;
- }
-
- .header-left {
- display: flex;
- align-items: center;
- gap: 12px;
- }
-
- .header-title {
- font-size: 16px;
- font-weight: 600;
- color: #ffffff;
- display: flex;
- align-items: center;
- gap: 8px;
- }
-
- .header-subtitle {
- font-size: 12px;
- color: #72767d;
- }
-
- .header-actions {
- display: flex;
- gap: 8px;
- }
-
- .btn {
- background-color: transparent;
- border: none;
- color: #b9bbbe;
- padding: 6px 12px;
- border-radius: 4px;
- font-size: 12px;
- cursor: pointer;
- transition: all 0.2s;
- display: flex;
- align-items: center;
- gap: 4px;
- }
-
- .btn:hover {
- background-color: #40444b;
- color: #ffffff;
- }
-
- .btn-primary {
- background-color: #5865f2;
- color: #ffffff;
- }
-
- .btn-primary:hover {
- background-color: #4752c4;
- }
-
- .main-content {
- flex: 1;
- padding: 20px;
- overflow-y: auto;
- }
-
- .search-bar {
- display: flex;
- gap: 12px;
- margin-bottom: 20px;
- flex-wrap: wrap;
- }
-
- .search-input {
- flex: 1;
- min-width: 200px;
- background-color: #40444b;
- border: 1px solid #202225;
- border-radius: 4px;
- padding: 8px 12px;
- color: #dcddde;
- font-size: 14px;
- outline: none;
- }
-
- .search-input:focus {
- border-color: #5865f2;
- }
-
- .search-input::placeholder {
- color: #72767d;
- }
-
- .select {
- background-color: #40444b;
- border: 1px solid #202225;
- border-radius: 4px;
- padding: 8px 12px;
- color: #dcddde;
- font-size: 14px;
- cursor: pointer;
- outline: none;
- }
-
- .select:focus {
- border-color: #5865f2;
- }
-
- .stats {
- display: flex;
- gap: 16px;
- margin-bottom: 20px;
- align-items: center;
- }
-
- .stat-item {
- display: flex;
- align-items: center;
- gap: 6px;
- color: #72767d;
- font-size: 12px;
- }
-
- .stat-number {
- color: #ffffff;
- font-weight: 600;
- }
-
- .file-container {
- background-color: #2f3136;
- border-radius: 8px;
- border: 1px solid #202225;
- overflow: hidden;
- }
-
- .file-table {
- width: 100%;
- border-collapse: collapse;
- }
-
- .file-table th {
- background-color: #36393f;
- padding: 12px 16px;
- text-align: left;
- font-size: 12px;
- font-weight: 600;
- color: #72767d;
- text-transform: uppercase;
- border-bottom: 1px solid #202225;
- }
-
- .file-table td {
- padding: 12px 16px;
- border-bottom: 1px solid #202225;
- vertical-align: middle;
- }
-
- .file-table tbody tr:hover {
- background-color: #36393f;
- }
-
- .file-table tbody tr:last-child td {
- border-bottom: none;
- }
-
- .file-link {
- color: #00aff4;
- text-decoration: none;
- display: flex;
- align-items: center;
- gap: 8px;
- font-weight: 500;
- }
-
- .file-link:hover {
- color: #ffffff;
- text-decoration: underline;
- }
-
- .file-icon {
- color: #72767d;
- width: 16px;
- text-align: center;
- }
-
- .file-description {
- color: #b9bbbe;
- max-width: 200px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .file-size {
- color: #72767d;
- font-size: 12px;
- font-family: 'Courier New', monospace;
- }
-
- .file-type {
- background-color: #5865f2;
- color: #ffffff;
- padding: 2px 6px;
- border-radius: 12px;
- font-size: 10px;
- font-weight: 600;
- text-transform: uppercase;
- }
-
- .file-date {
- color: #72767d;
- font-size: 12px;
- }
-
- .empty-state {
- text-align: center;
- padding: 60px 20px;
- color: #72767d;
- }
-
- .empty-state-icon {
- font-size: 48px;
- margin-bottom: 16px;
- opacity: 0.5;
- }
-
- .empty-state h3 {
- color: #b9bbbe;
- margin-bottom: 8px;
- font-size: 18px;
- font-weight: 600;
- }
-
- .empty-state p {
- font-size: 14px;
- line-height: 1.5;
- }
-
- .htmx-indicator {
- display: none;
- }
-
- .htmx-request .htmx-indicator {
- display: inline;
- }
-
- .loading-spinner {
- animation: spin 1s linear infinite;
- }
-
- @keyframes spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
- }
-
- .filter-badge {
- background-color: #5865f2;
- color: #ffffff;
- padding: 2px 6px;
- border-radius: 12px;
- font-size: 10px;
- margin-left: 8px;
- }
-
- @media (max-width: 768px) {
- .search-bar {
- flex-direction: column;
- }
-
- .search-input {
- min-width: 100%;
- }
-
- .file-table th:nth-child(3),
- .file-table td:nth-child(3),
- .file-table th:nth-child(4),
- .file-table td:nth-child(4),
- .file-table th:nth-child(5),
- .file-table td:nth-child(5),
- .file-table th:nth-child(6),
- .file-table td:nth-child(6) {
- display: none;
- }
- }
- </style>
- </head>
- <body hx-boost="true">
- <div class="app-container">
- <header class="header">
- <div class="header-left">
- <div class="header-title">
- <i class="fab fa-discord"></i>
- nitro-fs
- </div>
- <div class="header-subtitle" id="current-directory"># loading...</div>
- </div>
- <div class="header-actions">
- <button class="btn"
- hx-get="/api/files?dir=$directoryId"
- hx-target="#file-content"
- hx-indicator="#loading-spinner">
- <i class="fas fa-sync-alt"></i>
- </button>
- <button class="btn"
- hx-get="/api/directories-html"
- hx-target="#directory-list"
- hx-trigger="click"
- onclick="toggleDirectoryPanel()">
- <i class="fas fa-folder"></i>
- </button>
- </div>
- </header>
-
- <main class="main-content">
- <div class="directory-panel" id="directory-panel" style="display: none;">
- <div class="panel-header">
- <h3>directories</h3>
- <button class="btn btn-sm" onclick="toggleDirectoryPanel()">
- <i class="fas fa-times"></i>
- </button>
- </div>
-
- <div class="create-directory-form">
- <form onsubmit="createDirectory(event)">
- <div class="form-row">
- <input type="text"
- id="new-directory-name"
- placeholder="directory name..."
- class="form-input"
- maxlength="100"
- pattern="[^<>:\"/\\|?*\x00-\x1f]+"
- title="Directory name cannot contain: < > : \" / \ | ? *"
- required>
- <button type="submit" class="btn btn-primary btn-sm" id="create-btn">
- <i class="fas fa-plus"></i>
- </button>
- </div>
- </form>
- <div id="create-directory-message" class="form-message"></div>
- </div>
-
- <div id="directory-list">
- <div class="empty-state">
- <i class="fas fa-spinner loading-spinner"></i>
- </div>
- </div>
- </div>
-
- <div class="search-bar">
- <input type="text"
- class="search-input"
- placeholder="search files..."
- hx-get="/api/files?dir=$directoryId"
- hx-trigger="keyup changed delay:300ms"
- hx-target="#file-content"
- hx-indicator="#loading-spinner"
- name="search">
-
- <select class="select"
- hx-get="/api/files?dir=$directoryId"
- hx-trigger="change"
- hx-target="#file-content"
- hx-indicator="#loading-spinner"
- name="mimeType">
- <option value="">all types</option>
- <option value="image/">images</option>
- <option value="video/">videos</option>
- <option value="audio/">audio</option>
- <option value="application/pdf">pdfs</option>
- <option value="text/">text</option>
- <option value="application/zip">archives</option>
- </select>
-
- <select class="select"
- hx-get="/api/files?dir=$directoryId"
- hx-trigger="change"
- hx-target="#file-content"
- hx-indicator="#loading-spinner"
- name="sortBy">
- <option value="created_at">newest</option>
- <option value="file_name">name</option>
- <option value="size">size</option>
- </select>
- </div>
-
- <div class="stats">
- <div class="stat-item">
- <span id="file-count" class="stat-number">
- <span class="htmx-indicator" id="loading-spinner">
- <i class="fas fa-spinner loading-spinner"></i>
- </span>
- <span id="count-value">loading</span>
- </span>
- files
- </div>
- </div>
-
- <div class="file-container"
- hx-get="/api/files?dir=$directoryId"
- hx-trigger="load"
- hx-target="#file-content"
- hx-indicator="#loading-spinner"
- <div id="file-content">
- <div class="empty-state">
- <div class="empty-state-icon">
- <i class="fas fa-spinner loading-spinner"></i>
- </div>
- <h3>loading files...</h3>
- <p>fetching your files from discord storage</p>
- </div>
- </div>
- </div>
- </main>
- </div>
-
- <script>
- let currentDirectoryId = $directoryId;
-
- function toggleDirectoryPanel() {
- const panel = document.getElementById('directory-panel');
- panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
- }
-
- function switchDirectory(dirId, dirPath) {
- console.log('Switching to directory:', dirId, dirPath);
- currentDirectoryId = dirId;
- const displayPath = dirPath === '' ? 'root' : dirPath;
- document.getElementById('current-directory').textContent = '# ' + displayPath;
-
- // Update all HTMX endpoints to use new directory
- const elementsWithFiles = document.querySelectorAll('[hx-get*="/api/files"]');
- console.log('Found', elementsWithFiles.length, 'elements to update');
- elementsWithFiles.forEach(el => {
- const currentUrl = el.getAttribute('hx-get');
- const baseUrl = currentUrl.split('?')[0];
- const params = new URLSearchParams(currentUrl.split('?')[1] || '');
- params.set('dir', dirId);
- const newUrl = baseUrl + '?' + params.toString();
- el.setAttribute('hx-get', newUrl);
- console.log('Updated element URL to:', newUrl);
- });
-
- // Clear any existing search/filters
- const searchInput = document.querySelector('input[name="search"]');
- const mimeSelect = document.querySelector('select[name="mimeType"]');
- const sortSelect = document.querySelector('select[name="sortBy"]');
-
- if (searchInput) searchInput.value = '';
- if (mimeSelect) mimeSelect.value = '';
- if (sortSelect) sortSelect.value = 'created_at';
-
- // Refresh file list with new directory
- console.log('Making HTMX request to: /api/files?dir=' + dirId);
- htmx.ajax('GET', '/api/files?dir=' + dirId, {
- target: '#file-content',
- indicator: '#loading-spinner'
- });
-
- toggleDirectoryPanel();
- }
-
- // Clear filters function
- function clearFilters() {
- document.querySelector('input[name="search"]').value = '';
- document.querySelector('select[name="mimeType"]').value = '';
- document.querySelector('select[name="sortBy"]').value = 'created_at';
- // Refresh file list with current directory
- htmx.ajax('GET', '/api/files?dir=' + currentDirectoryId, {
- target: '#file-content',
- indicator: '#loading-spinner'
- });
- }
-
- function createDirectory(event) {
- event.preventDefault();
- const input = document.getElementById('new-directory-name');
- const message = document.getElementById('create-directory-message');
- const path = input.value.trim();
-
- // Validation
- if (!path) {
- showMessage(message, 'Please enter a directory name', 'error');
- return;
- }
-
- if (path.length < 1 || path.length > 100) {
- showMessage(message, 'Directory name must be 1-100 characters', 'error');
- return;
- }
-
- // Check for invalid characters
- const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
- if (invalidChars.test(path)) {
- showMessage(message, 'Directory name contains invalid characters', 'error');
- return;
- }
-
- // Check for reserved names
- const reserved = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
- if (reserved.includes(path.toUpperCase())) {
- showMessage(message, 'Directory name is reserved', 'error');
- return;
- }
-
- if (path === '.' || path === '..') {
- showMessage(message, 'Invalid directory name', 'error');
- return;
- }
-
- // Show loading
- input.disabled = true;
- showMessage(message, 'Creating directory...', 'loading');
-
- fetch('/api/directories', {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body: 'path=' + encodeURIComponent(path)
- })
- .then(response => response.json())
- .then(data => {
- input.disabled = false;
- if (data.success) {
- showMessage(message, 'Directory created!', 'success');
- input.value = '';
- // Refresh directory list
- htmx.ajax('GET', '/api/directories-html', {
- target: '#directory-list'
- });
- } else {
- showMessage(message, data.message || 'Failed to create directory', 'error');
- }
- })
- .catch(error => {
- input.disabled = false;
- showMessage(message, 'Network error: ' + error.message, 'error');
- });
- }
-
- function showMessage(element, text, type) {
- element.textContent = text;
- element.className = 'form-message ' + type;
- if (type === 'success') {
- setTimeout(() => {
- element.textContent = '';
- element.className = 'form-message';
- }, 3000);
- }
- }
-
- // Update directory name on load
- console.log('Loading current directory info for ID:', currentDirectoryId);
- fetch('/api/directory/' + currentDirectoryId)
- .then(r => {
- console.log('Directory API response status:', r.status);
- return r.json();
- })
- .then(dir => {
- console.log('Directory info loaded:', dir);
- const displayPath = dir.path === '' ? 'root' : dir.path;
- document.getElementById('current-directory').textContent = '# ' + displayPath;
- })
- .catch(error => {
- console.error('Failed to load directory info:', error);
- document.getElementById('current-directory').textContent = '# root';
- });
-
- function deleteDirectory(dirId, dirName) {
- if (confirm('Are you sure you want to delete directory "' + dirName + '"?\\n\\nNote: Directory must be empty to delete.')) {
- fetch('/api/directories/' + dirId, {
- method: 'DELETE'
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- // Refresh directory list
- htmx.ajax('GET', '/api/directories-html', {
- target: '#directory-list'
- });
- // If we're currently in the deleted directory, switch to root
- if (currentDirectoryId == dirId) {
- switchDirectory(1, '');
- }
- } else {
- alert('Failed to delete directory: ' + data.message);
- }
- })
- .catch(error => {
- alert('Error deleting directory: ' + error.message);
- });
- }
- }
- </script>
-
- <style>
- .directory-panel {
- position: fixed;
- top: 53px;
- right: 20px;
- width: 300px;
- background-color: #2f3136;
- border: 1px solid #202225;
- border-radius: 8px;
- z-index: 200;
- max-height: 400px;
- overflow-y: auto;
- }
-
- .panel-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 12px 16px;
- border-bottom: 1px solid #202225;
- }
-
- .panel-header h3 {
- margin: 0;
- font-size: 14px;
- font-weight: 600;
- color: #ffffff;
- }
-
- .directory-item {
- padding: 12px 16px;
- cursor: pointer;
- border-bottom: 1px solid #202225;
- display: flex;
- justify-content: space-between;
- align-items: center;
- transition: background-color 0.2s ease;
- }
-
- .directory-item:hover {
- background-color: #36393f;
- }
-
- .directory-item:last-child {
- border-bottom: none;
- }
-
- .directory-count {
- color: #72767d;
- font-size: 12px;
- }
-
- .directory-content {
- display: flex;
- align-items: center;
- gap: 12px;
- flex: 1;
- }
-
- .directory-icon {
- color: #72767d;
- font-size: 16px;
- width: 20px;
- text-align: center;
- }
-
- .directory-info {
- flex: 1;
- }
-
- .directory-name {
- color: #dcddde;
- font-size: 14px;
- font-weight: 500;
- margin-bottom: 2px;
- }
-
- .directory-meta {
- display: flex;
- align-items: center;
- gap: 8px;
- }
-
- .file-count {
- color: #72767d;
- font-size: 11px;
- }
-
- .directory-arrow {
- color: #72767d;
- font-size: 10px;
- transition: transform 0.2s ease;
- }
-
- .directory-item:hover .directory-arrow {
- transform: translateX(2px);
- color: #b9bbbe;
- }
-
- .directory-item:hover .directory-icon {
- color: #b9bbbe;
- }
-
- .directory-item:hover .directory-name {
- color: #ffffff;
- }
-
- .create-directory-form {
- padding: 12px 16px;
- border-bottom: 1px solid #202225;
- }
-
- .form-row {
- display: flex;
- gap: 8px;
- align-items: center;
- }
-
- .form-input {
- flex: 1;
- background-color: #40444b;
- border: 1px solid #202225;
- border-radius: 4px;
- padding: 6px 8px;
- color: #dcddde;
- font-size: 12px;
- outline: none;
- }
-
- .form-input:focus {
- border-color: #5865f2;
- }
-
- .form-input::placeholder {
- color: #72767d;
- }
-
- .form-input:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
-
- .btn-sm {
- padding: 6px 8px;
- font-size: 12px;
- }
-
- .form-message {
- font-size: 11px;
- margin-top: 8px;
- padding: 4px 0;
- }
-
- .form-message.success {
- color: #43b581;
- }
-
- .form-message.error {
- color: #f04747;
- }
-
- .form-message.loading {
- color: #72767d;
- }
-
- .file-actions {
- text-align: center;
- }
-
- .btn-delete {
- background: none;
- border: none;
- color: #f04747;
- cursor: pointer;
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 12px;
- transition: all 0.2s;
- }
-
- .btn-delete:hover {
- background-color: #f04747;
- color: #ffffff;
- }
-
- .directory-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
-
- .directory-content {
- display: flex;
- align-items: center;
- gap: 12px;
- flex: 1;
- cursor: pointer;
- }
-
- .directory-actions {
- opacity: 0;
- transition: opacity 0.2s;
- }
-
- .directory-item:hover .directory-actions {
- opacity: 1;
- }
- </style>
- </body>
- </html>
- """.trimIndent()
+ return HtmlTemplates.generateMainPage(directoryId)
}
fun generateDirectoryListHtml(directories: List<Map<String, Any>>): String {
- if (directories.isEmpty()) {
- return """
- <div class="empty-state" style="padding: 20px;">
- <div class="empty-state-icon">
- <i class="fas fa-folder-open" style="opacity: 0.3;"></i>
- </div>
- <p style="font-size: 12px; color: #72767d; margin: 8px 0 0 0;">no directories found</p>
- </div>
- """.trimIndent()
- }
-
- val directoryItems = directories.joinToString("") { dir ->
- val path = dir["path"] as String
- val displayName = if (path.isEmpty()) "root" else path
- val fileCount = dir["fileCount"] as Int
- val countDisplay = if (fileCount == 0) "" else " (${fileCount})"
-
- """
- <div class="directory-item">
- <div class="directory-content" onclick="switchDirectory(${dir["id"]}, '${path.replace("'", "\\'")}')" title="Switch to $displayName directory">
- <div class="directory-icon">
- <i class="fas ${if (path.isEmpty()) "fa-home" else "fa-folder"}"></i>
- </div>
- <div class="directory-info">
- <div class="directory-name">$displayName</div>
- <div class="directory-meta">
- <span class="file-count">$fileCount files</span>
- </div>
- </div>
- <div class="directory-arrow">
- <i class="fas fa-chevron-right"></i>
- </div>
- </div>
- ${if (!path.isEmpty()) """
- <div class="directory-actions">
- <button class="btn-delete btn-sm" onclick="deleteDirectory(${dir["id"]}, '$displayName')" title="Delete directory">
- <i class="fas fa-trash"></i>
- </button>
- </div>
- """ else ""}
- </div>
- """.trimIndent()
- }
-
- return directoryItems
+ return HtmlTemplates.generateDirectoryList(directories)
}
fun formatFileSize(bytes: Long): String {
@@ -1129,131 +249,5 @@ fun formatFileSize(bytes: Long): String {
}
fun generateFileTableHtml(files: List<Map<String, Any>>, search: String = "", mimeTypeFilter: String = ""): String {
- fun getFileIcon(mimeType: String?): String {
- if (mimeType == null) return "fas fa-file"
-
- return when {
- mimeType.startsWith("image/") -> "fas fa-file-image"
- mimeType.startsWith("video/") -> "fas fa-file-video"
- mimeType.startsWith("audio/") -> "fas fa-file-audio"
- mimeType.contains("pdf") -> "fas fa-file-pdf"
- mimeType.startsWith("text/") -> "fas fa-file-alt"
- mimeType.contains("zip") || mimeType.contains("tar") || mimeType.contains("rar") -> "fas fa-file-archive"
- else -> "fas fa-file"
- }
- }
-
- val fileRows = files.joinToString("") { file ->
- """
- <tr>
- <td>
- <a href="/fetch?fileId=${file["id"]}" target="_blank" class="file-link">
- <i class="${getFileIcon(file["mimeType"] as? String)} file-icon"></i>
- ${file["name"]}
- </a>
- </td>
- <td class="file-description">${file["description"]}</td>
- <td class="file-size">${file["size"]}</td>
- <td><span class="file-type">${(file["mimeType"] as? String)?.split("/")?.get(0) ?: "file"}</span></td>
- <td class="file-date">${(file["created"] as String).split(" ")[0]}</td>
- <td class="file-actions">
- <button class="btn-delete" onclick="deleteFile(${file["id"]}, '${(file["name"] as String).replace("'", "\\'")}')" title="Delete file">
- <i class="fas fa-trash"></i>
- </button>
- </td>
- </tr>
- """.trimIndent()
- }
-
- return if (files.isEmpty()) {
- val emptyMessage = if (search.isNotEmpty() || mimeTypeFilter.isNotEmpty()) {
- """
- <div class="empty-state">
- <div class="empty-state-icon">
- <i class="fas fa-search"></i>
- </div>
- <h3>no matches found</h3>
- <p>try different search terms or clear your filters</p>
- <button class="btn btn-primary" onclick="clearFilters()">
- <i class="fas fa-times"></i> clear filters
- </button>
- </div>
- """
- } else {
- """
- <div class="empty-state">
- <div class="empty-state-icon">
- <i class="fas fa-folder-open"></i>
- </div>
- <h3>no files yet</h3>
- <p>upload some files through discord to see them here</p>
- </div>
- """
- }
-
- """
- $emptyMessage
- <script>
- document.getElementById('count-value').textContent = '0';
- function clearFilters() {
- document.querySelector('input[name="search"]').value = '';
- document.querySelector('select[name="mimeType"]').value = '';
- document.querySelector('select[name="sortBy"]').value = 'created_at';
- htmx.trigger('.file-container', 'refresh');
- }
- </script>
- """.trimIndent()
- } else {
- """
- <table class="file-table">
- <thead>
- <tr>
- <th style="width: 40%;">name</th>
- <th style="width: 25%;">description</th>
- <th style="width: 10%;">size</th>
- <th style="width: 10%;">type</th>
- <th style="width: 10%;">date</th>
- <th style="width: 5%;">actions</th>
- </tr>
- </thead>
- <tbody>
- $fileRows
- </tbody>
- </table>
- <script>
- document.getElementById('count-value').textContent = '${files.size}';
- function clearFilters() {
- document.querySelector('input[name="search"]').value = '';
- document.querySelector('select[name="mimeType"]').value = '';
- document.querySelector('select[name="sortBy"]').value = 'created_at';
- htmx.ajax('GET', '/api/files?dir=' + currentDirectoryId, {
- target: '#file-content',
- indicator: '#loading-spinner'
- });
- }
- function deleteFile(fileId, fileName) {
- if (confirm('Are you sure you want to delete "' + fileName + '"?')) {
- fetch('/api/files/' + fileId, {
- method: 'DELETE'
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- // Refresh file list
- htmx.ajax('GET', '/api/files?dir=' + currentDirectoryId, {
- target: '#file-content',
- indicator: '#loading-spinner'
- });
- } else {
- alert('Failed to delete file: ' + data.message);
- }
- })
- .catch(error => {
- alert('Error deleting file: ' + error.message);
- });
- }
- }
- </script>
- """.trimIndent()
- }
+ return HtmlTemplates.generateFileTable(files, search, mimeTypeFilter)
}
diff --git a/src/main/java/com/pinapelz/frontend/HtmlTemplates.kt b/src/main/java/com/pinapelz/frontend/HtmlTemplates.kt
new file mode 100644
index 0000000..21c8033
--- /dev/null
+++ b/src/main/java/com/pinapelz/frontend/HtmlTemplates.kt
@@ -0,0 +1,111 @@
+package com.pinapelz.frontend
+
+import java.io.InputStream
+
+object HtmlTemplates {
+
+ private fun loadTemplate(templatePath: String): String {
+ val inputStream: InputStream = this::class.java.classLoader.getResourceAsStream("templates/$templatePath")
+ ?: throw IllegalArgumentException("Template not found: $templatePath")
+ return inputStream.bufferedReader().use { it.readText() }
+ }
+
+ private fun String.substitute(vararg pairs: Pair<String, Any>): String {
+ var result = this
+ for ((placeholder, value) in pairs) {
+ result = result.replace("{{$placeholder}}", value.toString())
+ }
+ return result
+ }
+
+ fun generateMainPage(directoryId: Int): String {
+ val template = loadTemplate("main.html")
+ return template.substitute(
+ "directoryId" to directoryId
+ )
+ }
+
+ fun generateDirectoryList(directories: List<Map<String, Any>>): String {
+ if (directories.isEmpty()) {
+ return loadTemplate("empty-directories.html")
+ }
+
+ val directoryTemplate = loadTemplate("directory-item.html")
+ val directoryItems = directories.joinToString("") { dir ->
+ val path = dir["path"] as String
+ val displayName = if (path.isEmpty()) "root" else path
+ val fileCount = dir["fileCount"] as Int
+ val iconClass = if (path.isEmpty()) "fa-home" else "fa-folder"
+ val deleteButton = if (path.isNotEmpty()) {
+ """
+ <div class="directory-actions">
+ <button class="btn-delete btn-sm" onclick="deleteDirectory(${dir["id"]}, '$displayName')" title="Delete directory">
+ <i class="fas fa-trash"></i>
+ </button>
+ </div>
+ """.trimIndent()
+ } else ""
+
+ directoryTemplate.substitute(
+ "id" to dir["id"]!!,
+ "path" to path.replace("'", "\\'"),
+ "displayName" to displayName,
+ "iconClass" to iconClass,
+ "fileCount" to fileCount,
+ "deleteButton" to deleteButton
+ )
+ }
+
+ return directoryItems
+ }
+
+ fun generateFileTable(files: List<Map<String, Any>>, search: String = "", mimeTypeFilter: String = ""): String {
+ if (files.isEmpty()) {
+ return if (search.isNotEmpty() || mimeTypeFilter.isNotEmpty()) {
+ loadTemplate("empty-search-results.html")
+ } else {
+ loadTemplate("empty-files.html")
+ }
+ }
+
+ val fileRowTemplate = loadTemplate("file-row.html")
+ val fileRows = files.joinToString("") { file ->
+ val mimeType = file["mimeType"] as? String
+ val fileIcon = getFileIcon(mimeType)
+ val fileName = file["name"] as String
+ val fileType = mimeType?.split("/")?.get(0) ?: "file"
+ val createdDate = (file["created"] as String).split(" ")[0]
+
+ fileRowTemplate.substitute(
+ "id" to file["id"]!!,
+ "fileIcon" to fileIcon,
+ "name" to fileName,
+ "description" to (file["description"] ?: ""),
+ "size" to (file["size"] ?: ""),
+ "fileType" to fileType,
+ "createdDate" to createdDate,
+ "escapedName" to fileName.replace("'", "\\'")
+ )
+ }
+
+ val tableTemplate = loadTemplate("file-table.html")
+ return tableTemplate.substitute(
+ "fileRows" to fileRows,
+ "fileCount" to files.size
+ )
+ }
+
+ private fun getFileIcon(mimeType: String?): String {
+ if (mimeType == null) return "fas fa-file"
+
+ return when {
+ mimeType.startsWith("image/") -> "fas fa-file-image"
+ mimeType.startsWith("video/") -> "fas fa-file-video"
+ mimeType.startsWith("audio/") -> "fas fa-file-audio"
+ mimeType.contains("pdf") -> "fas fa-file-pdf"
+ mimeType.startsWith("text/") -> "fas fa-file-alt"
+ mimeType.contains("zip") || mimeType.contains("tar") || mimeType.contains("rar") -> "fas fa-file-archive"
+ else -> "fas fa-file"
+ }
+ }
+} \ No newline at end of file
diff --git a/src/main/resources/templates/directory-item.html b/src/main/resources/templates/directory-item.html
new file mode 100644
index 0000000..391bcca
--- /dev/null
+++ b/src/main/resources/templates/directory-item.html
@@ -0,0 +1,17 @@
+<div class="directory-item">
+ <div class="directory-content" onclick="switchDirectory({{id}}, '{{path}}')" title="Switch to {{displayName}} directory">
+ <div class="directory-icon">
+ <i class="fas {{iconClass}}"></i>
+ </div>
+ <div class="directory-info">
+ <div class="directory-name">{{displayName}}</div>
+ <div class="directory-meta">
+ <span class="file-count">{{fileCount}} files</span>
+ </div>
+ </div>
+ <div class="directory-arrow">
+ <i class="fas fa-chevron-right"></i>
+ </div>
+ </div>
+ {{deleteButton}}
+</div> \ No newline at end of file
diff --git a/src/main/resources/templates/empty-directories.html b/src/main/resources/templates/empty-directories.html
new file mode 100644
index 0000000..665dbd8
--- /dev/null
+++ b/src/main/resources/templates/empty-directories.html
@@ -0,0 +1,6 @@
+<div class="empty-state" style="padding: 20px;">
+ <div class="empty-state-icon">
+ <i class="fas fa-folder-open" style="opacity: 0.3;"></i>
+ </div>
+ <p style="font-size: 12px; color: #72767d; margin: 8px 0 0 0;">no directories found</p>
+</div> \ No newline at end of file
diff --git a/src/main/resources/templates/empty-files.html b/src/main/resources/templates/empty-files.html
new file mode 100644
index 0000000..d5ca35a
--- /dev/null
+++ b/src/main/resources/templates/empty-files.html
@@ -0,0 +1,16 @@
+<div class="empty-state">
+ <div class="empty-state-icon">
+ <i class="fas fa-folder-open"></i>
+ </div>
+ <h3>no files yet</h3>
+ <p>upload some files through discord to see them here</p>
+</div>
+<script>
+ document.getElementById('count-value').textContent = '0';
+ function clearFilters() {
+ document.querySelector('input[name="search"]').value = '';
+ document.querySelector('select[name="mimeType"]').value = '';
+ document.querySelector('select[name="sortBy"]').value = 'created_at';
+ htmx.trigger('.file-container', 'refresh');
+ }
+</script> \ No newline at end of file
diff --git a/src/main/resources/templates/empty-search-results.html b/src/main/resources/templates/empty-search-results.html
new file mode 100644
index 0000000..a26aa37
--- /dev/null
+++ b/src/main/resources/templates/empty-search-results.html
@@ -0,0 +1,22 @@
+<div class="empty-state">
+ <div class="empty-state-icon">
+ <i class="fas fa-search"></i>
+ </div>
+ <h3>no matches found</h3>
+ <p>try different search terms or clear your filters</p>
+ <button class="btn btn-primary" onclick="clearFilters()">
+ <i class="fas fa-times"></i> clear filters
+ </button>
+</div>
+<script>
+ document.getElementById('count-value').textContent = '0';
+ function clearFilters() {
+ document.querySelector('input[name="search"]').value = '';
+ document.querySelector('select[name="mimeType"]').value = '';
+ document.querySelector('select[name="sortBy"]').value = 'created_at';
+ htmx.ajax('GET', '/api/files?dir=' + currentDirectoryId, {
+ target: '#file-content',
+ indicator: '#loading-spinner'
+ });
+ }
+</script> \ No newline at end of file
diff --git a/src/main/resources/templates/file-row.html b/src/main/resources/templates/file-row.html
new file mode 100644
index 0000000..b6219eb
--- /dev/null
+++ b/src/main/resources/templates/file-row.html
@@ -0,0 +1,17 @@
+<tr>
+ <td>
+ <a href="/fetch?fileId={{id}}" target="_blank" class="file-link">
+ <i class="{{fileIcon}} file-icon"></i>
+ {{name}}
+ </a>
+ </td>
+ <td class="file-description">{{description}}</td>
+ <td class="file-size">{{size}}</td>
+ <td><span class="file-type">{{fileType}}</span></td>
+ <td class="file-date">{{createdDate}}</td>
+ <td class="file-actions">
+ <button class="btn-delete" onclick="deleteFile({{id}}, '{{escapedName}}')" title="Delete file">
+ <i class="fas fa-trash"></i>
+ </button>
+ </td>
+</tr> \ No newline at end of file
diff --git a/src/main/resources/templates/file-table.html b/src/main/resources/templates/file-table.html
new file mode 100644
index 0000000..02b9553
--- /dev/null
+++ b/src/main/resources/templates/file-table.html
@@ -0,0 +1,27 @@
+<table class="file-table">
+ <thead>
+ <tr>
+ <th style="width: 40%;">name</th>
+ <th style="width: 25%;">description</th>
+ <th style="width: 10%;">size</th>
+ <th style="width: 10%;">type</th>
+ <th style="width: 10%;">date</th>
+ <th style="width: 5%;">actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{fileRows}}
+ </tbody>
+</table>
+<script>
+ document.getElementById('count-value').textContent = '{{fileCount}}';
+ function clearFilters() {
+ document.querySelector('input[name="search"]').value = '';
+ document.querySelector('select[name="mimeType"]').value = '';
+ document.querySelector('select[name="sortBy"]').value = 'created_at';
+ htmx.ajax('GET', '/api/files?dir=' + currentDirectoryId, {
+ target: '#file-content',
+ indicator: '#loading-spinner'
+ });
+ }
+</script> \ No newline at end of file
diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html
deleted file mode 100644
index 98bc45e..0000000
--- a/src/main/resources/templates/index.html
+++ /dev/null
@@ -1,573 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>File Storage</title>
- <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
- <script src="https://unpkg.com/htmx.org@1.9.10"></script>
- <style>
- /* === RESET & BASE === */
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
-
- body {
- background-color: #36393f;
- color: #dcddde;
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
- font-size: 14px;
- line-height: 1.4;
- }
-
- /* === LAYOUT === */
- .app-container {
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- }
-
- /* === HEADER === */
- .header {
- background-color: #2f3136;
- padding: 12px 20px;
- border-bottom: 1px solid #202225;
- display: flex;
- align-items: center;
- justify-content: space-between;
- position: sticky;
- top: 0;
- z-index: 100;
- }
-
- .header-left {
- display: flex;
- align-items: center;
- gap: 12px;
- }
-
- .header-title {
- font-size: 16px;
- font-weight: 600;
- color: #ffffff;
- display: flex;
- align-items: center;
- gap: 8px;
- }
-
- .header-subtitle {
- font-size: 12px;
- color: #72767d;
- }
-
- .header-actions {
- display: flex;
- gap: 8px;
- }
-
- /* === BUTTONS === */
- .btn {
- background-color: transparent;
- border: none;
- color: #b9bbbe;
- padding: 6px 12px;
- border-radius: 4px;
- font-size: 12px;
- cursor: pointer;
- transition: all 0.2s;
- display: flex;
- align-items: center;
- gap: 4px;
- }
-
- .btn:hover {
- background-color: #40444b;
- color: #ffffff;
- }
-
- .btn-primary {
- background-color: #5865f2;
- color: #ffffff;
- }
-
- .btn-primary:hover {
- background-color: #4752c4;
- }
-
- /* === MAIN CONTENT === */
- .main-content {
- flex: 1;
- padding: 20px;
- overflow-y: auto;
- }
-
- /* === SEARCH BAR === */
- .search-bar {
- display: flex;
- gap: 12px;
- margin-bottom: 20px;
- flex-wrap: wrap;
- }
-
- .search-input {
- flex: 1;
- min-width: 200px;
- background-color: #40444b;
- border: 1px solid #202225;
- border-radius: 4px;
- padding: 8px 12px;
- color: #dcddde;
- font-size: 14px;
- outline: none;
- }
-
- .search-input:focus {
- border-color: #5865f2;
- }
-
- .search-input::placeholder {
- color: #72767d;
- }
-
- .select {
- background-color: #40444b;
- border: 1px solid #202225;
- border-radius: 4px;
- padding: 8px 12px;
- color: #dcddde;
- font-size: 14px;
- cursor: pointer;
- outline: none;
- }
-
- .select:focus {
- border-color: #5865f2;
- }
-
- /* === STATS === */
- .stats {
- display: flex;
- gap: 16px;
- margin-bottom: 20px;
- align-items: center;
- }
-
- .stat-item {
- display: flex;
- align-items: center;
- gap: 6px;
- color: #72767d;
- font-size: 12px;
- }
-
- .stat-number {
- color: #ffffff;
- font-weight: 600;
- }
-
- /* === FILE CONTAINER === */
- .file-container {
- background-color: #2f3136;
- border-radius: 8px;
- border: 1px solid #202225;
- overflow: hidden;
- }
-
- /* === FILE TABLE === */
- .file-table {
- width: 100%;
- border-collapse: collapse;
- }
-
- .file-table th {
- background-color: #36393f;
- padding: 12px 16px;
- text-align: left;
- font-size: 12px;
- font-weight: 600;
- color: #72767d;
- text-transform: uppercase;
- border-bottom: 1px solid #202225;
- }
-
- .file-table td {
- padding: 12px 16px;
- border-bottom: 1px solid #202225;
- vertical-align: middle;
- }
-
- .file-table tbody tr:hover {
- background-color: #36393f;
- }
-
- .file-table tbody tr:last-child td {
- border-bottom: none;
- }
-
- /* === FILE ELEMENTS === */
- .file-link {
- color: #00aff4;
- text-decoration: none;
- display: flex;
- align-items: center;
- gap: 8px;
- font-weight: 500;
- }
-
- .file-link:hover {
- color: #ffffff;
- text-decoration: underline;
- }
-
- .file-icon {
- color: #72767d;
- width: 16px;
- text-align: center;
- }
-
- .file-description {
- color: #b9bbbe;
- max-width: 200px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .file-size {
- color: #72767d;
- font-size: 12px;
- font-family: 'Courier New', monospace;
- }
-
- .file-type {
- background-color: #5865f2;
- color: #ffffff;
- padding: 2px 6px;
- border-radius: 12px;
- font-size: 10px;
- font-weight: 600;
- text-transform: uppercase;
- }
-
- .file-date {
- color: #72767d;
- font-size: 12px;
- }
-
- /* === EMPTY STATE === */
- .empty-state {
- text-align: center;
- padding: 60px 20px;
- color: #72767d;
- }
-
- .empty-state-icon {
- font-size: 48px;
- margin-bottom: 16px;
- opacity: 0.5;
- }
-
- .empty-state h3 {
- color: #b9bbbe;
- margin-bottom: 8px;
- font-size: 18px;
- font-weight: 600;
- }
-
- .empty-state p {
- font-size: 14px;
- line-height: 1.5;
- }
-
- /* === LOADING STATES === */
- .htmx-indicator {
- display: none;
- }
-
- .htmx-request .htmx-indicator {
- display: inline;
- }
-
- .loading-spinner {
- animation: spin 1s linear infinite;
- }
-
- @keyframes spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
- }
-
- /* === RESPONSIVE === */
- @media (max-width: 768px) {
- .search-bar {
- flex-direction: column;
- }
-
- .search-input {
- min-width: 100%;
- }
-
- .file-table th:nth-child(3),
- .file-table td:nth-child(3),
- .file-table th:nth-child(4),
- .file-table td:nth-child(4),
- .file-table th:nth-child(5),
- .file-table td:nth-child(5) {
- display: none;
- }
-
- .header {
- padding: 8px 16px;
- }
-
- .main-content {
- padding: 16px;
- }
- }
- </style>
-</head>
-<body hx-boost="true">
- <div class="app-container">
- <!-- Header -->
- <header class="header">
- <div class="header-left">
- <div class="header-title">
- <i class="fab fa-discord"></i>
- file storage
- </div>
- <div class="header-subtitle"># root</div>
- </div>
- <div class="header-actions">
- <button class="btn"
- hx-get="http://localhost:7070/api/files"
- hx-target="#file-content"
- hx-indicator="#loading-spinner">
- <i class="fas fa-sync-alt"></i>
- </button>
- </div>
- </header>
-
- <!-- Main Content -->
- <main class="main-content">
- <!-- Search and Filters -->
- <div class="search-bar">
- <input type="text"
- class="search-input"
- placeholder="search files..."
- hx-get="http://localhost:7070/api/files"
- hx-trigger="keyup changed delay:300ms"
- hx-target="#file-content"
- hx-indicator="#loading-spinner"
- name="search">
-
- <select class="select"
- hx-get="http://localhost:7070/api/files"
- hx-trigger="change"
- hx-target="#file-content"
- hx-indicator="#loading-spinner"
- name="mimeType">
- <option value="">all types</option>
- <option value="image/">images</option>
- <option value="video/">videos</option>
- <option value="audio/">audio</option>
- <option value="application/pdf">pdfs</option>
- <option value="text/">text</option>
- <option value="application/zip">archives</option>
- </select>
-
- <select class="select"
- hx-get="http://localhost:7070/api/files"
- hx-trigger="change"
- hx-target="#file-content"
- hx-indicator="#loading-spinner"
- name="sortBy">
- <option value="created_at">newest</option>
- <option value="file_name">name</option>
- <option value="size">size</option>
- </select>
- </div>
-
- <!-- Stats -->
- <div class="stats">
- <div class="stat-item">
- <span id="file-count" class="stat-number">
- <span class="htmx-indicator" id="loading-spinner">
- <i class="fas fa-spinner loading-spinner"></i>
- </span>
- <span id="count-value">loading</span>
- </span>
- files
- </div>
- <div class="stat-item">
- <i class="fas fa-server"></i>
- localhost:7070
- </div>
- <div class="stat-item" id="last-updated">
- <i class="fas fa-clock"></i>
- <span id="timestamp">connecting...</span>
- </div>
- </div>
-
- <!-- File Container -->
- <div class="file-container"
- hx-get="http://localhost:7070/api/files"
- hx-trigger="load"
- hx-target="#file-content"
- hx-indicator="#loading-spinner">
- <div id="file-content">
- <div class="empty-state">
- <div class="empty-state-icon">
- <i class="fas fa-spinner loading-spinner"></i>
- </div>
- <h3>loading files...</h3>
- <p>fetching your files from discord storage</p>
- </div>
- </div>
- </div>
- </main>
- </div>
-
- <!-- Scripts -->
- <script>
- // Update timestamp
- function updateTimestamp() {
- const now = new Date();
- const timeString = now.toLocaleTimeString();
- document.getElementById('timestamp').textContent = `updated ${timeString}`;
- }
-
- // HTMX event listeners
- document.addEventListener('htmx:afterSwap', function(evt) {
- if (evt.detail.target.id === 'file-content') {
- updateTimestamp();
-
- // Update file count
- const tables = evt.detail.target.querySelectorAll('table');
- if (tables.length > 0) {
- const rows = tables[0].querySelectorAll('tbody tr');
- document.getElementById('count-value').textContent = rows.length;
- } else {
- document.getElementById('count-value').textContent = '0';
- }
- }
- });
-
- // Error handling
- document.addEventListener('htmx:responseError', function(evt) {
- document.getElementById('file-content').innerHTML = `
- <div class="empty-state">
- <div class="empty-state-icon">
- <i class="fas fa-exclamation-triangle"></i>
- </div>
- <h3>connection failed</h3>
- <p>unable to connect to the backend server</p>
- <button class="btn btn-primary"
- hx-get="http://localhost:7070/api/files"
- hx-target="#file-content">
- <i class="fas fa-retry"></i> try again
- </button>
- </div>
- `;
- document.getElementById('count-value').textContent = 'error';
- document.getElementById('timestamp').textContent = 'connection failed';
- });
-
- // Auto-refresh every 30 seconds
- setInterval(function() {
- if (document.visibilityState === 'visible') {
- htmx.trigger('.file-container', 'refresh');
- }
- }, 30000);
-
- // Keyboard shortcuts
- document.addEventListener('keydown', function(e) {
- // Ctrl+R or F5 to refresh
- if ((e.ctrlKey && e.key === 'r') || e.key === 'F5') {
- e.preventDefault();
- htmx.trigger('.file-container', 'refresh');
- }
- });
-
- // Global clear filters function
- window.clearFilters = function() {
- document.querySelector('input[name="search"]').value = '';
- document.querySelector('select[name="mimeType"]').value = '';
- document.querySelector('select[name="sortBy"]').value = 'created_at';
- htmx.trigger('.file-container', 'refresh');
- };
-
- // Directory creation function
- function createDirectory(event) {
- event.preventDefault();
- const input = document.getElementById('new-directory-name');
- const message = document.getElementById('create-directory-message');
- const path = input.value.trim();
-
- // Validation
- if (!path) {
- showMessage(message, 'Please enter a directory name', 'error');
- return;
- }
-
- if (path.length < 1 || path.length > 100) {
- showMessage(message, 'Directory name must be 1-100 characters', 'error');
- return;
- }
-
- // Check for invalid characters
- const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
- if (invalidChars.test(path)) {
- showMessage(message, 'Directory name contains invalid characters', 'error');
- return;
- }
-
- if (path === '.' || path === '..') {
- showMessage(message, 'Invalid directory name', 'error');
- return;
- }
-
- // Show loading
- input.disabled = true;
- showMessage(message, 'Creating directory...', 'loading');
-
- fetch('http://localhost:7070/api/directories', {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body: 'path=' + encodeURIComponent(path)
- })
- .then(response => response.json())
- .then(data => {
- input.disabled = false;
- if (data.success) {
- showMessage(message, 'Directory created!', 'success');
- input.value = '';
- // Refresh directory list
- htmx.trigger('[hx-get*="/api/directories-html"]', 'click');
- } else {
- showMessage(message, data.message || 'Failed to create directory', 'error');
- }
- })
- .catch(error => {
- input.disabled = false;
- showMessage(message, 'Network error: ' + error.message, 'error');
- });
- }
-
- function showMessage(element, text, type) {
- element.textContent = text;
- element.className = 'form-message ' + type;
- if (type === 'success') {
- setTimeout(() => {
- element.textContent = '';
- element.className = 'form-message';
- }, 3000);
- }
- }
-
- // Initialize
- updateTimestamp();
- </script>
-</body>
-</html> \ No newline at end of file
diff --git a/src/main/resources/templates/main.html b/src/main/resources/templates/main.html
new file mode 100644
index 0000000..85e2007
--- /dev/null
+++ b/src/main/resources/templates/main.html
@@ -0,0 +1,840 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>nitro-fs</title>
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
+ <script src="https://unpkg.com/htmx.org@1.9.10"></script>
+ <style>
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+
+ body {
+ background-color: #36393f;
+ color: #dcddde;
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ font-size: 14px;
+ line-height: 1.4;
+ }
+
+ .app-container {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .header {
+ background-color: #2f3136;
+ padding: 12px 20px;
+ border-bottom: 1px solid #202225;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ }
+
+ .header-left {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .header-title {
+ font-size: 16px;
+ font-weight: 600;
+ color: #ffffff;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .header-subtitle {
+ font-size: 12px;
+ color: #72767d;
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 8px;
+ }
+
+ .btn {
+ background-color: transparent;
+ border: none;
+ color: #b9bbbe;
+ padding: 6px 12px;
+ border-radius: 4px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .btn:hover {
+ background-color: #40444b;
+ color: #ffffff;
+ }
+
+ .btn-primary {
+ background-color: #5865f2;
+ color: #ffffff;
+ }
+
+ .btn-primary:hover {
+ background-color: #4752c4;
+ }
+
+ .main-content {
+ flex: 1;
+ padding: 20px;
+ overflow-y: auto;
+ }
+
+ .search-bar {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+ }
+
+ .search-input {
+ flex: 1;
+ min-width: 200px;
+ background-color: #40444b;
+ border: 1px solid #202225;
+ border-radius: 4px;
+ padding: 8px 12px;
+ color: #dcddde;
+ font-size: 14px;
+ outline: none;
+ }
+
+ .search-input:focus {
+ border-color: #5865f2;
+ }
+
+ .search-input::placeholder {
+ color: #72767d;
+ }
+
+ .select {
+ background-color: #40444b;
+ border: 1px solid #202225;
+ border-radius: 4px;
+ padding: 8px 12px;
+ color: #dcddde;
+ font-size: 14px;
+ cursor: pointer;
+ outline: none;
+ }
+
+ .select:focus {
+ border-color: #5865f2;
+ }
+
+ .stats {
+ display: flex;
+ gap: 16px;
+ margin-bottom: 20px;
+ align-items: center;
+ }
+
+ .stat-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: #72767d;
+ font-size: 12px;
+ }
+
+ .stat-number {
+ color: #ffffff;
+ font-weight: 600;
+ }
+
+ .file-container {
+ background-color: #2f3136;
+ border-radius: 8px;
+ border: 1px solid #202225;
+ overflow: hidden;
+ }
+
+ .file-table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ .file-table th {
+ background-color: #36393f;
+ padding: 12px 16px;
+ text-align: left;
+ font-size: 12px;
+ font-weight: 600;
+ color: #72767d;
+ text-transform: uppercase;
+ border-bottom: 1px solid #202225;
+ }
+
+ .file-table td {
+ padding: 12px 16px;
+ border-bottom: 1px solid #202225;
+ vertical-align: middle;
+ }
+
+ .file-table tbody tr:hover {
+ background-color: #36393f;
+ }
+
+ .file-table tbody tr:last-child td {
+ border-bottom: none;
+ }
+
+ .file-link {
+ color: #00aff4;
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 500;
+ }
+
+ .file-link:hover {
+ color: #ffffff;
+ text-decoration: underline;
+ }
+
+ .file-icon {
+ color: #72767d;
+ width: 16px;
+ text-align: center;
+ }
+
+ .file-description {
+ color: #b9bbbe;
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .file-size {
+ color: #72767d;
+ font-size: 12px;
+ font-family: 'Courier New', monospace;
+ }
+
+ .file-type {
+ background-color: #5865f2;
+ color: #ffffff;
+ padding: 2px 6px;
+ border-radius: 12px;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ }
+
+ .file-date {
+ color: #72767d;
+ font-size: 12px;
+ }
+
+ .empty-state {
+ text-align: center;
+ padding: 60px 20px;
+ color: #72767d;
+ }
+
+ .empty-state-icon {
+ font-size: 48px;
+ margin-bottom: 16px;
+ opacity: 0.5;
+ }
+
+ .empty-state h3 {
+ color: #b9bbbe;
+ margin-bottom: 8px;
+ font-size: 18px;
+ font-weight: 600;
+ }
+
+ .empty-state p {
+ font-size: 14px;
+ line-height: 1.5;
+ }
+
+ .htmx-indicator {
+ display: none;
+ }
+
+ .htmx-request .htmx-indicator {
+ display: inline;
+ }
+
+ .loading-spinner {
+ animation: spin 1s linear infinite;
+ }
+
+ @keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+ }
+
+ .filter-badge {
+ background-color: #5865f2;
+ color: #ffffff;
+ padding: 2px 6px;
+ border-radius: 12px;
+ font-size: 10px;
+ margin-left: 8px;
+ }
+
+ .directory-panel {
+ position: fixed;
+ top: 53px;
+ right: 20px;
+ width: 300px;
+ background-color: #2f3136;
+ border: 1px solid #202225;
+ border-radius: 8px;
+ z-index: 200;
+ max-height: 400px;
+ overflow-y: auto;
+ }
+
+ .panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ border-bottom: 1px solid #202225;
+ }
+
+ .panel-header h3 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: #ffffff;
+ }
+
+ .directory-item {
+ padding: 12px 16px;
+ cursor: pointer;
+ border-bottom: 1px solid #202225;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: background-color 0.2s ease;
+ }
+
+ .directory-item:hover {
+ background-color: #36393f;
+ }
+
+ .directory-item:last-child {
+ border-bottom: none;
+ }
+
+ .directory-content {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+ cursor: pointer;
+ }
+
+ .directory-icon {
+ color: #72767d;
+ font-size: 16px;
+ width: 20px;
+ text-align: center;
+ }
+
+ .directory-info {
+ flex: 1;
+ }
+
+ .directory-name {
+ color: #dcddde;
+ font-size: 14px;
+ font-weight: 500;
+ margin-bottom: 2px;
+ }
+
+ .directory-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .file-count {
+ color: #72767d;
+ font-size: 11px;
+ }
+
+ .directory-arrow {
+ color: #72767d;
+ font-size: 10px;
+ transition: transform 0.2s ease;
+ }
+
+ .directory-item:hover .directory-arrow {
+ transform: translateX(2px);
+ color: #b9bbbe;
+ }
+
+ .directory-item:hover .directory-icon {
+ color: #b9bbbe;
+ }
+
+ .directory-item:hover .directory-name {
+ color: #ffffff;
+ }
+
+ .create-directory-form {
+ padding: 12px 16px;
+ border-bottom: 1px solid #202225;
+ }
+
+ .form-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ }
+
+ .form-input {
+ flex: 1;
+ background-color: #40444b;
+ border: 1px solid #202225;
+ border-radius: 4px;
+ padding: 6px 8px;
+ color: #dcddde;
+ font-size: 12px;
+ outline: none;
+ }
+
+ .form-input:focus {
+ border-color: #5865f2;
+ }
+
+ .form-input::placeholder {
+ color: #72767d;
+ }
+
+ .form-input:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ .btn-sm {
+ padding: 6px 8px;
+ font-size: 12px;
+ }
+
+ .form-message {
+ font-size: 11px;
+ margin-top: 8px;
+ padding: 4px 0;
+ }
+
+ .form-message.success {
+ color: #43b581;
+ }
+
+ .form-message.error {
+ color: #f04747;
+ }
+
+ .form-message.loading {
+ color: #72767d;
+ }
+
+ .file-actions {
+ text-align: center;
+ }
+
+ .btn-delete {
+ background: none;
+ border: none;
+ color: #f04747;
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ transition: all 0.2s;
+ }
+
+ .btn-delete:hover {
+ background-color: #f04747;
+ color: #ffffff;
+ }
+
+ .directory-actions {
+ opacity: 0;
+ transition: opacity 0.2s;
+ }
+
+ .directory-item:hover .directory-actions {
+ opacity: 1;
+ }
+
+ @media (max-width: 768px) {
+ .search-bar {
+ flex-direction: column;
+ }
+
+ .search-input {
+ min-width: 100%;
+ }
+
+ .file-table th:nth-child(3),
+ .file-table td:nth-child(3),
+ .file-table th:nth-child(4),
+ .file-table td:nth-child(4),
+ .file-table th:nth-child(5),
+ .file-table td:nth-child(5),
+ .file-table th:nth-child(6),
+ .file-table td:nth-child(6) {
+ display: none;
+ }
+ }
+ </style>
+</head>
+<body hx-boost="true">
+ <div class="app-container">
+ <header class="header">
+ <div class="header-left">
+ <div class="header-title">
+ <i class="fab fa-discord"></i>
+ nitro-fs
+ </div>
+ <div class="header-subtitle" id="current-directory"># loading...</div>
+ </div>
+ <div class="header-actions">
+ <button class="btn"
+ hx-get="/api/files?dir={{directoryId}}"
+ hx-target="#file-content"
+ hx-indicator="#loading-spinner">
+ <i class="fas fa-sync-alt"></i>
+ </button>
+ <button class="btn"
+ hx-get="/api/directories-html"
+ hx-target="#directory-list"
+ hx-trigger="click"
+ onclick="toggleDirectoryPanel()">
+ <i class="fas fa-folder"></i>
+ </button>
+ </div>
+ </header>
+
+ <main class="main-content">
+ <div class="directory-panel" id="directory-panel" style="display: none;">
+ <div class="panel-header">
+ <h3>directories</h3>
+ <button class="btn btn-sm" onclick="toggleDirectoryPanel()">
+ <i class="fas fa-times"></i>
+ </button>
+ </div>
+
+ <div class="create-directory-form">
+ <form onsubmit="createDirectory(event)">
+ <div class="form-row">
+ <input type="text"
+ id="new-directory-name"
+ placeholder="directory name..."
+ class="form-input"
+ maxlength="100"
+ pattern="[^<>:\"/\\|?*\x00-\x1f]+"
+ title="Directory name cannot contain: < > : \" / \ | ? *"
+ required>
+ <button type="submit" class="btn btn-primary btn-sm" id="create-btn">
+ <i class="fas fa-plus"></i>
+ </button>
+ </div>
+ </form>
+ <div id="create-directory-message" class="form-message"></div>
+ </div>
+
+ <div id="directory-list">
+ <div class="empty-state">
+ <i class="fas fa-spinner loading-spinner"></i>
+ </div>
+ </div>
+ </div>
+
+ <div class="search-bar">
+ <input type="text"
+ class="search-input"
+ placeholder="search files..."
+ hx-get="/api/files?dir={{directoryId}}"
+ hx-trigger="keyup changed delay:300ms"
+ hx-target="#file-content"
+ hx-indicator="#loading-spinner"
+ name="search">
+
+ <select class="select"
+ hx-get="/api/files?dir={{directoryId}}"
+ hx-trigger="change"
+ hx-target="#file-content"
+ hx-indicator="#loading-spinner"
+ name="mimeType">
+ <option value="">all types</option>
+ <option value="image/">images</option>
+ <option value="video/">videos</option>
+ <option value="audio/">audio</option>
+ <option value="application/pdf">pdfs</option>
+ <option value="text/">text</option>
+ <option value="application/zip">archives</option>
+ </select>
+
+ <select class="select"
+ hx-get="/api/files?dir={{directoryId}}"
+ hx-trigger="change"
+ hx-target="#file-content"
+ hx-indicator="#loading-spinner"
+ name="sortBy">
+ <option value="created_at">newest</option>
+ <option value="file_name">name</option>
+ <option value="size">size</option>
+ </select>
+ </div>
+
+ <div class="stats">
+ <div class="stat-item">
+ <span id="file-count" class="stat-number">
+ <span class="htmx-indicator" id="loading-spinner">
+ <i class="fas fa-spinner loading-spinner"></i>
+ </span>
+ <span id="count-value">loading</span>
+ </span>
+ files
+ </div>
+ </div>
+
+ <div class="file-container"
+ hx-get="/api/files?dir={{directoryId}}"
+ hx-trigger="load"
+ hx-target="#file-content"
+ hx-indicator="#loading-spinner">
+ <div id="file-content">
+ <div class="empty-state">
+ <div class="empty-state-icon">
+ <i class="fas fa-spinner loading-spinner"></i>
+ </div>
+ <h3>loading files...</h3>
+ <p>fetching your files from discord storage</p>
+ </div>
+ </div>
+ </div>
+ </main>
+ </div>
+
+ <script>
+ let currentDirectoryId = {{directoryId}};
+
+ function toggleDirectoryPanel() {
+ const panel = document.getElementById('directory-panel');
+ panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
+ }
+
+ function switchDirectory(dirId, dirPath) {
+ console.log('Switching to directory:', dirId, dirPath);
+ currentDirectoryId = dirId;
+ const displayPath = dirPath === '' ? 'root' : dirPath;
+ document.getElementById('current-directory').textContent = '# ' + displayPath;
+
+ // Update all HTMX endpoints to use new directory
+ const elementsWithFiles = document.querySelectorAll('[hx-get*="/api/files"]');
+ console.log('Found', elementsWithFiles.length, 'elements to update');
+ elementsWithFiles.forEach(el => {
+ const currentUrl = el.getAttribute('hx-get');
+ const baseUrl = currentUrl.split('?')[0];
+ const params = new URLSearchParams(currentUrl.split('?')[1] || '');
+ params.set('dir', dirId);
+ const newUrl = baseUrl + '?' + params.toString();
+ el.setAttribute('hx-get', newUrl);
+ console.log('Updated element URL to:', newUrl);
+ });
+
+ // Clear any existing search/filters
+ const searchInput = document.querySelector('input[name="search"]');
+ const mimeSelect = document.querySelector('select[name="mimeType"]');
+ const sortSelect = document.querySelector('select[name="sortBy"]');
+
+ if (searchInput) searchInput.value = '';
+ if (mimeSelect) mimeSelect.value = '';
+ if (sortSelect) sortSelect.value = 'created_at';
+
+ // Refresh file list with new directory
+ console.log('Making HTMX request to: /api/files?dir=' + dirId);
+ htmx.ajax('GET', '/api/files?dir=' + dirId, {
+ target: '#file-content',
+ indicator: '#loading-spinner'
+ });
+
+ toggleDirectoryPanel();
+ }
+
+ // Clear filters function
+ function clearFilters() {
+ document.querySelector('input[name="search"]').value = '';
+ document.querySelector('select[name="mimeType"]').value = '';
+ document.querySelector('select[name="sortBy"]').value = 'created_at';
+ // Refresh file list with current directory
+ htmx.ajax('GET', '/api/files?dir=' + currentDirectoryId, {
+ target: '#file-content',
+ indicator: '#loading-spinner'
+ });
+ }
+
+ function createDirectory(event) {
+ event.preventDefault();
+ const input = document.getElementById('new-directory-name');
+ const message = document.getElementById('create-directory-message');
+ const path = input.value.trim();
+
+ // Validation
+ if (!path) {
+ showMessage(message, 'Please enter a directory name', 'error');
+ return;
+ }
+
+ if (path.length < 1 || path.length > 100) {
+ showMessage(message, 'Directory name must be 1-100 characters', 'error');
+ return;
+ }
+
+ // Check for invalid characters
+ const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
+ if (invalidChars.test(path)) {
+ showMessage(message, 'Directory name contains invalid characters', 'error');
+ return;
+ }
+
+ // Check for reserved names
+ const reserved = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
+ if (reserved.includes(path.toUpperCase())) {
+ showMessage(message, 'Directory name is reserved', 'error');
+ return;
+ }
+
+ if (path === '.' || path === '..') {
+ showMessage(message, 'Invalid directory name', 'error');
+ return;
+ }
+
+ // Show loading
+ input.disabled = true;
+ showMessage(message, 'Creating directory...', 'loading');
+
+ fetch('/api/directories', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: 'path=' + encodeURIComponent(path)
+ })
+ .then(response => response.json())
+ .then(data => {
+ input.disabled = false;
+ if (data.success) {
+ showMessage(message, 'Directory created!', 'success');
+ input.value = '';
+ // Refresh directory list
+ htmx.ajax('GET', '/api/directories-html', {
+ target: '#directory-list'
+ });
+ } else {
+ showMessage(message, data.message || 'Failed to create directory', 'error');
+ }
+ })
+ .catch(error => {
+ input.disabled = false;
+ showMessage(message, 'Network error: ' + error.message, 'error');
+ });
+ }
+
+ function showMessage(element, text, type) {
+ element.textContent = text;
+ element.className = 'form-message ' + type;
+ if (type === 'success') {
+ setTimeout(() => {
+ element.textContent = '';
+ element.className = 'form-message';
+ }, 3000);
+ }
+ }
+
+ // Update directory name on load
+ console.log('Loading current directory info for ID:', currentDirectoryId);
+ fetch('/api/directory/' + currentDirectoryId)
+ .then(r => {
+ console.log('Directory API response status:', r.status);
+ return r.json();
+ })
+ .then(dir => {
+ console.log('Directory info loaded:', dir);
+ const displayPath = dir.path === '' ? 'root' : dir.path;
+ document.getElementById('current-directory').textContent = '# ' + displayPath;
+ })
+ .catch(error => {
+ console.error('Failed to load directory info:', error);
+ document.getElementById('current-directory').textContent = '# root';
+ });
+
+ function deleteDirectory(dirId, dirName) {
+ if (confirm('Are you sure you want to delete directory "' + dirName + '"?\\n\\nNote: Directory must be empty to delete.')) {
+ fetch('/api/directories/' + dirId, {
+ method: 'DELETE'
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ // Refresh directory list
+ htmx.ajax('GET', '/api/directories-html', {
+ target: '#directory-list'
+ });
+ // If we're currently in the deleted directory, switch to root
+ if (currentDirectoryId == dirId) {
+ switchDirectory(1, '');
+ }
+ } else {
+ alert('Failed to delete directory: ' + data.message);
+ }
+ })
+ .catch(error => {
+ alert('Error deleting directory: ' + error.message);
+ });
+ }
+ }
+
+ function deleteFile(fileId, fileName) {
+ if (confirm('Are you sure you want to delete "' + fileName + '"?')) {
+ fetch('/api/files/' + fileId, {
+ method: 'DELETE'
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ // Refresh file list
+ htmx.ajax('GET', '/api/files?dir=' + currentDirectoryId, {
+ target: '#file-content',
+ indicator: '#loading-spinner'
+ });
+ } else {
+ alert('Failed to delete file: ' + data.message);
+ }
+ })
+ .catch(error => {
+ alert('Error deleting file: ' + error.message);
+ });
+ }
+ }
+ </script>
+</body>
+</html> \ No newline at end of file
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage