Convert to single-tenant portfolio tool, replace Alpine with HTMX

- Remove groups app and multi-user features (user listing, registration,
  @username profiles, SINGLE_TENANT toggle)
- Switch from JWT/localStorage auth to Django session auth
- Replace Alpine.js with HTMX + vanilla JS where needed
- Add SiteConfig model for customizable site title/tagline
- Add photo manager page (/manage/) with multi-select and bulk actions
- Add python-dotenv so manage.py loads .env automatically
- Server-render dashboard, search, collections (no client-side framework)
- Keep vanilla JS only for upload (drag-drop/progress) and manage (multi-select)
- Simplify gallery URLs from /@username/collections/ to /collections/
- Clean up API: remove groups/users endpoints, add /images/manage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 14:26:18 -04:00
parent f611c980b3
commit 54bd5bc122
34 changed files with 1046 additions and 1039 deletions
+6 -7
View File
@@ -2,7 +2,7 @@
## Project
ExifTree — a community platform where visual creators showcase work organized by the gear used to create it. Browse photography through cameras, lenses, and EXIF metadata.
ExifTree — a personal photography portfolio organized by the gear used to create it. Browse photos through cameras, lenses, and EXIF metadata.
**Domain:** exiftree.org
**Stack:** Django 5.x · Python 3.12+ · PostgreSQL · Celery + Redis
@@ -26,11 +26,12 @@ The project is organized into focused Django apps:
- `core` — foundational models (User, Image, Camera, Lens, ExifData). Other apps import from here but never the reverse.
- `tree` — browse-by-gear discovery pages. No models, reads from core.
- `gallery`user portfolios and collections.
- `groups` — community spaces and memberships.
- `gallery`collections for organizing photos.
- `ingest` — upload pipeline, EXIF extraction, thumbnail generation.
- `search` — EXIF-powered filtering and search.
**Single-tenant:** This is a single-photographer tool. There is one owner account; there are no public user profiles, user listings, groups, or community features.
**Dependency rule:** `core` depends on nothing. All other apps may depend on `core`. Avoid cross-dependencies between feature apps — if two apps need to share logic, it probably belongs in `core`.
## Models
@@ -73,16 +74,14 @@ Django templates + HTMX for interactivity unless otherwise decided. Keep JavaScr
PostgreSQL. Key indexing priorities:
- ExifData: camera_id, lens_id, focal_length, aperture, iso, date_taken
- Image: user_id, upload_date, visibility
- Image: upload_date, visibility
- Camera/Lens: slug, manufacturer
## URLs
- Camera tree: `/cameras/`, `/cameras/<manufacturer>/`, `/cameras/<manufacturer>/<model>/`
- Lens tree: `/lenses/`, `/lenses/<manufacturer>/<model>/`
- User profiles: `/@<username>/`
- Collections: `/@<username>/collections/<slug>/`
- Groups: `/groups/<slug>/`
- Collections: `/collections/`, `/collections/<slug>/`
## When Working on This Project
+12 -1
View File
@@ -1,7 +1,18 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from core.models import Camera, ExifData, Image, Lens, User
from core.models import Camera, ExifData, Image, Lens, SiteConfig, User
@admin.register(SiteConfig)
class SiteConfigAdmin(admin.ModelAdmin):
list_display = ['site_title', 'tagline']
def has_add_permission(self, request):
return not SiteConfig.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
@admin.register(User)
+4 -2
View File
@@ -1,7 +1,9 @@
from django.conf import settings
from core.models import SiteConfig
def site_context(request):
config = SiteConfig.load()
return {
'SINGLE_TENANT': settings.SINGLE_TENANT,
'site_title': config.site_title,
'site_tagline': config.tagline,
}
+41
View File
@@ -0,0 +1,41 @@
# Generated by Django 6.0.4 on 2026-04-08 17:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0003_image_perceptual_hash"),
]
operations = [
migrations.CreateModel(
name="SiteConfig",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("site_title", models.CharField(default="ExifTree", max_length=255)),
(
"tagline",
models.CharField(
blank=True,
default="Browse photography through the gear that made it.",
max_length=255,
),
),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "site configuration",
"verbose_name_plural": "site configuration",
},
),
]
+26
View File
@@ -4,6 +4,32 @@ from django.contrib.auth.models import AbstractUser
from django.db import models
class SiteConfig(models.Model):
"""Singleton site configuration — only one row should ever exist."""
site_title = models.CharField(max_length=255, default='ExifTree')
tagline = models.CharField(
max_length=255, blank=True,
default="Browse photography through the gear that made it."
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'site configuration'
verbose_name_plural = 'site configuration'
def __str__(self) -> str:
return self.site_title
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def load(cls) -> 'SiteConfig':
obj, _ = cls.objects.get_or_create(pk=1)
return obj
class User(AbstractUser):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
bio = models.TextField(blank=True)
+10 -34
View File
@@ -1,46 +1,22 @@
{% extends "base.html" %}
{% block title %}Log in — ExifTree{% endblock %}
{% block title %}Log in — {{ site_title }}{% endblock %}
{% block content %}
<div class="auth-form">
<h1>Log in</h1>
<form id="login-form">
<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>Username</label>
<input type="text" id="username" required autofocus>
<label for="id_username">Username</label>
<input type="text" name="username" id="id_username" required autofocus>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" required>
<label for="id_password">Password</label>
<input type="password" name="password" id="id_password" required>
</div>
<p id="error" style="color: #f44; font-size: 0.9rem; margin-bottom: 0.5rem; display: none;"></p>
<button type="submit" class="btn btn-primary">Log in</button>
</form>
<p class="alt">Don't have an account? <a href="{% url 'register' %}">Sign up</a></p>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('login-form').addEventListener('submit', async function(e) {
e.preventDefault();
var err = document.getElementById('error');
err.style.display = 'none';
var res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
if (res.ok) {
var data = await res.json();
localStorage.setItem('token', data.token);
window.location.href = '/';
} else {
var data = await res.json().catch(function() { return {}; });
err.textContent = data.detail || 'Login failed';
err.style.display = 'block';
}
});
</script>
{% endblock %}
-51
View File
@@ -1,51 +0,0 @@
{% extends "base.html" %}
{% block title %}Sign up — ExifTree{% endblock %}
{% block content %}
<div class="auth-form">
<h1>Sign up</h1>
<form id="register-form">
<div class="field">
<label>Username</label>
<input type="text" id="username" required autofocus>
</div>
<div class="field">
<label>Email</label>
<input type="email" id="email" required>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" required minlength="8">
</div>
<p id="error" style="color: #f44; font-size: 0.9rem; margin-bottom: 0.5rem; display: none;"></p>
<button type="submit" class="btn btn-primary">Sign up</button>
</form>
<p class="alt">Already have an account? <a href="{% url 'login' %}">Log in</a></p>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('register-form').addEventListener('submit', async function(e) {
e.preventDefault();
var err = document.getElementById('error');
err.style.display = 'none';
var res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
email: document.getElementById('email').value,
password: document.getElementById('password').value,
}),
});
if (res.ok) {
var data = await res.json();
localStorage.setItem('token', data.token);
window.location.href = '/';
} else {
var data = await res.json().catch(function() { return {}; });
err.textContent = data.detail || 'Registration failed';
err.style.display = 'block';
}
});
</script>
{% endblock %}
+118 -19
View File
@@ -1,8 +1,10 @@
from django.contrib.auth.decorators import login_required
from django.db.models import Count
from django.db.models.functions import ExtractYear
from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from core.models import Camera, ExifData, Image, Lens, User
from core.models import ExifData, Image
from gallery.models import Collection
@@ -41,7 +43,6 @@ def image_detail(request, image_id):
Image.objects.select_related('user', 'exif', 'exif__camera', 'exif__lens'),
id=image_id, visibility=Image.Visibility.PUBLIC,
)
# Increment view count
from django.db import models as m
Image.objects.filter(id=image_id).update(view_count=m.F('view_count') + 1)
image.view_count += 1
@@ -49,26 +50,124 @@ def image_detail(request, image_id):
return render(request, 'image_detail.html', {'image': image})
@login_required
def dashboard(request):
return render(request, 'dashboard.html')
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,
})
def users_list(request):
q = request.GET.get('q', '')
users = User.objects.order_by('-created_at')
if q:
users = users.filter(username__icontains=q)
users = users[:50]
return render(request, 'users.html', {'users': users, 'query': q})
@login_required
@require_POST
def dashboard_create_collection(request):
title = request.POST.get('title', '').strip()
if title:
from django.utils.text import slugify
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()
return redirect('dashboard')
@login_required
def manage(request):
images = (
Image.objects.filter(user=request.user, is_processing=False)
.select_related('exif', 'exif__camera', 'exif__lens')
.order_by('-upload_date')
)
collections = (
Collection.objects.filter(user=request.user)
.annotate(image_count=Count('collection_images'))
.order_by('-created_at')
)
return render(request, 'manage.html', {
'images': images,
'collections': collections,
})
@login_required
@require_POST
def manage_set_visibility(request):
image_ids = request.POST.getlist('image_ids')
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 = request.POST.getlist('image_ids')
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):
from gallery.models import CollectionImage
image_ids = request.POST.getlist('image_ids')
collection_id = request.POST.get('collection_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:
import uuid as _uuid
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')
@login_required
def flickr_import(request):
return render(request, 'flickr_import.html')
def register_view(request):
return render(request, 'registration/register.html')
def login_view(request):
return render(request, 'registration/login.html')
+20 -209
View File
@@ -10,8 +10,6 @@ from __future__ import annotations
from typing import Annotated
import msgspec
from django.contrib.auth import authenticate
from django.contrib.auth.hashers import make_password
from django.db import models
from django.db.models import Count
from django.utils.text import slugify
@@ -30,7 +28,6 @@ from django_bolt import rate_limit
from core.models import Camera, ExifData, Image, Lens, User
from gallery.models import Collection, CollectionImage
from groups.models import Group, GroupImage, GroupMembership
from ingest.tasks import process_image_task
# Rate limits (requests per second per IP)
@@ -50,12 +47,6 @@ class ErrorSchema(msgspec.Struct):
# Auth
class RegisterInput(msgspec.Struct):
username: str
email: str
password: str
class LoginInput(msgspec.Struct):
username: str
password: str
@@ -145,6 +136,7 @@ class ImageListSchema(msgspec.Struct):
slug: str
user: str
upload_date: str
visibility: str = 'public'
thumbnail_small: str = ''
thumbnail_medium: str = ''
thumbnail_large: str = ''
@@ -197,32 +189,6 @@ class CollectionUpdateInput(msgspec.Struct):
date: str | None = None
# Groups
class GroupSchema(msgspec.Struct):
id: str
name: str
slug: str
description: str
visibility: str
member_count: int = 0
class GroupDetailSchema(msgspec.Struct):
id: str
name: str
slug: str
description: str
visibility: str
member_count: int = 0
members: list[MemberSchema] = []
class MemberSchema(msgspec.Struct):
username: str
role: str
joined_at: str
# Search
class SearchResultSchema(msgspec.Struct):
images: list[ImageListSchema]
@@ -280,6 +246,7 @@ def _image_list_schema(img: Image) -> ImageListSchema:
thumbnail_small=img.thumbnail_small.url if img.thumbnail_small else '',
thumbnail_medium=img.thumbnail_medium.url if img.thumbnail_medium else '',
thumbnail_large=img.thumbnail_large.url if img.thumbnail_large else '',
visibility=img.visibility,
camera=camera, lens=lens, focal_length=focal_length,
aperture=aperture, iso=iso,
)
@@ -336,9 +303,7 @@ auth_router = Router(prefix="/api/auth", tags=["auth"])
cameras_router = Router(prefix="/api/cameras", tags=["cameras"])
lenses_router = Router(prefix="/api/lenses", tags=["lenses"])
images_router = Router(prefix="/api/images", tags=["images"])
users_router = Router(prefix="/api/users", tags=["users"])
collections_router = Router(prefix="/api/collections", tags=["collections"])
groups_router = Router(prefix="/api/groups", tags=["groups"])
search_router = Router(prefix="/api/search", tags=["search"])
@@ -346,23 +311,6 @@ search_router = Router(prefix="/api/search", tags=["search"])
# Auth
# ---------------------------------------------------------------------------
@auth_router.post("/register")
@rate_limit(rps=RATE_AUTH, key="ip")
async def register(data: RegisterInput):
if await User.objects.filter(username=data.username).aexists():
return Response({"detail": "Username taken"}, status_code=409)
if await User.objects.filter(email=data.email).aexists():
return Response({"detail": "Email already registered"}, status_code=409)
user = await User.objects.acreate(
username=data.username,
email=data.email,
password=make_password(data.password),
)
token = create_jwt_for_user(user)
return TokenSchema(token=token)
@auth_router.post("/login")
@rate_limit(rps=RATE_AUTH, key="ip")
async def login(data: LoginInput):
@@ -506,6 +454,22 @@ async def explore_images(limit: int = 48, year: int | None = None) -> list[Image
return images
@images_router.get("/manage", auth=[JWTAuthentication()], guards=[IsAuthenticated()])
@rate_limit(rps=RATE_READ, key="ip")
async def manage_images(request: Request) -> list[ImageListSchema]:
"""All images for the authenticated user, including private/unlisted."""
images = []
qs = (
Image.objects.filter(user=request.user, is_processing=False)
.select_related('user', 'exif', 'exif__camera', 'exif__lens')
.order_by('-upload_date')
)
async for img in qs:
item = _image_list_schema(img)
images.append(item)
return images
@images_router.get("/{image_id}")
@rate_limit(rps=RATE_READ, key="ip")
async def get_image(image_id: str):
@@ -628,51 +592,15 @@ async def delete_image(request: Request, image_id: str):
return Response({}, status_code=204)
# ---------------------------------------------------------------------------
# Users (public profiles)
# ---------------------------------------------------------------------------
@users_router.get("")
@rate_limit(rps=RATE_READ, key="ip")
async def list_users(q: str = '') -> list[UserSchema]:
users = []
qs = User.objects.order_by('-created_at')
if q:
qs = qs.filter(username__icontains=q)
async for u in qs[:50]:
users.append(_user_schema(u))
return users
@users_router.get("/{username}")
@rate_limit(rps=RATE_READ, key="ip")
async def get_user(username: str) -> UserSchema:
u = await User.objects.aget(username=username)
return _user_schema(u)
@users_router.get("/{username}/images")
@rate_limit(rps=RATE_READ, key="ip")
async def user_images(username: str) -> list[ImageListSchema]:
images = []
qs = _public_images_qs().filter(
user__username=username,
).order_by('-upload_date')[:50]
async for img in qs:
images.append(_image_list_schema(img))
return images
# ---------------------------------------------------------------------------
# Collections
# ---------------------------------------------------------------------------
@collections_router.get("/user/{username}")
@collections_router.get("")
@rate_limit(rps=RATE_READ, key="ip")
async def user_collections(username: str) -> list[CollectionSchema]:
async def list_collections() -> list[CollectionSchema]:
collections = []
qs = Collection.objects.filter(
user__username=username,
visibility=Image.Visibility.PUBLIC,
).annotate(image_count=Count('collection_images')).order_by('-created_at')
async for c in qs:
@@ -845,121 +773,6 @@ async def remove_image_from_collection(request: Request, collection_id: str, ima
return Response({}, status_code=204)
# ---------------------------------------------------------------------------
# Groups
# ---------------------------------------------------------------------------
@groups_router.get("")
@rate_limit(rps=RATE_READ, key="ip")
async def list_groups() -> list[GroupSchema]:
groups = []
qs = (
Group.objects.filter(visibility=Group.Visibility.PUBLIC)
.annotate(member_count=Count('memberships'))
.order_by('-created_at')
)
async for g in qs:
groups.append(GroupSchema(
id=str(g.id), name=g.name, slug=g.slug,
description=g.description, visibility=g.visibility,
member_count=g.member_count,
))
return groups
@groups_router.get("/{slug}")
@rate_limit(rps=RATE_READ, key="ip")
async def get_group(slug: str) -> GroupDetailSchema:
g = await Group.objects.annotate(
member_count=Count('memberships')
).aget(slug=slug)
members = []
async for m in g.memberships.select_related('user').order_by('role', 'joined_at'):
members.append(MemberSchema(
username=m.user.username, role=m.role,
joined_at=m.joined_at.isoformat(),
))
return GroupDetailSchema(
id=str(g.id), name=g.name, slug=g.slug,
description=g.description, visibility=g.visibility,
member_count=g.member_count, members=members,
)
@groups_router.get("/{slug}/images")
@rate_limit(rps=RATE_READ, key="ip")
async def group_images(slug: str) -> list[ImageListSchema]:
images = []
qs = _public_images_qs().filter(
group_entries__group__slug=slug,
).order_by('-group_entries__submitted_at')[:50]
async for img in qs:
images.append(_image_list_schema(img))
return images
@groups_router.post(
"/{slug}/join",
auth=[JWTAuthentication()],
guards=[IsAuthenticated()],
)
@rate_limit(rps=RATE_WRITE, key="ip")
async def join_group(request: Request, slug: str):
g = await Group.objects.aget(slug=slug)
if g.visibility == Group.Visibility.PRIVATE:
return Response({"detail": "Private group"}, status_code=403)
if await GroupMembership.objects.filter(user=request.user, group=g).aexists():
return Response({"detail": "Already a member"}, status_code=409)
await GroupMembership.objects.acreate(
user=request.user, group=g, role=GroupMembership.Role.MEMBER,
)
return Response({}, status_code=201)
@groups_router.post(
"/{slug}/leave",
auth=[JWTAuthentication()],
guards=[IsAuthenticated()],
)
@rate_limit(rps=RATE_WRITE, key="ip")
async def leave_group(request: Request, slug: str):
deleted, _ = await GroupMembership.objects.filter(
user=request.user, group__slug=slug,
).adelete()
if not deleted:
return Response({"detail": "Not a member"}, status_code=404)
return Response({}, status_code=204)
@groups_router.post(
"/{slug}/images/{image_id}",
auth=[JWTAuthentication()],
guards=[IsAuthenticated()],
)
@rate_limit(rps=RATE_WRITE, key="ip")
async def submit_image_to_group(request: Request, slug: str, image_id: str):
g = await Group.objects.aget(slug=slug)
# Must be a member
if not await GroupMembership.objects.filter(user=request.user, group=g).aexists():
return Response({"detail": "Not a member"}, status_code=403)
# Must own the image
img = await Image.objects.aget(id=image_id)
if str(img.user_id) != str(request.user.id):
return Response({"detail": "Not your image"}, status_code=403)
if await GroupImage.objects.filter(image=img, group=g).aexists():
return Response({"detail": "Image already in group"}, status_code=409)
await GroupImage.objects.acreate(image=img, group=g)
return Response({}, status_code=201)
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@@ -1057,9 +870,7 @@ api.include_router(auth_router)
api.include_router(cameras_router)
api.include_router(lenses_router)
api.include_router(images_router)
api.include_router(users_router)
api.include_router(collections_router)
api.include_router(groups_router)
api.include_router(search_router)
api.include_router(import_router)
+3 -4
View File
@@ -30,9 +30,6 @@ DEBUG = os.environ.get("DEBUG", "1") == "1"
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "https://exiftree.fly.dev,https://exiftree.org").split(",")
# Single-tenant mode — set to a username to disable multi-user UI
SINGLE_TENANT = os.environ.get("SINGLE_TENANT", "")
# Application definition
@@ -48,7 +45,6 @@ INSTALLED_APPS = [
"core",
"tree",
"gallery",
"groups",
"ingest",
"search",
]
@@ -171,6 +167,9 @@ CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localho
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
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>"
+23 -5
View File
@@ -1,22 +1,40 @@
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.urls import include, path
from core.views import dashboard, flickr_import, home, image_detail, login_view, register_view, users_list
from core.views import (
dashboard,
dashboard_create_collection,
dashboard_delete_collection,
dashboard_delete_image,
flickr_import,
home,
image_detail,
manage,
manage_add_to_collection,
manage_delete_images,
manage_set_visibility,
)
urlpatterns = [
path("admin/", admin.site.urls),
path("", home, name="home"),
path("login/", login_view, name="login"),
path("register/", register_view, name="register"),
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("import/flickr/", flickr_import, name="flickr-import"),
path("users/", users_list, name="users"),
path("images/<uuid:image_id>/", image_detail, name="image-detail"),
path("", include("tree.urls")),
path("", include("gallery.urls")),
path("", include("groups.urls")),
path("", include("ingest.urls")),
path("", include("search.urls")),
]
@@ -1,97 +1,25 @@
{% extends "base.html" %}
{% block title %}{{ collection.title }} — ExifTree{% endblock %}
{% block title %}{{ collection.title }} — {{ site_title }}{% endblock %}
{% block content %}
<div x-data="{
col: null,
currentUser: null,
isOwner: false,
editing: false,
allImages: [],
colImageIds: new Set(),
async init() {
const res = await fetch('/api/collections/{{ collection.id }}');
this.col = await res.json();
this.colImageIds = new Set(this.col.images.map(i => i.id));
<div class="collection-header">
<h1>{{ collection.title }}</h1>
{% if collection.description %}<p class="desc">{{ collection.description }}</p>{% endif %}
<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-muted);">
{{ images|length }} images
</p>
</div>
const token = localStorage.getItem('token');
if (token) {
const me = await fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } });
if (me.ok) {
this.currentUser = await me.json();
this.isOwner = this.currentUser.id === this.col.user.id;
}
}
},
async startEditing() {
this.editing = true;
if (!this.allImages.length) {
const res = await fetch('/api/users/' + this.col.user.username + '/images');
this.allImages = await res.json();
}
},
async toggleImage(imageId) {
const token = localStorage.getItem('token');
const h = { 'Authorization': 'Bearer ' + token };
if (this.colImageIds.has(imageId)) {
await fetch('/api/collections/{{ collection.id }}/images/' + imageId, { method: 'DELETE', headers: h });
this.colImageIds.delete(imageId);
this.col.images = this.col.images.filter(i => i.id !== imageId);
} else {
await fetch('/api/collections/{{ collection.id }}/images/' + imageId, { method: 'POST', headers: h });
this.colImageIds.add(imageId);
const img = this.allImages.find(i => i.id === imageId);
if (img) this.col.images.push(img);
}
}
}">
<template x-if="col">
<div>
<div class="collection-header" style="display: flex; justify-content: space-between; align-items: start;">
<div>
<h1 x-text="col.title"></h1>
<p class="desc" x-text="col.description" x-show="col.description"></p>
<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-muted);">
by <a :href="'/@' + col.user.username" x-text="'@' + col.user.username"></a>
· <span x-text="col.images.length + ' images'"></span>
</p>
</div>
<button x-show="isOwner" class="btn" @click="editing ? (editing = false) : startEditing()"
x-text="editing ? 'Done' : 'Edit'"></button>
</div>
<!-- Edit mode: pick photos -->
<div x-show="editing" style="margin-bottom: 2rem;">
<p style="color: var(--text-muted); margin-bottom: 1rem; font-size: 0.9rem;">Click images to add or remove them from this collection.</p>
<div class="image-grid">
<template x-for="img in allImages" :key="img.id">
<div class="image-card" :class="colImageIds.has(img.id) ? 'image-card-selected' : ''"
style="cursor: pointer;" @click="toggleImage(img.id)">
<img :src="img.thumbnail_medium || img.thumbnail_small" :alt="img.title">
<div x-show="colImageIds.has(img.id)"
style="position: absolute; top: 0.5rem; left: 0.5rem; background: var(--accent); color: #000; font-weight: 700; width: 1.5rem; height: 1.5rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.8rem;">
</div>
</div>
</template>
</div>
</div>
<!-- View mode: collection images -->
<div x-show="!editing">
<div class="image-grid">
<template x-for="(img, i) in col.images" :key="img.id">
<div>
<div class="image-card" @click="openLightbox(col.images, i)" style="cursor: pointer;">
<img :src="img.thumbnail_medium || img.thumbnail_small" :alt="img.title">
{% include "includes/image_overlay.html" %}
</div>
{% if not SINGLE_TENANT %}<a :href="'/@' + img.user" class="image-card-user" x-text="'@' + img.user"></a>{% endif %}
</div>
</template>
<div class="empty" x-show="col.images.length === 0">This collection is empty.</div>
</div>
</div>
</div>
</template>
<div class="image-grid">
{% for img in images %}
<div>
<a href="/images/{{ img.id }}/" class="image-card">
{% 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 %}
{% include "includes/image_overlay.html" %}
</a>
</div>
{% empty %}
<div class="empty" style="grid-column: 1 / -1;">This collection is empty.</div>
{% endfor %}
</div>
{% endblock %}
+11 -16
View File
@@ -1,23 +1,18 @@
{% extends "base.html" %}
{% block title %}@{{ profile_user.username }}'s Collections — ExifTree{% endblock %}
{% block title %}Collections — ExifTree{% endblock %}
{% block content %}
<div class="page-header">
<h1>@{{ profile_user.username }}'s Collections</h1>
<h1>Collections</h1>
</div>
<div class="gear-grid" x-data="{
collections: [],
async init() {
const res = await fetch('/api/collections/user/{{ profile_user.username }}');
this.collections = await res.json();
}
}">
<template x-for="c in collections" :key="c.id">
<a :href="'/@{{ profile_user.username }}/collections/' + c.slug + '/'" class="gear-card" style="color: inherit;">
<h3 x-text="c.title"></h3>
<span class="count" x-text="c.image_count + ' images'"></span>
</a>
</template>
<div class="empty" x-show="collections.length === 0">No collections yet.</div>
<div class="gear-grid">
{% for c in collections %}
<a href="/collections/{{ c.slug }}/" class="gear-card" style="color: inherit;">
<h3>{{ c.title }}</h3>
<span class="count">{{ c.image_count }} images</span>
</a>
{% empty %}
<div class="empty" style="grid-column: 1 / -1;">No collections yet.</div>
{% endfor %}
</div>
{% endblock %}
+2 -11
View File
@@ -1,21 +1,13 @@
{% extends "base.html" %}
{% block title %}@{{ profile_user.username }} — ExifTree{% endblock %}
{% block title %}Collections — ExifTree{% endblock %}
{% block content %}
<div class="profile-header">
{% if profile_user.avatar %}<img src="{{ profile_user.avatar.url }}" class="avatar">{% endif %}
<div>
<h1>@{{ profile_user.username }}</h1>
{% if profile_user.bio %}<p class="bio">{{ profile_user.bio }}</p>{% endif %}
{% if profile_user.website %}<a href="{{ profile_user.website }}" target="_blank">{{ profile_user.website }}</a>{% endif %}
</div>
</div>
{% if collections %}
<div style="margin-bottom: 2rem;">
<h2 style="font-size: 1.25rem; margin-bottom: 1rem;">Collections</h2>
<div class="gear-grid">
{% for c in collections %}
<a href="/@{{ profile_user.username }}/collections/{{ c.slug }}/" class="gear-card" style="color: inherit;">
<a href="/collections/{{ c.slug }}/" class="gear-card" style="color: inherit;">
<h3>{{ c.title }}</h3>
<span class="count">{{ c.image_count }} images</span>
</a>
@@ -33,7 +25,6 @@
{% elif img.thumbnail_small %}<img src="{{ img.thumbnail_small.url }}" alt="{{ img.title }}">{% endif %}
{% include "includes/image_overlay.html" %}
</a>
{% if not SINGLE_TENANT %}<a href="/@{{ img.user.username }}" class="image-card-user">@{{ img.user.username }}</a>{% endif %}
</div>
{% empty %}
<div class="empty">No images yet.</div>
+2 -3
View File
@@ -5,7 +5,6 @@ from gallery import views
app_name = 'gallery'
urlpatterns = [
path('@<str:username>/', views.profile, name='profile'),
path('@<str:username>/collections/', views.collection_list, name='collection-list'),
path('@<str:username>/collections/<slug:slug>/', views.collection_detail, name='collection-detail'),
path('collections/', views.collection_list, name='collection-list'),
path('collections/<slug:slug>/', views.collection_detail, name='collection-detail'),
]
+6 -21
View File
@@ -1,37 +1,23 @@
from django.db.models import Count
from django.shortcuts import get_object_or_404, render
from core.models import Image, User
from core.models import Image
from gallery.models import Collection
def profile(request, username):
user = get_object_or_404(User, username=username)
images = (
Image.objects.filter(user=user, visibility=Image.Visibility.PUBLIC, is_processing=False)
.select_related('exif', 'exif__camera', 'exif__lens')
.order_by('-upload_date')
)
def collection_list(request):
collections = (
Collection.objects.filter(user=user, visibility=Image.Visibility.PUBLIC)
Collection.objects.filter(visibility=Image.Visibility.PUBLIC)
.annotate(image_count=Count('collection_images'))
.order_by('-created_at')
)
return render(request, 'gallery/profile.html', {
'profile_user': user,
'images': images,
return render(request, 'gallery/collection_list.html', {
'collections': collections,
})
def collection_list(request, username):
user = get_object_or_404(User, username=username)
return render(request, 'gallery/collection_list.html', {'profile_user': user})
def collection_detail(request, username, slug):
user = get_object_or_404(User, username=username)
collection = get_object_or_404(Collection, user=user, slug=slug)
def collection_detail(request, slug):
collection = get_object_or_404(Collection, slug=slug)
images = (
Image.objects.filter(
collection_entries__collection=collection, is_processing=False
@@ -40,7 +26,6 @@ def collection_detail(request, username, slug):
.order_by('collection_entries__sort_order')
)
return render(request, 'gallery/collection_detail.html', {
'profile_user': user,
'collection': collection,
'images': images,
})
+157 -196
View File
@@ -1,128 +1,7 @@
{% extends "base.html" %}
{% block title %}Upload — ExifTree{% endblock %}
{% block title %}Upload — {{ site_title }}{% endblock %}
{% block content %}
<div x-data="{
files: [],
dragover: false,
error: '',
collections: [],
selectedCollection: '',
newCollectionName: '',
creatingCollection: false,
async init() {
const token = localStorage.getItem('token');
if (token) {
const me = await fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } });
if (me.ok) {
const user = await me.json();
const res = await fetch('/api/collections/user/' + user.username);
if (res.ok) this.collections = await res.json();
}
}
},
addFiles(fileList) {
for (const f of fileList) {
if (!f.type.startsWith('image/')) continue;
this.files.push({ file: f, name: f.name, size: f.size, status: 'pending', progress: 0, id: null, error: '' });
}
this.uploadAll();
},
concurrency: 6,
active: 0,
async uploadAll() {
const token = localStorage.getItem('token');
if (!token) { this.error = 'Please log in first'; return; }
// Create new collection if requested
if (this.newCollectionName.trim()) {
const res = await fetch('/api/collections', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: this.newCollectionName }),
});
if (res.ok) {
const c = await res.json();
this.collections.unshift(c);
this.selectedCollection = c.id;
this.newCollectionName = '';
this.creatingCollection = false;
}
}
this.drain(token);
},
drain(token) {
while (this.active < this.concurrency) {
const item = this.files.find(f => f.status === 'pending');
if (!item) return;
item.status = 'uploading';
this.active++;
this.uploadOne(item, token).then(() => {
this.active--;
this.drain(token);
});
}
},
uploadOne(item, token) {
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append('image', item.file);
xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable) item.progress = Math.round((e.loaded / e.total) * 100);
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
item.id = data.id;
} catch(e) {}
item.status = 'done';
item.progress = 100;
// Add to collection if selected
if (this.selectedCollection && item.id) {
fetch('/api/collections/' + this.selectedCollection + '/images/' + item.id, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
});
}
} else if (xhr.status === 409) {
try {
const data = JSON.parse(xhr.responseText);
item.id = data.id;
} catch(e) {}
item.status = 'duplicate';
item.progress = 100;
} else {
try {
const data = JSON.parse(xhr.responseText);
item.error = data.detail || 'Upload failed';
} catch(e) { item.error = 'Upload failed'; }
item.status = 'error';
}
resolve();
});
xhr.addEventListener('error', () => {
item.error = 'Upload failed';
item.status = 'error';
resolve();
});
xhr.open('POST', '/api/images/upload');
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
xhr.send(form);
});
}
}"
@dragover.window.prevent="dragover = true"
@dragleave.window.prevent="if (!$event.relatedTarget) dragover = false"
@drop.window.prevent="dragover = false; addFiles($event.dataTransfer.files)"
:class="{ 'upload-page-dragover': dragover }"
class="upload-page"
>
<div id="upload-page" class="upload-page">
<div class="upload-dropoverlay">Drop images to upload</div>
<div class="page-header">
@@ -134,92 +13,174 @@
<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 x-model="selectedCollection"
<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>
<template x-for="c in collections" :key="c.id">
<option :value="c.id" x-text="c.title"></option>
</template>
{% for c in collections %}
<option value="{{ c.id }}">{{ c.title }}</option>
{% endfor %}
</select>
</div>
<template x-if="!creatingCollection">
<button class="btn" @click="creatingCollection = true" style="font-size: 0.85rem;">+ New collection</button>
</template>
<template x-if="creatingCollection">
<div style="display: flex; gap: 0.5rem; align-items: end;">
<input type="text" x-model="newCollectionName" placeholder="Collection name"
@keydown.enter.prevent="if (newCollectionName.trim()) { async function c() {
const token = localStorage.getItem('token');
const res = await fetch('/api/collections', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newCollectionName }),
});
if (res.ok) { const col = await res.json(); collections.unshift(col); selectedCollection = col.id; newCollectionName = ''; creatingCollection = false; }
}; c(); }"
style="padding: 0.5rem 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text);">
<button class="btn btn-primary" @click="async function c() {
if (!newCollectionName.trim()) return;
const token = localStorage.getItem('token');
const res = await fetch('/api/collections', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newCollectionName }),
});
if (res.ok) { const col = await res.json(); collections.unshift(col); selectedCollection = col.id; newCollectionName = ''; creatingCollection = false; }
}; c();">Create</button>
<button class="btn" @click="creatingCollection = false; newCollectionName = ''">Cancel</button>
</div>
</template>
</div>
<p x-show="error" x-text="error" style="color: #f44; margin-bottom: 1rem;"></p>
<p id="upload-error" style="color: #f44; margin-bottom: 1rem; display: none;"></p>
<div class="upload-zone" @click="$refs.fileInput.click()">
<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 x-ref="fileInput" style="display:none"
@change="addFiles($event.target.files)">
<input type="file" accept="image/*" multiple id="file-input" style="display:none">
<!-- Upload queue -->
<div x-show="files.length > 0" style="margin-top: 2rem;">
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;"
x-text="files.filter(f => f.status === 'done').length + ' of ' + files.length + ' uploaded'"></h2>
<div class="upload-queue">
<template x-for="(item, i) in files" :key="i">
<div class="upload-item" :class="'upload-' + item.status">
<div class="upload-item-info">
<span class="upload-item-name" x-text="item.name"></span>
<span class="upload-item-size" x-text="(item.size / 1024 / 1024).toFixed(1) + ' MB'"></span>
</div>
<div class="upload-item-right">
<template x-if="item.status === 'pending'">
<span style="color: var(--text-muted);">Waiting</span>
</template>
<template x-if="item.status === 'uploading'">
<span style="color: var(--accent);" x-text="item.progress + '%'"></span>
</template>
<template x-if="item.status === 'done'">
<a :href="'/images/' + item.id + '/'" style="color: #4c4;">View</a>
</template>
<template x-if="item.status === 'duplicate'">
<a :href="'/images/' + item.id + '/'" style="color: var(--text-muted);">Already uploaded</a>
</template>
<template x-if="item.status === 'error'">
<span style="color: #f44;" x-text="item.error"></span>
</template>
</div>
<!-- Progress bar -->
<div class="upload-progress-track" x-show="item.status === 'uploading' || item.status === 'done'">
<div class="upload-progress-bar"
:class="item.status === 'done' ? 'upload-progress-done' : ''"
:style="'width: ' + item.progress + '%'"></div>
</div>
</div>
</template>
<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
View File
@@ -6,4 +6,5 @@ app_name = 'ingest'
urlpatterns = [
path('upload/', views.upload, name='upload'),
path('upload/image/', views.upload_image, name='upload-image'),
]
+54 -2
View File
@@ -1,6 +1,58 @@
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):
"""Upload page — auth is handled client-side via JWT."""
return render(request, 'ingest/upload.html')
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,
)
try:
process_image_task.apply_async(args=[str(img.id)], ignore_result=True)
except Exception:
from ingest.pipeline import process_image
process_image(img)
return JsonResponse({'id': str(img.id), 'title': img.title}, status=201)
+4
View File
@@ -4,6 +4,10 @@
import os
import sys
from dotenv import load_dotenv
load_dotenv()
def main():
"""Run administrative tasks."""
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"imagehash>=4.3.2",
"pillow>=12.2.0",
"psycopg[binary]>=3.3.3",
"python-dotenv>=1.2.2",
"redis>=7.4.0",
"requests>=2.33.1",
]
+61 -101
View File
@@ -1,111 +1,71 @@
{% extends "base.html" %}
{% block title %}Search — ExifTree{% endblock %}
{% block title %}Search — {{ site_title }}{% endblock %}
{% block content %}
<div class="page-header">
<h1>Search</h1>
<p>Find images by EXIF data</p>
</div>
<div x-data="{
results: [], total: 0, cameras: [], lenses: [],
q: '', camera: '', lens: '', focal_min: '', focal_max: '',
aperture_min: '', aperture_max: '', iso_min: '', iso_max: '',
async search() {
const params = new URLSearchParams();
if (this.q) params.set('q', this.q);
if (this.camera) params.set('camera', this.camera);
if (this.lens) params.set('lens', this.lens);
if (this.focal_min) params.set('focal_min', this.focal_min);
if (this.focal_max) params.set('focal_max', this.focal_max);
if (this.aperture_min) params.set('aperture_min', this.aperture_min);
if (this.aperture_max) params.set('aperture_max', this.aperture_max);
if (this.iso_min) params.set('iso_min', this.iso_min);
if (this.iso_max) params.set('iso_max', this.iso_max);
const res = await fetch('/api/search?' + params);
const data = await res.json();
this.results = data.images;
this.total = data.total;
}
async init() {
const [cRes, lRes] = await Promise.all([
fetch('/api/cameras'), fetch('/api/lenses')
]);
this.cameras = await cRes.json();
this.lenses = await lRes.json();
this.search();
}
}">
<form class="search-form" @submit.prevent="search()">
<div class="field">
<label>Keyword</label>
<input type="text" x-model="q" placeholder="Title or description...">
</div>
<div class="field">
<label>Camera</label>
<select x-model="camera">
<option value="">Any</option>
<template x-for="c in cameras" :key="c.id">
<option :value="c.id" x-text="c.display_name"></option>
</template>
</select>
</div>
<div class="field">
<label>Lens</label>
<select x-model="lens">
<option value="">Any</option>
<template x-for="l in lenses" :key="l.id">
<option :value="l.id" x-text="l.display_name"></option>
</template>
</select>
</div>
<div class="field">
<label>Focal (mm)</label>
<div style="display: flex; gap: 0.25rem;">
<input type="number" x-model="focal_min" placeholder="min" style="width: 5rem;">
<input type="number" x-model="focal_max" placeholder="max" style="width: 5rem;">
</div>
</div>
<div class="field">
<label>Aperture (f/)</label>
<div style="display: flex; gap: 0.25rem;">
<input type="number" step="0.1" x-model="aperture_min" placeholder="min" style="width: 5rem;">
<input type="number" step="0.1" x-model="aperture_max" placeholder="max" style="width: 5rem;">
</div>
</div>
<div class="field">
<label>ISO</label>
<div style="display: flex; gap: 0.25rem;">
<input type="number" x-model="iso_min" placeholder="min" style="width: 5rem;">
<input type="number" x-model="iso_max" placeholder="max" style="width: 5rem;">
</div>
</div>
<button type="submit" class="btn btn-primary">Search</button>
</form>
<p style="color: var(--text-muted); margin-bottom: 1rem; font-size: 0.85rem;" x-text="total + ' results'"></p>
<div class="image-grid">
<template x-for="img in results" :key="img.id">
<div>
<a :href="'/images/' + img.id + '/'" class="image-card">
<img :src="img.thumbnail_medium || img.thumbnail_small" :alt="img.title">
<div class="overlay">
<div class="exif-row">
<div class="exif-left">
<span x-text="img.camera" x-show="img.camera"></span>
</div>
<div class="exif-right">
<span x-show="img.focal_length" x-text="img.focal_length + 'mm'"></span>
<span x-show="img.aperture" x-text="'f/' + img.aperture"></span>
<span x-show="img.iso" x-text="'ISO ' + img.iso"></span>
</div>
</div>
</div>
</a>
{% if not SINGLE_TENANT %}<a :href="'/@' + img.user" class="image-card-user" x-text="'@' + img.user"></a>{% endif %}
</div>
</template>
<div class="empty" x-show="results.length === 0">No images match your filters.</div>
<form method="get" class="search-form">
<div class="field">
<label>Keyword</label>
<input type="text" name="q" value="{{ q }}" placeholder="Title or description...">
</div>
<div class="field">
<label>Camera</label>
<select name="camera">
<option value="">Any</option>
{% for c in cameras %}
<option value="{{ c.id }}" {% if camera == c.id|slugify %}selected{% endif %}>{{ c.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Lens</label>
<select name="lens">
<option value="">Any</option>
{% for l in lenses %}
<option value="{{ l.id }}" {% if lens == l.id|slugify %}selected{% endif %}>{{ l.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Focal (mm)</label>
<div style="display: flex; gap: 0.25rem;">
<input type="number" name="focal_min" value="{{ focal_min }}" placeholder="min" style="width: 5rem;">
<input type="number" name="focal_max" value="{{ focal_max }}" placeholder="max" style="width: 5rem;">
</div>
</div>
<div class="field">
<label>Aperture (f/)</label>
<div style="display: flex; gap: 0.25rem;">
<input type="number" step="0.1" name="aperture_min" value="{{ aperture_min }}" placeholder="min" style="width: 5rem;">
<input type="number" step="0.1" name="aperture_max" value="{{ aperture_max }}" placeholder="max" style="width: 5rem;">
</div>
</div>
<div class="field">
<label>ISO</label>
<div style="display: flex; gap: 0.25rem;">
<input type="number" name="iso_min" value="{{ iso_min }}" placeholder="min" style="width: 5rem;">
<input type="number" name="iso_max" value="{{ iso_max }}" placeholder="max" style="width: 5rem;">
</div>
</div>
<button type="submit" class="btn btn-primary">Search</button>
</form>
<p style="color: var(--text-muted); margin-bottom: 1rem; font-size: 0.85rem;">{{ total }} results</p>
<div class="image-grid">
{% for img in images %}
<div>
<a href="/images/{{ img.id }}/" class="image-card">
{% 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 %}
{% include "includes/image_overlay.html" %}
</a>
</div>
{% empty %}
<div class="empty" style="grid-column: 1 / -1;">No images match your filters.</div>
{% endfor %}
</div>
{% endblock %}
+57 -1
View File
@@ -1,5 +1,61 @@
from django.shortcuts import render
from core.models import Camera, Image, Lens
def search(request):
return render(request, 'search/search.html')
cameras = Camera.objects.order_by('manufacturer', 'model')
lenses = Lens.objects.order_by('manufacturer', 'model')
qs = Image.objects.filter(
visibility=Image.Visibility.PUBLIC, is_processing=False,
).select_related('user', 'exif', 'exif__camera', 'exif__lens')
q = request.GET.get('q', '')
camera = request.GET.get('camera', '')
lens = request.GET.get('lens', '')
focal_min = request.GET.get('focal_min', '')
focal_max = request.GET.get('focal_max', '')
aperture_min = request.GET.get('aperture_min', '')
aperture_max = request.GET.get('aperture_max', '')
iso_min = request.GET.get('iso_min', '')
iso_max = request.GET.get('iso_max', '')
if q:
from django.db.models import Q
qs = qs.filter(Q(title__icontains=q) | Q(description__icontains=q))
if camera:
qs = qs.filter(exif__camera_id=camera)
if lens:
qs = qs.filter(exif__lens_id=lens)
if focal_min:
qs = qs.filter(exif__focal_length__gte=float(focal_min))
if focal_max:
qs = qs.filter(exif__focal_length__lte=float(focal_max))
if aperture_min:
qs = qs.filter(exif__aperture__gte=float(aperture_min))
if aperture_max:
qs = qs.filter(exif__aperture__lte=float(aperture_max))
if iso_min:
qs = qs.filter(exif__iso__gte=int(iso_min))
if iso_max:
qs = qs.filter(exif__iso__lte=int(iso_max))
images = qs.order_by('-upload_date')[:50]
total = qs.count()
return render(request, 'search/search.html', {
'cameras': cameras,
'lenses': lenses,
'images': images,
'total': total,
'q': q,
'camera': camera,
'lens': lens,
'focal_min': focal_min,
'focal_max': focal_max,
'aperture_min': aperture_min,
'aperture_max': aperture_max,
'iso_min': iso_min,
'iso_max': iso_max,
})
+81
View File
@@ -394,6 +394,87 @@ a:hover { color: var(--accent-hover); }
}
.lightbox-link:hover { border-color: var(--accent); }
/* Photo manager */
.manage-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 0.75rem 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.manage-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
}
@media (max-width: 768px) { .manage-grid { grid-template-columns: repeat(3, 1fr); } }
.manage-item {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
background: var(--surface);
transition: transform 0.1s;
}
.manage-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.manage-item:hover { transform: scale(1.02); }
.manage-item-selected {
outline: 3px solid var(--accent);
outline-offset: -3px;
transform: scale(0.95);
}
.manage-item-selected:hover { transform: scale(0.95); }
.manage-check {
position: absolute;
top: 0.4rem;
left: 0.4rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.5);
background: rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: #000;
font-weight: 700;
transition: all 0.1s;
}
.manage-check-on {
background: var(--accent);
border-color: var(--accent);
}
.manage-vis-badge {
position: absolute;
bottom: 0.4rem;
right: 0.4rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vis-private { background: #d33; color: #fff; }
.vis-unlisted { background: #c80; color: #fff; }
/* Footer */
.site-footer {
padding: 2rem;
+20 -28
View File
@@ -3,35 +3,42 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}ExifTree{% endblock %}</title>
<title>{% block title %}{{ site_title }}{% endblock %}</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block head %}{% endblock %}
</head>
<body>
<nav class="site-nav">
<a href="/" class="nav-brand">ExifTree</a>
<a href="/" class="nav-brand">{{ site_title }}</a>
<div class="nav-links">
<a href="{% url 'tree:camera-list' %}">Cameras</a>
<a href="{% url 'tree:lens-list' %}">Lenses</a>
{% if SINGLE_TENANT %}<a href="/@{{ SINGLE_TENANT }}/collections/">Collections</a>{% else %}<a href="{% url 'users' %}">Users</a>{% endif %}
<a href="{% url 'gallery:collection-list' %}">Collections</a>
<a href="{% url 'search:search' %}">Search</a>
</div>
<div class="nav-auth" id="nav-auth">
<div class="nav-guest" id="nav-guest">
<a href="{% url 'login' %}">Log in</a>
{% if not SINGLE_TENANT %}<a href="{% url 'register' %}" class="btn btn-primary">Sign up</a>{% endif %}
</div>
<div class="nav-user-dropdown" id="nav-user" style="display: none;">
<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'"></button>
<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 id="nav-profile-link" href="#">Profile</a>
<a href="/dashboard/">Dashboard</a>
<a href="/manage/">Manage Photos</a>
<a href="/upload/">Upload</a>
<a href="/import/flickr/">Import from Flickr</a>
<hr>
<a href="#" onclick="localStorage.removeItem('token'); location.href='/';">Log out</a>
<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>
@@ -40,25 +47,10 @@
</main>
<footer class="site-footer">
<p>ExifTree — browse photography through the gear that made it.</p>
<p>{{ site_title }}{% if site_tagline %} — {{ site_tagline }}{% endif %}</p>
</footer>
<script>
// Auth check — minimal JS, no framework
(async function() {
var token = localStorage.getItem('token');
if (!token) return;
try {
var res = await fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } });
if (!res.ok) { localStorage.removeItem('token'); return; }
var user = await res.json();
document.getElementById('nav-guest').style.display = 'none';
document.getElementById('nav-user').style.display = '';
document.getElementById('nav-user-btn').textContent = '@' + user.username;
document.getElementById('nav-profile-link').href = '/@' + user.username;
} catch(e) {}
})();
// Close dropdown on outside click
document.addEventListener('click', function(e) {
var dd = document.getElementById('nav-dropdown');
var btn = document.getElementById('nav-user-btn');
+76 -184
View File
@@ -1,193 +1,85 @@
{% extends "base.html" %}
{% block title %}Dashboard — ExifTree{% endblock %}
{% block title %}Dashboard — {{ site_title }}{% endblock %}
{% block content %}
<div x-data="{
user: null, images: [], collections: [],
editing: false, bio: '', website: '',
newSet: false, setTitle: '', setDesc: '', setDate: '',
addingTo: null,
async load() {
const token = localStorage.getItem('token');
if (!token) { window.location.href = '/login/'; return; }
const h = { 'Authorization': 'Bearer ' + token };
const uRes = await fetch('/api/auth/me', { headers: h });
this.user = await uRes.json();
this.bio = this.user.bio;
this.website = this.user.website;
const [iRes, cRes] = await Promise.all([
fetch('/api/users/' + this.user.username + '/images'),
fetch('/api/collections/user/' + this.user.username),
]);
this.images = await iRes.json();
this.collections = await cRes.json();
},
async saveProfile() {
const token = localStorage.getItem('token');
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ bio: this.bio, website: this.website }),
});
if (res.ok) { this.user = await res.json(); this.editing = false; }
},
async deleteImage(id) {
if (!confirm('Delete this image?')) return;
const token = localStorage.getItem('token');
await fetch('/api/images/' + id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
this.images = this.images.filter(i => i.id !== id);
},
async createSet() {
if (!this.setTitle.trim()) return;
const token = localStorage.getItem('token');
const res = await fetch('/api/collections', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: this.setTitle, description: this.setDesc, date: this.setDate || null }),
});
if (res.ok) {
const c = await res.json();
this.collections.unshift(c);
this.setTitle = ''; this.setDesc = ''; this.newSet = false;
}
},
async deleteSet(id) {
if (!confirm('Delete this collection?')) return;
const token = localStorage.getItem('token');
await fetch('/api/collections/' + id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
this.collections = this.collections.filter(c => c.id !== id);
},
async addToSet(collectionId, imageId) {
const token = localStorage.getItem('token');
await fetch('/api/collections/' + collectionId + '/images/' + imageId, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
});
this.addingTo = null;
// Refresh collection counts
const cRes = await fetch('/api/collections/user/' + this.user.username);
this.collections = await cRes.json();
}
}" x-init="load()">
<!-- 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>
<template x-if="user">
<div>
<!-- Profile Section -->
<div class="page-header" style="display: flex; justify-content: space-between; align-items: start;">
<div>
<h1 x-text="'@' + user.username"></h1>
<p style="color: var(--text-muted);" x-text="user.email"></p>
</div>
<div style="display: flex; gap: 0.5rem;">
<a href="/upload/" class="btn btn-primary">Upload</a>
<button class="btn" @click="editing = !editing" x-text="editing ? 'Cancel' : 'Edit Profile'"></button>
</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>
<!-- Edit Profile Form -->
<div x-show="editing" style="margin-bottom: 2rem; max-width: 500px;">
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
<div>
<label style="font-size: 0.85rem; color: var(--text-muted); display: block; margin-bottom: 0.25rem;">Bio</label>
<textarea x-model="bio" rows="3"
style="width: 100%; padding: 0.5rem 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); resize: vertical;"></textarea>
</div>
<div>
<label style="font-size: 0.85rem; color: var(--text-muted); display: block; margin-bottom: 0.25rem;">Website</label>
<input type="url" x-model="website"
style="width: 100%; padding: 0.5rem 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text);">
</div>
<button class="btn btn-primary" @click="saveProfile()" style="align-self: start;">Save</button>
</div>
</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>
<!-- Bio display -->
<div x-show="!editing && (user.bio || user.website)" style="margin-bottom: 2rem;">
<p x-show="user.bio" x-text="user.bio"></p>
<a x-show="user.website" :href="user.website" x-text="user.website" target="_blank" style="font-size: 0.9rem;"></a>
</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>
<!-- Stats -->
<div style="display: flex; gap: 2rem; margin-bottom: 2rem; font-size: 0.9rem; color: var(--text-muted);">
<span x-text="user.image_count + ' images'"></span>
<span x-text="collections.length + ' collections'"></span>
</div>
<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>
<!-- Collections -->
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1rem;">
<h2 style="font-size: 1.25rem;">Collections</h2>
<button class="btn" @click="newSet = !newSet" x-text="newSet ? 'Cancel' : 'New Collection'"></button>
</div>
<!-- New Collection Form -->
<div x-show="newSet" style="margin-bottom: 1.5rem; max-width: 500px;">
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<input type="text" x-model="setTitle" placeholder="Set title"
style="padding: 0.5rem 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text);">
<input type="text" x-model="setDesc" placeholder="Description (optional)"
style="padding: 0.5rem 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text);">
<input type="date" x-model="setDate"
style="padding: 0.5rem 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); color-scheme: dark;">
<button class="btn btn-primary" @click="createSet()" style="align-self: start;">Create</button>
</div>
</div>
<div class="gear-grid" style="margin-bottom: 3rem;">
<template x-for="c in collections" :key="c.id">
<div class="gear-card">
<a :href="'/@' + user.username + '/collections/' + c.slug + '/'" style="color: inherit;">
<h3 x-text="c.title"></h3>
<span class="count" x-text="c.image_count + ' images'"></span>
<span class="count" x-show="c.date" x-text="' · ' + c.date"></span>
<span class="count" x-show="!c.date" x-text="' · ' + new Date(c.created_at).toLocaleDateString()"></span>
</a>
<button @click="deleteSet(c.id)"
style="margin-top: 0.5rem; background: none; border: none; color: #f44; cursor: pointer; font-size: 0.8rem; padding: 0;">
Delete
</button>
</div>
</template>
<div class="empty" x-show="collections.length === 0" style="grid-column: 1 / -1;">No sets yet.</div>
</div>
<!-- Images -->
<h2 style="font-size: 1.25rem; margin-bottom: 1rem;">Your Images</h2>
<div class="image-grid">
<template x-for="img in images" :key="img.id">
<div class="image-card" style="position: relative;">
<a :href="'/images/' + img.id + '/'">
<img :src="img.thumbnail_medium || img.thumbnail_small" :alt="img.title">
</a>
<div style="position: absolute; top: 0.5rem; right: 0.5rem; display: flex; gap: 0.25rem;">
<div style="position: relative;" @click.outside="addingTo = (addingTo === img.id ? null : addingTo)">
<button @click="addingTo = (addingTo === img.id ? null : img.id)"
style="background: rgba(0,0,0,0.7); border: none; color: var(--text); cursor: pointer; padding: 0.25rem 0.5rem; border-radius: var(--radius); font-size: 0.8rem;">
+ Set
</button>
<div x-show="addingTo === img.id" class="dropdown-menu" style="right: 0; top: 100%; min-width: 140px; margin-top: 0.25rem;">
<template x-for="c in collections" :key="c.id">
<a href="#" @click.prevent="addToSet(c.id, img.id)" x-text="c.title"></a>
</template>
<div x-show="collections.length === 0" style="padding: 0.5rem 1rem; color: var(--text-muted); font-size: 0.85rem;">Create a collection first</div>
</div>
</div>
<button @click="deleteImage(img.id)"
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>
</div>
</div>
</template>
<div class="empty" x-show="images.length === 0">
No images yet. <a href="/upload/">Upload your first image</a>
</div>
</div>
</div>
</template>
<!-- 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 %}
+5 -14
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Import from Flickr — ExifTree{% endblock %}
{% block title %}Import from Flickr — {{ site_title }}{% endblock %}
{% block content %}
<div class="page-header">
<h1>Import from Flickr</h1>
@@ -7,6 +7,7 @@
</div>
<form id="import-form" style="max-width: 500px;">
{% csrf_token %}
<div class="field" style="margin-bottom: 1rem;">
<label style="font-size: 0.85rem; color: var(--text-muted); display: block; margin-bottom: 0.25rem;">Flickr Username</label>
<input type="text" id="flickr-user" required placeholder="e.g. kennethreitz"
@@ -48,9 +49,6 @@ document.getElementById('import-form').addEventListener('submit', async function
var statusDiv = document.getElementById('import-status');
var statusText = document.getElementById('status-text');
var error = document.getElementById('import-error');
var token = localStorage.getItem('token');
if (!token) { window.location.href = '/login/'; return; }
btn.disabled = true;
btn.textContent = 'Importing... (this may take a while)';
@@ -62,8 +60,8 @@ document.getElementById('import-form').addEventListener('submit', async function
var res = await fetch('/api/import/flickr', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
},
body: JSON.stringify({
flickr_user: document.getElementById('flickr-user').value,
@@ -79,20 +77,13 @@ document.getElementById('import-form').addEventListener('submit', async function
if (res.ok) {
statusDiv.style.display = 'block';
statusText.textContent = data.message || 'Import complete!';
if (data.detail) {
statusText.textContent += '\n' + data.detail.substring(0, 500);
}
} else {
error.style.display = 'block';
if (data.detail && typeof data.detail === 'object') {
error.textContent = JSON.stringify(data.detail, null, 2);
} else {
error.textContent = data.detail || data.message || text || 'Import failed';
}
error.textContent = data.detail || data.message || 'Import failed';
}
} catch(err) {
error.style.display = 'block';
error.textContent = 'Request failed: ' + err.message + ' (try Chrome or disable browser extensions)';
error.textContent = 'Request failed: ' + err.message;
}
btn.disabled = false;
+3 -8
View File
@@ -1,9 +1,8 @@
{% extends "base.html" %}
{% block title %}ExifTree — Browse photography through the gear that made it{% endblock %}
{% block title %}{{ site_title }}{% if site_tagline %} — {{ site_tagline }}{% endif %}{% endblock %}
{% block content %}
<div style="text-align: center; padding: 2rem 0 1.5rem;">
<h1 style="font-size: 2.5rem; letter-spacing: -0.03em;">ExifTree</h1>
{% if SINGLE_TENANT %}
<h1 style="font-size: 2.5rem; letter-spacing: -0.03em;">{{ site_title }}</h1>
<div style="margin-top: 0.75rem;">
<div style="display: flex; flex-wrap: wrap; gap: 0.4rem; justify-content: center;">
<a href="/" class="brand-pill {% if not selected_year %}brand-pill-active{% endif %}">All</a>
@@ -12,9 +11,6 @@
{% endfor %}
</div>
</div>
{% else %}
<p style="color: var(--text-muted); font-size: 1.1rem; margin-top: 0.5rem;">Browse photography through the gear that made it.</p>
{% endif %}
</div>
<div class="image-grid" style="grid-template-columns: repeat(4, 1fr);">
@@ -25,10 +21,9 @@
{% elif img.thumbnail_small %}<img src="{{ img.thumbnail_small.url }}" alt="{{ img.title }}">{% endif %}
{% include "includes/image_overlay.html" %}
</a>
{% if not SINGLE_TENANT %}<a href="/@{{ img.user.username }}" class="image-card-user">@{{ img.user.username }}</a>{% endif %}
</div>
{% empty %}
<div class="empty" style="grid-column: 1 / -1;">No images yet. Be the first to <a href="/upload/">upload</a>.</div>
<div class="empty" style="grid-column: 1 / -1;">No images yet. <a href="/upload/">Upload</a> some photos.</div>
{% endfor %}
</div>
{% endblock %}
+1 -2
View File
@@ -17,8 +17,7 @@
<div style="display: flex; justify-content: space-between; align-items: start; flex-wrap: wrap; gap: 1rem;">
<div>
<p style="color: var(--text-muted);">
by <a href="/@{{ image.user.username }}">@{{ image.user.username }}</a>
· {{ image.upload_date|date:"N j, Y" }}
{{ image.upload_date|date:"N j, Y" }}
· {{ image.view_count }} views
</p>
{% if image.description %}<p style="margin-top: 0.5rem;">{{ image.description }}</p>{% endif %}
+210
View File
@@ -0,0 +1,210 @@
{% extends "base.html" %}
{% block title %}Manage Photos — {{ site_title }}{% endblock %}
{% block content %}
<div id="manage-app">
<!-- 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">
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<button class="btn" onclick="manageToggleAll()" style="font-size: 0.85rem;" id="select-all-btn">Select all</button>
<select id="filter-visibility" onchange="manageFilter()" 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">All</option>
<option value="public">Public</option>
<option value="private">Private</option>
<option value="unlisted">Unlisted</option>
</select>
</div>
<!-- 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" style="display: none; left: 0; top: 100%; margin-top: 0.25rem; min-width: 180px;">
{% for c in collections %}
<a href="#" onclick="event.preventDefault(); submitAddToCollection('{{ c.id }}'); this.parentElement.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">
</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_medium %}<img src="{{ img.thumbnail_medium.url }}" alt="{{ img.title }}" loading="lazy">
{% elif img.thumbnail_small %}<img src="{{ img.thumbnail_small.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.manageFilter = function() {
var vis = document.getElementById('filter-visibility').value;
document.querySelectorAll('.manage-item').forEach(function(el) {
if (vis === 'all' || el.dataset.vis === vis) {
el.style.display = '';
} else {
el.style.display = 'none';
}
});
selected.clear();
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.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() {
var ids = [];
document.querySelectorAll('.manage-item').forEach(function(el) {
if (el.style.display !== 'none') ids.push(el.dataset.id);
});
return ids;
}
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 %}
-26
View File
@@ -1,26 +0,0 @@
{% extends "base.html" %}
{% block title %}Users — ExifTree{% endblock %}
{% block content %}
<div class="page-header">
<h1>Users</h1>
<p>Photographers on ExifTree</p>
</div>
<form method="get" style="margin-bottom: 1.5rem;">
<input type="text" name="q" value="{{ query }}" placeholder="Search users..."
style="width: 100%; max-width: 400px; padding: 0.5rem 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text);">
</form>
<p style="color: var(--text-muted); font-size: 0.85rem; margin-bottom: 1rem;">{{ users|length }} users</p>
<div class="gear-grid">
{% for u in users %}
<a href="/@{{ u.username }}" class="gear-card" style="color: inherit;">
<h3>@{{ u.username }}</h3>
{% if u.bio %}<p class="count" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ u.bio }}</p>{% endif %}
</a>
{% empty %}
<div class="empty" style="grid-column: 1 / -1;">No users found.</div>
{% endfor %}
</div>
{% endblock %}
-1
View File
@@ -14,7 +14,6 @@
{% elif img.thumbnail_small %}<img src="{{ img.thumbnail_small.url }}" alt="{{ img.title }}">{% endif %}
{% include "includes/image_overlay.html" %}
</a>
{% if not SINGLE_TENANT %}<a href="/@{{ img.user.username }}" class="image-card-user">@{{ img.user.username }}</a>{% endif %}
</div>
{% empty %}
<div class="empty" style="grid-column: 1 / -1;">No images yet for this camera.</div>
-1
View File
@@ -14,7 +14,6 @@
{% elif img.thumbnail_small %}<img src="{{ img.thumbnail_small.url }}" alt="{{ img.title }}">{% endif %}
{% include "includes/image_overlay.html" %}
</a>
{% if not SINGLE_TENANT %}<a href="/@{{ img.user.username }}" class="image-card-user">@{{ img.user.username }}</a>{% endif %}
</div>
{% empty %}
<div class="empty" style="grid-column: 1 / -1;">No images yet for this lens.</div>
Generated
+11
View File
@@ -317,6 +317,7 @@ dependencies = [
{ name = "imagehash" },
{ name = "pillow" },
{ name = "psycopg", extra = ["binary"] },
{ name = "python-dotenv" },
{ name = "redis" },
{ name = "requests" },
]
@@ -334,6 +335,7 @@ requires-dist = [
{ name = "imagehash", specifier = ">=4.3.2" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "redis", specifier = ">=7.4.0" },
{ name = "requests", specifier = ">=2.33.1" },
]
@@ -693,6 +695,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pywavelets"
version = "1.9.0"