Skip to content

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 component

Task 1: Django URLs & Views

Covers: [S2]

Files:

  • Create: apps/drive/urls_web.py

  • Create: apps/drive/views_web.py

  • Modify: project/urls.py:41

  • [ ] Step 1: Create web URL patterns

python
# 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
python
# 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
python
# 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
bash
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.html

  • Modify: templates/web/components/app_nav_menu_items.html:38

  • [ ] Step 1: Add Drive link to navigation

html
<!-- 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
html
<!-- 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
bash
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

html
<!-- 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
bash
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.html

  • Create: templates/drive/components/file_card.html

  • Create: templates/drive/components/breadcrumb.html

  • [ ] Step 1: Create breadcrumb component

html
<!-- 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
html
<!-- 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
html
<!-- 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
bash
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

html
<!-- 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
bash
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

html
<!-- 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
bash
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

javascript
// 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
bash
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

html
<!-- 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
bash
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

html
<!-- 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:

javascript
// 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
bash
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

python
# 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:

html
<div id="drive-data"
     data-folders='{{ folders_json }}'
     data-files='{{ files_json }}'
     class="hidden"></div>

In templates/drive/trash.html, add:

html
<div id="trash-data"
     data-files='{{ files_json }}'
     class="hidden"></div>
  • [ ] Step 3: Commit
bash
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

TaskDescriptionFiles
1URLs & Viewsurls_web.py, views_web.py, urls.py
2Base Template & Navindex.html, app_nav_menu_items.html
3Folder Sidebarsidebar.html
4File Gridgrid.html, breadcrumb.html, batch_actions.html
5Upload Areaupload_area.html
6Folder Modalmodal_folder.html
7Alpine.js Componentdrive.js
8Trash Pagetrash.html
9Context Menucontext_menu.html
10Data Serializationviews_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

Strum — Documentação.