mirror of
https://github.com/kennethreitz/photos.kennethreitz.org.git
synced 2026-06-05 06:46:13 +00:00
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:
@@ -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
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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")),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 = '✓';
|
||||
} 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 %}
|
||||
Reference in New Issue
Block a user