ui update

This commit is contained in:
2026-07-15 15:37:19 +10:00
parent bbce62034a
commit 93f1a1ccae
2 changed files with 362 additions and 345 deletions
+16 -1
View File
@@ -66,7 +66,20 @@ class FileUploadView(View):
results = []
for f in uploaded:
record = _save_uploaded_file(f)
results.append({'id': str(record.pk), 'name': record.name, 'size': record.size})
results.append({
'id': str(record.pk),
'name': record.name,
'mime_type': record.mime_type,
'size': record.size,
'formatted_size': record.formatted_size(),
'is_public': record.is_public,
'public_url': record.public_url,
'expires_at': record.expires_at.isoformat() if record.expires_at else None,
'download_count': record.download_count,
'created_at': record.created_at.isoformat(),
'download_url': record.download_url,
'is_image': record.is_image,
})
if is_ajax:
return JsonResponse({'uploaded': results})
return redirect('file-list')
@@ -90,6 +103,8 @@ class FileDeleteView(View):
if os.path.exists(record.file_path):
os.remove(record.file_path)
record.delete()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({'deleted': str(pk)})
return redirect('file-list')
+346 -344
View File
@@ -3,21 +3,22 @@
{% load static %}
{% block extra_css %}
<style>[x-cloak] { display: none !important; }</style>
{% 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)">
@copy-link.window="copyAndToast($event.detail.url)"
@open-preview.window="openPreview($event.detail)">
<!-- Hidden file input -->
<input type="file" id="globalFileInput" multiple class="hidden">
<input type="file" id="globalFileInput" multiple class="hidden" aria-hidden="true">
<!-- 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">
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"
aria-hidden="true">
<div class="text-center px-4">
<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"/>
@@ -26,15 +27,14 @@
</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">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 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">
<button type="button" @click="openPicker()"
class="inline-flex items-center justify-center px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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>
@@ -42,275 +42,176 @@
</button>
</div>
<!-- Files Table -->
<!-- File list card -->
<div class="bg-white shadow-md rounded-lg overflow-hidden">
<div class="bg-gray-50 border-b border-gray-200 px-4 py-3">
<!-- Card header with count -->
<div class="bg-gray-50 border-b border-gray-200 px-4 sm:px-6 py-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-700">
{% trans "All Files" %} <span class="text-gray-400 font-normal">({{ files.count }})</span>
{% trans "All Files" %}
<span class="text-gray-400 font-normal" x-text="'(' + visibleFiles.length + ')'">({{ files.count }})</span>
</h2>
</div>
{% if files %}
<!-- ── Desktop: Table layout (hidden on small screens) ── -->
<div class="hidden sm:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<caption class="sr-only">{% trans "File list" %}</caption>
<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" id="desktop-file-table">
{% 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-[200px] lg:max-w-xs"
@click.prevent="window.dispatchEvent(new CustomEvent('open-preview', {detail: {name: '{{ file.name|escapejs }}', mimeType: '{{ file.mime_type }}', url: '{{ file.download_url }}'}}))">
<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>
<!-- Hidden JSON template of each initial file -->
<script id="initial-file-data" type="application/json" aria-hidden="true">
[{% for file in files %}{
"id": "{{ file.pk }}",
"name": "{{ file.name|escapejs }}",
"mime_type": "{{ file.mime_type|escapejs }}",
"size": "{{ file.formatted_size }}",
"download_count": {{ file.download_count }},
"created_at": "{{ file.created_at|date:'c' }}",
"is_public": {{ file.is_public|lower }},
"public_url": "{{ file.public_url|escapejs }}",
"expires_at": "{{ file.expires_at|date:'c' }}",
"download_url": "{{ file.download_url|escapejs }}",
"is_image": {{ file.is_image|lower }}
}{% if not forloop.last %},{% endif %}{% endfor %}]
</script>
<!-- List body. Single render per file — responsive layout adapts to viewport. -->
<ul x-ref="listEl" role="list" class="divide-y divide-gray-100" @remove-file.window="removeFile($event.detail.id)">
<template x-for="f in visibleFiles" :key="f.id">
<li class="px-4 sm:px-6 py-4 hover:bg-gray-50 transition-colors"
x-data="fileRow(f)"
:data-pk="f.id">
<!-- Anchor row: filename + actions -->
<div class="flex items-start gap-3">
<!-- File icon -->
<span class="flex-shrink-0 mt-0.5 text-gray-400" aria-hidden="true">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<template x-if="f.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"/>
</template>
<template x-if="!f.is_image">
<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"/>
</template>
</svg>
</span>
<!-- Filename (clickable -> preview) -->
<div class="flex-1 min-w-0">
<a href="#" @click.prevent="$root.dispatchEvent(new CustomEvent('open-preview', {bubbles:true, detail:{name:f.name, mimeType:f.mime_type, url:f.download_url}}))"
class="block font-medium text-blue-600 hover:text-blue-800 hover:underline break-all sm:truncate sm:max-w-xs"
:title="f.name"
x-text="f.name"></a>
<!-- Metadata line: below filename -->
<dl class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-gray-500">
<dt class="sr-only">{% trans "Size" %}</dt>
<dd class="whitespace-nowrap" x-text="f.size"></dd>
<span class="text-gray-300" aria-hidden="true">·</span>
<dt class="sr-only">{% trans "Type" %}</dt>
<dd class="truncate max-w-[12rem]" x-text="f.mime_type" :title="f.mime_type"></dd>
<span class="text-gray-300 hidden sm:inline" aria-hidden="true">·</span>
<dt class="sr-only">{% trans "Uploaded" %}</dt>
<dd class="whitespace-nowrap" x-text="formatDate(f.created_at)"></dd>
<span class="text-gray-300 hidden sm:inline" aria-hidden="true">·</span>
<dt class="sr-only">{% trans "Downloads" %}</dt>
<dd class="whitespace-nowrap">
<span x-text="f.download_count"></span>
<span class="text-gray-400 ml-0.5">{% trans "downloads" %}</span>
</dd>
</dl>
<!-- Public status + copy link -->
<div class="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<button type="button" @click="togglePublic()"
class="inline-flex items-center px-2 py-0.5 rounded-full font-medium cursor-pointer focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-400"
:class="isPublic ? 'bg-green-100 text-green-800 hover:bg-green-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
:title="isPublic ? '{% trans "Click to make private" %}' : '{% trans "Click to make public" %}'">
<span x-show="isPublic" aria-hidden="true"></span>
<span x-show="!isPublic" aria-hidden="true"></span>&nbsp;<span x-text="isPublic ? '{% trans "Public" %}' : '{% trans "Private" %}'"></span>
</button>
<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>
class="cursor-pointer hover:underline"
@click="$root.dispatchEvent(new CustomEvent('open-expiry', {bubbles:true, detail:{pk, expiresAt}}))"
x-text="isExpired ? '⚠ {% trans "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}}))">
class="text-gray-400 cursor-pointer hover:underline"
@click="$root.dispatchEvent(new CustomEvent('open-expiry', {bubbles:true, 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"/>
<button type="button" x-show="isPublic" @click="copyPublicUrl()"
class="inline-flex items-center text-blue-500 hover:text-blue-700 focus:outline-none focus:underline">
<svg class="w-3 h-3 mr-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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>
<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>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- ── Mobile: Card layout (shown only on small screens) ── -->
<div class="block sm:hidden divide-y divide-gray-100">
{% for file in files %}
<div class="px-4 py-4 space-y-2"
x-data="fileRow('{{ file.pk }}', {{ file.is_public|lower }}, '{{ file.expires_at|date:'c'|default:'' }}', '{{ file.public_url }}')">
<!-- File name row -->
<div class="flex items-start justify-between gap-2">
<a href="{{ file.download_url }}"
class="font-medium text-blue-600 hover:text-blue-800 hover:underline flex items-center flex-1 min-w-0"
@click.prevent="window.dispatchEvent(new CustomEvent('open-preview', {detail: {name: '{{ file.name|escapejs }}', mimeType: '{{ file.mime_type }}', url: '{{ file.download_url }}'}}))">
<svg class="w-4 h-4 mr-2 mt-0.5 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="text-sm leading-snug break-all">{{ file.name }}</span>
</a>
<!-- Toggle public badge (compact) -->
<span x-show="isPublic"
class="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 cursor-pointer"
@click="togglePublic()">
</span>
<span x-show="!isPublic"
class="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 cursor-pointer"
@click="togglePublic()">
</span>
</div>
<!-- Metadata grid: 2-col layout -->
<div class="grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-gray-500">
<div>
<span class="text-gray-400">{% trans "Size" %}:</span>
<span class="text-gray-700 font-medium">{{ file.formatted_size }}</span>
</div>
<div>
<span class="text-gray-400">{% trans "Type" %}:</span>
<span class="text-gray-700 truncate block">{{ file.mime_type|truncatechars:25 }}</span>
</div>
<div>
<span class="text-gray-400">{% trans "Uploaded" %}:</span>
<span class="text-gray-700">{{ file.created_at|date:"Y-m-d H:i" }}</span>
</div>
<div>
<span class="text-gray-400">{% trans "Downloads" %}:</span>
<span class="text-gray-700 font-medium">{{ file.download_count }}</span>
</div>
</div>
<!-- Public status detail + actions -->
<div class="flex items-center justify-between pt-1">
<!-- Expiry / copy link -->
<div class="flex items-center gap-2 flex-wrap">
<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">
<button @click="copyPublicUrl()"
class="text-xs 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"/>
<!-- Action buttons -->
<div class="flex-shrink-0 flex items-center gap-1">
<a :href="f.download_url" :download="f.name"
class="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400"
:title="'{% trans "Download / View" %}'" aria-label="{% trans "Download / View" %}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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 type="button" x-show="isPublic"
@click="$root.dispatchEvent(new CustomEvent('open-expiry', {bubbles:true, detail:{pk, expiresAt}}))"
class="p-2 text-gray-500 hover:text-yellow-600 hover:bg-yellow-50 rounded-lg focus:outline-none focus:ring-2 focus:ring-yellow-400"
title="{% trans 'Set expiry' %}" aria-label="{% trans 'Set expiry' %}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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>
{% trans "Copy link" %}
</button>
</span>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<a href="{{ file.download_url }}"
{% if file.is_image %}target="_blank"{% endif %}
class="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
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-2 text-gray-500 hover:text-yellow-600 hover:bg-yellow-50 rounded-lg"
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-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="{% trans 'Delete' %}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button type="button" @click="confirmDelete()"
class="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-400"
title="{% trans 'Delete' %}" aria-label="{% trans 'Delete' %}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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>
</div>
</div>
</div>
{% endfor %}
</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 %}
</li>
</template>
<!-- Empty state -->
<li x-show="visibleFiles.length === 0" 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" aria-hidden="true">
<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>
</li>
</ul>
</div>
</div><!-- /main content -->
</div>
<!-- ── Toast notification ──────────────────────────────────────── -->
<div x-show="toastVisible"
<div x-show="toastVisible" x-cloak
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">
style="position:fixed;top:5rem;right:1.25rem;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;"
role="status" aria-live="polite">
<svg style="width:1rem;height:1rem;color:#4ade80;flex-shrink:0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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"
<div x-show="expiryModal.open" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
@@ -319,16 +220,18 @@
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">
@click.self="expiryModal.open = false"
role="dialog" aria-modal="true" :aria-label="'{% trans "Set expiry date" %}'">
<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">
<button type="button" @click="expiryModal.open = false"
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-red-400"
aria-label="{% trans 'Close' %}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
@@ -337,34 +240,30 @@
<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>
<button type="button" @click="clearExpiry()"
class="text-sm text-red-500 hover:text-red-700 font-medium focus:outline-none focus:underline">{% 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>
<button type="button" @click="expiryModal.open = false"
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-400">{% trans "Cancel" %}</button>
<button type="button" @click="saveExpiry()"
class="px-4 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500">{% 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">
<!-- ── Upload Progress HUD ──────────────────────────────────────── -->
<div style="position:fixed;bottom:1.25rem;right:1.25rem;z-index:9999;width:20rem;max-width:calc(100vw - 2rem)">
<div class="bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden">
<!-- Header — always visible, click to fold/unfold -->
<!-- Header — 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">
class="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-200 cursor-pointer select-none"
role="button" tabindex="0" @keydown.enter.prevent="hudOpen = !hudOpen" @keydown.space.prevent="hudOpen = !hudOpen"
:aria-expanded="hudOpen">
<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 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>
@@ -374,20 +273,19 @@
<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">
class="text-xs text-red-500 hover:text-red-700 font-medium px-2 py-0.5 rounded hover:bg-red-50 transition-colors focus:outline-none focus:ring-2 focus:ring-red-400">
{% 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">
fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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"
<div x-show="hudOpen" x-cloak
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"
@@ -395,13 +293,10 @@
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">
<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">
@@ -429,95 +324,80 @@
</div>
</template>
</div>
</div><!-- /collapsible body -->
</div>
</div>
</div><!-- /HUD -->
</div>
<!-- ── File Preview Modal ─────────────────────────────────────── -->
<div x-show="previewModal.open" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
@click.self="closePreview()"
@keydown.escape.window="closePreview()">
@keydown.escape.window="closePreview()"
role="dialog" aria-modal="true" aria-label="{% trans 'File preview' %}">
<!-- Modal: auto-sizes to content, never exceeds 92vw × 92vh -->
<div class="relative bg-white rounded-xl shadow-2xl flex flex-col overflow-hidden"
style="max-width:min(92vw,1280px); max-height:92vh; width:max-content; min-width:300px;">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 flex-shrink-0 w-full box-border">
<h3 class="text-sm font-semibold text-gray-800 truncate mr-4 min-w-0" x-text="previewModal.name"></h3>
<button @click="closePreview()" class="text-gray-400 hover:text-gray-600 flex-shrink-0 p-0.5 rounded hover:bg-gray-100">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button type="button" @click="closePreview()" class="text-gray-400 hover:text-gray-600 flex-shrink-0 p-1 rounded hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-red-400" aria-label="{% trans 'Close' %}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Body: flex-1 + min-h-0 lets it shrink properly; overflow-auto for scroll safety -->
<div class="flex-1 min-h-0 overflow-auto flex items-center justify-center bg-gray-50 relative">
<!-- Prev button -->
<button x-show="fileList.length > 1"
@click="prevFile()"
class="absolute left-2 z-10 bg-black/40 hover:bg-black/60 text-white rounded-full p-2 transition-colors"
title="Previous (←)">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button type="button" x-show="fileList.length > 1" @click="prevFile()"
class="absolute left-2 z-10 bg-black/40 hover:bg-black/60 text-white rounded-full p-2 transition-colors focus:outline-none focus:ring-2 focus:ring-white"
title="{% trans 'Previous' %} (←)" aria-label="{% trans 'Previous' %}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</button>
<!-- Next button -->
<button x-show="fileList.length > 1"
@click="nextFile()"
class="absolute right-2 z-10 bg-black/40 hover:bg-black/60 text-white rounded-full p-2 transition-colors"
title="Next (→)">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button type="button" x-show="fileList.length > 1" @click="nextFile()"
class="absolute right-2 z-10 bg-black/40 hover:bg-black/60 text-white rounded-full p-2 transition-colors focus:outline-none focus:ring-2 focus:ring-white"
title="{% trans 'Next' %} (→)" aria-label="{% trans 'Next' %}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
<!-- Image: respects natural dimensions, capped at viewport -->
<img x-show="previewModal.open && previewModal.mimeType.startsWith('image/')"
<img x-show="previewModal.open && previewModal.mimeType.startsWith('image/')" x-cloak
:src="previewModal.url" :alt="previewModal.name"
style="display:block; max-width:min(88vw,1200px); max-height:calc(92vh - 96px); width:auto; height:auto; object-fit:contain;">
<!-- Video: x-show keeps the element mounted so drag-seeking doesn't reset -->
<video x-show="previewModal.open && previewModal.mimeType.startsWith('video/')"
<video x-show="previewModal.open && previewModal.mimeType.startsWith('video/')" x-cloak
controls
style="display:block; max-width:min(88vw,1200px); max-height:calc(92vh - 96px); width:auto; height:auto;"
:src="previewModal.url"></video>
<!-- Audio -->
<div x-show="previewModal.open && previewModal.mimeType.startsWith('audio/')"
<div x-show="previewModal.open && previewModal.mimeType.startsWith('audio/')" x-cloak
class="text-center space-y-6 py-10 px-8" style="width:360px;">
<svg class="w-16 h-16 text-gray-300 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-16 h-16 text-gray-300 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
<p class="text-sm text-gray-600 font-medium" x-text="previewModal.name"></p>
<audio controls class="w-full" :src="previewModal.url"></audio>
</div>
<!-- Unsupported -->
<div x-show="previewModal.open && !previewModal.mimeType.startsWith('image/') && !previewModal.mimeType.startsWith('video/') && !previewModal.mimeType.startsWith('audio/')"
<div x-show="previewModal.open && !previewModal.mimeType.startsWith('image/') && !previewModal.mimeType.startsWith('video/') && !previewModal.mimeType.startsWith('audio/')" x-cloak
class="text-center space-y-4 py-12 px-10" style="width:420px;">
<svg class="w-16 h-16 text-gray-200 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-16 h-16 text-gray-200 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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"/>
</svg>
<p class="text-base font-semibold text-gray-700">{% trans "Unfortunately, we can't preview this file." %}</p>
<p class="text-sm text-gray-400">{% trans "But you can click the link below to download it." %}</p>
<a :href="previewModal.url" :download="previewModal.name"
class="inline-flex items-center px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors mt-2">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="inline-flex items-center px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors mt-2 focus:outline-none focus:ring-2 focus:ring-blue-400">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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>
<span x-text="previewModal.name"></span>
</a>
</div>
</div>
<!-- Footer -->
<div class="px-4 py-2.5 border-t border-gray-100 flex items-center justify-between flex-shrink-0 bg-white w-full box-border">
<div class="flex items-center gap-3">
<span class="text-xs text-gray-400" x-text="previewModal.mimeType"></span>
@@ -525,14 +405,13 @@
x-text="(currentIndex + 1) + ' / ' + fileList.length"></span>
</div>
<a :href="previewModal.url" :download="previewModal.name"
class="inline-flex items-center text-sm text-blue-600 hover:text-blue-800">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="inline-flex items-center text-sm text-blue-600 hover:text-blue-800 focus:outline-none focus:underline">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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>
{% trans "Download" %}
</a>
</div>
</div>
</div>
@@ -541,6 +420,7 @@
<script>
function fileManager() {
return {
visibleFiles: [],
uploads: [],
hudOpen: false,
toastMsg: '',
@@ -552,14 +432,16 @@ function fileManager() {
currentIndex: -1,
init() {
window.addEventListener('open-preview', e => this.openPreview(e.detail));
// Load file list for keyboard navigation
// Seed initial file list from server-rendered JSON
try {
this.fileList = JSON.parse(document.getElementById('file-list-data').textContent);
} catch(e) { this.fileList = []; }
this.visibleFiles = JSON.parse(document.getElementById('initial-file-data').textContent);
} catch (e) {
this.visibleFiles = [];
}
// Build file list for preview keyboard navigation
this.fileList = this.visibleFiles.map(f => ({ name: f.name, mimeType: f.mime_type, url: f.download_url }));
// Arrow key navigation
// Arrow key navigation in preview
window.addEventListener('keydown', e => {
if (!this.previewModal.open) return;
if (e.key === 'ArrowRight') { e.preventDefault(); this.nextFile(); }
@@ -571,7 +453,7 @@ function fileManager() {
let dragCounter = 0;
document.addEventListener('dragenter', e => {
if (!e.dataTransfer?.types?.includes('Files')) return;
if (!e.dataTransfer || !e.dataTransfer.types || !e.dataTransfer.types.includes('Files')) return;
dragCounter++;
overlay.style.opacity = '1';
});
@@ -584,14 +466,23 @@ function fileManager() {
e.preventDefault();
dragCounter = 0;
overlay.style.opacity = '0';
const files = e.dataTransfer?.files;
if (files?.length) this.uploadFiles(files);
const files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length) this.uploadFiles(files);
});
input.addEventListener('change', e => {
if (e.target.files.length) this.uploadFiles(e.target.files);
e.target.value = '';
});
// Re-sync expiry updates propagated from rows
window.addEventListener('expiry-updated', e => {
const id = String(e.detail.pk);
const f = this.visibleFiles.find(x => String(x.id) === id);
if (f) {
f.expires_at = e.detail.expires_at || null;
}
});
},
openPicker() {
@@ -602,7 +493,7 @@ function fileManager() {
uploadFiles(fileList) {
this.hudOpen = true;
Array.from(fileList).forEach(file => {
const id = Date.now() + Math.random();
const id = Date.now() + '_' + Math.random().toString(36).slice(2);
this.uploads.push({ id, name: file.name, progress: 0, status: 'uploading', xhr: null });
this._uploadOne(file, id);
});
@@ -622,23 +513,77 @@ function fileManager() {
};
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);
if (u) {
if (xhr.status >= 200 && xhr.status < 300) {
u.status = 'done';
u.progress = 100;
// Insert uploaded rows from server response — no full page reload
try {
const data = JSON.parse(xhr.responseText);
const inserted = (data && data.uploaded) || [];
inserted.forEach(rec => {
const newFile = {
id: rec.id,
name: rec.name,
mime_type: rec.mime_type || guessMime(file.name),
size: rec.formatted_size || formatBytes(rec.size || file.size),
download_count: 0,
created_at: rec.created_at || new Date().toISOString(),
is_public: false,
public_url: '',
expires_at: null,
download_url: rec.download_url || ('/ui/files/' + rec.id + '-' + encodeURIComponent(rec.name)),
is_image: (rec.mime_type || guessMime(file.name)).startsWith('image/'),
};
// Avoid inserting duplicate (key by id)
if (!this.visibleFiles.some(x => String(x.id) === String(newFile.id))) {
this.visibleFiles.unshift(newFile);
this.fileList.unshift({ name: newFile.name, mimeType: newFile.mime_type, url: newFile.download_url });
}
});
} catch (e) { /* ignore parse error */ }
} else {
u.status = 'error';
}
}
this._maybeCollapseHud();
};
xhr.onerror = () => { const u = find(); if (u) u.status = 'error'; };
xhr.onerror = () => { const u = find(); if (u) u.status = 'error'; this._maybeCollapseHud(); };
xhr.onabort = () => { const u = find(); if (u) u.status = 'cancelled'; this._maybeCollapseHud(); };
xhr.open('POST', '{% url "file-upload" %}');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.send(formData);
},
_maybeCollapseHud(delay = 2000) {
if (this.uploads.length && this.uploads.every(u => u.status !== 'uploading')) {
setTimeout(() => {
if (this.uploads.every(u => u.status !== 'uploading')) {
this.hudOpen = false;
// Optionally clear the list after a moment so the HUD shows its empty state
setTimeout(() => { this.uploads = []; }, 800);
}
}, delay);
}
},
cancelAll() {
this.uploads.forEach(u => { if (u.xhr && u.status === 'uploading') { u.xhr.abort(); u.status = 'cancelled'; } });
setTimeout(() => { this.uploads = []; }, 800);
},
removeFile(id) {
const idx = this.visibleFiles.findIndex(f => String(f.id) === String(id));
if (idx !== -1) this.visibleFiles.splice(idx, 1);
const fidx = this.fileList.findIndex(f => f.url === (idx !== -1 ? 'placeholder' : ''));
// rebuild fileList to keep preview nav in sync
this.fileList = this.visibleFiles.map(f => ({ name: f.name, mimeType: f.mime_type, url: f.download_url }));
},
// ── Preview modal ────────────────────────────────────────────
openPreview({ name, mimeType, url }) {
this.currentIndex = this.fileList.findIndex(f => f.url === url);
if (this.currentIndex === -1) this.currentIndex = 0;
this.previewModal = { open: true, name, mimeType, url };
},
prevFile() {
@@ -679,9 +624,7 @@ function fileManager() {
// ── 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);
copyToClipboard(url);
this.showToast('{% trans "Link copied!" %}');
},
showToast(msg) {
@@ -690,43 +633,102 @@ function fileManager() {
clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500);
},
// ── misc ────────────────────────────────────────────────────
formatDate(iso) {
if (!iso) return '';
try {
const d = new Date(iso);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
} catch (e) { return iso; }
},
};
}
function fileRow(pk, isPublic, expiresAt, publicUrl) {
function fileRow(f) {
return {
pk, isPublic, publicUrl,
expiresAt: expiresAt || null,
f: f,
pk: f.id,
isPublic: !!f.is_public,
publicUrl: f.public_url || '',
expiresAt: f.expires_at || 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;
if (String(e.detail.pk) === String(this.pk)) {
this.expiresAt = e.detail.expires_at || null;
}
});
},
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; });
}).then(r => r.json()).then(d => {
this.isPublic = d.is_public;
this.publicUrl = d.public_url || '';
// sync underlying model so preview/toast use the right URL
this.f.is_public = d.is_public;
this.f.public_url = d.public_url || '';
});
},
formatExpiry(dt) { return dt ? new Date(dt).toLocaleDateString() : ''; },
copyPublicUrl() {
const url = this.publicUrl.startsWith('http') ? this.publicUrl : 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
copyToClipboard(url);
window.dispatchEvent(new CustomEvent('copy-link', { detail: { url } }));
},
confirmDelete() {
if (!confirm('{% trans "Delete this file?" %}')) return;
fetch(`/ui/files/${this.pk}/delete/`, {
method: 'POST',
headers: { 'X-CSRFToken': getCsrfToken() },
}).then(r => {
if (r.ok) {
window.dispatchEvent(new CustomEvent('remove-file', { detail: { id: this.pk } }));
}
}).catch(() => {/* ignore */});
},
};
}
function getCsrfToken() {
return document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='))?.split('=')[1] || '';
}
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const el = document.createElement('textarea');
el.value = text; el.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(el); el.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(el);
}
function guessMime(name) {
const ext = (name.split('.').pop() || '').toLowerCase();
const map = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf',
mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg', wav: 'audio/wav',
txt: 'text/plain', zip: 'application/zip',
};
return map[ext] || 'application/octet-stream';
}
function formatBytes(bytes) {
if (!bytes) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
</script>
<script id="file-list-data" type="application/json">
[{% for file in files %}{"name":"{{ file.name|escapejs }}","mimeType":"{{ file.mime_type|escapejs }}","url":"{{ file.download_url|escapejs }}"}{% if not forloop.last %},{% endif %}{% endfor %}]
</script>
{% endblock %}
{% endblock %}