mirror of
https://github.com/kennethreitz/photos.kennethreitz.org.git
synced 2026-06-05 06:46:13 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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,6 +4,10 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 = '✓';
|
||||
} 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user