Drive Frontend Implementation Plan
NOTE
This document may not reflect the current implementation. See the final report for up-to-date state: Final Report
For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build an internal admin Drive UI using Django templates, Alpine.js for reactivity, and htmx for server interactions.
Architecture: Extend app_base.html with a sidebar (folder tree) + grid (file cards). Alpine.js manages client state (selection, drag, upload progress). htmx handles server calls (CRUD, move, trash). TUS webhook handles uploads.
Tech Stack: Django templates, Alpine.js, htmx, Tailwind CSS, DaisyUI
File Structure
apps/drive/
├── urls_web.py # NEW: Django template URLs
├── views_web.py # NEW: Template views (index, trash)
├── htmx_views.py # NEW: HTMX partial responses
templates/drive/
├── index.html # NEW: Main drive page
├── trash.html # NEW: Trash page
├── components/
│ ├── sidebar.html # NEW: Folder tree sidebar
│ ├── grid.html # NEW: File grid
│ ├── file_card.html # NEW: Individual file card
│ ├── breadcrumb.html # NEW: Path breadcrumb
│ ├── upload_area.html # NEW: Upload drop zone
│ ├── modal_folder.html # NEW: Create/edit folder modal
│ ├── context_menu.html # NEW: Right-click context menu
│ └── batch_actions.html # NEW: Batch action bar
assets/javascript/
├── drive.js # NEW: Alpine.js drive data componentTask 1: Django URLs & Views
Covers: [S2]
Files:
Create:
apps/drive/urls_web.pyCreate:
apps/drive/views_web.pyModify:
project/urls.py:41[ ] Step 1: Create web URL patterns
# apps/drive/urls_web.py
from django.urls import path
from apps.drive import views_web
app_name = "drive_web"
urlpatterns = [
path("", views_web.drive_index, name="index"),
path("trash/", views_web.drive_trash, name="trash"),
]- [ ] Step 2: Create template views
# apps/drive/views_web.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from apps.communities.models import Membership
from apps.drive.models import DriveFile, DriveFolder
@login_required
def drive_index(request):
community = request.community
folders = DriveFolder.objects.in_community(community).select_related("parent").order_by("name")
files = DriveFile.objects.in_community(community).active().select_related("folder", "uploader").order_by("-created_at")
return render(request, "drive/index.html", {
"folders": folders,
"files": files,
"active_tab": "drive",
})
@login_required
def drive_trash(request):
community = request.community
files = DriveFile.objects.in_community(community).trashed().select_related("folder", "uploader").order_by("-trashed_at")
return render(request, "drive/trash.html", {
"files": files,
"active_tab": "drive",
})- [ ] Step 3: Include web URLs in project
# project/urls.py — add after line 55 (api/v1 drive)
path("drive/", include("apps.drive.urls_web")),- [ ] Step 4: Verify server starts
Run: make dev Expected: No errors on startup
- [ ] Step 5: Commit
git add apps/drive/urls_web.py apps/drive/views_web.py project/urls.py
git commit -m "feat(drive): add web URLs and template views"Task 2: Base Template & Sidebar Navigation
Covers: [S1]
Files:
Create:
templates/drive/index.htmlModify:
templates/web/components/app_nav_menu_items.html:38[ ] Step 1: Add Drive link to navigation
<!-- templates/web/components/app_nav_menu_items.html — add before {% if user.is_superuser %} on line 38 -->
{% if user.is_superuser %}
<li class="menu-title">
<span>{% translate "Drive" %}</span>
</li>
<ul class="menu-list">
<li>
<a href="{% url 'drive_web:index' %}" {% if active_tab == 'drive' %}class="menu-active"{% endif %}>
<i class="fa fa-folder h-4 w-4"></i>
{% translate "Drive" %}
</a>
</li>
</ul>
{% endif %}- [ ] Step 2: Create main drive template
<!-- templates/drive/index.html -->
{% extends "web/app/app_base.html" %}
{% load i18n %}
{% block app %}
<div class="flex flex-col h-[calc(100vh-4rem)]" x-data="drive">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center gap-2">
<h1 class="text-xl font-bold">{% translate "Drive" %}</h1>
</div>
<div class="flex items-center gap-2">
<button class="btn btn-primary btn-sm" @click="showUpload = true">
<i class="fa fa-upload"></i> {% translate "Upload" %}
</button>
<button class="btn btn-ghost btn-sm" @click="showNewFolder = true">
<i class="fa fa-folder-plus"></i> {% translate "New Folder" %}
</button>
<a href="{% url 'drive_web:trash' %}" class="btn btn-ghost btn-sm">
<i class="fa fa-trash"></i>
</a>
</div>
</div>
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar -->
<div class="w-64 border-r overflow-y-auto p-2 hidden lg:block">
{% include "drive/components/sidebar.html" %}
</div>
<!-- Main content -->
<div class="flex-1 overflow-y-auto p-4">
{% include "drive/components/breadcrumb.html" %}
{% include "drive/components/batch_actions.html" %}
{% include "drive/components/grid.html" %}
</div>
</div>
<!-- Modals -->
{% include "drive/components/modal_folder.html" %}
{% include "drive/components/upload_area.html" %}
</div>
{% endblock %}
{% block page_js %}
<script src="{% static 'javascript/drive.js' %}"></script>
{% endblock %}- [ ] Step 3: Commit
git add templates/drive/index.html templates/web/components/app_nav_menu_items.html
git commit -m "feat(drive): add base template with sidebar and nav link"Task 3: Folder Tree Sidebar
Covers: [S1, S5]
Files:
Create:
templates/drive/components/sidebar.html[ ] Step 1: Create sidebar component
<!-- templates/drive/components/sidebar.html -->
{% load i18n %}
<div class="space-y-1">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-sm font-semibold">{% translate "Folders" %}</span>
</div>
<!-- Root level -->
<template x-for="folder in rootFolders" :key="folder.id">
<div>
<div class="flex items-center gap-1 px-2 py-1 rounded cursor-pointer hover:bg-base-200"
:class="{ 'bg-primary/10': currentFolder?.id === folder.id }"
@click="navigateToFolder(folder)"
@dragover.prevent="dragOverFolder = folder.id"
@dragleave="dragOverFolder = null"
@drop.prevent="dropOnFolder(folder)"
:data-folder-id="folder.id">
<button class="btn btn-ghost btn-xs" @click.stop="toggleFolder(folder)">
<i class="fa text-xs" :class="folder.expanded ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
</button>
<i class="fa fa-folder text-warning"></i>
<span class="text-sm truncate" x-text="folder.name"></span>
</div>
<!-- Children -->
<div x-show="folder.expanded" x-collapse class="pl-4">
<template x-for="child in getChildren(folder.id)" :key="child.id">
<div>
<div class="flex items-center gap-1 px-2 py-1 rounded cursor-pointer hover:bg-base-200"
:class="{ 'bg-primary/10': currentFolder?.id === child.id }"
@click="navigateToFolder(child)"
@dragover.prevent="dragOverFolder = child.id"
@dragleave="dragOverFolder = null"
@drop.prevent="dropOnFolder(child)"
:data-folder-id="child.id">
<button class="btn btn-ghost btn-xs" @click.stop="toggleFolder(child)">
<i class="fa text-xs" :class="child.expanded ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
</button>
<i class="fa fa-folder text-warning"></i>
<span class="text-sm truncate" x-text="child.name"></span>
</div>
<!-- Recursive children (max 2 levels shown) -->
<div x-show="child.expanded" x-collapse class="pl-4">
<template x-for="grandchild in getChildren(child.id)" :key="grandchild.id">
<div class="flex items-center gap-1 px-2 py-1 rounded cursor-pointer hover:bg-base-200"
:class="{ 'bg-primary/10': currentFolder?.id === grandchild.id }"
@click="navigateToFolder(grandchild)"
@dragover.prevent="dragOverFolder = grandchild.id"
@dragleave="dragOverFolder = null"
@drop.prevent="dropOnFolder(grandchild)"
:data-folder-id="grandchild.id">
<i class="fa fa-folder text-warning ml-6"></i>
<span class="text-sm truncate" x-text="grandchild.name"></span>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- All files link -->
<div class="flex items-center gap-1 px-2 py-1 rounded cursor-pointer hover:bg-base-200"
:class="{ 'bg-primary/10': currentFolder === null }"
@click="navigateToFolder(null)">
<i class="fa fa-home text-info"></i>
<span class="text-sm">{% translate "All Files" %}</span>
</div>
</div>- [ ] Step 2: Commit
git add templates/drive/components/sidebar.html
git commit -m "feat(drive): add folder tree sidebar component"Task 4: File Grid & Cards
Covers: [S1, S5]
Files:
Create:
templates/drive/components/grid.htmlCreate:
templates/drive/components/file_card.htmlCreate:
templates/drive/components/breadcrumb.html[ ] Step 1: Create breadcrumb component
<!-- templates/drive/components/breadcrumb.html -->
{% load i18n %}
<nav class="text-sm breadcrumbs mb-4">
<ul>
<li>
<a @click.prevent="navigateToFolder(null)" class="link link-hover">
<i class="fa fa-home"></i>
</a>
</li>
<template x-for="(crumb, index) in breadcrumbs" :key="crumb.id">
<li>
<a @click.prevent="navigateToFolder(crumb)" class="link link-hover" x-text="crumb.name"></a>
</li>
</template>
</ul>
</nav>- [ ] Step 2: Create batch actions bar
<!-- templates/drive/components/batch_actions.html -->
{% load i18n %}
<div x-show="selected.length > 0" x-transition class="flex items-center gap-2 p-2 mb-4 bg-primary/10 rounded-lg">
<span class="text-sm" x-text="`${selected.length} selected`"></span>
<div class="flex gap-1 ml-auto">
<button class="btn btn-ghost btn-xs" @click="batchMove()" title="{% translate 'Move' %}">
<i class="fa fa-arrows-alt"></i>
</button>
<button class="btn btn-ghost btn-xs text-error" @click="batchDelete()" title="{% translate 'Delete' %}">
<i class="fa fa-trash"></i>
</button>
<button class="btn btn-ghost btn-xs" @click="clearSelection()" title="{% translate 'Clear' %}">
<i class="fa fa-times"></i>
</button>
</div>
</div>- [ ] Step 3: Create file grid
<!-- templates/drive/components/grid.html -->
{% load i18n %}
<div id="list-content" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<!-- Empty state -->
<div x-show="filteredFiles.length === 0 && filteredFolders.length === 0" class="col-span-full text-center py-12">
<i class="fa fa-folder-open text-6xl text-base-300 mb-4"></i>
<p class="text-base-content/60">{% translate "No files or folders" %}</p>
</div>
<!-- Folders -->
<template x-for="folder in filteredFolders" :key="'f-'+folder.id">
<div class="card bg-base-200 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
@dblclick="navigateToFolder(folder)"
@click="toggleSelectFolder(folder)"
@contextmenu.prevent="showContextMenu($event, folder, 'folder')"
@dragover.prevent="dragOverFolder = folder.id"
@dragleave="dragOverFolder = null"
@drop.prevent="dropOnFolder(folder)"
:class="{ 'ring-2 ring-primary': selected.includes('folder-'+folder.id), 'ring-2 ring-accent': dragOverFolder === folder.id }"
:data-folder-id="folder.id">
<div class="card-body p-3">
<div class="flex items-center gap-2">
<i class="fa fa-folder text-2xl text-warning"></i>
<span class="text-sm font-medium truncate" x-text="folder.name"></span>
</div>
</div>
</div>
</template>
<!-- Files -->
<template x-for="file in filteredFiles" :key="'file-'+file.id">
<div class="card bg-base-200 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
@click="toggleSelectFile(file)"
@contextmenu.prevent="showContextMenu($event, file, 'file')"
@dragstart="dragStartFile($event, file)"
@dragend="dragEndFile()"
draggable="true"
:class="{ 'ring-2 ring-primary': selected.includes('file-'+file.id) }">
<div class="card-body p-3">
<div class="flex items-center gap-2 mb-2">
<i class="fa text-2xl" :class="getFileIcon(file.content_type)"></i>
<span class="text-sm font-medium truncate" x-text="file.name"></span>
</div>
<div class="flex items-center justify-between text-xs text-base-content/60">
<span x-text="formatSize(file.size)"></span>
<span x-text="formatDate(file.created_at)"></span>
</div>
</div>
</div>
</template>
</div>- [ ] Step 4: Create file card helper functions (in drive.js)
This will be added to the drive.js file in Task 7.
- [ ] Step 5: Commit
git add templates/drive/components/grid.html templates/drive/components/file_card.html templates/drive/components/breadcrumb.html templates/drive/components/batch_actions.html
git commit -m "feat(drive): add file grid, breadcrumb, and batch actions"Task 5: Upload Area
Covers: [S3]
Files:
Create:
templates/drive/components/upload_area.html[ ] Step 1: Create upload modal with drag area
<!-- templates/drive/components/upload_area.html -->
{% load i18n %}
<div x-show="showUpload" x-transition.opacity class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-base-100 rounded-lg shadow-xl w-full max-w-lg mx-4" @click.outside="showUpload = false">
<div class="flex items-center justify-between p-4 border-b">
<h2 class="text-lg font-bold">{% translate "Upload Files" %}</h2>
<button class="btn btn-ghost btn-sm" @click="showUpload = false">
<i class="fa fa-times"></i>
</button>
</div>
<div class="p-4">
<!-- Drop zone -->
<div class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
:class="{ 'border-primary bg-primary/5': isDragging }"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop($event)"
@click="$refs.fileInput.click()">
<i class="fa fa-cloud-upload-alt text-4xl text-base-300 mb-2"></i>
<p class="text-sm text-base-content/60">
{% translate "Drag files here or click to browse" %}
</p>
<input type="file" multiple class="hidden" x-ref="fileInput" @change="handleFiles($event.target.files)">
</div>
<!-- Upload progress -->
<div x-show="uploads.length > 0" class="mt-4 space-y-2">
<template x-for="(upload, index) in uploads" :key="index">
<div class="flex items-center gap-2 p-2 bg-base-200 rounded">
<i class="fa fa-file text-base-content/60"></i>
<span class="text-sm truncate flex-1" x-text="upload.name"></span>
<div class="w-24">
<div class="h-1.5 bg-base-300 rounded-full overflow-hidden">
<div class="h-full bg-primary transition-all" :style="`width: ${upload.progress}%`"></div>
</div>
</div>
<span class="text-xs text-base-content/60" x-text="upload.progress + '%'"></span>
</div>
</template>
</div>
</div>
<div class="flex justify-end gap-2 p-4 border-t">
<button class="btn btn-ghost btn-sm" @click="showUpload = false">{% translate "Close" %}</button>
</div>
</div>
</div>- [ ] Step 2: Commit
git add templates/drive/components/upload_area.html
git commit -m "feat(drive): add upload modal with drag-and-drop zone"Task 6: Create/Edit Folder Modal
Covers: [S5]
Files:
Create:
templates/drive/components/modal_folder.html[ ] Step 1: Create folder modal
<!-- templates/drive/components/modal_folder.html -->
{% load i18n %}
<div x-show="showNewFolder" x-transition.opacity class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-base-100 rounded-lg shadow-xl w-full max-w-sm mx-4" @click.outside="showNewFolder = false">
<div class="flex items-center justify-between p-4 border-b">
<h2 class="text-lg font-bold" x-text="editingFolder ? '{% trans "Rename Folder" %}' : '{% trans "New Folder" %}'"></h2>
<button class="btn btn-ghost btn-sm" @click="showNewFolder = false">
<i class="fa fa-times"></i>
</button>
</div>
<div class="p-4">
<input type="text"
class="input input-bordered w-full"
placeholder="{% translate 'Folder name' %}"
x-model="folderName"
@keydown.enter="saveFolder()"
x-ref="folderInput">
</div>
<div class="flex justify-end gap-2 p-4 border-t">
<button class="btn btn-ghost btn-sm" @click="showNewFolder = false">{% translate "Cancel" %}</button>
<button class="btn btn-primary btn-sm" @click="saveFolder()">{% translate "Save" %}</button>
</div>
</div>
</div>- [ ] Step 2: Commit
git add templates/drive/components/modal_folder.html
git commit -m "feat(drive): add create/edit folder modal"Task 7: Alpine.js Drive Data Component
Covers: [S1, S3, S4, S5, S7]
Files:
Create:
assets/javascript/drive.js[ ] Step 1: Create Alpine.js drive component
// assets/javascript/drive.js
document.addEventListener('alpine:init', () => {
Alpine.data('drive', () => ({
// State
folders: [],
files: [],
currentFolder: null,
selected: [],
showUpload: false,
showNewFolder: false,
editingFolder: null,
folderName: '',
isDragging: false,
dragOverFolder: null,
uploads: [],
// Computed
get rootFolders() {
return this.folders.filter(f => !f.parent);
},
get breadcrumbs() {
const crumbs = [];
let folder = this.currentFolder;
while (folder) {
crumbs.unshift(folder);
folder = this.folders.find(f => f.id === folder.parent);
}
return crumbs;
},
get filteredFolders() {
if (!this.currentFolder) return this.rootFolders;
return this.folders.filter(f => f.parent === this.currentFolder.id);
},
get filteredFiles() {
return this.files.filter(f => {
if (this.currentFolder) return f.folder === this.currentFolder.id;
return !f.folder;
});
},
// Init
init() {
this.folders = JSON.parse(document.getElementById('drive-data')?.dataset.folders || '[]');
this.files = JSON.parse(document.getElementById('drive-data')?.dataset.files || '[]');
},
// Navigation
navigateToFolder(folder) {
this.currentFolder = folder;
this.selected = [];
},
toggleFolder(folder) {
folder.expanded = !folder.expanded;
},
getChildren(parentId) {
return this.folders.filter(f => f.parent === parentId);
},
// Selection
toggleSelectFile(file) {
const id = 'file-' + file.id;
if (this.selected.includes(id)) {
this.selected = this.selected.filter(s => s !== id);
} else {
this.selected.push(id);
}
},
toggleSelectFolder(folder) {
const id = 'folder-' + folder.id;
if (this.selected.includes(id)) {
this.selected = this.selected.filter(s => s !== id);
} else {
this.selected.push(id);
}
},
clearSelection() {
this.selected = [];
},
// Drag & Drop
dragStartFile(event, file) {
event.dataTransfer.setData('text/plain', JSON.stringify({ type: 'file', id: file.id }));
event.dataTransfer.effectAllowed = 'move';
},
dragEndFile() {
this.dragOverFolder = null;
},
async dropOnFolder(targetFolder) {
this.dragOverFolder = null;
const data = event.dataTransfer.getData('text/plain');
if (!data) return;
const payload = JSON.parse(data);
const endpoint = payload.type === 'file'
? `/api/v1/drive/files/${payload.id}/move/`
: `/api/v1/drive/folders/${payload.id}/move/`;
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
},
body: JSON.stringify({ folder: targetFolder.id, parent: targetFolder.id }),
});
if (response.ok) {
if (payload.type === 'file') {
const file = this.files.find(f => f.id === payload.id);
if (file) file.folder = targetFolder.id;
}
}
} catch (error) {
console.error('Move failed:', error);
}
},
// Upload
handleDrop(event) {
this.isDragging = false;
this.handleFiles(event.dataTransfer.files);
},
handleFiles(fileList) {
for (const file of fileList) {
this.uploadFile(file);
}
},
async uploadFile(file) {
const upload = { name: file.name, progress: 0 };
this.uploads.push(upload);
// TUS upload implementation would go here
// For now, simulate progress
const interval = setInterval(() => {
upload.progress += 10;
if (upload.progress >= 100) {
clearInterval(interval);
this.uploads = this.uploads.filter(u => u !== upload);
}
}, 100);
},
// Folder CRUD
async saveFolder() {
if (!this.folderName.trim()) return;
const endpoint = this.editingFolder
? `/api/v1/drive/folders/${this.editingFolder.id}/`
: '/api/v1/drive/folders/';
const method = this.editingFolder ? 'PATCH' : 'POST';
const body = { name: this.folderName };
if (this.currentFolder) body.parent = this.currentFolder.id;
try {
const response = await fetch(endpoint, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
},
body: JSON.stringify(body),
});
if (response.ok) {
const folder = await response.json();
if (this.editingFolder) {
const idx = this.folders.findIndex(f => f.id === folder.id);
if (idx >= 0) this.folders[idx] = folder;
} else {
this.folders.push(folder);
}
this.showNewFolder = false;
this.folderName = '';
this.editingFolder = null;
}
} catch (error) {
console.error('Save folder failed:', error);
}
},
// Batch operations
async batchMove() {
// Implementation for batch move
},
async batchDelete() {
const fileIds = this.selected
.filter(s => s.startsWith('file-'))
.map(s => parseInt(s.replace('file-', '')));
if (fileIds.length === 0) return;
try {
await fetch('/api/v1/drive/files/batch_delete/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
},
body: JSON.stringify({ ids: fileIds }),
});
this.files = this.files.filter(f => !fileIds.includes(f.id));
this.selected = [];
} catch (error) {
console.error('Batch delete failed:', error);
}
},
// Helpers
getFileIcon(contentType) {
if (contentType?.startsWith('image/')) return 'fa-image text-primary';
if (contentType?.startsWith('video/')) return 'fa-video text-secondary';
if (contentType?.startsWith('audio/')) return 'fa-music text-accent';
if (contentType?.includes('pdf')) return 'fa-file-pdf text-error';
if (contentType?.includes('word') || contentType?.includes('document')) return 'fa-file-word text-blue-500';
if (contentType?.includes('sheet') || contentType?.includes('excel')) return 'fa-file-excel text-green-500';
return 'fa-file text-base-content/60';
},
formatSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024;
i++;
}
return bytes.toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
},
formatDate(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString();
},
}));
});- [ ] Step 2: Commit
git add assets/javascript/drive.js
git commit -m "feat(drive): add Alpine.js drive data component"Task 8: Trash Page
Covers: [S2, S5]
Files:
Create:
templates/drive/trash.html[ ] Step 1: Create trash template
<!-- templates/drive/trash.html -->
{% extends "web/app/app_base.html" %}
{% load i18n %}
{% block app %}
<div class="max-w-4xl mx-auto p-4" x-data="driveTrash">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-2">
<a href="{% url 'drive_web:index' %}" class="btn btn-ghost btn-sm">
<i class="fa fa-arrow-left"></i>
</a>
<h1 class="text-xl font-bold">{% translate "Trash" %}</h1>
</div>
<div x-show="selected.length > 0" class="flex gap-2">
<button class="btn btn-primary btn-sm" @click="batchRestore()">
<i class="fa fa-undo"></i> {% translate "Restore" %}
</button>
<button class="btn btn-error btn-sm" @click="batchPermanentDelete()">
<i class="fa fa-trash"></i> {% translate "Delete Permanently" %}
</button>
</div>
</div>
<!-- Empty state -->
<div x-show="files.length === 0" class="text-center py-12">
<i class="fa fa-trash text-6xl text-base-300 mb-4"></i>
<p class="text-base-content/60">{% translate "Trash is empty" %}</p>
</div>
<!-- File list -->
<div class="space-y-2">
<template x-for="file in files" :key="file.id">
<div class="flex items-center gap-4 p-3 bg-base-200 rounded-lg hover:bg-base-300"
:class="{ 'ring-2 ring-primary': selected.includes(file.id) }">
<input type="checkbox" class="checkbox checkbox-sm" :value="file.id" x-model="selected">
<i class="fa text-xl" :class="getFileIcon(file.content_type)"></i>
<div class="flex-1 min-w-0">
<p class="font-medium truncate" x-text="file.name"></p>
<p class="text-xs text-base-content/60">
<span x-text="formatSize(file.size)"></span> ·
{% translate "Trashed" %} <span x-text="formatDate(file.trashed_at)"></span>
</p>
</div>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs" @click="restoreFile(file)" title="{% translate 'Restore' %}">
<i class="fa fa-undo"></i>
</button>
<button class="btn btn-ghost btn-xs text-error" @click="permanentDelete(file)" title="{% translate 'Delete permanently' %}">
<i class="fa fa-trash"></i>
</button>
</div>
</div>
</template>
</div>
</div>
{% endblock %}
{% block page_js %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('driveTrash', () => ({
files: [],
selected: [],
init() {
this.files = JSON.parse(document.getElementById('trash-data')?.dataset.files || '[]');
},
async restoreFile(file) {
try {
await fetch(`/api/v1/drive/files/${file.id}/restore/`, {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
},
});
this.files = this.files.filter(f => f.id !== file.id);
} catch (error) {
console.error('Restore failed:', error);
}
},
async permanentDelete(file) {
if (!confirm('{% trans "Delete permanently?" %}')) return;
try {
await fetch(`/api/v1/drive/files/${file.id}/`, {
method: 'DELETE',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
},
});
this.files = this.files.filter(f => f.id !== file.id);
} catch (error) {
console.error('Delete failed:', error);
}
},
async batchRestore() {
for (const id of this.selected) {
const file = this.files.find(f => f.id === id);
if (file) await this.restoreFile(file);
}
this.selected = [];
},
async batchPermanentDelete() {
if (!confirm('{% trans "Delete all selected permanently?" %}')) return;
for (const id of this.selected) {
const file = this.files.find(f => f.id === id);
if (file) await this.permanentDelete(file);
}
this.selected = [];
},
getFileIcon(contentType) {
if (contentType?.startsWith('image/')) return 'fa-image text-primary';
if (contentType?.startsWith('video/')) return 'fa-video text-secondary';
return 'fa-file text-base-content/60';
},
formatSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024;
i++;
}
return bytes.toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
},
formatDate(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString();
},
}));
});
</script>
{% endblock %}- [ ] Step 2: Commit
git add templates/drive/trash.html
git commit -m "feat(drive): add trash page with restore and permanent delete"Task 9: Context Menu
Covers: [S5]
Files:
Create:
templates/drive/components/context_menu.html[ ] Step 1: Create context menu component
<!-- templates/drive/components/context_menu.html -->
{% load i18n %}
<div x-show="contextMenu.show" x-transition
class="fixed z-50 bg-base-100 rounded-lg shadow-lg border py-1 min-w-[160px]"
:style="`top: ${contextMenu.y}px; left: ${contextMenu.x}px`"
@click.outside="contextMenu.show = false"
@keydown.escape.window="contextMenu.show = false">
<template x-if="contextMenu.type === 'file'">
<div>
<button class="w-full px-4 py-2 text-sm text-left hover:bg-base-200 flex items-center gap-2"
@click="downloadFile(contextMenu.item)">
<i class="fa fa-download w-4"></i> {% translate "Download" %}
</button>
<button class="w-full px-4 py-2 text-sm text-left hover:bg-base-200 flex items-center gap-2"
@click="renameItem(contextMenu.item)">
<i class="fa fa-pen w-4"></i> {% translate "Rename" %}
</button>
<button class="w-full px-4 py-2 text-sm text-left hover:bg-base-200 flex items-center gap-2"
@click="moveItem(contextMenu.item)">
<i class="fa fa-arrows-alt w-4"></i> {% translate "Move to..." %}
</button>
<div class="divider my-0"></div>
<button class="w-full px-4 py-2 text-sm text-left hover:bg-base-200 text-error flex items-center gap-2"
@click="trashItem(contextMenu.item)">
<i class="fa fa-trash w-4"></i> {% translate "Move to trash" %}
</button>
</div>
</template>
<template x-if="contextMenu.type === 'folder'">
<div>
<button class="w-full px-4 py-2 text-sm text-left hover:bg-base-200 flex items-center gap-2"
@click="renameItem(contextMenu.item)">
<i class="fa fa-pen w-4"></i> {% translate "Rename" %}
</button>
<button class="w-full px-4 py-2 text-sm text-left hover:bg-base-200 flex items-center gap-2"
@click="deleteFolder(contextMenu.item)">
<i class="fa fa-trash w-4"></i> {% translate "Delete" %}
</button>
</div>
</template>
</div>- [ ] Step 2: Add context menu state to drive.js
Add these properties and methods to the drive data component in assets/javascript/drive.js:
// Add to Alpine.data('drive', () => ({ ... }))
contextMenu: { show: false, x: 0, y: 0, item: null, type: null },
showContextMenu(event, item, type) {
this.contextMenu = { show: true, x: event.clientX, y: event.clientY, item, type };
},
async downloadFile(file) {
const response = await fetch(`/api/v1/drive/files/${file.id}/download/`, {
headers: { 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '' },
});
const data = await response.json();
if (data.download_url) window.open(data.download_url, '_blank');
this.contextMenu.show = false;
},
renameItem(item) {
this.editingFolder = item.type === 'folder' ? item : null;
this.folderName = item.name;
this.showNewFolder = true;
this.contextMenu.show = false;
},
async trashItem(file) {
await fetch(`/api/v1/drive/files/${file.id}/`, {
method: 'DELETE',
headers: { 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '' },
});
this.files = this.files.filter(f => f.id !== file.id);
this.contextMenu.show = false;
},
async deleteFolder(folder) {
if (!confirm('Delete folder and all contents?')) return;
await fetch(`/api/v1/drive/folders/${folder.id}/`, {
method: 'DELETE',
headers: { 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '' },
});
this.folders = this.folders.filter(f => f.id !== folder.id);
this.contextMenu.show = false;
},- [ ] Step 3: Commit
git add templates/drive/components/context_menu.html assets/javascript/drive.js
git commit -m "feat(drive): add context menu with file/folder actions"Task 10: Pass Data to Templates
Covers: [S1]
Files:
Modify:
apps/drive/views_web.py[ ] Step 1: Update views to serialize data for Alpine.js
# apps/drive/views_web.py
import json
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.shortcuts import render
from apps.drive.models import DriveFile, DriveFolder
from apps.drive.serializers import DriveFileSerializer, DriveFolderSerializer
@login_required
def drive_index(request):
community = request.community
folders = DriveFolder.objects.in_community(community).select_related("parent").order_by("name")
files = DriveFile.objects.in_community(community).active().select_related("folder", "uploader").order_by("-created_at")
folders_data = json.dumps([{
"id": f.id,
"name": f.name,
"parent": f.parent_id,
"expanded": False,
} for f in folders])
files_data = json.dumps([{
"id": f.id,
"name": f.name,
"folder": f.folder_id,
"content_type": f.content_type,
"size": f.size,
"created_at": f.created_at.isoformat() if f.created_at else None,
} for f in files])
return render(request, "drive/index.html", {
"folders_json": folders_data,
"files_json": files_data,
"active_tab": "drive",
})
@login_required
def drive_trash(request):
community = request.community
files = DriveFile.objects.in_community(community).trashed().select_related("folder", "uploader").order_by("-trashed_at")
files_data = json.dumps([{
"id": f.id,
"name": f.name,
"content_type": f.content_type,
"size": f.size,
"trashed_at": f.trashed_at.isoformat() if f.trashed_at else None,
} for f in files])
return render(request, "drive/trash.html", {
"files_json": files_data,
"active_tab": "drive",
})- [ ] Step 2: Update templates to use data attributes
In templates/drive/index.html, add before the Alpine.js script:
<div id="drive-data"
data-folders='{{ folders_json }}'
data-files='{{ files_json }}'
class="hidden"></div>In templates/drive/trash.html, add:
<div id="trash-data"
data-files='{{ files_json }}'
class="hidden"></div>- [ ] Step 3: Commit
git add apps/drive/views_web.py templates/drive/index.html templates/drive/trash.html
git commit -m "feat(drive): pass serialized data to templates for Alpine.js"Summary
| Task | Description | Files |
|---|---|---|
| 1 | URLs & Views | urls_web.py, views_web.py, urls.py |
| 2 | Base Template & Nav | index.html, app_nav_menu_items.html |
| 3 | Folder Sidebar | sidebar.html |
| 4 | File Grid | grid.html, breadcrumb.html, batch_actions.html |
| 5 | Upload Area | upload_area.html |
| 6 | Folder Modal | modal_folder.html |
| 7 | Alpine.js Component | drive.js |
| 8 | Trash Page | trash.html |
| 9 | Context Menu | context_menu.html |
| 10 | Data Serialization | views_web.py, templates |
Execution
How would you like to execute this plan?
- Subagent, always — Fresh subagent per task, remember for future
- Subagent, this time — Fresh subagent per task, just this once
- Inline, always — Execute in this session, remember for future
- Inline, this time — Execute in this session, just this once