mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
ui update
This commit is contained in:
+16
-1
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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> <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 %}
|
||||
Reference in New Issue
Block a user