mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Update files function!
This commit is contained in:
@@ -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 ./
|
||||
|
||||
@@ -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
@@ -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')),
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user