aboutsummaryrefslogtreecommitdiffstats
path: root/src/main/java/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/com')
-rw-r--r--src/main/java/com/pinapelz/Database.java159
-rw-r--r--src/main/java/com/pinapelz/FileSystem.java49
-rw-r--r--src/main/java/com/pinapelz/MessageListener.java57
-rw-r--r--src/main/java/com/pinapelz/Retriever.java4
-rw-r--r--src/main/java/com/pinapelz/frontend/App.kt1243
5 files changed, 1500 insertions, 12 deletions
diff --git a/src/main/java/com/pinapelz/Database.java b/src/main/java/com/pinapelz/Database.java
index 7116328..b52b45e 100644
--- a/src/main/java/com/pinapelz/Database.java
+++ b/src/main/java/com/pinapelz/Database.java
@@ -88,4 +88,163 @@ public class Database {
}
}
+ public ResultSet getFilesByDirectoryId(int directoryId, String search, String mimeTypeFilter, String sortBy) {
+ StringBuilder sql = new StringBuilder("""
+ SELECT
+ file_id,
+ file_name,
+ file_description,
+ size,
+ mime_type,
+ created_at
+ FROM files
+ WHERE directory_id = ?
+ """);
+
+ if (search != null && !search.trim().isEmpty()) {
+ sql.append(" AND (LOWER(file_name) LIKE ? OR LOWER(file_description) LIKE ?)");
+ }
+
+ if (mimeTypeFilter != null && !mimeTypeFilter.trim().isEmpty()) {
+ sql.append(" AND mime_type LIKE ?");
+ }
+
+ switch (sortBy) {
+ case "file_name":
+ sql.append(" ORDER BY file_name ASC");
+ break;
+ case "size":
+ sql.append(" ORDER BY size DESC");
+ break;
+ default:
+ sql.append(" ORDER BY created_at DESC");
+ break;
+ }
+
+ try {
+ PreparedStatement ps = conn.prepareStatement(sql.toString());
+ int paramIndex = 1;
+
+ ps.setInt(paramIndex++, directoryId);
+
+ if (search != null && !search.trim().isEmpty()) {
+ String searchPattern = "%" + search.toLowerCase() + "%";
+ ps.setString(paramIndex++, searchPattern);
+ ps.setString(paramIndex++, searchPattern);
+ }
+
+ if (mimeTypeFilter != null && !mimeTypeFilter.trim().isEmpty()) {
+ ps.setString(paramIndex++, mimeTypeFilter + "%");
+ }
+
+ return ps.executeQuery();
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to fetch filtered files for directory", e);
+ }
+ }
+
+ public ResultSet getAllDirectories() {
+ String sql = """
+ SELECT
+ directory_id,
+ path,
+ created_at,
+ (SELECT COUNT(*) FROM files WHERE directory_id = directories.directory_id) as file_count
+ FROM directories
+ ORDER BY path ASC
+ """;
+
+ try {
+ PreparedStatement ps = conn.prepareStatement(sql);
+ return ps.executeQuery();
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to fetch directories", e);
+ }
+ }
+
+ public ResultSet getDirectoryById(int directoryId) {
+ String sql = """
+ SELECT
+ directory_id,
+ path,
+ created_at,
+ (SELECT COUNT(*) FROM files WHERE directory_id = directories.directory_id) as file_count
+ FROM directories
+ WHERE directory_id = ?
+ """;
+
+ try {
+ PreparedStatement ps = conn.prepareStatement(sql);
+ ps.setInt(1, directoryId);
+ return ps.executeQuery();
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to fetch directory", e);
+ }
+ }
+
+ public int createDirectory(String path) throws SQLException {
+ String sql = """
+ INSERT INTO directories (path)
+ VALUES (?)
+ ON CONFLICT (path) DO UPDATE SET path = EXCLUDED.path
+ RETURNING directory_id
+ """;
+
+ try (PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setString(1, path);
+ try (ResultSet rs = ps.executeQuery()) {
+ if (rs.next()) {
+ return rs.getInt("directory_id");
+ }
+ throw new SQLException("Failed to get directory ID");
+ }
+ }
+ }
+
+ public boolean deleteFile(int fileId) throws SQLException {
+ String sql = """
+ DELETE FROM files
+ WHERE file_id = ?
+ """;
+
+ try (PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setInt(1, fileId);
+ int rowsAffected = ps.executeUpdate();
+ return rowsAffected > 0;
+ }
+ }
+
+ public boolean deleteDirectory(int directoryId) throws SQLException {
+ // Check if directory has files
+ String checkSql = """
+ SELECT COUNT(*) as file_count
+ FROM files
+ WHERE directory_id = ?
+ """;
+
+ try (PreparedStatement checkPs = conn.prepareStatement(checkSql)) {
+ checkPs.setInt(1, directoryId);
+ try (ResultSet rs = checkPs.executeQuery()) {
+ if (rs.next() && rs.getInt("file_count") > 0) {
+ throw new SQLException("Cannot delete directory: contains files");
+ }
+ }
+ }
+
+ if (directoryId == 1) {
+ throw new SQLException("Cannot delete root directory");
+ }
+
+ String deleteSql = """
+ DELETE FROM directories
+ WHERE directory_id = ?
+ """;
+
+ try (PreparedStatement deletePs = conn.prepareStatement(deleteSql)) {
+ deletePs.setInt(1, directoryId);
+ int rowsAffected = deletePs.executeUpdate();
+ return rowsAffected > 0;
+ }
+ }
+
}
diff --git a/src/main/java/com/pinapelz/FileSystem.java b/src/main/java/com/pinapelz/FileSystem.java
index 1f252a2..d073691 100644
--- a/src/main/java/com/pinapelz/FileSystem.java
+++ b/src/main/java/com/pinapelz/FileSystem.java
@@ -2,6 +2,7 @@ package com.pinapelz;
import net.dv8tion.jda.api.entities.Message;
+import java.sql.ResultSet;
import java.sql.SQLException;
public class FileSystem {
@@ -14,14 +15,58 @@ public class FileSystem {
return database.getFileById(fileId);
}
- public void createNewFile(String channelId, String messageId, String description, Message.Attachment attachment){
+ public void createNewFile(String channelId, String messageId, int directoryId, String description, Message.Attachment attachment){
int fileSize = attachment.getSize();
String filename = attachment.getFileName();
String mimeType = attachment.getContentType();
try {
- database.recordFileMetadata(channelId, messageId, 1, filename, description, fileSize, mimeType );
+ database.recordFileMetadata(channelId, messageId, directoryId, filename, description, fileSize, mimeType );
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
+
+ // Backward compatibility - defaults to root directory (ID 1)
+ public void createNewFile(String channelId, String messageId, String description, Message.Attachment attachment){
+ createNewFile(channelId, messageId, 1, description, attachment);
+ }
+ public ResultSet getFilesByDirectoryIdFiltered(int directoryId, String search, String mimeTypeFilter, String sortBy) {
+ return database.getFilesByDirectoryId(directoryId, search, mimeTypeFilter, sortBy);
+ }
+
+ public int findOrCreateDirectory(String path) throws SQLException {
+ // Try to find existing directory
+ ResultSet rs = getAllDirectories();
+ while (rs.next()) {
+ if (path.equals(rs.getString("path"))) {
+ int id = rs.getInt("directory_id");
+ rs.close();
+ return id;
+ }
+ }
+ rs.close();
+
+ // Create new directory if not found
+ return createDirectory(path);
+ }
+
+ public ResultSet getAllDirectories() {
+ return database.getAllDirectories();
+ }
+
+ public ResultSet getDirectoryById(int directoryId) {
+ return database.getDirectoryById(directoryId);
+ }
+
+ public int createDirectory(String path) throws SQLException {
+ return database.createDirectory(path);
+ }
+
+ public boolean deleteFile(int fileId) throws SQLException {
+ return database.deleteFile(fileId);
+ }
+
+ public boolean deleteDirectory(int directoryId) throws SQLException {
+ return database.deleteDirectory(directoryId);
+ }
}
diff --git a/src/main/java/com/pinapelz/MessageListener.java b/src/main/java/com/pinapelz/MessageListener.java
index 3922c51..e4d435f 100644
--- a/src/main/java/com/pinapelz/MessageListener.java
+++ b/src/main/java/com/pinapelz/MessageListener.java
@@ -2,8 +2,10 @@ package com.pinapelz;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
+import net.dv8tion.jda.api.entities.emoji.Emoji;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
+import java.sql.SQLException;
public class MessageListener extends ListenerAdapter {
@@ -21,18 +23,65 @@ public class MessageListener extends ListenerAdapter {
Message message = event.getMessage();
String content = message.getContentRaw();
- System.out.println(message.getAttachments().get(0).getUrl());
if(!message.getAttachments().isEmpty()){
- System.out.println("Attachment Received! Filing this away now...");
+ DirectoryInfo dirInfo = parseDirectoryFromMessage(content);
+
for(Message.Attachment attachment : message.getAttachments()){
- fileSystem.createNewFile(message.getChannelId(), message.getId(), content, attachment);
+ try {
+ fileSystem.createNewFile(
+ message.getChannelId(),
+ message.getId(),
+ dirInfo.directoryId,
+ dirInfo.description,
+ attachment
+ );
+ message.addReaction(Emoji.fromUnicode("✅")).queue();
+ System.out.println("File uploaded to directory: " + dirInfo.path + " (" + attachment.getFileName() + ")");
+
+ } catch (Exception e) {
+ message.addReaction(Emoji.fromUnicode("❌")).queue();
+ System.err.println("Upload failed for " + attachment.getFileName() + ": " + e.getMessage());
+ }
}
}
if (content.equals("!ping"))
{
MessageChannel channel = event.getChannel();
- channel.sendMessage("Pong!").queue(); // Important to call .queue() on the RestAction returned by sendMessage(...)
+ channel.sendMessage("Pong!").queue();
+ }
+ }
+
+ private DirectoryInfo parseDirectoryFromMessage(String message) {
+ if (message.contains(":")) {
+ String[] parts = message.split(":", 2);
+ String dirPath = parts[0].trim();
+ String description = parts.length > 1 ? parts[1].trim() : "";
+ int directoryId = findOrCreateDirectory(dirPath);
+ return new DirectoryInfo(directoryId, description, dirPath);
+ }
+
+ return new DirectoryInfo(1, message, "root");
+ }
+
+ private int findOrCreateDirectory(String path) {
+ try {
+ return fileSystem.findOrCreateDirectory(path);
+ } catch (SQLException e) {
+ System.err.println("Directory creation failed for '" + path + "', using root: " + e.getMessage());
+ return 1;
+ }
+ }
+
+ private static class DirectoryInfo {
+ int directoryId;
+ String description;
+ String path;
+
+ DirectoryInfo(int directoryId, String description, String path) {
+ this.directoryId = directoryId;
+ this.description = description;
+ this.path = path;
}
}
diff --git a/src/main/java/com/pinapelz/Retriever.java b/src/main/java/com/pinapelz/Retriever.java
index 1d78785..856dab1 100644
--- a/src/main/java/com/pinapelz/Retriever.java
+++ b/src/main/java/com/pinapelz/Retriever.java
@@ -4,8 +4,6 @@ import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
-import java.util.concurrent.CompletableFuture;
-
public class Retriever {
private final JDA jda;
@@ -25,7 +23,7 @@ public class Retriever {
for (Message.Attachment file : message.getAttachments()) {
if (file.getFileName().equals(fileName)) {
- return file.getUrl();
+ return file.getProxyUrl();
}
}
diff --git a/src/main/java/com/pinapelz/frontend/App.kt b/src/main/java/com/pinapelz/frontend/App.kt
index ab176da..66b19a4 100644
--- a/src/main/java/com/pinapelz/frontend/App.kt
+++ b/src/main/java/com/pinapelz/frontend/App.kt
@@ -4,18 +4,1255 @@ import io.javalin.Javalin
import io.javalin.http.staticfiles.Location
import com.pinapelz.Retriever
import com.pinapelz.FileSystem
+import java.sql.ResultSet
+import java.text.SimpleDateFormat
fun startFrontend(retriever: Retriever, fileSystem: FileSystem) {
val app = Javalin.create {
it.staticFiles.add("/public", Location.CLASSPATH)
}
+
+ app.get("/") { ctx ->
+ val directoryId = ctx.queryParam("dir")?.toIntOrNull() ?: 1
+ ctx.html(generateMainHtml(directoryId))
+ }
+
+ app.get("/api/directories") { ctx ->
+ val directories = mutableListOf<Map<String, Any>>()
+ val rs = fileSystem.getAllDirectories()
+
+ while (rs.next()) {
+ directories.add(mapOf(
+ "id" to rs.getInt("directory_id"),
+ "path" to rs.getString("path"),
+ "fileCount" to rs.getInt("file_count"),
+ "created" to rs.getTimestamp("created_at").toString()
+ ))
+ }
+ rs.close()
+
+ ctx.json(directories)
+ }
+
+ app.get("/api/directory/{id}") { ctx ->
+ val directoryId = ctx.pathParam("id").toInt()
+ val rs = fileSystem.getDirectoryById(directoryId)
+
+ if (rs.next()) {
+ val directory = mapOf(
+ "id" to rs.getInt("directory_id"),
+ "path" to rs.getString("path"),
+ "fileCount" to rs.getInt("file_count"),
+ "created" to rs.getTimestamp("created_at").toString()
+ )
+ rs.close()
+ ctx.json(directory)
+ } else {
+ rs.close()
+ ctx.status(404).result("Directory not found")
+ }
+ }
+
+ app.get("/api/files") { ctx ->
+ val directoryId = ctx.queryParam("dir")?.toIntOrNull() ?: 1
+ val search = ctx.queryParam("search") ?: ""
+ val mimeTypeFilter = ctx.queryParam("mimeType") ?: ""
+ val sortBy = ctx.queryParam("sortBy") ?: "created_at"
+
+ val files = mutableListOf<Map<String, Any>>()
+ val rs: ResultSet = fileSystem.getFilesByDirectoryIdFiltered(directoryId, search, mimeTypeFilter, sortBy)
+ val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
+
+ while (rs.next()) {
+ files.add(mapOf(
+ "id" to rs.getInt("file_id"),
+ "name" to rs.getString("file_name"),
+ "description" to (rs.getString("file_description") ?: ""),
+ "size" to formatFileSize(rs.getLong("size")),
+ "mimeType" to (rs.getString("mime_type") ?: "unknown"),
+ "created" to dateFormat.format(rs.getTimestamp("created_at"))
+ ))
+ }
+ rs.close()
+
+ val html = generateFileTableHtml(files, search, mimeTypeFilter)
+ ctx.html(html)
+ ctx.header("HX-Trigger", "updateFileCount")
+ ctx.header("X-File-Count", files.size.toString())
+ }
+
+ app.get("/api/directories-html") { ctx ->
+ val directories = mutableListOf<Map<String, Any>>()
+ val rs = fileSystem.getAllDirectories()
+
+ while (rs.next()) {
+ directories.add(mapOf(
+ "id" to rs.getInt("directory_id"),
+ "path" to rs.getString("path"),
+ "fileCount" to rs.getInt("file_count"),
+ "created" to rs.getTimestamp("created_at").toString()
+ ))
+ }
+ rs.close()
+
+ val html = generateDirectoryListHtml(directories)
+ ctx.html(html)
+ }
+
+ app.post("/api/directories") { ctx ->
+ val path = ctx.formParam("path")
+ if (path.isNullOrBlank()) {
+ ctx.status(400).json(mapOf(
+ "success" to false,
+ "message" to "Directory path is required"
+ ))
+ return@post
+ }
+
+ val trimmedPath = path.trim()
+
+ // Server-side validation
+ val validationError = validateDirectoryName(trimmedPath)
+ if (validationError != null) {
+ ctx.status(400).json(mapOf(
+ "success" to false,
+ "message" to validationError
+ ))
+ return@post
+ }
+
+ try {
+ val directoryId = fileSystem.createDirectory(trimmedPath)
+ ctx.json(mapOf(
+ "success" to true,
+ "id" to directoryId,
+ "path" to trimmedPath,
+ "message" to "Directory created successfully"
+ ))
+ } catch (e: Exception) {
+ ctx.status(500).json(mapOf(
+ "success" to false,
+ "message" to "Failed to create directory: ${e.message}"
+ ))
+ }
+ }
+
+ app.delete("/api/files/{id}") { ctx ->
+ val fileId = ctx.pathParam("id").toIntOrNull()
+ if (fileId == null) {
+ ctx.status(400).json(mapOf(
+ "success" to false,
+ "message" to "Invalid file ID"
+ ))
+ return@delete
+ }
+
+ try {
+ val deleted = fileSystem.deleteFile(fileId)
+ if (deleted) {
+ ctx.json(mapOf(
+ "success" to true,
+ "message" to "File deleted successfully"
+ ))
+ } else {
+ ctx.status(404).json(mapOf(
+ "success" to false,
+ "message" to "File not found"
+ ))
+ }
+ } catch (e: Exception) {
+ ctx.status(500).json(mapOf(
+ "success" to false,
+ "message" to "Failed to delete file: ${e.message}"
+ ))
+ }
+ }
+
+ app.delete("/api/directories/{id}") { ctx ->
+ val directoryId = ctx.pathParam("id").toIntOrNull()
+ if (directoryId == null) {
+ ctx.status(400).json(mapOf(
+ "success" to false,
+ "message" to "Invalid directory ID"
+ ))
+ return@delete
+ }
+
+ try {
+ val deleted = fileSystem.deleteDirectory(directoryId)
+ if (deleted) {
+ ctx.json(mapOf(
+ "success" to true,
+ "message" to "Directory deleted successfully"
+ ))
+ } else {
+ ctx.status(404).json(mapOf(
+ "success" to false,
+ "message" to "Directory not found"
+ ))
+ }
+ } catch (e: Exception) {
+ ctx.status(500).json(mapOf(
+ "success" to false,
+ "message" to "Failed to delete directory: ${e.message}"
+ ))
+ }
+ }
+
app.get("/fetch") { ctx ->
val fileId = ctx.queryParam("fileId")
val fileMetadata = fileSystem.getFileById(Integer.parseInt(fileId));
print(fileMetadata[1])
- ctx.html(retriever.getFileUrl(fileMetadata[0], fileMetadata[1], fileMetadata[2]));
+ ctx.redirect(retriever.getFileUrl(fileMetadata[0], fileMetadata[1], fileMetadata[2]));
+ }
+ app.start(7070)
+}
+fun validateDirectoryName(path: String): String? {
+ if (path.length < 1 || path.length > 100) {
+ return "Directory name must be 1-100 characters long"
+ }
+ val invalidChars = Regex("[<>:\"/\\\\|?*\\x00-\\x1f]")
+ if (invalidChars.containsMatchIn(path)) {
+ return "Directory name contains invalid characters"
+ }
+ if (path == "." || path == "..") {
+ return "Invalid directory name"
}
- app.start(7070)
-} \ No newline at end of file
+ if (path.startsWith(" ") || path.endsWith(" ") || path.endsWith(".")) {
+ return "Directory name cannot start/end with spaces or end with dots"
+ }
+
+ return null
+}
+
+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()
+}
+
+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
+}
+
+fun formatFileSize(bytes: Long): String {
+ if (bytes < 1024) return "$bytes B"
+ val kb = bytes / 1024.0
+ if (kb < 1024) return "%.1f KB".format(kb)
+ val mb = kb / 1024.0
+ if (mb < 1024) return "%.1f MB".format(mb)
+ val gb = mb / 1024.0
+ return "%.1f GB".format(gb)
+}
+
+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()
+ }
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage