Drag and drop files between folders
This commit is contained in:
+16
-2
@@ -18,8 +18,8 @@
|
|||||||
<button :class="{active: view === 'prefs'}" @click="view = 'prefs'">⚙️ Preferences</button>
|
<button :class="{active: view === 'prefs'}" @click="view = 'prefs'">⚙️ Preferences</button>
|
||||||
<button v-if="isAdmin" :class="{active: view === 'admin'}" @click="view = 'admin'">👤 Admin</button>
|
<button v-if="isAdmin" :class="{active: view === 'admin'}" @click="view = 'admin'">👤 Admin</button>
|
||||||
</div>
|
</div>
|
||||||
<FileTree v-if="view === 'files'" :files="filteredFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" />
|
<FileTree v-if="view === 'files'" :files="filteredFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" @move="moveFile" />
|
||||||
<FileTree v-if="view === 'shared'" :files="sharedFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" />
|
<FileTree v-if="view === 'shared'" :files="sharedFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" @move="moveFile" />
|
||||||
</aside>
|
</aside>
|
||||||
<main class="editor-area" v-if="view === 'files' || view === 'shared'">
|
<main class="editor-area" v-if="view === 'files' || view === 'shared'">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -361,6 +361,20 @@ async function deleteItem(item) {
|
|||||||
await loadFiles()
|
await loadFiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function moveFile({ from, to }) {
|
||||||
|
const filename = from.split('/').pop()
|
||||||
|
const newPath = to + '/' + filename
|
||||||
|
try {
|
||||||
|
await api('/api/files/move', { from, to: newPath })
|
||||||
|
if (currentFile.value === from) {
|
||||||
|
currentFile.value = newPath
|
||||||
|
}
|
||||||
|
await loadFiles()
|
||||||
|
} catch (e) {
|
||||||
|
alert('Move failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Shared ──────────────────────────────────────────────────────────────────
|
// ─── Shared ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadShared() {
|
async function loadShared() {
|
||||||
|
|||||||
@@ -3,36 +3,67 @@
|
|||||||
<div v-for="item in files" :key="item.path" class="tree-item">
|
<div v-for="item in files" :key="item.path" class="tree-item">
|
||||||
<div
|
<div
|
||||||
class="tree-node"
|
class="tree-node"
|
||||||
:class="{ selected: item.path === selected, folder: item.isDir }"
|
:class="{ selected: item.path === selected, folder: item.isDir, 'drop-target': dropTarget === item.path }"
|
||||||
@click="item.isDir ? toggleFolder(item) : $emit('select', item.path)"
|
@click="item.isDir ? toggleFolder(item) : $emit('select', item.path)"
|
||||||
@contextmenu.prevent="showContext($event, item)"
|
@contextmenu.prevent="null"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart($event, item)"
|
||||||
|
@dragover.prevent="onDragOver($event, item)"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop="onDrop($event, item)"
|
||||||
>
|
>
|
||||||
<span class="icon">{{ item.isDir ? (expanded[item.path] ? '📂' : '📁') : '📄' }}</span>
|
<span class="icon">{{ item.isDir ? (expanded[item.path] ? '📂' : '📁') : '📄' }}</span>
|
||||||
<span class="name">{{ item.name }}</span>
|
<span class="name">{{ item.name }}</span>
|
||||||
<button class="delete-btn" @click.stop="$emit('delete', item)" title="Delete">×</button>
|
<button class="delete-btn" @click.stop="$emit('delete', item)" title="Delete">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.isDir && expanded[item.path] && item.children" class="children">
|
<div v-if="item.isDir && expanded[item.path] && item.children" class="children">
|
||||||
<FileTree :files="item.children" :selected="selected" @select="$emit('select', $event)" @delete="$emit('delete', $event)" />
|
<FileTree :files="item.children" :selected="selected" @select="$emit('select', $event)" @delete="$emit('delete', $event)" @move="$emit('move', $event)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
files: { type: Array, default: () => [] },
|
files: { type: Array, default: () => [] },
|
||||||
selected: { type: String, default: '' },
|
selected: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['select', 'delete'])
|
const emit = defineEmits(['select', 'delete', 'move'])
|
||||||
|
|
||||||
const expanded = reactive({})
|
const expanded = reactive({})
|
||||||
|
const dropTarget = ref('')
|
||||||
|
|
||||||
function toggleFolder(item) {
|
function toggleFolder(item) {
|
||||||
expanded[item.path] = !expanded[item.path]
|
expanded[item.path] = !expanded[item.path]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDragStart(e, item) {
|
||||||
|
e.dataTransfer.setData('text/plain', item.path)
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e, item) {
|
||||||
|
if (item.isDir) {
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
dropTarget.value = item.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave() {
|
||||||
|
dropTarget.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e, targetItem) {
|
||||||
|
dropTarget.value = ''
|
||||||
|
const sourcePath = e.dataTransfer.getData('text/plain')
|
||||||
|
if (!sourcePath || !targetItem.isDir || sourcePath === targetItem.path) return
|
||||||
|
// Don't drop into itself
|
||||||
|
if (targetItem.path.startsWith(sourcePath + '/')) return
|
||||||
|
emit('move', { from: sourcePath, to: targetItem.path })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -50,6 +81,7 @@ function toggleFolder(item) {
|
|||||||
}
|
}
|
||||||
.tree-node:hover { background: var(--hover-bg, #313244); }
|
.tree-node:hover { background: var(--hover-bg, #313244); }
|
||||||
.tree-node.selected { background: var(--selected-bg, #45475a); }
|
.tree-node.selected { background: var(--selected-bg, #45475a); }
|
||||||
|
.tree-node.drop-target { background: var(--accent, #89b4fa); opacity: 0.7; }
|
||||||
.icon { font-size: 14px; }
|
.icon { font-size: 14px; }
|
||||||
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
|
|||||||
@@ -263,6 +263,24 @@ func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 200, map[string]string{"status": "deleted"})
|
writeJSON(w, 200, map[string]string{"status": "deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMoveFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
}
|
||||||
|
if err := decodeBody(r, &req); err != nil || req.From == "" || req.To == "" {
|
||||||
|
writeJSON(w, 400, map[string]string{"error": "from and to required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(r)
|
||||||
|
if err := files.MoveFile(s.dataDir, userID, req.From, req.To); err != nil {
|
||||||
|
writeJSON(w, 500, map[string]string{"error": "move failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]string{"status": "moved"})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleSharedFiles(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSharedFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: query permissions table for files shared with this user
|
// TODO: query permissions table for files shared with this user
|
||||||
// For now return empty list
|
// For now return empty list
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func NewRouter(db *sql.DB, dataDir, secret string) http.Handler {
|
|||||||
mux.HandleFunc("POST /api/files/create", s.requireAuth(s.handleCreateFile))
|
mux.HandleFunc("POST /api/files/create", s.requireAuth(s.handleCreateFile))
|
||||||
mux.HandleFunc("POST /api/files/create-folder", s.requireAuth(s.handleCreateFolder))
|
mux.HandleFunc("POST /api/files/create-folder", s.requireAuth(s.handleCreateFolder))
|
||||||
mux.HandleFunc("POST /api/files/delete", s.requireAuth(s.handleDeleteFile))
|
mux.HandleFunc("POST /api/files/delete", s.requireAuth(s.handleDeleteFile))
|
||||||
|
mux.HandleFunc("POST /api/files/move", s.requireAuth(s.handleMoveFile))
|
||||||
mux.HandleFunc("POST /api/files/search", s.requireAuth(s.handleSearchFiles))
|
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/shared", s.requireAuth(s.handleListSharedFiles))
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ func DeleteFile(dataDir, userID, relPath string) error {
|
|||||||
return os.RemoveAll(p)
|
return os.RemoveAll(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveFile moves a file or folder to a new path.
|
||||||
|
func MoveFile(dataDir, userID, fromRel, toRel string) error {
|
||||||
|
from := safePath(dataDir, userID, fromRel)
|
||||||
|
to := safePath(dataDir, userID, toRel)
|
||||||
|
if from == "" || to == "" {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(to), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
// ListTree returns the file tree for a user.
|
// ListTree returns the file tree for a user.
|
||||||
func ListTree(dataDir, userID string) ([]FileInfo, error) {
|
func ListTree(dataDir, userID string) ([]FileInfo, error) {
|
||||||
root := UserDir(dataDir, userID)
|
root := UserDir(dataDir, userID)
|
||||||
|
|||||||
Reference in New Issue
Block a user