Initial commit: simple file server

Add a new simple-file-server project. Includes Express server, auth and file routes, static client assets (public), and a db/connection module that uses MariaDB to log file actions and initialize a users table. Add Dockerfile and docker-compose.yml (exposes 3000 → 8080 and mounts ./uploads), .env.example, .gitignore, package.json and package-lock.json, and an uploads scaffold. This provides a ready-to-run app with container support and basic DB integration.
This commit is contained in:
Toni
2026-02-09 10:08:56 +01:00
commit 2a263af98a
17 changed files with 5874 additions and 0 deletions

465
public/app.js Normal file
View File

@@ -0,0 +1,465 @@
// State
let currentPath = '';
let currentUser = null;
let selectedFiles = new Set();
let filesData = [];
let viewMode = localStorage.getItem('viewMode') || 'list'; // 'list' or 'grid'
// Elements
const loginScreen = document.getElementById('login-screen');
const appScreen = document.getElementById('app-screen');
const loginForm = document.getElementById('login-form');
const loginError = document.getElementById('login-error');
const fileList = document.getElementById('file-list');
const currentPathDisplay = document.getElementById('current-path-display');
const upDirBtn = document.getElementById('up-dir-btn');
const bulkDownloadBtn = document.getElementById('bulk-download-btn');
const viewToggleBtn = document.getElementById('view-toggle-btn');
const selectAllCheckbox = document.getElementById('select-all');
const dropZone = document.getElementById('drop-zone');
// Init
checkAuth();
// --- Auth ---
async function checkAuth() {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
if (data.authenticated) {
currentUser = data.user;
showApp();
} else {
showLogin();
}
} catch (e) {
showLogin();
}
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
currentUser = result.user;
showApp();
} else {
loginError.textContent = result.error;
}
} catch (e) {
loginError.textContent = 'Login failed';
}
});
document.getElementById('logout-btn').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.reload();
});
function showLogin() {
loginScreen.classList.remove('hidden');
appScreen.classList.add('hidden');
}
function showApp() {
loginScreen.classList.add('hidden');
appScreen.classList.remove('hidden');
document.getElementById('user-display').textContent = currentUser.username;
loadFiles();
updateViewIcon();
}
// --- View Mode ---
function toggleView() {
viewMode = viewMode === 'list' ? 'grid' : 'list';
localStorage.setItem('viewMode', viewMode);
updateViewIcon();
renderFiles(filesData);
}
function updateViewIcon() {
viewToggleBtn.textContent = viewMode === 'list' ? '📅' : '📄';
const filesSection = document.querySelector('.files-section');
if (viewMode === 'grid') filesSection.classList.add('grid-view');
else filesSection.classList.remove('grid-view');
}
viewToggleBtn.addEventListener('click', toggleView);
// --- Files ---
async function loadFiles(path = currentPath) {
try {
const res = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
if (res.status === 401) return window.location.reload();
const files = await res.json();
filesData = files;
currentPath = path;
updateBreadcrumbs();
renderFiles(files);
selectedFiles.clear();
updateToolbar();
} catch (e) {
console.error("Load failed", e);
}
}
function renderFiles(files) {
fileList.innerHTML = '';
// Parent Directory Item
if (currentPath) {
const li = document.createElement('li');
li.className = 'file-item';
li.dataset.name = '..';
li.dataset.type = 'folder';
const icon = '🔙';
li.innerHTML = `
<span class="col-select" style="visibility:hidden"></span>
<span class="col-icon">${icon}</span>
<span class="file-name is-folder col-name">..</span>
<span class="col-size">-</span>
<span class="col-date">-</span>
<span class="col-actions"></span>
`;
li.addEventListener('click', () => {
const parts = currentPath.split('/');
parts.pop();
loadFiles(parts.join('/'));
});
li.addEventListener('dragover', handleDragOverFolder);
li.addEventListener('drop', handleDropOnParent);
li.addEventListener('dragleave', handleDragLeaveFolder);
fileList.appendChild(li);
}
if (files.length === 0 && !currentPath) {
fileList.innerHTML = '<div style="padding:1rem; text-align:center; color:var(--text-secondary)">Empty directory</div>';
return;
}
files.forEach(file => {
const li = document.createElement('li');
li.className = 'file-item';
li.draggable = true;
li.dataset.name = file.name;
li.dataset.type = file.isDirectory ? 'folder' : 'file';
const icon = file.isDirectory ? '📁' : getFileIcon(file.name);
const size = file.isDirectory ? '-' : formatSize(file.size);
const date = new Date(file.mtime).toLocaleDateString();
li.innerHTML = `
<span class="col-select"><input type="checkbox" class="file-select" value="${file.name}"></span>
<span class="col-icon">${icon}</span>
<span class="file-name ${file.isDirectory ? 'is-folder' : ''} col-name" title="${file.name}">${file.name}</span>
<span class="col-size">${size}</span>
<span class="col-date">${date}</span>
<span class="col-actions">
<button class="btn danger" onclick="deleteItem('${file.name}')" title="Delete">🗑️</button>
</span>
`;
const nameEl = li.querySelector('.file-name');
const openItem = () => {
if (file.isDirectory) {
loadFiles(currentPath ? currentPath + '/' + file.name : file.name);
} else {
previewOrDownload(file);
}
};
nameEl.addEventListener('click', openItem);
// Also click icon in grid view
li.querySelector('.col-icon').addEventListener('click', () => {
if (viewMode === 'grid') openItem();
});
// Drag events
li.addEventListener('dragstart', handleDragStart);
if (file.isDirectory) {
li.addEventListener('dragover', handleDragOverFolder);
li.addEventListener('drop', handleDropOnFolder);
li.addEventListener('dragleave', handleDragLeaveFolder);
}
// Checkbox event
const checkbox = li.querySelector('.file-select');
checkbox.addEventListener('change', (e) => {
if (e.target.checked) selectedFiles.add(file.name);
else selectedFiles.delete(file.name);
updateToolbar();
});
fileList.appendChild(li);
});
}
function updateBreadcrumbs() {
currentPathDisplay.textContent = currentPath ? '/' + currentPath : '/';
upDirBtn.disabled = !currentPath;
}
upDirBtn.addEventListener('click', () => {
if (!currentPath) return;
const parts = currentPath.split('/');
parts.pop();
loadFiles(parts.join('/'));
});
document.getElementById('refresh-btn').addEventListener('click', () => loadFiles());
// --- File Operations ---
function getFileIcon(filename) {
const ext = filename.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) return '🖼️';
if (['mp4', 'webm', 'mov'].includes(ext)) return '🎥';
if (['mp3', 'wav'].includes(ext)) return '🎵';
if (['zip', 'rar', '7z', 'tar'].includes(ext)) return '📦';
if (['pdf'].includes(ext)) return '📄';
return '📄';
}
function previewOrDownload(file) {
const ext = file.name.split('.').pop().toLowerCase();
const filePath = currentPath ? currentPath + '/' + file.name : file.name;
const encodedPath = encodeURIComponent(filePath);
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
openMediaModal(`<img src="/api/download?path=${encodedPath}" alt="${file.name}">`);
} else if (['mp4', 'webm', 'mov'].includes(ext)) {
openMediaModal(`<video controls src="/api/download?path=${encodedPath}"></video>`);
} else if (['zip'].includes(ext)) {
openZipModal(filePath);
} else {
window.open(`/api/download?path=${encodedPath}`, '_blank');
}
}
// Media Modal
const mediaModal = document.getElementById('media-modal');
const mediaContainer = document.getElementById('media-container');
function openMediaModal(html) {
mediaContainer.innerHTML = html;
mediaModal.classList.remove('hidden');
}
// ZIP Modal
const zipModal = document.getElementById('zip-modal');
const zipList = document.getElementById('zip-list');
async function openZipModal(filePath) {
try {
const res = await fetch(`/api/zip/preview?path=${encodeURIComponent(filePath)}`);
if (!res.ok) throw new Error('Failed');
const entries = await res.json();
zipList.innerHTML = '';
entries.forEach(entry => {
const li = document.createElement('li');
li.textContent = `${entry.isDirectory ? '📁' : '📄'} ${entry.name} (${formatSize(entry.size)})`;
zipList.appendChild(li);
});
zipModal.classList.remove('hidden');
} catch (e) {
alert("Could not preview ZIP");
}
}
// Close Modals on backdrop click
[mediaModal, zipModal].forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.add('hidden');
if (modal === mediaModal) mediaContainer.innerHTML = '';
}
});
const closeBtn = modal.querySelector('.close-modal');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
modal.classList.add('hidden');
if (modal === mediaModal) mediaContainer.innerHTML = '';
});
}
});
// Bulk Actions
selectAllCheckbox.addEventListener('change', (e) => {
const checkboxes = document.querySelectorAll('.file-select');
checkboxes.forEach(cb => {
cb.checked = e.target.checked;
if (e.target.checked) selectedFiles.add(cb.value);
else selectedFiles.delete(cb.value);
});
updateToolbar();
});
function updateToolbar() {
if (selectedFiles.size > 0) {
bulkDownloadBtn.classList.remove('hidden');
bulkDownloadBtn.textContent = `Download (${selectedFiles.size})`;
} else {
bulkDownloadBtn.classList.add('hidden');
}
}
bulkDownloadBtn.addEventListener('click', async () => {
const paths = Array.from(selectedFiles).map(name => currentPath ? currentPath + '/' + name : name);
try {
const res = await fetch('/api/bulk-download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths })
});
if (res.ok) {
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'files.zip';
document.body.appendChild(a);
a.click();
a.remove();
}
} catch (e) {
console.error(e);
}
});
// New Folder
document.getElementById('new-folder-btn').addEventListener('click', async () => {
const name = prompt("Folder name:");
if (!name) return;
await fetch('/api/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, path: currentPath })
});
loadFiles();
});
// Delete
async function deleteItem(name) {
if (!confirm(`Delete ${name}?`)) return;
const itemPath = currentPath ? currentPath + '/' + name : name;
await fetch(`/api/files?path=${encodeURIComponent(itemPath)}`, { method: 'DELETE' });
loadFiles();
}
// Drag & Drop Upload
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
uploadFiles(e.dataTransfer.files);
}
});
async function uploadFiles(files) {
const statusDiv = document.getElementById('upload-status');
statusDiv.innerHTML = 'Uploading...';
for (let file of files) {
const formData = new FormData();
formData.append('file', file);
if (currentPath) {
await fetch(`/api/upload?path=${encodeURIComponent(currentPath)}`, { method: 'POST', body: formData });
} else {
await fetch('/api/upload', { method: 'POST', body: formData });
}
}
statusDiv.innerHTML = 'Done!';
setTimeout(() => statusDiv.innerHTML = '', 2000);
loadFiles();
}
// Drag & Drop Move (File to Folder)
let draggedItem = null;
function handleDragStart(e) {
draggedItem = e.target.dataset.name;
}
function handleDragOverFolder(e) {
e.preventDefault();
e.currentTarget.style.backgroundColor = 'rgba(59, 130, 246, 0.1)';
}
function handleDragLeaveFolder(e) {
e.currentTarget.style.backgroundColor = '';
}
async function handleDropOnFolder(e) {
e.preventDefault();
e.currentTarget.style.backgroundColor = '';
const targetFolder = e.currentTarget.dataset.name;
if (draggedItem === targetFolder) return;
if (confirm(`Move ${draggedItem} to ${targetFolder}?`)) {
const source = currentPath ? currentPath + '/' + draggedItem : draggedItem;
const dest = currentPath ? currentPath + '/' + targetFolder + '/' + draggedItem : targetFolder + '/' + draggedItem;
await performMove(source, dest);
}
}
async function handleDropOnParent(e) {
e.preventDefault();
e.currentTarget.style.backgroundColor = '';
if (!currentPath) return;
if (confirm(`Move ${draggedItem} up one level?`)) {
const source = currentPath + '/' + draggedItem;
const parts = currentPath.split('/');
parts.pop();
const parentPath = parts.join('/');
const dest = parentPath ? parentPath + '/' + draggedItem : draggedItem;
await performMove(source, dest);
}
}
async function performMove(source, dest) {
await fetch('/api/ops/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, destination: dest })
});
loadFiles();
}
// Helpers
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

111
public/index.html Normal file
View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple File Server</title>
<link rel="stylesheet" href="style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<!-- Login Screen -->
<div id="login-screen" class="screen">
<div class="login-card">
<h2>Login</h2>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div id="login-error" class="error-msg"></div>
<button type="submit" class="btn primary full-width">Login</button>
</form>
</div>
</div>
<!-- Main App -->
<div id="app-screen" class="screen hidden">
<div class="container">
<header>
<div class="header-left">
<h1>My File Server</h1>
<span id="current-path-display" class="path-breadcrumb">/</span>
</div>
<div class="controls">
<span id="user-display"></span>
<button id="theme-toggle" class="btn secondary" aria-label="Toggle Dark Mode">
<span class="icon">🌓</span>
</button>
<button id="logout-btn" class="btn secondary">Logout</button>
</div>
</header>
<main>
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<button class="btn secondary" id="up-dir-btn" disabled>⬆ Up</button>
<button class="btn primary" id="new-folder-btn">New Folder</button>
<button class="btn secondary" id="refresh-btn">Refresh</button>
</div>
<div class="toolbar-right">
<button class="btn secondary" id="view-toggle-btn" title="Toggle Grid/List View">📅</button>
<button class="btn primary hidden" id="bulk-download-btn">Download Selected</button>
</div>
</div>
<!-- Upload Area -->
<div class="upload-area" id="drop-zone">
<p>Drag & drop to upload or move items</p>
<input type="file" id="file-input" hidden multiple>
<button class="btn secondary outline" onclick="document.getElementById('file-input').click()">Select
Files</button>
</div>
<div id="upload-status"></div>
<!-- File List -->
<section class="files-section">
<div class="file-list-header">
<span class="col-select"><input type="checkbox" id="select-all"></span>
<span class="col-icon"></span>
<span class="col-name">Name</span>
<span class="col-size">Size</span>
<span class="col-date">Date</span>
<span class="col-actions">Actions</span>
</div>
<ul id="file-list" class="file-list">
<!-- Items -->
</ul>
</section>
</main>
</div>
</div>
<!-- Media Modal -->
<div id="media-modal" class="modal hidden">
<div class="modal-content">
<span class="close-modal">&times;</span>
<div id="media-container"></div>
</div>
</div>
<!-- ZIP Modal -->
<div id="zip-modal" class="modal hidden">
<div class="modal-content">
<span class="close-modal">&times;</span>
<h3>Archive Preview</h3>
<ul id="zip-list"></ul>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

430
public/style.css Normal file
View File

@@ -0,0 +1,430 @@
:root {
--bg-color: #f8f9fa;
--card-bg: #ffffff;
--text-color: #1f2937;
--text-secondary: #6b7280;
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--border-color: #e5e7eb;
--danger-color: #ef4444;
--modal-bg: rgba(0, 0, 0, 0.8);
}
/* Dark Mode Variables */
[data-theme="dark"] {
--bg-color: #111827;
--card-bg: #1f2937;
--text-color: #f9fafb;
--text-secondary: #9ca3af;
--primary-color: #60a5fa;
--primary-hover: #3b82f6;
--border-color: #374151;
--modal-bg: rgba(0, 0, 0, 0.9);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-color: #111827;
--card-bg: #1f2937;
--text-color: #f9fafb;
--text-secondary: #9ca3af;
--primary-color: #60a5fa;
--primary-hover: #3b82f6;
--border-color: #374151;
--modal-bg: rgba(0, 0, 0, 0.9);
}
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 0;
height: 100vh;
display: flex;
flex-direction: column;
}
.container {
max-width: 1200px;
/* Wider for better file view */
margin: 0 auto;
padding: 20px;
width: 100%;
box-sizing: border-box;
}
/* Screens */
.screen {
width: 100%;
height: 100%;
overflow-y: auto;
}
.screen.hidden {
display: none !important;
}
/* Login */
#login-screen {
display: flex;
justify-content: center;
align-items: center;
}
.login-card {
background: var(--card-bg);
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background: var(--bg-color);
color: var(--text-color);
box-sizing: border-box;
}
.full-width {
width: 100%;
}
.error-msg {
color: var(--danger-color);
margin-bottom: 1rem;
font-size: 0.9rem;
}
/* Header & Toolbar */
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
gap: 1rem;
}
.toolbar-left,
.toolbar-right {
display: flex;
gap: 0.5rem;
}
.path-breadcrumb {
color: var(--text-secondary);
font-family: monospace;
background: var(--card-bg);
padding: 0.2rem 0.5rem;
border-radius: 4px;
margin-left: 1rem;
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
font-family: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn.primary {
background-color: var(--primary-color);
color: white;
}
.btn.primary:hover {
background-color: var(--primary-hover);
}
.btn.secondary {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
}
.btn.secondary:hover {
background-color: var(--border-color);
}
.btn.danger {
background-color: transparent;
color: var(--danger-color);
}
.btn.danger:hover {
background-color: rgba(239, 68, 68, 0.1);
}
.btn.hidden {
display: none;
}
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
/* File List */
.files-section {
background-color: var(--card-bg);
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--border-color);
}
.file-list-header,
.file-item {
display: grid;
grid-template-columns: 40px 40px 1fr 100px 180px 100px;
/* Checkbox, Icon, Name, Size, Date, Actions */
padding: 0.75rem 1rem;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.file-list-header {
font-weight: 600;
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.02);
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background-color: rgba(0, 0, 0, 0.02);
}
[data-theme="dark"] .file-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.file-name {
display: flex;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.file-name.is-folder {
font-weight: 500;
color: var(--text-color);
}
.file-name:hover {
color: var(--primary-color);
}
.file-icon {
font-size: 1.2rem;
margin-right: 0.5rem;
}
/* Upload */
.upload-area {
background-color: var(--card-bg);
border: 2px dashed var(--border-color);
border-radius: 1rem;
padding: 2rem;
text-align: center;
margin-bottom: 1rem;
transition: all 0.3s;
}
.upload-area.dragover {
border-color: var(--primary-color);
background-color: rgba(59, 130, 246, 0.05);
transform: scale(1.02);
}
/* Modals */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--modal-bg);
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
}
.modal.hidden {
display: none;
}
.modal-content {
background: var(--card-bg);
padding: 2rem;
border-radius: 1rem;
max-width: 90%;
max-height: 90%;
overflow: auto;
position: relative;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.close-modal {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 2rem;
cursor: pointer;
line-height: 1;
color: var(--text-secondary);
}
#media-container img,
#media-container video {
max-width: 100%;
max-height: 80vh;
border-radius: 0.5rem;
}
#zip-list {
list-style: none;
padding: 0;
}
#zip-list li {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
/* Responsive */
@media (max-width: 768px) {
.file-list-header,
.file-item {
grid-template-columns: 40px 40px 1fr 80px;
}
.col-date,
.file-date,
.col-actions,
.file-actions {
display: none;
}
/* Simplify on mobile for now */
}
/* Grid View */
.files-section.grid-view .file-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
padding: 1rem;
}
.files-section.grid-view .file-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
text-align: center;
position: relative;
grid-template-columns: 1fr;
/* Reset grid layout from list view */
}
.files-section.grid-view .file-list-header {
display: none;
/* Hide header in grid view */
}
.files-section.grid-view .col-select {
position: absolute;
top: 0.5rem;
left: 0.5rem;
}
.files-section.grid-view .col-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.files-section.grid-view .file-name {
width: 100%;
margin-bottom: 0.5rem;
justify-content: center;
}
.files-section.grid-view .col-size,
.files-section.grid-view .col-date {
font-size: 0.8rem;
color: var(--text-secondary);
display: block;
/* Show in grid */
}
.files-section.grid-view .col-actions {
margin-top: 0.5rem;
display: block;
}
/* Modal Improvements */
.modal-content {
position: relative;
/* Context for sticky */
}
.close-modal {
position: sticky;
top: 0;
right: 0;
float: right;
/* Fallback */
background: var(--card-bg);
/* Opaque background behind X */
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10;
margin-bottom: 1rem;
margin-top: -1rem;
/* Adjust for padding */
margin-right: -1rem;
}