From 54bd5bc1228df5e199386d3dcd48c11a97312737 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 8 Apr 2026 14:26:18 -0400 Subject: [PATCH] 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) --- CLAUDE.md | 13 +- core/admin.py | 13 +- core/context_processors.py | 6 +- core/migrations/0004_add_site_config.py | 41 ++ core/models.py | 26 ++ core/templates/registration/login.html | 44 +-- core/templates/registration/register.html | 51 --- core/views.py | 137 ++++++- exiftree/api.py | 229 +----------- exiftree/settings.py | 7 +- exiftree/urls.py | 28 +- .../templates/gallery/collection_detail.html | 112 +----- .../templates/gallery/collection_list.html | 27 +- gallery/templates/gallery/profile.html | 13 +- gallery/urls.py | 5 +- gallery/views.py | 27 +- ingest/templates/ingest/upload.html | 353 ++++++++---------- ingest/urls.py | 1 + ingest/views.py | 56 ++- manage.py | 4 + pyproject.toml | 1 + search/templates/search/search.html | 162 +++----- search/views.py | 58 ++- static/css/style.css | 81 ++++ templates/base.html | 48 +-- templates/dashboard.html | 260 ++++--------- templates/flickr_import.html | 19 +- templates/home.html | 11 +- templates/image_detail.html | 3 +- templates/manage.html | 210 +++++++++++ templates/users.html | 26 -- tree/templates/tree/camera_detail.html | 1 - tree/templates/tree/lens_detail.html | 1 - uv.lock | 11 + 34 files changed, 1046 insertions(+), 1039 deletions(-) create mode 100644 core/migrations/0004_add_site_config.py delete mode 100644 core/templates/registration/register.html create mode 100644 templates/manage.html delete mode 100644 templates/users.html diff --git a/CLAUDE.md b/CLAUDE.md index 57d696a..5b84baf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//`, `/cameras///` - Lens tree: `/lenses/`, `/lenses///` -- User profiles: `/@/` -- Collections: `/@/collections//` -- Groups: `/groups//` +- Collections: `/collections/`, `/collections//` ## When Working on This Project diff --git a/core/admin.py b/core/admin.py index 34fb0c6..4d2eb70 100644 --- a/core/admin.py +++ b/core/admin.py @@ -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) diff --git a/core/context_processors.py b/core/context_processors.py index c0263b2..cb81cfa 100644 --- a/core/context_processors.py +++ b/core/context_processors.py @@ -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, } diff --git a/core/migrations/0004_add_site_config.py b/core/migrations/0004_add_site_config.py new file mode 100644 index 0000000..c3e5562 --- /dev/null +++ b/core/migrations/0004_add_site_config.py @@ -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", + }, + ), + ] diff --git a/core/models.py b/core/models.py index e8ebb12..b79b9ba 100644 --- a/core/models.py +++ b/core/models.py @@ -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) diff --git a/core/templates/registration/login.html b/core/templates/registration/login.html index b05965f..ddedb1c 100644 --- a/core/templates/registration/login.html +++ b/core/templates/registration/login.html @@ -1,46 +1,22 @@ {% extends "base.html" %} -{% block title %}Log in — ExifTree{% endblock %} +{% block title %}Log in — {{ site_title }}{% endblock %} {% block content %}

Log in

-
+ + {% csrf_token %} + {% if form.errors %} +

Invalid username or password.

+ {% endif %}
- - + +
- - + +
-
-

Don't have an account? Sign up

{% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/core/templates/registration/register.html b/core/templates/registration/register.html deleted file mode 100644 index ee2f121..0000000 --- a/core/templates/registration/register.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends "base.html" %} -{% block title %}Sign up — ExifTree{% endblock %} -{% block content %} -
-

Sign up

-
-
- - -
-
- - -
-
- - -
- - -
-

Already have an account? Log in

-
-{% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/core/views.py b/core/views.py index eccfa5a..4597760 100644 --- a/core/views.py +++ b/core/views.py @@ -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') diff --git a/exiftree/api.py b/exiftree/api.py index bf5c370..3ca1618 100644 --- a/exiftree/api.py +++ b/exiftree/api.py @@ -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) diff --git a/exiftree/settings.py b/exiftree/settings.py index ea588bc..283205b 100644 --- a/exiftree/settings.py +++ b/exiftree/settings.py @@ -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 " diff --git a/exiftree/urls.py b/exiftree/urls.py index 08f0223..2969cbd 100644 --- a/exiftree/urls.py +++ b/exiftree/urls.py @@ -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//delete/", dashboard_delete_collection, name="dashboard-delete-collection"), + path("dashboard/images//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//", 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")), ] diff --git a/gallery/templates/gallery/collection_detail.html b/gallery/templates/gallery/collection_detail.html index a96e9bc..3086ae7 100644 --- a/gallery/templates/gallery/collection_detail.html +++ b/gallery/templates/gallery/collection_detail.html @@ -1,97 +1,25 @@ {% extends "base.html" %} -{% block title %}{{ collection.title }} — ExifTree{% endblock %} +{% block title %}{{ collection.title }} — {{ site_title }}{% endblock %} {% block content %} -
+

{{ collection.title }}

+ {% if collection.description %}

{{ collection.description }}

{% endif %} +

+ {{ images|length }} images +

+
- 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); - } - } -}"> - +
+ {% for img in images %} + + {% empty %} +
This collection is empty.
+ {% endfor %}
{% endblock %} diff --git a/gallery/templates/gallery/collection_list.html b/gallery/templates/gallery/collection_list.html index cef4fdc..9836d82 100644 --- a/gallery/templates/gallery/collection_list.html +++ b/gallery/templates/gallery/collection_list.html @@ -1,23 +1,18 @@ {% extends "base.html" %} -{% block title %}@{{ profile_user.username }}'s Collections — ExifTree{% endblock %} +{% block title %}Collections — ExifTree{% endblock %} {% block content %} -
- -
No collections yet.
+
+ {% for c in collections %} + +

{{ c.title }}

+ {{ c.image_count }} images +
+ {% empty %} +
No collections yet.
+ {% endfor %}
{% endblock %} diff --git a/gallery/templates/gallery/profile.html b/gallery/templates/gallery/profile.html index 8d1603a..8ed6c49 100644 --- a/gallery/templates/gallery/profile.html +++ b/gallery/templates/gallery/profile.html @@ -1,21 +1,13 @@ {% extends "base.html" %} -{% block title %}@{{ profile_user.username }} — ExifTree{% endblock %} +{% block title %}Collections — ExifTree{% endblock %} {% block content %} -
- {% if profile_user.avatar %}{% endif %} -
-

@{{ profile_user.username }}

- {% if profile_user.bio %}

{{ profile_user.bio }}

{% endif %} - {% if profile_user.website %}{{ profile_user.website }}{% endif %} -
-
{% if collections %}

Collections

{% for c in collections %} - +

{{ c.title }}

{{ c.image_count }} images
@@ -33,7 +25,6 @@ {% elif img.thumbnail_small %}{{ img.title }}{% endif %} {% include "includes/image_overlay.html" %} - {% if not SINGLE_TENANT %}@{{ img.user.username }}{% endif %}
{% empty %}
No images yet.
diff --git a/gallery/urls.py b/gallery/urls.py index 649dc24..7c51fdd 100644 --- a/gallery/urls.py +++ b/gallery/urls.py @@ -5,7 +5,6 @@ from gallery import views app_name = 'gallery' urlpatterns = [ - path('@/', views.profile, name='profile'), - path('@/collections/', views.collection_list, name='collection-list'), - path('@/collections//', views.collection_detail, name='collection-detail'), + path('collections/', views.collection_list, name='collection-list'), + path('collections//', views.collection_detail, name='collection-detail'), ] diff --git a/gallery/views.py b/gallery/views.py index becb501..0fcbef4 100644 --- a/gallery/views.py +++ b/gallery/views.py @@ -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, }) diff --git a/ingest/templates/ingest/upload.html b/ingest/templates/ingest/upload.html index f5650ec..4e81424 100644 --- a/ingest/templates/ingest/upload.html +++ b/ingest/templates/ingest/upload.html @@ -1,128 +1,7 @@ {% extends "base.html" %} -{% block title %}Upload — ExifTree{% endblock %} +{% block title %}Upload — {{ site_title }}{% endblock %} {% block content %} -
+
Drop images to upload