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 = `![${data.filename}](${data.url})\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))