diff --git a/TODO.md b/TODO.md
index 68f727a..acfbdcf 100644
--- a/TODO.md
+++ b/TODO.md
@@ -9,7 +9,7 @@
## Features
- [x] Rename files/folders (double-click in tree)
-- [ ] Image upload (drag-drop into editor, store in assets folder)
+- [x] Image upload (drag-drop into editor, store in .assets folder)
- [x] Browser `beforeunload` warning with unsaved changes
- [x] Mobile hamburger menu to toggle sidebar
- [x] PWA icons (icon-192.png, icon-512.png)
@@ -28,8 +28,8 @@
## Polish
- [x] Error toasts instead of alert()
-- [ ] Loading spinners on API calls
+- [x] Loading spinners on API calls
- [x] Keyboard shortcut help overlay (Ctrl+/)
- [x] File rename inline in tree (double-click)
-- [ ] Drag files to trash
-- [ ] Sort files (name, date, size)
+- [x] Drag files to trash
+- [x] Sort files (name, date)
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 55d63bb..1a0075a 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -20,12 +20,17 @@
+
+
+
+
+
@@ -92,6 +97,8 @@
v-model="content"
@input="isDirty = true"
@keydown="handleKeydown"
+ @drop.prevent="handleImageDrop"
+ @dragover.prevent
placeholder="Start writing markdown..."
>
@@ -324,6 +331,8 @@ const collabActive = ref(false)
const sidebarOpen = ref(false)
const toasts = ref([])
const showShortcuts = ref(false)
+const loading = ref(false)
+const sortBy = ref('name')
let toastId = 0
function toast(msg, type = 'info') {
@@ -427,8 +436,10 @@ async function syncPending() {
// âââ Files âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
async function loadFiles() {
+ loading.value = true
fileTree.value = await api('/api/files/list', {})
filteredFiles.value = fileTree.value
+ loading.value = false
}
function filterFiles() {
@@ -578,6 +589,44 @@ async function renameFile(item) {
}
}
+function sortFiles(by) {
+ sortBy.value = by
+ const sorter = (a, b) => {
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
+ if (by === 'name') return a.name.localeCompare(b.name)
+ return 0 // date sort would need mtime from server
+ }
+ filteredFiles.value = [...filteredFiles.value].sort(sorter)
+}
+
+async function handleImageDrop(e) {
+ const files = e.dataTransfer?.files
+ if (!files || !files.length) return
+ for (const file of files) {
+ if (!file.type.startsWith('image/')) continue
+ const form = new FormData()
+ form.append('image', file)
+ try {
+ const res = await fetch('/api/files/upload-image', {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + token.value },
+ body: form,
+ })
+ const data = await res.json()
+ if (data.url) {
+ const md = `\n`
+ const ta = editor.value
+ const pos = ta ? ta.selectionStart : content.value.length
+ content.value = content.value.substring(0, pos) + md + content.value.substring(pos)
+ isDirty.value = true
+ toast('Image uploaded', 'success')
+ }
+ } catch {
+ toast('Image upload failed', 'error')
+ }
+ }
+}
+
// âââ Shared ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
async function loadShared() {
@@ -608,6 +657,12 @@ async function emptyTrash() {
trashItems.value = []
}
+function dropToTrash(e) {
+ const path = e.dataTransfer.getData('text/plain')
+ if (!path) return
+ deleteItem({ path, name: path.split('/').pop(), isDir: false })
+}
+
// âââ Preferences âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
function savePrefs() {
@@ -1365,6 +1420,30 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.ai-content ul, .ai-content ol { padding-left: 2em; margin: 0 0 12px; }
.trash-view { padding: 8px; flex: 1; overflow-y: auto; }
+
+.sort-bar {
+ display: flex;
+ gap: 4px;
+ padding: 4px 8px;
+ border-bottom: 1px solid var(--border);
+}
+.sort-bar button {
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ font-size: 11px;
+ cursor: pointer;
+ padding: 2px 8px;
+ border-radius: 3px;
+}
+.sort-bar button.active { background: var(--bg-hover); color: var(--text); }
+
+.loading-bar {
+ height: 2px;
+ background: var(--accent);
+ animation: loadPulse 1s infinite;
+}
+@keyframes loadPulse { 0%,100% { opacity: 0.3; } 50% { opacity: 1; } }
.trash-header { display: flex; justify-content: space-between; align-items: center; padding: 8px; font-size: 12px; color: var(--text-muted); }
.empty-trash-btn { background: var(--danger); color: white; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.trash-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; border-radius: 4px; font-size: 13px; }
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 63c7081..11cd091 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"io"
"net/http"
+ "os"
"path/filepath"
"sync"
"time"
@@ -417,6 +418,48 @@ func (s *Server) handleSharedFiles(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, []files.FileInfo{})
}
+func (s *Server) handleUploadImage(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
+ if err := r.ParseMultipartForm(10 << 20); err != nil {
+ writeJSON(w, 413, map[string]string{"error": "file too large (max 10MB)"})
+ return
+ }
+ file, header, err := r.FormFile("image")
+ if err != nil {
+ writeJSON(w, 400, map[string]string{"error": "image field required"})
+ return
+ }
+ defer file.Close()
+
+ userID := getUserID(r)
+ assetsDir := filepath.Join(files.UserDir(s.dataDir, userID), ".assets")
+ os.MkdirAll(assetsDir, 0755)
+
+ filename := header.Filename
+ dest := filepath.Join(assetsDir, filename)
+ out, err := os.Create(dest)
+ if err != nil {
+ writeJSON(w, 500, map[string]string{"error": "save failed"})
+ return
+ }
+ defer out.Close()
+ io.Copy(out, file)
+
+ url := "/api/files/image/" + filename
+ writeJSON(w, 200, map[string]string{"url": url, "filename": filename})
+}
+
+func (s *Server) handleServeImage(w http.ResponseWriter, r *http.Request) {
+ userID := getUserID(r)
+ filename := filepath.Base(r.URL.Path)
+ if filename == "" || filename == "." {
+ http.Error(w, "not found", 404)
+ return
+ }
+ p := filepath.Join(files.UserDir(s.dataDir, userID), ".assets", filename)
+ http.ServeFile(w, r, p)
+}
+
func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
items, err := files.ListTrash(s.dataDir, userID)
diff --git a/internal/api/router.go b/internal/api/router.go
index 270ea03..e1d7cf0 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -43,6 +43,8 @@ func NewRouter(db *sql.DB, dataDir, secret string) http.Handler {
mux.HandleFunc("POST /api/files/trash/empty", s.requireAuth(s.handleEmptyTrash))
mux.HandleFunc("POST /api/files/search", s.requireAuth(s.handleSearchFiles))
mux.HandleFunc("POST /api/files/shared", s.requireAuth(s.handleListSharedFiles))
+ mux.HandleFunc("POST /api/files/upload-image", s.requireAuth(s.handleUploadImage))
+ mux.HandleFunc("GET /api/files/image/", s.requireAuth(s.handleServeImage))
// Sharing
mux.HandleFunc("POST /api/share", s.requireAuth(s.handleShareFile))