Update files function!

This commit is contained in:
2026-03-21 20:30:44 +11:00
parent 1368eb795d
commit 7f7a890ad5
14 changed files with 1121 additions and 3 deletions
+1
View File
@@ -133,6 +133,7 @@ COPY --from=builder --chown=appuser:appuser /app/locale /app/locale
COPY --chown=appuser:appuser manage.py ./
COPY --chown=appuser:appuser core/ ./core/
COPY --chown=appuser:appuser links/ ./links/
COPY --chown=appuser:appuser netscan/ ./netscan/
COPY --chown=appuser:appuser new_theme/ ./new_theme/
COPY --chown=appuser:appuser templates/ ./templates/
COPY --chown=appuser:appuser qdrant_sync.py ./
+4
View File
@@ -129,6 +129,10 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'data', 'media')
MUSIC_ROOT = os.path.join(BASE_DIR, 'data', 'music')
# File uploads folder — override via FILE_UPLOADS_FOLDER env var.
# Defaults to ~/Downloads locally; set to /uploads on k8s.
FILE_UPLOADS_FOLDER = os.environ.get('FILE_UPLOADS_FOLDER', os.path.expanduser('~/Downloads'))
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
+10 -1
View File
@@ -4,6 +4,7 @@ from django.conf import settings
from django.conf.urls.static import static
from django.views.static import serve
from links.views import LinkDetailView, LinkUpdateView, CustomLinkView
from links.file_views import PublicFileView
from django.urls import path, include, re_path
from django.conf.urls.i18n import i18n_patterns
@@ -26,8 +27,16 @@ urlpatterns = [
path('custom/<slug:alias>/', CustomLinkView.as_view(), name='custom_link'),
path('custom/<slug:alias>/edit/', LinkUpdateView.as_view(), name='custom_link_update'),
# Include netscan BEFORE links.urls to prevent the alias catch-all from intercepting it
# Include netscan and files BEFORE links.urls to prevent the alias catch-all from intercepting them
path('ui/netscan/', include('netscan.urls')),
path('ui/files/', include('links.file_urls')),
# Public file access — /public/files/{uuid}-{filename}
re_path(
r'^public/files/(?P<pk>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P<filename>.+)$',
PublicFileView.as_view(),
name='public-file',
),
# Include main app URLs with locale
path('', include('links.urls')),
BIN
View File
Binary file not shown.
+29
View File
@@ -26,6 +26,35 @@ spec:
requests:
storage: 100Gi
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: uploads-nfs-apps
spec:
capacity:
storage: 1000Gi
accessModes:
- ReadWriteMany
nfs:
server: 192.168.1.5
path: "/fs/1000/nfs/data/uploads"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: "uploads-nfs-apps"
spec:
storageClassName: ""
volumeName: uploads-nfs-apps
accessModes:
- ReadWriteMany
resources:
requests:
storage: 1000Gi
# ---
# apiVersion: v1
+2
View File
@@ -3,12 +3,14 @@ from rest_framework.routers import DefaultRouter
from . import page_views
from . import post_views
from . import api_views
from . import file_views
# Create a router and register our viewsets with it
router = DefaultRouter(trailing_slash=False)
router.register('pages', page_views.PageViewSet, basename='api-pages')
router.register('posts', post_views.PostViewSet, basename='api-posts')
router.register('music', api_views.MusicViewSet, basename='api-music')
router.register('files', file_views.FileUploadViewSet, basename='api-files')
# The API URLs are determined automatically by the router
urlpatterns = [
+16
View File
@@ -0,0 +1,16 @@
from django.urls import path, re_path
from . import file_views
urlpatterns = [
path('', file_views.FileListView.as_view(), name='file-list'),
path('upload/', file_views.FileUploadView.as_view(), name='file-upload'),
# /ui/files/{uuid}-{filename} — filename is cosmetic, lookup is by uuid only
re_path(
r'^(?P<pk>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P<filename>.+)$',
file_views.FileDownloadView.as_view(),
name='file-download',
),
path('<uuid:pk>/delete/', file_views.FileDeleteView.as_view(), name='file-delete'),
path('<uuid:pk>/toggle-public/', file_views.FileTogglePublicView.as_view(), name='file-toggle-public'),
path('<uuid:pk>/set-expiry/', file_views.FileSetExpiryView.as_view(), name='file-set-expiry'),
]
+207
View File
@@ -0,0 +1,207 @@
import json
import mimetypes
import os
import secrets
import logging
from pathlib import Path
from django.conf import settings
from django.db.models import F
from django.http import FileResponse, JsonResponse, Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.dateparse import parse_datetime
from django.views import View
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from .models import FileUpload
from .serializers import FileUploadSerializer
logger = logging.getLogger(__name__)
def _get_upload_folder():
folder = settings.FILE_UPLOADS_FOLDER
Path(folder).mkdir(parents=True, exist_ok=True)
return folder
def _save_uploaded_file(f):
"""Save an in-memory upload to FILE_UPLOADS_FOLDER and return a FileUpload instance."""
folder = _get_upload_folder()
ext = Path(f.name).suffix.lower()
stored_name = f"{secrets.token_hex(16)}{ext}"
dest_path = os.path.join(folder, stored_name)
with open(dest_path, 'wb') as dst:
for chunk in f.chunks():
dst.write(chunk)
mime_type = f.content_type or mimetypes.guess_type(f.name)[0] or 'application/octet-stream'
return FileUpload.objects.create(
name=f.name,
stored_name=stored_name,
mime_type=mime_type,
size=f.size,
)
# ── UI Views ──────────────────────────────────────────────────────────────────
class FileListView(View):
def get(self, request):
files = FileUpload.objects.all()
return render(request, 'links/files/list.html', {'files': files})
class FileUploadView(View):
def post(self, request):
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
uploaded = request.FILES.getlist('files')
if not uploaded:
if is_ajax:
return JsonResponse({'error': 'No files provided'}, status=400)
return redirect('file-list')
results = []
for f in uploaded:
record = _save_uploaded_file(f)
results.append({'id': str(record.pk), 'name': record.name, 'size': record.size})
if is_ajax:
return JsonResponse({'uploaded': results})
return redirect('file-list')
class FileDownloadView(View):
def get(self, request, pk, filename=''):
record = get_object_or_404(FileUpload, pk=pk)
if not os.path.exists(record.file_path):
raise Http404("File not found on disk")
FileUpload.objects.filter(pk=pk).update(download_count=F('download_count') + 1)
disposition = 'inline' if record.is_image else f'attachment; filename="{record.name}"'
response = FileResponse(open(record.file_path, 'rb'), content_type=record.mime_type)
response['Content-Disposition'] = disposition
return response
class FileDeleteView(View):
def post(self, request, pk):
record = get_object_or_404(FileUpload, pk=pk)
if os.path.exists(record.file_path):
os.remove(record.file_path)
record.delete()
return redirect('file-list')
class FileTogglePublicView(View):
def post(self, request, pk):
record = get_object_or_404(FileUpload, pk=pk)
if record.is_public:
record.is_public = False
record.save(update_fields=['is_public', 'updated_at'])
return JsonResponse({'is_public': False, 'public_url': None})
record.is_public = True
record.save(update_fields=['is_public', 'updated_at'])
return JsonResponse({
'is_public': True,
'public_url': record.public_url,
})
class FileSetExpiryView(View):
def post(self, request, pk):
record = get_object_or_404(FileUpload, pk=pk)
try:
data = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'Invalid JSON'}, status=400)
expires_at = data.get('expires_at')
if expires_at:
dt = parse_datetime(expires_at)
if not dt:
return JsonResponse({'error': 'Invalid datetime format. Use ISO 8601.'}, status=400)
record.expires_at = dt
else:
record.expires_at = None
record.save(update_fields=['expires_at', 'updated_at'])
return JsonResponse({
'expires_at': record.expires_at.isoformat() if record.expires_at else None,
'is_expired': record.is_expired,
})
class PublicFileView(View):
def get(self, request, pk, filename=''):
record = get_object_or_404(FileUpload, pk=pk, is_public=True)
if record.is_expired:
raise Http404("This public link has expired")
if not os.path.exists(record.file_path):
raise Http404("File not found")
FileUpload.objects.filter(pk=record.pk).update(download_count=F('download_count') + 1)
disposition = 'inline' if record.is_image else f'attachment; filename="{record.name}"'
response = FileResponse(open(record.file_path, 'rb'), content_type=record.mime_type)
response['Content-Disposition'] = disposition
return response
# ── REST API ViewSet ──────────────────────────────────────────────────────────
class FileUploadViewSet(viewsets.ModelViewSet):
queryset = FileUpload.objects.all()
serializer_class = FileUploadSerializer
parser_classes = [MultiPartParser, FormParser]
http_method_names = ['get', 'post', 'delete', 'head', 'options']
def create(self, request, *args, **kwargs):
uploaded = request.FILES.getlist('files')
if not uploaded:
return Response({'error': 'No files provided. Use files[] field.'}, status=status.HTTP_400_BAD_REQUEST)
created = [_save_uploaded_file(f) for f in uploaded]
serializer = self.get_serializer(created, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def destroy(self, request, *args, **kwargs):
record = self.get_object()
if os.path.exists(record.file_path):
os.remove(record.file_path)
record.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['post'], url_path='toggle-public')
def toggle_public(self, request, pk=None):
record = self.get_object()
if record.is_public:
record.is_public = False
record.save(update_fields=['is_public', 'updated_at'])
return Response({'is_public': False, 'public_url': None})
record.is_public = True
record.save(update_fields=['is_public', 'updated_at'])
return Response({
'is_public': True,
'public_url': record.public_url,
})
@action(detail=True, methods=['post'], url_path='set-expiry')
def set_expiry(self, request, pk=None):
record = self.get_object()
expires_at = request.data.get('expires_at')
if expires_at:
dt = parse_datetime(str(expires_at))
if not dt:
return Response({'error': 'Invalid datetime. Use ISO 8601.'}, status=status.HTTP_400_BAD_REQUEST)
record.expires_at = dt
else:
record.expires_at = None
record.save(update_fields=['expires_at', 'updated_at'])
return Response(self.get_serializer(record).data)
@action(detail=True, methods=['get'])
def download(self, request, pk=None):
record = self.get_object()
if not os.path.exists(record.file_path):
raise Http404("File not found")
FileUpload.objects.filter(pk=pk).update(download_count=F('download_count') + 1)
disposition = 'inline' if record.is_image else f'attachment; filename="{record.name}"'
response = FileResponse(open(record.file_path, 'rb'), content_type=record.mime_type)
response['Content-Disposition'] = disposition
return response
+35
View File
@@ -0,0 +1,35 @@
# Generated by Django 5.2.11 on 2026-03-21 06:18
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('links', '0038_remove_iptv_models'),
]
operations = [
migrations.CreateModel(
name='FileUpload',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255, verbose_name='Name')),
('stored_name', models.CharField(max_length=255, verbose_name='Stored Name')),
('mime_type', models.CharField(blank=True, max_length=128, verbose_name='MIME Type')),
('size', models.PositiveBigIntegerField(default=0, verbose_name='Size')),
('is_public', models.BooleanField(db_index=True, default=False, verbose_name='Is Public')),
('public_token', models.CharField(blank=True, max_length=64, null=True, unique=True, verbose_name='Public Token')),
('expires_at', models.DateTimeField(blank=True, null=True, verbose_name='Expires At')),
('download_count', models.PositiveIntegerField(default=0, verbose_name='Download Count')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
],
options={
'verbose_name': 'File Upload',
'verbose_name_plural': 'File Uploads',
'ordering': ['-created_at'],
},
),
]
+61
View File
@@ -338,3 +338,64 @@ class Image(models.Model):
height=height,
fit='cover'
)
class FileUpload(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(_('Name'), max_length=255)
stored_name = models.CharField(_('Stored Name'), max_length=255)
mime_type = models.CharField(_('MIME Type'), max_length=128, blank=True)
size = models.PositiveBigIntegerField(_('Size'), default=0)
is_public = models.BooleanField(_('Is Public'), default=False, db_index=True)
public_token = models.CharField(_('Public Token'), max_length=64, unique=True, null=True, blank=True)
expires_at = models.DateTimeField(_('Expires At'), null=True, blank=True)
download_count = models.PositiveIntegerField(_('Download Count'), default=0)
created_at = models.DateTimeField(_('Created At'), auto_now_add=True)
updated_at = models.DateTimeField(_('Updated At'), auto_now=True)
class Meta:
ordering = ['-created_at']
verbose_name = _('File Upload')
verbose_name_plural = _('File Uploads')
def __str__(self):
return self.name
@property
def file_path(self):
return os.path.join(settings.FILE_UPLOADS_FOLDER, self.stored_name)
@property
def is_expired(self):
if self.expires_at:
return timezone.now() > self.expires_at
return False
@property
def is_image(self):
return self.mime_type.startswith('image/')
@property
def is_publicly_accessible(self):
return self.is_public and not self.is_expired
@property
def download_url(self):
import re
safe_name = re.sub(r'[^\w.\-]', '-', self.name)
return f'/ui/files/{self.pk}-{safe_name}'
@property
def public_url(self):
import re
safe_name = re.sub(r'[^\w.\-]', '-', self.name)
return f'/public/files/{self.pk}-{safe_name}'
def formatted_size(self):
if self.size < 1024:
return f"{self.size} B"
elif self.size < 1024 * 1024:
return f"{self.size / 1024:.1f} KB"
elif self.size < 1024 * 1024 * 1024:
return f"{self.size / (1024 * 1024):.1f} MB"
return f"{self.size / (1024 * 1024 * 1024):.2f} GB"
+31 -1
View File
@@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import Page, Post, ImageCollection, Image, Tag
from .models import Page, Post, ImageCollection, Image, Tag, FileUpload
class PageSerializer(serializers.ModelSerializer):
class Meta:
@@ -83,3 +83,33 @@ class ImageCollectionSerializer(serializers.ModelSerializer):
def get_image_count(self, obj):
return obj.images.count()
class FileUploadSerializer(serializers.ModelSerializer):
formatted_size = serializers.SerializerMethodField()
public_url = serializers.SerializerMethodField()
is_expired = serializers.SerializerMethodField()
class Meta:
model = FileUpload
fields = [
'id', 'name', 'mime_type', 'size', 'formatted_size',
'is_public', 'public_token', 'public_url',
'expires_at', 'is_expired',
'download_count', 'created_at', 'updated_at',
]
read_only_fields = [
'id', 'mime_type', 'size', 'formatted_size',
'public_token', 'public_url', 'is_expired',
'download_count', 'created_at', 'updated_at',
]
def get_formatted_size(self, obj):
return obj.formatted_size()
def get_public_url(self, obj):
if obj.is_public and obj.public_token:
return f'/public/files/{obj.public_token}/'
return None
def get_is_expired(self, obj):
return obj.is_expired
+473
View File
@@ -0,0 +1,473 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% block extra_css %}
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
{% endblock %}
{% block content %}
<!-- Single Alpine scope wraps the entire page + HUD -->
<div x-data="fileManager()"
@open-expiry.window="openExpiry($event.detail)"
@copy-link.window="copyAndToast($event.detail.url)">
<!-- Hidden file input -->
<input type="file" id="globalFileInput" multiple class="hidden">
<!-- Full-page drag overlay -->
<div id="dragOverlay"
class="fixed inset-0 z-40 bg-red-50/80 border-4 border-dashed border-red-400 flex items-center justify-center pointer-events-none opacity-0 transition-opacity duration-150">
<div class="text-center">
<svg class="w-16 h-16 mx-auto text-red-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<p class="text-xl font-semibold text-red-600">{% trans "Drop files to upload" %}</p>
</div>
</div>
<!-- Main content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">{% trans "Files" %}</h1>
<button @click="openPicker()"
class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
{% trans "Upload Files" %}
</button>
</div>
<!-- Files Table -->
<div class="bg-white shadow-md rounded-lg overflow-hidden">
<div class="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h2 class="text-sm font-semibold text-gray-700">
{% trans "All Files" %} <span class="text-gray-400 font-normal">({{ files.count }})</span>
</h2>
</div>
{% if files %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{% trans "Name" %}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden md:table-cell">{% trans "Type" %}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{% trans "Size" %}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell">{% trans "Uploaded" %}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{% trans "Public" %}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell">{% trans "Downloads" %}</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
{% for file in files %}
<tr class="hover:bg-gray-50"
x-data="fileRow('{{ file.pk }}', {{ file.is_public|lower }}, '{{ file.expires_at|date:'c'|default:'' }}', '{{ file.public_url }}')">
<!-- Name -->
<td class="px-4 py-3">
<a href="{{ file.download_url }}"
class="font-medium text-blue-600 hover:text-blue-800 hover:underline flex items-center max-w-xs"
{% if file.is_image %}target="_blank"{% endif %}>
<svg class="w-4 h-4 mr-2 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{% if file.is_image %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
{% else %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
{% endif %}
</svg>
<span class="truncate">{{ file.name }}</span>
</a>
</td>
<!-- Type -->
<td class="px-4 py-3 text-gray-500 hidden md:table-cell">{{ file.mime_type|truncatechars:30 }}</td>
<!-- Size -->
<td class="px-4 py-3 text-gray-600 whitespace-nowrap">{{ file.formatted_size }}</td>
<!-- Uploaded -->
<td class="px-4 py-3 text-gray-500 whitespace-nowrap hidden sm:table-cell">{{ file.created_at|date:"Y-m-d H:i" }}</td>
<!-- Public -->
<td class="px-4 py-3">
<div class="flex flex-col space-y-1">
<span x-show="isPublic"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 cursor-pointer w-fit"
@click="togglePublic()"
title="{% trans 'Click to make private' %}">
● {% trans "Public" %}
</span>
<span x-show="!isPublic"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 cursor-pointer w-fit"
@click="togglePublic()"
title="{% trans 'Click to make public' %}">
○ {% trans "Private" %}
</span>
<span x-show="isPublic && expiresAt"
:class="isExpired ? 'text-red-500' : 'text-gray-400'"
class="text-xs cursor-pointer"
@click="window.dispatchEvent(new CustomEvent('open-expiry', {detail: {pk, expiresAt}}))"
x-text="isExpired ? '⚠ Expired' : '⏱ ' + formatExpiry(expiresAt)">
</span>
<span x-show="isPublic && !expiresAt"
class="text-xs text-gray-400 cursor-pointer"
@click="window.dispatchEvent(new CustomEvent('open-expiry', {detail: {pk, expiresAt}}))">
{% trans "No expiry" %}
</span>
<span x-show="isPublic" class="text-xs">
<button @click="copyPublicUrl()"
class="text-blue-500 hover:text-blue-700 flex items-center">
<svg class="w-3 h-3 mr-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{% trans "Copy link" %}
</button>
</span>
</div>
</td>
<!-- Downloads -->
<td class="px-4 py-3 text-gray-600 hidden sm:table-cell">{{ file.download_count }}</td>
<!-- Actions -->
<td class="px-4 py-3 text-right relative">
<div class="flex items-center justify-end space-x-1">
<a href="{{ file.download_url }}"
{% if file.is_image %}target="_blank"{% endif %}
class="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded"
title="{% trans 'Download / View' %}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
</a>
<button x-show="isPublic"
@click="window.dispatchEvent(new CustomEvent('open-expiry', {detail: {pk, expiresAt}}))"
class="p-1.5 text-gray-500 hover:text-yellow-600 hover:bg-yellow-50 rounded"
title="{% trans 'Set expiry' %}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
<form method="post" action="{% url 'file-delete' file.pk %}"
@submit.prevent="if(confirm('{% trans 'Delete this file?' %}')) $el.submit()">
{% csrf_token %}
<button type="submit"
class="p-1.5 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded"
title="{% trans 'Delete' %}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="py-16 text-center text-gray-400">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<p class="text-sm font-medium">{% trans "No files yet" %}</p>
<p class="text-xs mt-1">{% trans "Drag files anywhere on this page, or click Upload Files above." %}</p>
</div>
{% endif %}
</div>
</div><!-- /main content -->
<!-- ── Toast notification ──────────────────────────────────────── -->
<div x-show="toastVisible"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
style="position:fixed;top:5rem;right:1.5rem;z-index:9999;pointer-events:none;background:#111827;color:#fff;font-size:.875rem;padding:.625rem 1rem;border-radius:.5rem;box-shadow:0 10px 25px rgba(0,0,0,.4);display:flex;align-items:center;gap:.5rem;"
<svg style="width:1rem;height:1rem;color:#4ade80;flex-shrink:0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span x-text="toastMsg"></span>
</div>
<!-- ── Global Expiry Modal ─────────────────────────────────────── -->
<div x-show="expiryModal.open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
style="position:fixed;inset:0;z-index:9999"
class="bg-black/40 flex items-center justify-center p-4"
@click.self="expiryModal.open = false">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-sm p-6"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">{% trans "Set Expiry Date" %}</h3>
<button @click="expiryModal.open = false"
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<p class="text-sm text-gray-500 mb-4">{% trans "Leave blank for no expiry. The public link will stop working after this date." %}</p>
<input type="datetime-local" x-model="expiryModal.expiryInput"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent">
<div class="flex justify-between items-center mt-5">
<button @click="clearExpiry()"
class="text-sm text-red-500 hover:text-red-700 font-medium">{% trans "Clear expiry" %}</button>
<div class="flex space-x-2">
<button @click="expiryModal.open = false"
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">{% trans "Cancel" %}</button>
<button @click="saveExpiry()"
class="px-4 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors">{% trans "Save" %}</button>
</div>
</div>
</div>
</div>
<!-- ── Upload Progress HUD ────────────────────────────────────────
Always rendered. Stuck to bottom-right via inline style so no
ancestor transform can interfere with CSS position:fixed.
──────────────────────────────────────────────────────────────── -->
<div style="position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;width:20rem">
<div class="bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden">
<!-- Header — always visible, click to fold/unfold -->
<div @click="hudOpen = !hudOpen"
class="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-200 cursor-pointer select-none">
<div class="flex items-center space-x-2">
<!-- animated pulse dot when uploading -->
<span x-show="uploads.some(u => u.status === 'uploading')"
class="w-2 h-2 rounded-full bg-blue-500 animate-pulse flex-shrink-0"></span>
<span x-show="!uploads.some(u => u.status === 'uploading')"
class="w-2 h-2 rounded-full bg-gray-300 flex-shrink-0"></span>
<span class="text-sm font-semibold text-gray-700">
<span x-show="uploads.some(u => u.status === 'uploading')"
x-text="'{% trans "Uploading" %} ' + uploads.filter(u => u.status === 'uploading').length + ' / ' + uploads.length"></span>
<span x-show="!uploads.some(u => u.status === 'uploading')">{% trans "Uploads" %}</span>
</span>
</div>
<div class="flex items-center space-x-2">
<button x-show="uploads.some(u => u.status === 'uploading')"
@click.stop="cancelAll()"
class="text-xs text-red-500 hover:text-red-700 font-medium px-2 py-0.5 rounded hover:bg-red-50 transition-colors">
{% trans "Cancel" %}
</button>
<!-- chevron rotates when open -->
<svg class="w-4 h-4 text-gray-400 transition-transform duration-200"
:class="hudOpen ? '' : 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<!-- Collapsible body -->
<div x-show="hudOpen"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1">
<!-- Empty state -->
<div x-show="uploads.length === 0"
class="px-4 py-5 text-center text-xs text-gray-400">
{% trans "Drag files anywhere or click Upload Files." %}
</div>
<!-- File rows -->
<div x-show="uploads.length > 0" class="max-h-60 overflow-y-auto divide-y divide-gray-100">
<template x-for="u in uploads" :key="u.id">
<div class="px-4 py-3">
<div class="flex items-center justify-between mb-1.5">
<span class="text-xs font-medium text-gray-700 truncate max-w-[180px]" x-text="u.name"></span>
<span class="text-xs ml-2 flex-shrink-0 font-medium"
:class="{
'text-blue-500': u.status === 'uploading',
'text-green-600': u.status === 'done',
'text-red-500': u.status === 'error',
'text-gray-400': u.status === 'cancelled'
}"
x-text="u.status === 'uploading' ? u.progress + '%' : u.status"></span>
</div>
<div class="w-full bg-gray-100 rounded-full h-1.5">
<div class="h-1.5 rounded-full transition-all duration-200"
:class="{
'bg-blue-500': u.status === 'uploading',
'bg-green-500': u.status === 'done',
'bg-red-400': u.status === 'error',
'bg-gray-300': u.status === 'cancelled'
}"
:style="'width:' + u.progress + '%'"></div>
</div>
</div>
</template>
</div>
</div><!-- /collapsible body -->
</div>
</div><!-- /HUD -->
</div><!-- /Alpine scope -->
<script>
function fileManager() {
return {
uploads: [],
hudOpen: false,
toastMsg: '',
toastVisible: false,
_toastTimer: null,
expiryModal: { open: false, pk: '', expiryInput: '' },
init() {
const overlay = document.getElementById('dragOverlay');
const input = document.getElementById('globalFileInput');
let dragCounter = 0;
document.addEventListener('dragenter', e => {
if (!e.dataTransfer?.types?.includes('Files')) return;
dragCounter++;
overlay.style.opacity = '1';
});
document.addEventListener('dragleave', () => {
dragCounter = Math.max(0, dragCounter - 1);
if (dragCounter === 0) overlay.style.opacity = '0';
});
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => {
e.preventDefault();
dragCounter = 0;
overlay.style.opacity = '0';
const files = e.dataTransfer?.files;
if (files?.length) this.uploadFiles(files);
});
input.addEventListener('change', e => {
if (e.target.files.length) this.uploadFiles(e.target.files);
e.target.value = '';
});
},
openPicker() {
document.getElementById('globalFileInput').click();
},
// ── Uploads ─────────────────────────────────────────────────
uploadFiles(fileList) {
this.hudOpen = true;
Array.from(fileList).forEach(file => {
const id = Date.now() + Math.random();
this.uploads.push({ id, name: file.name, progress: 0, status: 'uploading', xhr: null });
this._uploadOne(file, id);
});
},
_uploadOne(file, itemId) {
const formData = new FormData();
formData.append('files', file);
formData.append('csrfmiddlewaretoken', getCsrfToken());
const xhr = new XMLHttpRequest();
const find = () => this.uploads.find(u => u.id === itemId);
const u0 = find(); if (u0) u0.xhr = xhr;
xhr.upload.onprogress = e => {
if (e.lengthComputable) { const u = find(); if (u) u.progress = Math.round((e.loaded / e.total) * 100); }
};
xhr.onload = () => {
const u = find();
if (u) { u.status = xhr.status >= 200 && xhr.status < 300 ? 'done' : 'error'; if (u.status === 'done') u.progress = 100; }
if (this.uploads.every(u => u.status !== 'uploading')) setTimeout(() => { window.location.reload(); }, 1500);
};
xhr.onerror = () => { const u = find(); if (u) u.status = 'error'; };
xhr.open('POST', '{% url "file-upload" %}');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.send(formData);
},
cancelAll() {
this.uploads.forEach(u => { if (u.xhr && u.status === 'uploading') { u.xhr.abort(); u.status = 'cancelled'; } });
setTimeout(() => { this.uploads = []; }, 800);
},
// ── Expiry modal ─────────────────────────────────────────────
openExpiry({ pk, expiresAt }) {
this.expiryModal.pk = pk;
this.expiryModal.expiryInput = expiresAt ? new Date(expiresAt).toISOString().slice(0, 16) : '';
this.expiryModal.open = true;
},
saveExpiry() {
const expires_at = this.expiryModal.expiryInput ? new Date(this.expiryModal.expiryInput).toISOString() : null;
fetch(`/ui/files/${this.expiryModal.pk}/set-expiry/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
body: JSON.stringify({ expires_at }),
}).then(r => r.json()).then(d => {
this.expiryModal.open = false;
window.dispatchEvent(new CustomEvent('expiry-updated', { detail: { pk: this.expiryModal.pk, expires_at: d.expires_at } }));
});
},
clearExpiry() { this.expiryModal.expiryInput = ''; this.saveExpiry(); },
// ── Toast ────────────────────────────────────────────────────
copyAndToast(url) {
const el = document.createElement('textarea');
el.value = url; el.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el);
this.showToast('{% trans "Link copied!" %}');
},
showToast(msg) {
this.toastMsg = msg;
this.toastVisible = true;
clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500);
},
};
}
function fileRow(pk, isPublic, expiresAt, publicUrl) {
return {
pk, isPublic, publicUrl,
expiresAt: expiresAt || null,
get isExpired() { return this.expiresAt && new Date(this.expiresAt) < new Date(); },
init() {
window.addEventListener('expiry-updated', e => {
if (String(e.detail.pk) === String(this.pk)) this.expiresAt = e.detail.expires_at;
});
},
togglePublic() {
fetch(`/ui/files/${this.pk}/toggle-public/`, {
method: 'POST', headers: { 'X-CSRFToken': getCsrfToken() },
}).then(r => r.json()).then(d => { this.isPublic = d.is_public; });
},
formatExpiry(dt) { return dt ? new Date(dt).toLocaleDateString() : ''; },
copyPublicUrl() {
const url = location.origin + this.publicUrl;
const el = document.createElement('textarea');
el.value = url; el.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el);
// Fire directly on window — $dispatch bubbles through <tr>/<table> unreliably
window.dispatchEvent(new CustomEvent('copy-link', { detail: { url } }));
},
};
}
function getCsrfToken() {
return document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='))?.split('=')[1] || '';
}
</script>
{% endblock %}
+242 -1
View File
@@ -221,7 +221,181 @@ paths:
schema:
$ref: '#/components/schemas/SuccessResponse'
components:
/api/files:
get:
summary: List all uploaded files
description: Retrieve a paginated list of all uploaded files
parameters:
- in: query
name: page
schema:
type: integer
description: Page number for pagination
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/FileList'
post:
summary: Upload one or more files
description: Upload one or multiple files. Files are stored in FILE_UPLOADS_FOLDER on the server.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- files
properties:
files:
type: array
items:
type: string
format: binary
description: One or more files to upload
responses:
'201':
description: Files uploaded successfully
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/FileUpload'
'400':
description: No files provided
/api/files/{id}:
get:
summary: Get file details
parameters:
- in: path
name: id
required: true
schema:
type: string
format: uuid
responses:
'200':
description: File details
content:
application/json:
schema:
$ref: '#/components/schemas/FileUpload'
'404':
description: File not found
delete:
summary: Delete a file
description: Deletes the file record and removes it from disk
parameters:
- in: path
name: id
required: true
schema:
type: string
format: uuid
responses:
'204':
description: File deleted
'404':
description: File not found
/api/files/{id}/download:
get:
summary: Download or view a file
description: Serves the file content. Images are served inline; other files as attachments.
parameters:
- in: path
name: id
required: true
schema:
type: string
format: uuid
responses:
'200':
description: File content
'404':
description: File not found
/api/files/{id}/toggle-public:
post:
summary: Toggle public/private visibility
description: Makes a private file public (generating a public token) or makes a public file private immediately.
parameters:
- in: path
name: id
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Updated visibility state
content:
application/json:
schema:
type: object
properties:
is_public:
type: boolean
public_token:
type: string
nullable: true
public_url:
type: string
nullable: true
example: /public/files/<token>/
/api/files/{id}/set-expiry:
post:
summary: Set or clear expiry date for a public file
parameters:
- in: path
name: id
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
expires_at:
type: string
format: date-time
nullable: true
description: ISO 8601 datetime. Send null or omit to clear the expiry.
responses:
'200':
description: Updated file details
content:
application/json:
schema:
$ref: '#/components/schemas/FileUpload'
/public/files/{token}/:
get:
summary: Access a public file by token
description: Serves a publicly shared file. Returns 404 if the file is private or the link has expired.
parameters:
- in: path
name: token
required: true
schema:
type: string
responses:
'200':
description: File content (inline for images, attachment for others)
'404':
description: File not found, is private, or link has expired
schemas:
Page:
type: object
@@ -417,6 +591,73 @@ components:
url:
type: string
FileUpload:
type: object
properties:
id:
type: string
format: uuid
readOnly: true
name:
type: string
description: Original filename
mime_type:
type: string
readOnly: true
size:
type: integer
readOnly: true
description: File size in bytes
formatted_size:
type: string
readOnly: true
description: Human-readable size (e.g. "1.4 MB")
is_public:
type: boolean
public_token:
type: string
nullable: true
readOnly: true
public_url:
type: string
nullable: true
readOnly: true
example: /public/files/<token>/
expires_at:
type: string
format: date-time
nullable: true
is_expired:
type: boolean
readOnly: true
download_count:
type: integer
readOnly: true
created_at:
type: string
format: date-time
readOnly: true
updated_at:
type: string
format: date-time
readOnly: true
FileList:
type: object
properties:
count:
type: integer
next:
type: string
nullable: true
previous:
type: string
nullable: true
results:
type: array
items:
$ref: '#/components/schemas/FileUpload'
SuccessResponse:
type: object
properties:
+10
View File
@@ -139,6 +139,16 @@
</div>
</a>
<a href="{% url 'file-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
{% trans "Files" %}
</div>
</a>
<a href="{% url 'mini-apps-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">