Remove login, dashboard, manage, upload — pure public read-only site

All content management via CLI (import_folder, ai_describe, cleanup).
No auth, no upload UI, no manage UI. Just photos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 14:45:07 -04:00
parent b279f5f599
commit 0040c3a51c
10 changed files with 9 additions and 854 deletions
-22
View File
@@ -1,22 +0,0 @@
{% extends "base.html" %}
{% block title %}Log in — {{ site_title }}{% endblock %}
{% block content %}
<div class="auth-form">
<h1>Log in</h1>
<form method="post">
{% csrf_token %}
{% if form.errors %}
<p style="color: #f44; font-size: 0.9rem; margin-bottom: 0.5rem;">Invalid username or password.</p>
{% endif %}
<div class="field">
<label for="id_username">Username</label>
<input type="text" name="username" id="id_username" required autofocus>
</div>
<div class="field">
<label for="id_password">Password</label>
<input type="password" name="password" id="id_password" required>
</div>
<button type="submit" class="btn btn-primary">Log in</button>
</form>
</div>
{% endblock %}
+4 -189
View File
@@ -1,20 +1,15 @@
from django.contrib.auth.decorators import login_required
import random
from django.db.models import Count
from django.db.models.functions import ExtractYear
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.text import slugify
from django.views.decorators.http import require_POST
from django.shortcuts import get_object_or_404, render
from core.models import ExifData, Image
from gallery.models import Collection
PAGE_SIZE = 48
def home(request):
import random
year = request.GET.get('year')
page = int(request.GET.get('page', 1))
@@ -25,7 +20,7 @@ def home(request):
if year:
qs = qs.filter(exif__date_taken__year=year)
# Shuffle with a stable seed per session so pagination is consistent
# Shuffle with a stable seed per session
seed = request.session.get('shuffle_seed')
if not seed or request.GET.get('reshuffle'):
seed = random.randint(0, 2**31)
@@ -35,7 +30,6 @@ def home(request):
rng = random.Random(seed)
rng.shuffle(all_ids)
# Paginate the shuffled IDs
start = (page - 1) * PAGE_SIZE
page_ids = all_ids[start:start + PAGE_SIZE]
has_more = start + PAGE_SIZE < len(all_ids)
@@ -44,7 +38,6 @@ def home(request):
Image.objects.filter(id__in=page_ids)
.select_related('user', 'exif', 'exif__camera', 'exif__lens')
)
# Preserve shuffle order
id_order = {uid: i for i, uid in enumerate(page_ids)}
images = sorted(images, key=lambda img: id_order[img.id])
@@ -56,7 +49,6 @@ def home(request):
.order_by('-year')
)
# HTMX partial for infinite scroll
if request.headers.get('HX-Request'):
return render(request, 'includes/image_grid_page.html', {
'images': images,
@@ -96,180 +88,3 @@ def image_detail(request, image_id):
'prev_image': prev_image,
'next_image': next_image,
})
@login_required
def dashboard(request):
user = request.user
images = (
Image.objects.filter(user=user, is_processing=False)
.select_related('exif', 'exif__camera', 'exif__lens')
.order_by('-upload_date')
)
collections = (
Collection.objects.filter(user=user)
.annotate(image_count=Count('collection_images'))
.order_by('-created_at')
)
return render(request, 'dashboard.html', {
'images': images,
'collections': collections,
})
@login_required
@require_POST
def dashboard_create_collection(request):
title = request.POST.get('title', '').strip()
if title:
import uuid
base_slug = slugify(title) or 'collection'
slug = base_slug
while Collection.objects.filter(user=request.user, slug=slug).exists():
slug = f"{base_slug}-{str(uuid.uuid4())[:8]}"
Collection.objects.create(
user=request.user,
title=title,
slug=slug,
description=request.POST.get('description', ''),
)
return redirect('dashboard')
@login_required
@require_POST
def dashboard_delete_collection(request, collection_id):
Collection.objects.filter(id=collection_id, user=request.user).delete()
return redirect('dashboard')
@login_required
@require_POST
def dashboard_delete_image(request, image_id):
Image.objects.filter(id=image_id, user=request.user).delete()
next_url = request.GET.get('next') or request.POST.get('next', '')
if next_url and next_url.startswith('/'):
return redirect(next_url)
return redirect('dashboard')
@login_required
def manage(request):
from core.models import Camera, Lens
qs = (
Image.objects.filter(user=request.user, is_processing=False)
.select_related('exif', 'exif__camera', 'exif__lens')
.order_by('-upload_date')
)
# Filters
camera = request.GET.get('camera', '')
lens = request.GET.get('lens', '')
year = request.GET.get('year', '')
visibility = request.GET.get('visibility', '')
if camera:
qs = qs.filter(exif__camera_id=camera)
if lens:
qs = qs.filter(exif__lens_id=lens)
if year:
qs = qs.filter(exif__date_taken__year=int(year))
if visibility:
qs = qs.filter(visibility=visibility)
# Facets: only cameras/lenses/years the user actually has
user_images = Image.objects.filter(user=request.user, is_processing=False)
cameras = (
Camera.objects.filter(images__image__in=user_images)
.distinct().order_by('manufacturer', 'model')
)
lenses = (
Lens.objects.filter(images__image__in=user_images)
.distinct().order_by('manufacturer', 'model')
)
years = (
ExifData.objects.filter(image__in=user_images, date_taken__isnull=False)
.dates('date_taken', 'year', order='DESC')
)
collections = (
Collection.objects.filter(user=request.user)
.annotate(image_count=Count('collection_images'))
.order_by('-created_at')
)
return render(request, 'manage.html', {
'images': qs,
'collections': collections,
'cameras': cameras,
'lenses': lenses,
'years': years,
'filter_camera': camera,
'filter_lens': lens,
'filter_year': year,
'filter_visibility': visibility,
})
@login_required
@require_POST
def manage_set_visibility(request):
image_ids = [x for x in request.POST.getlist('image_ids') if x.strip()]
visibility = request.POST.get('visibility', 'public')
if visibility in ('public', 'private', 'unlisted') and image_ids:
Image.objects.filter(id__in=image_ids, user=request.user).update(visibility=visibility)
return redirect('manage')
@login_required
@require_POST
def manage_delete_images(request):
image_ids = [x for x in request.POST.getlist('image_ids') if x.strip()]
if image_ids:
Image.objects.filter(id__in=image_ids, user=request.user).delete()
return redirect('manage')
@login_required
@require_POST
def manage_add_to_collection(request):
import uuid as _uuid
from gallery.models import CollectionImage
image_ids = [x for x in request.POST.getlist('image_ids') if x.strip()]
collection_id = request.POST.get('collection_id', '').strip()
new_collection_name = request.POST.get('new_collection', '').strip()
# Create new collection if requested
if new_collection_name and not collection_id:
base_slug = slugify(new_collection_name) or 'collection'
slug = base_slug
while Collection.objects.filter(user=request.user, slug=slug).exists():
slug = f"{base_slug}-{str(_uuid.uuid4())[:8]}"
col = Collection.objects.create(
user=request.user, title=new_collection_name, slug=slug,
)
collection_id = str(col.id)
if image_ids and collection_id:
collection = Collection.objects.filter(id=collection_id, user=request.user).first()
if collection:
existing = set(
CollectionImage.objects.filter(collection=collection, image_id__in=image_ids)
.values_list('image_id', flat=True)
)
count = CollectionImage.objects.filter(collection=collection).count()
new_entries = []
for img_id in image_ids:
parsed = _uuid.UUID(img_id)
if parsed not in existing:
new_entries.append(CollectionImage(
collection=collection, image_id=parsed, sort_order=count,
))
count += 1
if new_entries:
CollectionImage.objects.bulk_create(new_entries)
return redirect('manage')
-3
View File
@@ -236,9 +236,6 @@ LOGGING = {
},
}
LOGIN_REDIRECT_URL = '/'
LOGIN_URL = '/login/'
# Email — configure a real backend in production
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
DEFAULT_FROM_EMAIL = "ExifTree <noreply@exiftree.org>"
+3 -25
View File
@@ -1,25 +1,14 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.views import LoginView, LogoutView
from django.contrib.sitemaps import Sitemap
from django.contrib.sitemaps.views import sitemap
from django.urls import include, path
from django.http import HttpResponse
from django.urls import include, path
from django.views.generic import TemplateView
from core.views import (
dashboard,
dashboard_create_collection,
dashboard_delete_collection,
dashboard_delete_image,
home,
image_detail,
manage,
manage_add_to_collection,
manage_delete_images,
manage_set_visibility,
)
from core.views import home, image_detail
class ImageSitemap(Sitemap):
changefreq = 'monthly'
@@ -136,20 +125,9 @@ urlpatterns = [
path("apple-touch-icon.png", lambda r: HttpResponse(status=204)),
path("apple-touch-icon-precomposed.png", lambda r: HttpResponse(status=204)),
path("", home, name="home"),
path("login/", LoginView.as_view(template_name='registration/login.html'), name="login"),
path("logout/", LogoutView.as_view(next_page='/'), name="logout"),
path("dashboard/", dashboard, name="dashboard"),
path("dashboard/collections/create/", dashboard_create_collection, name="dashboard-create-collection"),
path("dashboard/collections/<uuid:collection_id>/delete/", dashboard_delete_collection, name="dashboard-delete-collection"),
path("dashboard/images/<uuid:image_id>/delete/", dashboard_delete_image, name="dashboard-delete-image"),
path("manage/", manage, name="manage"),
path("manage/visibility/", manage_set_visibility, name="manage-set-visibility"),
path("manage/delete/", manage_delete_images, name="manage-delete-images"),
path("manage/add-to-collection/", manage_add_to_collection, name="manage-add-to-collection"),
path("images/<uuid:image_id>/", image_detail, name="image-detail"),
path("", include("tree.urls")),
path("", include("gallery.urls")),
path("", include("ingest.urls")),
path("", include("search.urls")),
]
-186
View File
@@ -1,186 +0,0 @@
{% extends "base.html" %}
{% block title %}Upload — {{ site_title }}{% endblock %}
{% block content %}
<div id="upload-page" class="upload-page">
<div class="upload-dropoverlay">Drop images to upload</div>
<div class="page-header">
<h1>Upload</h1>
<p>Drag images anywhere on this page, or click below to browse.</p>
</div>
<!-- Collection picker -->
<div style="margin-bottom: 1.5rem; display: flex; gap: 0.75rem; align-items: end; flex-wrap: wrap;">
<div class="field">
<label style="font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; display: block; margin-bottom: 0.25rem;">Add to collection</label>
<select id="collection-select"
style="padding: 0.5rem 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.9rem;">
<option value="">None</option>
{% for c in collections %}
<option value="{{ c.id }}">{{ c.title }}</option>
{% endfor %}
</select>
</div>
</div>
<p id="upload-error" style="color: #f44; margin-bottom: 1rem; display: none;"></p>
<div class="upload-zone" onclick="document.getElementById('file-input').click()">
<p>Drop images here or click to browse</p>
<p style="font-size: 0.8rem; color: var(--text-muted);">JPEG, PNG, WebP — up to 50MB each</p>
</div>
<input type="file" accept="image/*" multiple id="file-input" style="display:none">
<!-- Upload queue -->
<div id="upload-queue" style="margin-top: 2rem; display: none;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="font-size: 1.1rem;" id="upload-progress-text"></h2>
<button id="retry-btn" class="btn" style="font-size: 0.85rem; color: #f44; border-color: #f44; display: none;" onclick="retryFailed()">Retry failed</button>
</div>
<div id="upload-list" class="upload-queue"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function() {
var files = [];
var active = 0;
var concurrency = 6;
var csrfToken = '{{ csrf_token }}';
var collectionId = '';
var page = document.getElementById('upload-page');
function addFiles(fileList) {
collectionId = document.getElementById('collection-select').value;
for (var i = 0; i < fileList.length; i++) {
var f = fileList[i];
if (!f.type.startsWith('image/')) continue;
files.push({ file: f, name: f.name, size: f.size, status: 'pending', progress: 0, id: null, error: '' });
}
renderQueue();
drain();
}
function drain() {
while (active < concurrency) {
var item = files.find(function(f) { return f.status === 'pending'; });
if (!item) return;
item.status = 'uploading';
active++;
uploadOne(item).then(function() {
active--;
drain();
});
}
}
function uploadOne(item) {
return new Promise(function(resolve) {
var xhr = new XMLHttpRequest();
var form = new FormData();
form.append('image', item.file);
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
item.progress = Math.round((e.loaded / e.total) * 100);
renderQueue();
}
});
xhr.addEventListener('load', function() {
if (xhr.status >= 200 && xhr.status < 300) {
try { var data = JSON.parse(xhr.responseText); item.id = data.id; } catch(e) {}
item.status = 'done';
item.progress = 100;
// Add to collection if selected
if (collectionId && item.id) {
var cf = new FormData();
cf.append('csrfmiddlewaretoken', csrfToken);
fetch('/api/collections/' + collectionId + '/images/' + item.id, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken },
});
}
} else if (xhr.status === 409) {
try { var data = JSON.parse(xhr.responseText); item.id = data.id; } catch(e) {}
item.status = 'duplicate';
item.progress = 100;
} else {
try { var data = JSON.parse(xhr.responseText); item.error = data.detail || 'Upload failed'; } catch(e) { item.error = 'Upload failed'; }
item.status = 'error';
}
renderQueue();
resolve();
});
xhr.addEventListener('error', function() {
item.error = 'Upload failed';
item.status = 'error';
renderQueue();
resolve();
});
xhr.open('POST', '{% url "ingest:upload-image" %}');
xhr.setRequestHeader('X-CSRFToken', csrfToken);
xhr.send(form);
});
}
window.retryFailed = function() {
files.forEach(function(f) {
if (f.status === 'error') { f.status = 'pending'; f.progress = 0; f.error = ''; }
});
renderQueue();
drain();
};
function renderQueue() {
var queue = document.getElementById('upload-queue');
var list = document.getElementById('upload-list');
var text = document.getElementById('upload-progress-text');
var retryBtn = document.getElementById('retry-btn');
if (files.length === 0) { queue.style.display = 'none'; return; }
queue.style.display = '';
var done = files.filter(function(f) { return f.status === 'done'; }).length;
var failed = files.filter(function(f) { return f.status === 'error'; }).length;
text.textContent = done + ' of ' + files.length + ' uploaded';
retryBtn.style.display = failed > 0 ? '' : 'none';
if (failed > 0) retryBtn.textContent = 'Retry ' + failed + ' failed';
var html = '';
files.forEach(function(item) {
var statusHtml = '';
if (item.status === 'pending') statusHtml = '<span style="color: var(--text-muted);">Waiting</span>';
else if (item.status === 'uploading') statusHtml = '<span style="color: var(--accent);">' + item.progress + '%</span>';
else if (item.status === 'done') statusHtml = '<a href="/images/' + item.id + '/" style="color: #4c4;">View</a>';
else if (item.status === 'duplicate') statusHtml = '<a href="/images/' + item.id + '/" style="color: var(--text-muted);">Already uploaded</a>';
else if (item.status === 'error') statusHtml = '<span style="color: #f44;">' + item.error + '</span>';
var cls = item.status === 'done' ? 'upload-done' : item.status === 'error' ? 'upload-error' : '';
html += '<div class="upload-item ' + cls + '">';
html += '<div class="upload-item-info"><span class="upload-item-name">' + item.name + '</span>';
html += '<span class="upload-item-size">' + (item.size / 1024 / 1024).toFixed(1) + ' MB</span></div>';
html += '<div class="upload-item-right">' + statusHtml + '</div>';
if (item.status === 'uploading' || item.status === 'done') {
html += '<div class="upload-progress-track"><div class="upload-progress-bar' + (item.status === 'done' ? ' upload-progress-done' : '') + '" style="width:' + item.progress + '%"></div></div>';
}
html += '</div>';
});
list.innerHTML = html;
}
// Drag and drop
window.addEventListener('dragover', function(e) { e.preventDefault(); page.classList.add('upload-page-dragover'); });
window.addEventListener('dragleave', function(e) { e.preventDefault(); if (!e.relatedTarget) page.classList.remove('upload-page-dragover'); });
window.addEventListener('drop', function(e) { e.preventDefault(); page.classList.remove('upload-page-dragover'); addFiles(e.dataTransfer.files); });
// File input
document.getElementById('file-input').addEventListener('change', function(e) { addFiles(e.target.files); });
})();
</script>
{% endblock %}
+1 -6
View File
@@ -1,10 +1,5 @@
from django.urls import path
from ingest import views
app_name = 'ingest'
urlpatterns = [
path('upload/', views.upload, name='upload'),
path('upload/image/', views.upload_image, name='upload-image'),
]
urlpatterns = []
+1 -73
View File
@@ -1,73 +1 @@
import hashlib
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.shortcuts import render
from django.utils.text import slugify
from django.views.decorators.http import require_POST
from core.models import Image
from gallery.models import Collection
from ingest.tasks import process_image_task
@login_required
def upload(request):
collections = Collection.objects.filter(user=request.user).order_by('-created_at')
return render(request, 'ingest/upload.html', {'collections': collections})
@login_required
@require_POST
def upload_image(request):
"""Handle image upload via XHR, return JSON."""
image_file = request.FILES.get('image')
if not image_file:
return JsonResponse({'detail': "No image provided"}, status=400)
from django.conf import settings
if image_file.size > settings.MAX_UPLOAD_SIZE:
return JsonResponse({'detail': "File too large"}, status=400)
contents = image_file.read()
content_hash = hashlib.sha256(contents).hexdigest()
existing = Image.objects.filter(content_hash=content_hash).first()
if existing:
return JsonResponse({'detail': "Duplicate image", 'id': str(existing.id)}, status=409)
title = request.POST.get('title', '')
slug = slugify(title) if title else slugify(image_file.name.rsplit('.', 1)[0])
from django.core.files.base import ContentFile
img = Image.objects.create(
user=request.user,
title=title,
slug=slug,
original=ContentFile(contents, name=image_file.name),
content_hash=content_hash,
is_processing=True,
)
def _process_and_describe(image_id):
from django.db import connection
connection.close()
from ingest.pipeline import process_image
from ingest.tasks import generate_ai_description_task
try:
img = Image.objects.get(id=image_id)
process_image(img)
generate_ai_description_task(str(image_id))
except Exception:
pass
finally:
connection.close()
try:
process_image_task.apply_async(args=[str(img.id)], ignore_result=True)
except Exception:
# No Celery — process in a background thread
import threading
threading.Thread(target=_process_and_describe, args=(str(img.id),), daemon=True).start()
return JsonResponse({'id': str(img.id), 'title': img.title}, status=201)
# Upload views removed — imports handled via CLI (manage.py import_folder)
-21
View File
@@ -38,27 +38,6 @@
{% if has_collections %}<a href="{% url 'gallery:collection-list' %}">Collections</a>{% endif %}
<a href="{% url 'search:search' %}">Search</a>
</div>
<div class="nav-auth">
{% if user.is_authenticated %}
<div class="nav-user-dropdown">
<button class="nav-user-btn" id="nav-user-btn"
onclick="document.getElementById('nav-dropdown').style.display = document.getElementById('nav-dropdown').style.display === 'block' ? 'none' : 'block'">
@{{ user.username }}
</button>
<div class="dropdown-menu" id="nav-dropdown" style="display: none;">
<a href="/dashboard/">Dashboard</a>
<a href="/manage/">Manage Photos</a>
<a href="/upload/">Upload</a>
<hr>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit" style="background: none; border: none; color: var(--text); cursor: pointer; padding: 0.5rem 1rem; font-size: 0.9rem; width: 100%; text-align: left;">Log out</button>
</form>
</div>
</div>
{% else %}
<a href="{% url 'login' %}">Log in</a>
{% endif %}
</div>
</nav>
-85
View File
@@ -1,85 +0,0 @@
{% extends "base.html" %}
{% block title %}Dashboard — {{ site_title }}{% endblock %}
{% block content %}
<!-- Profile Section -->
<div class="page-header" style="display: flex; justify-content: space-between; align-items: start;">
<div>
<h1>@{{ user.username }}</h1>
<p style="color: var(--text-muted);">{{ user.email }}</p>
{% if user.bio %}<p style="margin-top: 0.5rem;">{{ user.bio }}</p>{% endif %}
{% if user.website %}<a href="{{ user.website }}" target="_blank" style="font-size: 0.9rem;">{{ user.website }}</a>{% endif %}
</div>
<div style="display: flex; gap: 0.5rem;">
<a href="/upload/" class="btn btn-primary">Upload</a>
<a href="/manage/" class="btn">Manage Photos</a>
</div>
</div>
<!-- Stats -->
<div style="display: flex; gap: 2rem; margin-bottom: 2rem; font-size: 0.9rem; color: var(--text-muted);">
<span>{{ images|length }} images</span>
<span>{{ collections|length }} collections</span>
</div>
<!-- Collections -->
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1rem;">
<h2 style="font-size: 1.25rem;">Collections</h2>
</div>
<form method="post" action="{% url 'dashboard-create-collection' %}" style="margin-bottom: 1.5rem; max-width: 500px;">
{% csrf_token %}
<div style="display: flex; gap: 0.5rem; align-items: end;">
<input type="text" name="title" placeholder="New collection title" required
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text);">
<button type="submit" class="btn btn-primary" style="font-size: 0.85rem;">Create</button>
</div>
</form>
<div class="gear-grid" style="margin-bottom: 3rem;">
{% for c in collections %}
<div class="gear-card">
<a href="/collections/{{ c.slug }}/" style="color: inherit;">
<h3>{{ c.title }}</h3>
<span class="count">{{ c.image_count }} images</span>
<span class="count"> · {{ c.created_at|date:"N j, Y" }}</span>
</a>
<form method="post" action="{% url 'dashboard-delete-collection' c.id %}" style="margin-top: 0.5rem;"
onsubmit="return confirm('Delete this collection?')">
{% csrf_token %}
<button type="submit"
style="background: none; border: none; color: #f44; cursor: pointer; font-size: 0.8rem; padding: 0;">
Delete
</button>
</form>
</div>
{% empty %}
<div class="empty" style="grid-column: 1 / -1;">No collections yet.</div>
{% endfor %}
</div>
<!-- Images -->
<h2 style="font-size: 1.25rem; margin-bottom: 1rem;">Your Images</h2>
<div class="image-grid">
{% for img in images %}
<div class="image-card" style="position: relative;">
<a href="/images/{{ img.id }}/">
{% if img.thumbnail_medium %}<img src="{{ img.thumbnail_medium.url }}" alt="{{ img.title }}">
{% elif img.thumbnail_small %}<img src="{{ img.thumbnail_small.url }}" alt="{{ img.title }}">{% endif %}
</a>
<form method="post" action="{% url 'dashboard-delete-image' img.id %}"
style="position: absolute; top: 0.5rem; right: 0.5rem;"
onsubmit="return confirm('Delete this image?')">
{% csrf_token %}
<button type="submit"
style="background: rgba(0,0,0,0.7); border: none; color: #f44; cursor: pointer; padding: 0.25rem 0.5rem; border-radius: var(--radius); font-size: 0.8rem;">
Delete
</button>
</form>
</div>
{% empty %}
<div class="empty">
No images yet. <a href="/upload/">Upload your first image</a>
</div>
{% endfor %}
</div>
{% endblock %}
-244
View File
@@ -1,244 +0,0 @@
{% extends "base.html" %}
{% block title %}Manage Photos — {{ site_title }}{% endblock %}
{% block content %}
<div id="manage-app" style="max-width: 100%;">
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em;">Manage Photos</h1>
<p style="color: var(--text-muted); margin-top: 0.25rem;">{{ images|length }} photos</p>
</div>
<a href="/upload/" class="btn btn-primary">Upload</a>
</div>
<!-- Toolbar -->
<div class="manage-toolbar">
<form method="get" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<button type="button" class="btn" onclick="manageToggleAll()" style="font-size: 0.85rem;" id="select-all-btn">Select all</button>
<select name="camera" onchange="this.form.submit()" style="padding: 0.4rem 0.6rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.85rem;">
<option value="">All cameras</option>
{% for c in cameras %}
<option value="{{ c.id }}" {% if filter_camera == c.id|slugify %}selected{% endif %}>{{ c.display_name }}</option>
{% endfor %}
</select>
<select name="lens" onchange="this.form.submit()" style="padding: 0.4rem 0.6rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.85rem;">
<option value="">All lenses</option>
{% for l in lenses %}
<option value="{{ l.id }}" {% if filter_lens == l.id|slugify %}selected{% endif %}>{{ l.display_name }}</option>
{% endfor %}
</select>
<select name="year" onchange="this.form.submit()" style="padding: 0.4rem 0.6rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.85rem;">
<option value="">All years</option>
{% for y in years %}
<option value="{{ y.year }}" {% if filter_year == y.year|stringformat:"d" %}selected{% endif %}>{{ y.year }}</option>
{% endfor %}
</select>
<select name="visibility" onchange="this.form.submit()" style="padding: 0.4rem 0.6rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.85rem;">
<option value="">All visibility</option>
<option value="public" {% if filter_visibility == "public" %}selected{% endif %}>Public</option>
<option value="private" {% if filter_visibility == "private" %}selected{% endif %}>Private</option>
<option value="unlisted" {% if filter_visibility == "unlisted" %}selected{% endif %}>Unlisted</option>
</select>
{% if filter_camera or filter_lens or filter_year or filter_visibility %}
<a href="{% url 'manage' %}" class="btn" style="font-size: 0.85rem;">Clear</a>
{% endif %}
</form>
<!-- Bulk actions -->
<div id="bulk-actions" style="display: none; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<span style="font-size: 0.85rem; color: var(--accent); font-weight: 600;" id="selected-count"></span>
<form method="post" action="{% url 'manage-set-visibility' %}" id="vis-form" style="display: inline;">
{% csrf_token %}
<input type="hidden" name="image_ids" id="vis-ids">
<input type="hidden" name="visibility" id="vis-value">
<button type="button" onclick="submitVis('public')" class="btn" style="font-size: 0.85rem;">Make public</button>
<button type="button" onclick="submitVis('private')" class="btn" style="font-size: 0.85rem;">Make private</button>
<button type="button" onclick="submitVis('unlisted')" class="btn" style="font-size: 0.85rem;">Make unlisted</button>
</form>
<!-- Add to collection -->
<div style="position: relative; display: inline-block;">
<button class="btn" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'block' ? 'none' : 'block'" style="font-size: 0.85rem;">Add to collection</button>
<div class="dropdown-menu" id="col-dropdown" style="display: none; left: 0; top: 100%; margin-top: 0.25rem; min-width: 220px;">
<div style="padding: 0.4rem 0.75rem;" id="new-col-row">
<div style="display: flex; gap: 0.25rem;">
<input type="text" id="new-col-name" placeholder="New collection..." onclick="event.stopPropagation()"
style="flex: 1; padding: 0.3rem 0.5rem; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.85rem;"
onkeydown="if(event.key==='Enter'){event.preventDefault(); submitNewCollection();}">
<button onclick="event.stopPropagation(); submitNewCollection();" class="btn btn-primary" style="font-size: 0.8rem; padding: 0.3rem 0.5rem;">+</button>
</div>
</div>
<hr>
{% for c in collections %}
<a href="#" onclick="event.preventDefault(); submitAddToCollection('{{ c.id }}'); document.getElementById('col-dropdown').style.display='none';">{{ c.title }}</a>
{% empty %}
<span style="padding: 0.5rem 1rem; color: var(--text-muted); font-size: 0.85rem; display: block;">No collections yet</span>
{% endfor %}
</div>
</div>
<form method="post" action="{% url 'manage-delete-images' %}" id="delete-form" style="display: inline;"
onsubmit="return confirm('Delete selected images? This cannot be undone.')">
{% csrf_token %}
<input type="hidden" name="image_ids" id="delete-ids">
<button type="submit" class="btn" style="font-size: 0.85rem; color: #f44; border-color: #f44;">Delete</button>
</form>
<form method="post" action="{% url 'manage-add-to-collection' %}" id="add-col-form" style="display: none;">
{% csrf_token %}
<input type="hidden" name="image_ids" id="add-col-ids">
<input type="hidden" name="collection_id" id="add-col-id">
<input type="hidden" name="new_collection" id="add-col-new-name">
</form>
</div>
</div>
<!-- Grid -->
<div class="manage-grid">
{% for img in images %}
<div class="manage-item" data-id="{{ img.id }}" data-vis="{{ img.visibility }}" onclick="manageToggle('{{ img.id }}', event)">
{% if img.thumbnail_small %}<img src="{{ img.thumbnail_small.url }}" alt="{{ img.title }}" loading="lazy">
{% elif img.thumbnail_medium %}<img src="{{ img.thumbnail_medium.url }}" alt="{{ img.title }}" loading="lazy">
{% elif img.original %}<img src="{{ img.original.url }}" alt="{{ img.title }}" loading="lazy">{% endif %}
<div class="manage-check" id="check-{{ img.id }}"></div>
{% if img.visibility != 'public' %}
<div class="manage-vis-badge {% if img.visibility == 'private' %}vis-private{% else %}vis-unlisted{% endif %}">{{ img.visibility }}</div>
{% endif %}
</div>
{% empty %}
<div class="empty" style="grid-column: 1 / -1;">No images yet. <a href="/upload/">Upload some photos</a>.</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function() {
var selected = new Set();
var lastClicked = null;
var allIds = [{% for img in images %}'{{ img.id }}'{% if not forloop.last %},{% endif %}{% endfor %}];
window.manageToggle = function(id, event) {
if (event.shiftKey && lastClicked !== null) {
var visible = getVisibleIds();
var from = visible.indexOf(lastClicked);
var to = visible.indexOf(id);
if (from !== -1 && to !== -1) {
var start = Math.min(from, to), end = Math.max(from, to);
for (var i = start; i <= end; i++) selected.add(visible[i]);
}
} else {
if (selected.has(id)) selected.delete(id); else selected.add(id);
}
lastClicked = id;
updateUI();
};
window.manageToggleAll = function() {
var visible = getVisibleIds();
if (selected.size === visible.length && visible.length > 0) {
selected.clear();
} else {
visible.forEach(function(id) { selected.add(id); });
}
updateUI();
};
window.submitVis = function(vis) {
document.getElementById('vis-ids').value = [...selected].join(',');
document.getElementById('vis-ids').name = 'image_ids';
// Need multiple values - use a trick
var form = document.getElementById('vis-form');
// Remove old hidden inputs
form.querySelectorAll('.dyn').forEach(function(el) { el.remove(); });
selected.forEach(function(id) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'image_ids'; inp.value = id; inp.className = 'dyn';
form.appendChild(inp);
});
document.getElementById('vis-value').value = vis;
form.submit();
};
window.submitNewCollection = function() {
var name = document.getElementById('new-col-name').value.trim();
if (!name) return;
var form = document.getElementById('add-col-form');
form.querySelectorAll('.dyn').forEach(function(el) { el.remove(); });
selected.forEach(function(id) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'image_ids'; inp.value = id; inp.className = 'dyn';
form.appendChild(inp);
});
document.getElementById('add-col-id').value = '';
document.getElementById('add-col-new-name').value = name;
form.submit();
};
window.submitAddToCollection = function(colId) {
var form = document.getElementById('add-col-form');
form.querySelectorAll('.dyn').forEach(function(el) { el.remove(); });
selected.forEach(function(id) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'image_ids'; inp.value = id; inp.className = 'dyn';
form.appendChild(inp);
});
document.getElementById('add-col-id').value = colId;
form.submit();
};
// Wire up delete form
document.getElementById('delete-form').addEventListener('submit', function() {
var form = this;
form.querySelectorAll('.dyn').forEach(function(el) { el.remove(); });
selected.forEach(function(id) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'image_ids'; inp.value = id; inp.className = 'dyn';
form.appendChild(inp);
});
});
function getVisibleIds() {
return allIds;
}
function updateUI() {
// Update selection visuals
document.querySelectorAll('.manage-item').forEach(function(el) {
var id = el.dataset.id;
var check = document.getElementById('check-' + id);
if (selected.has(id)) {
el.classList.add('manage-item-selected');
check.classList.add('manage-check-on');
check.innerHTML = '&#10003;';
} else {
el.classList.remove('manage-item-selected');
check.classList.remove('manage-check-on');
check.innerHTML = '';
}
});
// Update toolbar
var bulk = document.getElementById('bulk-actions');
var count = document.getElementById('selected-count');
if (selected.size > 0) {
bulk.style.display = 'flex';
count.textContent = selected.size + ' selected';
} else {
bulk.style.display = 'none';
}
// Update select all button
var visible = getVisibleIds();
var btn = document.getElementById('select-all-btn');
btn.textContent = (selected.size === visible.length && visible.length > 0) ? 'Deselect all' : 'Select all';
}
})();
</script>
{% endblock %}