diff --git a/app/src/assets/dev/index.html b/app/src/assets/dev/index.html index 454e480..9611e53 100644 --- a/app/src/assets/dev/index.html +++ b/app/src/assets/dev/index.html @@ -39,7 +39,7 @@ - + @@ -71,7 +71,6 @@ function initTheme() { const savedTheme = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', savedTheme); - updateThemeButton(); } function toggleTheme() { @@ -79,13 +78,6 @@ const newTheme = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', newTheme); localStorage.setItem('theme', newTheme); - updateThemeButton(); - } - - function updateThemeButton() { - const theme = document.documentElement.getAttribute('data-theme'); - const btn = document.querySelector('.theme-toggle'); - btn.textContent = theme === 'dark' ? '☀️' : '🌙'; } function getIcon(type, name) { diff --git a/app/src/assets/style/style.css b/app/src/assets/style/style.css index 7449460..7f06512 100644 --- a/app/src/assets/style/style.css +++ b/app/src/assets/style/style.css @@ -4,7 +4,6 @@ box-sizing: border-box; } -/* Light Mode (Cloudflare Dashboard Style) */ :root[data-theme="light"] { --bg-primary: #ffffff; --bg-secondary: #f5f7fb; @@ -15,9 +14,9 @@ --accent: #0051ba; --accent-hover: #003e8f; --file-hover: #f0f2f5; + --icon: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyNHB4IiBmaWxsPSJpbmhlcml0Ij48cGF0aCBkPSJNNjAwLTY0MCA0ODAtNzYwbDEyMC0xMjAgMTIwIDEyMC0xMjAgMTIwWm0yMDAgMTIwLTgwLTgwIDgwLTgwIDgwIDgwLTgwIDgwWk00ODMtODBxLTg0IDAtMTU3LjUtMzJ0LTEyOC04Ni41UTE0My0yNTMgMTExLTMyNi41VDc5LTQ4NHEwLTE0NiA5My0yNTcuNVQ0MDktODgwcS0xOCA5OSAxMSAxOTMuNVQ1MjAtNTIxcTcxIDcxIDE2NS41IDEwMFQ4NzktNDEwcS0yNiAxNDQtMTM4IDIzN1Q0ODMtODBabTAtODBxODggMCAxNjMtNDR0MTE4LTEyMXEtODYtOC0xNjMtNDMuNVQ0NjMtNDY1cS02MS02MS05Ny0xMzh0LTQzLTE2M3EtNzcgNDMtMTIwLjUgMTE4LjVUMTU5LTQ4NHEwIDEzNSA5NC41IDIyOS41VDQ4My0xNjBabS0yMC0zMDVaIi8+PC9zdmc+') } -/* Dark Mode (GitHub Style) */ :root[data-theme="dark"] { --bg-primary: #0d1117; --bg-secondary: #161b22; @@ -28,6 +27,7 @@ --accent: #58a6ff; --accent-hover: #79c0ff; --file-hover: #1c2128; + --icon: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyNHB4IiBmaWxsPSIjZTNlM2UzIj48cGF0aCBkPSJNNDQwLTgwMHYtMTIwaDgwdjEyMGgtODBabTAgNzYwdi0xMjBoODB2MTIwaC04MFptMzYwLTQwMHYtODBoMTIwdjgwSDgwMFptLTc2MCAwdi04MGgxMjB2ODBINDBabTcwOC0yNTItNTYtNTYgNzAtNzIgNTggNTgtNzIgNzBaTTE5OC0xNDBsLTU4LTU4IDcyLTcwIDU2IDU2LTcwIDcyWm01NjQgMC03MC03MiA1Ni01NiA3MiA3MC01OCA1OFpNMjEyLTY5MmwtNzItNzAgNTgtNTggNzAgNzItNTYgNTZabTI2OCA0NTJxLTEwMCAwLTE3MC03MHQtNzAtMTcwcTAtMTAwIDcwLTE3MHQxNzAtNzBxMTAwIDAgMTcwIDcwdDcwIDE3MHEwIDEwMC03MCAxNzB0LTE3MCA3MFptMC04MHE2NyAwIDExMy41LTQ2LjVUNjQwLTQ4MHEwLTY3LTQ2LjUtMTEzLjVUNDgwLTY0MHEtNjcgMC0xMTMuNSA0Ni41VDMyMC00ODBxMCA2NyA0Ni41IDExMy41VDQ4MC0zMjBabTAtMTYwWiIvPjwvc3ZnPg==') } /* Default to dark mode */ @@ -53,7 +53,7 @@ body { .container { max-width: 1200px; - margin: 0 auto; + margin: 2vh auto; padding: 0; min-height: 100vh; display: flex; @@ -63,7 +63,7 @@ body { .header { background-color: var(--bg-secondary); border-bottom: 1px solid var(--border-color); - padding: 1.5rem; + padding: 3rem; position: sticky; top: 0; z-index: 10; @@ -131,6 +131,12 @@ body { white-space: nowrap; } +.theme-toggle { + background: var(--text-secondary); + -webkit-mask: var(--icon) center/60% no-repeat; + mask: var(--icon) center/60% no-repeat; +} + .btn:hover { background-color: var(--accent); border-color: var(--accent); @@ -150,6 +156,7 @@ body { gap: 1rem; border-bottom: 1px solid var(--border-color); background-color: var(--bg-secondary); + transition: background-color 0.3s ease, border-color 0.3s ease; } .meta { @@ -157,6 +164,8 @@ body { gap: 2rem; font-size: 0.875rem; flex-wrap: wrap; + justify-content: space-between; + width: 100%; } #summary { @@ -224,6 +233,7 @@ body { top: 0; background-color: var(--bg-secondary); z-index: 5; + transition: background-color 0.3s ease, border-color 0.3s ease; } .file-table th { diff --git a/app/src/main.v b/app/src/main.v index 998c275..cd97804 100644 --- a/app/src/main.v +++ b/app/src/main.v @@ -27,6 +27,8 @@ pub struct App { veb.Middleware[Context] pub mut: embed util.Embedded +pub: + cfg util.Config } pub struct Auth {} @@ -35,7 +37,27 @@ pub struct Auth {} @['/:path...'] pub fn (app &App) root(mut ctx Context, path string) veb.Result { + abs_root := util.Utility.normalize_path(os.abs_path(app.cfg.root)) + abs_path := util.Utility.normalize_path(abs_root + os.abs_path(path)) + + if !abs_path.starts_with(abs_root) && abs_path != abs_root { + return ctx.forbidden() + } + + if os.is_file(abs_path) { + content := os.read_file(abs_path) or { return ctx.not_found() } + + ctx.res.header.add(.content_type, 'application/octet-stream') + ctx.res.header.add(.content_disposition, 'attachment; filename="${os.base(abs_path)}"') + return ctx.file(abs_path) + } + + entries := util.Utility.list_files(abs_path, abs_root) or { return ctx.not_found() } + + directory := util.HtmlBuilder.generate_breadcrumbs(abs_path.split(abs_root)[1]) + files, meta := util.HtmlBuilder.generate_file_list(entries, abs_path) style := app.embed.style_css + return ctx.html($tmpl('template/dashboard.html')) } @@ -63,6 +85,11 @@ fn (mut ctx Context) error_page(code int, short string, long string) string { return $tmpl('template/error.html') } +pub fn (mut ctx Context) forbidden() veb.Result { + ctx.res.set_status(.forbidden) + return ctx.html(ctx.error_page(403, 'Forbidden', "Oops! You aren't allowed around here.")) +} + pub fn (mut ctx Context) not_found() veb.Result { ctx.res.set_status(.not_found) return ctx.html(ctx.error_page(404, 'Not found', 'Oops! The page you are looking for does not exist.')) @@ -96,9 +123,9 @@ fn populate() &util.Config { } fn main() { - cfg := populate() - - mut app := &App{} + mut app := &App{ + cfg: populate() + } mut auth := &Auth{} app.embed = &util.Embedded{ style_css: $embed_file('assets/style/style.css', .zlib).to_string() @@ -117,5 +144,5 @@ fn main() { } }) - veb.run[App, Context](mut app, cfg.port) + veb.run[App, Context](mut app, app.cfg.port) } diff --git a/app/src/template/dashboard.html b/app/src/template/dashboard.html index 206aed4..8e6af6f 100644 --- a/app/src/template/dashboard.html +++ b/app/src/template/dashboard.html @@ -4,33 +4,46 @@ File Browser - + -
-
- -
- @{path} -
-
-
-
- @{meta} +
+
+ +
+ @{directory} +
+
+
+
+ @{meta} +
+
-
-
-
-
-
- @{files} +
+
+
+ @{files} +
-
+ diff --git a/app/src/util/structs.v b/app/src/util/structs.v index 968244e..5491bb7 100644 --- a/app/src/util/structs.v +++ b/app/src/util/structs.v @@ -1,6 +1,9 @@ // src/controller/structs/structs.v module util +import os +import time + pub struct Database { pub: username string @@ -20,3 +23,147 @@ pub mut: style_css string error_css string } + +pub struct FileEntry { +pub: + name string + abs_path string + path string + is_dir bool + size i64 + modified i64 +} + +pub struct Utility {} + +pub fn Utility.format_size(bytes i64) string { + if bytes == 0 { + return '-' + } + + units := ['B', 'KB', 'MB', 'GB'] + mut size := f64(bytes) + mut unit_idx := 0 + + for size >= 1024 && unit_idx < units.len - 1 { + size /= 1024 + unit_idx++ + } + + if size < 10 { + return '${size:.2f}${units[unit_idx]}' + } + + return '${size:.0f}${units[unit_idx]}' +} + +pub fn Utility.format_date(unix_time i64) string { + return time.unix(unix_time).format_ss_milli() +} + +pub fn Utility.list_files(dir_path string, relative string) ![]FileEntry { + mut entries := []FileEntry{} + + files := os.ls(dir_path)! + + for file in files { + full_path := os.join_path(dir_path, file) + is_dir := os.is_dir(full_path) + file_info := os.stat(full_path)! + + entries << FileEntry{ + name: if is_dir { '${file}/' } else { file } + abs_path: Utility.normalize_path(full_path) + path: Utility.normalize_path(full_path).split(relative)[1] // if relative != "" { Utility.normalize_path(full_path.split(relative)[1]) } else { Utility.normalize_path(full_path) } + is_dir: is_dir + size: if !is_dir { file_info.size } else { 0 } + modified: file_info.mtime + } + } + + entries.sort_with_compare(fn (a &FileEntry, b &FileEntry) int { + if a.is_dir != b.is_dir { + return if a.is_dir { -1 } else { 1 } + } + + if a.name < b.name { + return -1 + } + if a.name > b.name { + return 1 + } + return 0 + }) + + return entries +} + +pub fn Utility.normalize_path(path string) string { + return path.replace('\\', '/') +} + +pub struct HtmlBuilder {} + +pub fn HtmlBuilder.generate_breadcrumbs(path string) string { + mut html := '/' + + parts := path.trim('/').split('/') + mut accumulated := '' + + for i, part in parts { + if part == '' { + continue + } + + accumulated += '/${part}' + is_last := i == parts.len - 1 + + if is_last { + html += '${part}' + } else { + html += '${part}' + } + + if i < parts.len - 1 { + html += '/' + } else { + html += '/' + } + } + + return html +} + +pub fn HtmlBuilder.generate_file_list(entries []FileEntry, current_path string) (string, string) { + if entries.len == 0 { + return '

This folder is empty

', '0 directories0 files0B total' + } + + mut html := '' + + mut dir_count := 0 + mut file_count := 0 + mut total_size := i64(0) + + for entry in entries { + if entry.is_dir { + dir_count++ + file_class := 'directory' + html += '' + html += '' + html += '' + html += '' + } else { + file_count++ + total_size += entry.size + html += '' + html += '' + html += '' + html += '' + } + } + + html += '
NameSizeModified
icon${entry.name}-${Utility.format_date(entry.modified)}
icon${entry.name}${Utility.format_size(entry.size)}${Utility.format_date(entry.modified)}
' + + return html, '${dir_count} directories${file_count} files${Utility.format_size(total_size)} total' +}