commit 4877e0a7797eb12b94374eff5c490af82ff9750d Author: Kris Date: Mon Aug 10 10:28:53 2026 -0500 Initial commit: Aurora Moments self-hosted photo/video sharing platform Django/DRF + PostgreSQL/Redis/Celery backend with accounts, library, moments, and notifications apps; React/TypeScript/Vite frontend with Instagram-style sidebar nav, feed, profiles, nested comment replies, notifications, and photo/video upload with ffmpeg-generated posters. Co-Authored-By: Claude Sonnet 5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d821679 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# Copy to .env and adjust for your environment. +# docker-compose reads this file automatically. + +POSTGRES_DB=aurora_moments +POSTGRES_USER=aurora +POSTGRES_PASSWORD=aurora +DATABASE_URL=postgres://aurora:aurora@postgres:5432/aurora_moments + +REDIS_URL=redis://redis:6379/0 + +DJANGO_SECRET_KEY=change-me-in-production +DEBUG=True +DJANGO_ALLOWED_HOSTS=* +CORS_ALLOWED_ORIGINS=http://localhost:5173 + +# Where original photos/videos and derived renditions are stored. +# filesystem: written under MEDIA_ROOT below (bind-mount this to your NAS +# path in production, e.g. /srv/aurora-moments/storage). +# s3: any S3-compatible object store (AWS S3, MinIO, Backblaze B2, ...). +MEDIA_BACKEND=filesystem +MEDIA_ROOT=/app/media + +# Only used when MEDIA_BACKEND=s3 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_STORAGE_BUCKET_NAME=aurora-moments +AWS_S3_ENDPOINT_URL= +AWS_S3_REGION_NAME=us-east-1 + +VITE_API_BASE_URL=http://localhost:8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1f85be --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Python / Django +__pycache__/ +*.py[cod] +*.sqlite3 +.venv/ +venv/ +api/media/ +api/staticfiles/ +api/.env + +# Node / web +web/node_modules/ +web/dist/ +web/.env + +# Mobile +mobile/node_modules/ +mobile/ios/Pods/ +mobile/.expo/ + +# Env files +.env +!.env.example + +# Editors / OS +.DS_Store +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..263e28f --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# Aurora Moments + +> Aurora Moments is not a social network that stores your pictures. It's your +> photo library with a social network built around it. + +A self-hostable photo/video platform: a private, full-resolution photo +library (like iCloud Photos) with an explicit, opt-in social layer +("Moments") built on top of it. Uploading never equals publishing — a photo +only enters the social graph when its owner chooses to share it as a Moment. + +## Architecture + +``` +docker-compose +│ +├── postgres PostgreSQL — primary datastore +├── redis Celery broker/result backend +├── api Django + Django REST Framework +├── worker Celery worker (thumbnail/feed/large renditions, EXIF) +└── web React + TypeScript (Vite), talks to the API over REST +``` + +- **api/** — Django project. Apps: `accounts` (users, follows), + `library` (private originals, renditions, albums), `moments` (the social + posts, comments, reactions). JWT auth via `djangorestframework-simplejwt`. +- **web/** — React + TypeScript SPA (Vite). Login/Register, a private + Library grid with upload, and a Home feed of Moments. +- **mobile/** — not started; see [mobile/README.md](mobile/README.md). + +### Storage + +Original uploads and derived renditions (thumbnail/feed/large) go through +`MEDIA_BACKEND` in `.env`: + +- `filesystem` (default) — written to `MEDIA_ROOT`, which you can bind-mount + to a NAS path in a self-hosted deployment. +- `s3` — any S3-compatible object store (AWS S3, MinIO, Backblaze B2, ...) + via `django-storages`, no code changes needed. + +### Privacy model + +- Everything uploaded to a user's Library is private by default. +- A **Moment** is one or more Library items + caption/location, shared with + an explicit `audience`: `public`, `followers`, `friends`, `family`, + `custom`, or `only_me`. +- `family` and `custom` currently fall back to owner-only visibility — full + circle/custom-audience membership is a V1.5 feature (see + `apps.moments.models.MomentQuerySet.visible_to`); shipping that stub now + was a deliberate choice to never over-share by default. +- Shared Albums (`apps.library.models.Album` + `AlbumCollaborator`) let + multiple people contribute full-resolution photos to one place (e.g. a + wedding), without the audience/feed logic of Moments. + +## Running locally + +```bash +cp .env.example .env +docker compose up --build +``` + +- Web: http://localhost:5173 +- API: http://localhost:8000/api/ +- Django admin: http://localhost:8000/admin/ (create a superuser first, see below) + +Create a superuser for the Django admin: + +```bash +docker compose exec api python manage.py createsuperuser +``` + +Migrations run automatically on `api` container start. + +## What's in V1 + +- Registration/login (JWT), profiles, follow/followers +- Private full-resolution photo/video upload with async thumbnail/feed/large + rendition generation and EXIF extraction (Celery + Pillow) +- Share a Moment from existing Library items, with audience controls +- Home feed, comments, reactions (likes) +- Shared Albums with per-user roles (viewer/contributor/admin) +- Original-quality downloads (the original file is never deleted or resized + in place) + +Deliberately **not** in V1 (see the original concept notes this repo is +based on): automatic iOS backup, push notifications, messaging, Memories, +maps, facial recognition, semantic AI search, Stories, video transcoding, +and server federation. Those come in V1.5/V2 once the core loop — private +library, explicit sharing — is proven. + +## Backend layout + +``` +api/ +├── config/ settings, urls, celery app +└── apps/ + ├── core/ shared abstract models (TimeStampedModel, Audience enum) + ├── accounts/ User, Follow + ├── library/ LibraryItem, MediaRendition, Album, AlbumItem, AlbumCollaborator + └── moments/ Moment, MomentItem, Comment, Reaction +``` + +REST API root: `/api/accounts/`, `/api/library/`, `/api/moments/`, +`/api/auth/token/` (JWT obtain/refresh). diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..9df72ee --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,7 @@ +__pycache__ +*.pyc +.venv +venv +media +staticfiles +.env diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..bce5ec9 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libpq-dev gcc ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /app/media /app/staticfiles + +EXPOSE 8000 + +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] diff --git a/api/apps/accounts/__init__.py b/api/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps/accounts/admin.py b/api/apps/accounts/admin.py new file mode 100644 index 0000000..9b90c49 --- /dev/null +++ b/api/apps/accounts/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin + +from .models import Follow, User + + +@admin.register(User) +class UserAdmin(DjangoUserAdmin): + fieldsets = DjangoUserAdmin.fieldsets + ( + ("Aurora Moments profile", {"fields": ("display_name", "bio", "avatar", "is_private")}), + ) + list_display = ("username", "email", "display_name", "is_private", "is_staff") + + +@admin.register(Follow) +class FollowAdmin(admin.ModelAdmin): + list_display = ("follower", "following", "status", "created_at") + list_filter = ("status",) diff --git a/api/apps/accounts/apps.py b/api/apps/accounts/apps.py new file mode 100644 index 0000000..7d07530 --- /dev/null +++ b/api/apps/accounts/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.accounts" + label = "accounts" diff --git a/api/apps/accounts/migrations/0001_initial.py b/api/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..e55286b --- /dev/null +++ b/api/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,69 @@ +# Generated by Django 5.0.9 on 2026-08-09 17:00 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(max_length=254, unique=True)), + ('display_name', models.CharField(blank=True, max_length=150)), + ('bio', models.TextField(blank=True, max_length=500)), + ('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')), + ('is_private', models.BooleanField(default=False, help_text='If true, new followers must be approved before they can see followers-only Moments.')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Follow', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted')], default='accepted', max_length=10)), + ('follower', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='following_edges', to=settings.AUTH_USER_MODEL)), + ('following', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='follower_edges', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddConstraint( + model_name='follow', + constraint=models.CheckConstraint(check=models.Q(('follower', models.F('following')), _negated=True), name='follow_no_self_follow'), + ), + migrations.AlterUniqueTogether( + name='follow', + unique_together={('follower', 'following')}, + ), + ] diff --git a/api/apps/accounts/migrations/__init__.py b/api/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps/accounts/models.py b/api/apps/accounts/models.py new file mode 100644 index 0000000..c9c84c7 --- /dev/null +++ b/api/apps/accounts/models.py @@ -0,0 +1,46 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + +from apps.core.models import TimeStampedModel + + +class User(AbstractUser): + """Aurora Moments account. Email is the login identifier; username is the + public @handle shown on profiles.""" + + email = models.EmailField(unique=True) + display_name = models.CharField(max_length=150, blank=True) + bio = models.TextField(max_length=500, blank=True) + avatar = models.ImageField(upload_to="avatars/", blank=True, null=True) + is_private = models.BooleanField( + default=False, + help_text="If true, new followers must be approved before they can see followers-only Moments.", + ) + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["username"] + + def __str__(self): + return self.username + + +class Follow(TimeStampedModel): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + ACCEPTED = "accepted", "Accepted" + + follower = models.ForeignKey(User, related_name="following_edges", on_delete=models.CASCADE) + following = models.ForeignKey(User, related_name="follower_edges", on_delete=models.CASCADE) + status = models.CharField(max_length=10, choices=Status.choices, default=Status.ACCEPTED) + + class Meta: + unique_together = ("follower", "following") + constraints = [ + models.CheckConstraint( + check=~models.Q(follower=models.F("following")), + name="follow_no_self_follow", + ) + ] + + def __str__(self): + return f"{self.follower} -> {self.following} ({self.status})" diff --git a/api/apps/accounts/serializers.py b/api/apps/accounts/serializers.py new file mode 100644 index 0000000..6346fdc --- /dev/null +++ b/api/apps/accounts/serializers.py @@ -0,0 +1,91 @@ +from django.contrib.auth import password_validation +from rest_framework import serializers + +from .models import Follow, User + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "username", "display_name", "bio", "avatar", "is_private", "date_joined"] + read_only_fields = ["id", "date_joined"] + + +class ProfileSerializer(UserSerializer): + """UserSerializer plus the counts/relationship state a profile page + needs. Kept separate from UserSerializer (used for owner/author fields + all over the feed) so those hot paths don't pay for two extra COUNT + queries per embedded user.""" + + follower_count = serializers.SerializerMethodField() + following_count = serializers.SerializerMethodField() + viewer_follow_id = serializers.SerializerMethodField() + viewer_follow_status = serializers.SerializerMethodField() + + class Meta(UserSerializer.Meta): + fields = UserSerializer.Meta.fields + [ + "follower_count", "following_count", "viewer_follow_id", "viewer_follow_status", + ] + + def _viewer_follow(self, obj): + request = self.context.get("request") + if not request or not request.user.is_authenticated or request.user.id == obj.id: + return None + if not hasattr(obj, "_viewer_follow_cache"): + obj._viewer_follow_cache = Follow.objects.filter(follower=request.user, following=obj).first() + return obj._viewer_follow_cache + + def get_follower_count(self, obj): + return obj.follower_edges.filter(status=Follow.Status.ACCEPTED).count() + + def get_following_count(self, obj): + return obj.following_edges.filter(status=Follow.Status.ACCEPTED).count() + + def get_viewer_follow_id(self, obj): + follow = self._viewer_follow(obj) + return follow.id if follow else None + + def get_viewer_follow_status(self, obj): + follow = self._viewer_follow(obj) + return follow.status if follow else None + + +class RegisterSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = ["id", "email", "username", "password", "display_name"] + + def validate_password(self, value): + password_validation.validate_password(value) + return value + + def create(self, validated_data): + password = validated_data.pop("password") + user = User(**validated_data) + user.set_password(password) + user.save() + return user + + +class FollowSerializer(serializers.ModelSerializer): + follower = UserSerializer(read_only=True) + following = UserSerializer(read_only=True) + following_id = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), source="following", write_only=True + ) + + class Meta: + model = Follow + fields = ["id", "follower", "following", "following_id", "status", "created_at"] + read_only_fields = ["id", "follower", "status", "created_at"] + + def create(self, validated_data): + follower = self.context["request"].user + following = validated_data["following"] + status = Follow.Status.PENDING if following.is_private else Follow.Status.ACCEPTED + follow, _ = Follow.objects.get_or_create( + follower=follower, following=following, defaults={"status": status} + ) + return follow diff --git a/api/apps/accounts/urls.py b/api/apps/accounts/urls.py new file mode 100644 index 0000000..bee9c5d --- /dev/null +++ b/api/apps/accounts/urls.py @@ -0,0 +1,13 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter + +from .views import FollowViewSet, MeView, RegisterView, UserViewSet + +router = DefaultRouter() +router.register("users", UserViewSet, basename="user") +router.register("follows", FollowViewSet, basename="follow") + +urlpatterns = [ + path("register/", RegisterView.as_view(), name="register"), + path("me/", MeView.as_view(), name="me"), +] + router.urls diff --git a/api/apps/accounts/views.py b/api/apps/accounts/views.py new file mode 100644 index 0000000..734b019 --- /dev/null +++ b/api/apps/accounts/views.py @@ -0,0 +1,48 @@ +from rest_framework import generics, permissions, viewsets +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import Follow, User +from .serializers import FollowSerializer, ProfileSerializer, RegisterSerializer, UserSerializer + + +class RegisterView(generics.CreateAPIView): + queryset = User.objects.all() + serializer_class = RegisterSerializer + permission_classes = [permissions.AllowAny] + + +class MeView(APIView): + def get(self, request): + return Response(ProfileSerializer(request.user, context={"request": request}).data) + + def patch(self, request): + serializer = UserSerializer(request.user, data=request.data, partial=True, context={"request": request}) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + +class UserViewSet(viewsets.ReadOnlyModelViewSet): + queryset = User.objects.all() + serializer_class = ProfileSerializer + lookup_field = "username" + + +class FollowViewSet(viewsets.ModelViewSet): + serializer_class = FollowSerializer + http_method_names = ["get", "post", "delete", "head", "options"] + + def get_queryset(self): + user = self.request.user + qs = Follow.objects.select_related("follower", "following") + direction = self.request.query_params.get("direction", "following") + if direction == "followers": + return qs.filter(following=user) + return qs.filter(follower=user) + + def perform_destroy(self, instance): + if instance.follower_id != self.request.user.id: + raise PermissionDenied("Cannot delete another user's follow edge.") + instance.delete() diff --git a/api/apps/core/__init__.py b/api/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps/core/apps.py b/api/apps/core/apps.py new file mode 100644 index 0000000..68868e8 --- /dev/null +++ b/api/apps/core/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.core" + label = "core" diff --git a/api/apps/core/migrations/__init__.py b/api/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps/core/models.py b/api/apps/core/models.py new file mode 100644 index 0000000..960d13e --- /dev/null +++ b/api/apps/core/models.py @@ -0,0 +1,29 @@ +import uuid + +from django.db import models + + +class TimeStampedModel(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class UUIDModel(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + class Meta: + abstract = True + + +class Audience(models.TextChoices): + """Who can see a Moment or Album. Mirrors the concept doc's privacy model.""" + + PUBLIC = "public", "Public" + FOLLOWERS = "followers", "Followers" + FRIENDS = "friends", "Friends" + FAMILY = "family", "Family" + CUSTOM = "custom", "Custom" + ONLY_ME = "only_me", "Only Me" diff --git a/api/apps/library/__init__.py b/api/apps/library/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps/library/admin.py b/api/apps/library/admin.py new file mode 100644 index 0000000..d802703 --- /dev/null +++ b/api/apps/library/admin.py @@ -0,0 +1,32 @@ +from django.contrib import admin + +from .models import Album, AlbumCollaborator, AlbumItem, LibraryItem, MediaRendition + + +class MediaRenditionInline(admin.TabularInline): + model = MediaRendition + extra = 0 + + +@admin.register(LibraryItem) +class LibraryItemAdmin(admin.ModelAdmin): + list_display = ("original_filename", "owner", "media_type", "processing_status", "captured_at") + list_filter = ("media_type", "processing_status") + search_fields = ("original_filename", "owner__username") + inlines = [MediaRenditionInline] + + +class AlbumItemInline(admin.TabularInline): + model = AlbumItem + extra = 0 + + +class AlbumCollaboratorInline(admin.TabularInline): + model = AlbumCollaborator + extra = 0 + + +@admin.register(Album) +class AlbumAdmin(admin.ModelAdmin): + list_display = ("name", "owner", "audience", "created_at") + inlines = [AlbumItemInline, AlbumCollaboratorInline] diff --git a/api/apps/library/apps.py b/api/apps/library/apps.py new file mode 100644 index 0000000..2c20c1b --- /dev/null +++ b/api/apps/library/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class LibraryConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.library" + label = "library" diff --git a/api/apps/library/migrations/0001_initial.py b/api/apps/library/migrations/0001_initial.py new file mode 100644 index 0000000..45af85a --- /dev/null +++ b/api/apps/library/migrations/0001_initial.py @@ -0,0 +1,104 @@ +# Generated by Django 5.0.9 on 2026-08-09 17:00 + +import apps.library.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='LibraryItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('file', models.FileField(upload_to=apps.library.models.library_upload_path)), + ('media_type', models.CharField(choices=[('photo', 'Photo'), ('video', 'Video')], max_length=10)), + ('original_filename', models.CharField(max_length=255)), + ('file_size', models.PositiveBigIntegerField(default=0)), + ('width', models.PositiveIntegerField(blank=True, null=True)), + ('height', models.PositiveIntegerField(blank=True, null=True)), + ('duration_seconds', models.FloatField(blank=True, null=True)), + ('captured_at', models.DateTimeField(blank=True, null=True)), + ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('camera_make', models.CharField(blank=True, max_length=100)), + ('camera_model', models.CharField(blank=True, max_length=100)), + ('checksum', models.CharField(db_index=True, help_text='sha256 of the original file, used for dedup', max_length=64)), + ('processing_status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('ready', 'Ready'), ('failed', 'Failed')], default='pending', max_length=12)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='library_items', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-captured_at', '-created_at'], + 'unique_together': {('owner', 'checksum')}, + }, + ), + migrations.CreateModel( + name='Album', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=150)), + ('description', models.TextField(blank=True)), + ('audience', models.CharField(choices=[('public', 'Public'), ('followers', 'Followers'), ('friends', 'Friends'), ('family', 'Family'), ('custom', 'Custom'), ('only_me', 'Only Me')], default='only_me', max_length=10)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='albums', to=settings.AUTH_USER_MODEL)), + ('cover_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='library.libraryitem')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AlbumCollaborator', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('viewer', 'Viewer'), ('contributor', 'Contributor'), ('admin', 'Admin')], default='viewer', max_length=11)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted')], default='pending', max_length=8)), + ('album', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collaborators', to='library.album')), + ('invited_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='album_invites', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('album', 'user')}, + }, + ), + migrations.CreateModel( + name='AlbumItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('added_at', models.DateTimeField(auto_now_add=True)), + ('added_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('album', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='library.album')), + ('library_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='album_items', to='library.libraryitem')), + ], + options={ + 'ordering': ['-added_at'], + 'unique_together': {('album', 'library_item')}, + }, + ), + migrations.CreateModel( + name='MediaRendition', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('thumbnail', 'Thumbnail'), ('feed', 'Feed'), ('large', 'Large / Fullscreen')], max_length=10)), + ('file', models.FileField(upload_to=apps.library.models.rendition_upload_path)), + ('width', models.PositiveIntegerField()), + ('height', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('library_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='renditions', to='library.libraryitem')), + ], + options={ + 'unique_together': {('library_item', 'kind')}, + }, + ), + ] diff --git a/api/apps/library/migrations/__init__.py b/api/apps/library/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps/library/models.py b/api/apps/library/models.py new file mode 100644 index 0000000..db1a03f --- /dev/null +++ b/api/apps/library/models.py @@ -0,0 +1,123 @@ +from django.conf import settings +from django.db import models + +from apps.core.models import Audience, TimeStampedModel + + +def library_upload_path(instance, filename): + return f"library/{instance.owner_id}/originals/{filename}" + + +def rendition_upload_path(instance, filename): + return f"library/{instance.library_item.owner_id}/renditions/{instance.kind}/{filename}" + + +class LibraryItem(TimeStampedModel): + """A single photo or video backed up to a user's private library. + Nothing here is visible to anyone but the owner until it is attached to + a Moment or a shared Album.""" + + class MediaType(models.TextChoices): + PHOTO = "photo", "Photo" + VIDEO = "video", "Video" + + class ProcessingStatus(models.TextChoices): + PENDING = "pending", "Pending" + PROCESSING = "processing", "Processing" + READY = "ready", "Ready" + FAILED = "failed", "Failed" + + owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="library_items", on_delete=models.CASCADE) + file = models.FileField(upload_to=library_upload_path) + media_type = models.CharField(max_length=10, choices=MediaType.choices) + original_filename = models.CharField(max_length=255) + file_size = models.PositiveBigIntegerField(default=0) + width = models.PositiveIntegerField(null=True, blank=True) + height = models.PositiveIntegerField(null=True, blank=True) + duration_seconds = models.FloatField(null=True, blank=True) + + captured_at = models.DateTimeField(null=True, blank=True) + latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + camera_make = models.CharField(max_length=100, blank=True) + camera_model = models.CharField(max_length=100, blank=True) + + checksum = models.CharField(max_length=64, db_index=True, help_text="sha256 of the original file, used for dedup") + processing_status = models.CharField( + max_length=12, choices=ProcessingStatus.choices, default=ProcessingStatus.PENDING + ) + + class Meta: + unique_together = ("owner", "checksum") + ordering = ["-captured_at", "-created_at"] + + def __str__(self): + return f"{self.original_filename} ({self.owner})" + + +class MediaRendition(models.Model): + """A derived, smaller version of a LibraryItem (thumbnail/feed/large), + generated asynchronously so the original never has to be served to the + browser just to render a grid.""" + + class Kind(models.TextChoices): + THUMBNAIL = "thumbnail", "Thumbnail" + FEED = "feed", "Feed" + LARGE = "large", "Large / Fullscreen" + + library_item = models.ForeignKey(LibraryItem, related_name="renditions", on_delete=models.CASCADE) + kind = models.CharField(max_length=10, choices=Kind.choices) + file = models.FileField(upload_to=rendition_upload_path) + width = models.PositiveIntegerField() + height = models.PositiveIntegerField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("library_item", "kind") + + def __str__(self): + return f"{self.library_item_id} - {self.kind}" + + +class Album(TimeStampedModel): + owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="albums", on_delete=models.CASCADE) + name = models.CharField(max_length=150) + description = models.TextField(blank=True) + cover_item = models.ForeignKey( + LibraryItem, related_name="+", on_delete=models.SET_NULL, null=True, blank=True + ) + audience = models.CharField(max_length=10, choices=Audience.choices, default=Audience.ONLY_ME) + + def __str__(self): + return self.name + + +class AlbumItem(models.Model): + album = models.ForeignKey(Album, related_name="items", on_delete=models.CASCADE) + library_item = models.ForeignKey(LibraryItem, related_name="album_items", on_delete=models.CASCADE) + added_by = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="+", on_delete=models.CASCADE) + added_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("album", "library_item") + ordering = ["-added_at"] + + +class AlbumCollaborator(models.Model): + class Role(models.TextChoices): + VIEWER = "viewer", "Viewer" + CONTRIBUTOR = "contributor", "Contributor" + ADMIN = "admin", "Admin" + + class Status(models.TextChoices): + PENDING = "pending", "Pending" + ACCEPTED = "accepted", "Accepted" + + album = models.ForeignKey(Album, related_name="collaborators", on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="album_invites", on_delete=models.CASCADE) + role = models.CharField(max_length=11, choices=Role.choices, default=Role.VIEWER) + invited_by = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="+", on_delete=models.CASCADE) + status = models.CharField(max_length=8, choices=Status.choices, default=Status.PENDING) + + class Meta: + unique_together = ("album", "user") diff --git a/api/apps/library/permissions.py b/api/apps/library/permissions.py new file mode 100644 index 0000000..be76265 --- /dev/null +++ b/api/apps/library/permissions.py @@ -0,0 +1,10 @@ +from rest_framework.permissions import BasePermission + + +class IsOwner(BasePermission): + """Library items and albums are private by default — only the owner may + read or write them through these endpoints. Sharing happens explicitly + via Moments / AlbumCollaborators, handled by their own views.""" + + def has_object_permission(self, request, view, obj): + return obj.owner_id == request.user.id diff --git a/api/apps/library/serializers.py b/api/apps/library/serializers.py new file mode 100644 index 0000000..0f8a9ec --- /dev/null +++ b/api/apps/library/serializers.py @@ -0,0 +1,83 @@ +import hashlib + +from rest_framework import serializers + +from apps.accounts.models import User +from apps.accounts.serializers import UserSerializer + +from .models import Album, AlbumCollaborator, AlbumItem, LibraryItem, MediaRendition + + +class MediaRenditionSerializer(serializers.ModelSerializer): + class Meta: + model = MediaRendition + fields = ["kind", "file", "width", "height"] + + +class LibraryItemSerializer(serializers.ModelSerializer): + renditions = MediaRenditionSerializer(many=True, read_only=True) + + class Meta: + model = LibraryItem + fields = [ + "id", "file", "media_type", "original_filename", "file_size", + "width", "height", "duration_seconds", "captured_at", + "latitude", "longitude", "camera_make", "camera_model", + "processing_status", "renditions", "created_at", + ] + read_only_fields = [ + "id", "width", "height", "duration_seconds", "captured_at", + "camera_make", "camera_model", "processing_status", "renditions", "created_at", + ] + + def create(self, validated_data): + upload = validated_data["file"] + checksum = hashlib.sha256(upload.read()).hexdigest() + upload.seek(0) + validated_data["checksum"] = checksum + validated_data["file_size"] = upload.size + validated_data.setdefault("original_filename", upload.name) + validated_data["owner"] = self.context["request"].user + library_item, _created = LibraryItem.objects.get_or_create( + owner=validated_data["owner"], + checksum=checksum, + defaults=validated_data, + ) + return library_item + + +class AlbumItemSerializer(serializers.ModelSerializer): + library_item = LibraryItemSerializer(read_only=True) + library_item_id = serializers.PrimaryKeyRelatedField( + queryset=LibraryItem.objects.all(), source="library_item", write_only=True + ) + + class Meta: + model = AlbumItem + fields = ["id", "album", "library_item", "library_item_id", "added_by", "added_at"] + read_only_fields = ["id", "album", "added_by", "added_at"] + + +class AlbumCollaboratorSerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + user_id = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), source="user", write_only=True + ) + + class Meta: + model = AlbumCollaborator + fields = ["id", "album", "user", "user_id", "role", "status", "invited_by"] + read_only_fields = ["id", "album", "status", "invited_by"] + + +class AlbumSerializer(serializers.ModelSerializer): + items = AlbumItemSerializer(many=True, read_only=True) + collaborators = AlbumCollaboratorSerializer(many=True, read_only=True) + + class Meta: + model = Album + fields = [ + "id", "owner", "name", "description", "cover_item", "audience", + "items", "collaborators", "created_at", "updated_at", + ] + read_only_fields = ["id", "owner", "items", "collaborators", "created_at", "updated_at"] diff --git a/api/apps/library/tasks.py b/api/apps/library/tasks.py new file mode 100644 index 0000000..38e8720 --- /dev/null +++ b/api/apps/library/tasks.py @@ -0,0 +1,159 @@ +import io +import json +import os +import subprocess +import tempfile +from datetime import datetime + +from celery import shared_task +from django.core.files.base import ContentFile +from PIL import ExifTags, Image + +from .models import LibraryItem, MediaRendition + +RENDITION_MAX_DIMENSIONS = { + MediaRendition.Kind.THUMBNAIL: 300, + MediaRendition.Kind.FEED: 1080, + MediaRendition.Kind.LARGE: 2048, +} + + +@shared_task +def process_library_item(library_item_id): + try: + item = LibraryItem.objects.get(id=library_item_id) + except LibraryItem.DoesNotExist: + return + + item.processing_status = LibraryItem.ProcessingStatus.PROCESSING + item.save(update_fields=["processing_status"]) + + try: + if item.media_type == LibraryItem.MediaType.PHOTO: + _process_photo(item) + else: + # Full transcoding to web-friendly renditions is a V1.5 feature + # (see README); the original file is served as-is for playback. + # We do extract dimensions/duration and a poster-frame thumbnail + # so videos aren't blank tiles in the UI. + _process_video(item) + item.processing_status = LibraryItem.ProcessingStatus.READY + except Exception: + item.processing_status = LibraryItem.ProcessingStatus.FAILED + raise + finally: + item.save(update_fields=["processing_status"]) + + +def _process_photo(item): + with item.file.open("rb") as fh: + image = Image.open(fh) + image.load() + + for field, value in _extract_exif(image).items(): + setattr(item, field, value) + item.width, item.height = image.size + item.save() + + _write_renditions(item, image) + + +def _process_video(item): + suffix = os.path.splitext(item.original_filename)[1] or ".mp4" + with tempfile.NamedTemporaryFile(suffix=suffix) as src: + with item.file.open("rb") as fh: + for chunk in fh.chunks(): + src.write(chunk) + src.flush() + + probe = _ffprobe(src.name) + if probe: + item.width = probe.get("width") or item.width + item.height = probe.get("height") or item.height + item.duration_seconds = probe.get("duration") + item.save() + + # A missing/corrupt frame just means no poster image; the video + # itself still uploaded fine, so this must not fail the whole job. + try: + frame_bytes = _extract_poster_frame(src.name, probe.get("duration") if probe else None) + if frame_bytes: + image = Image.open(io.BytesIO(frame_bytes)) + image.load() + _write_renditions(item, image) + except Exception: + pass + + +def _write_renditions(item, image): + for kind, max_dimension in RENDITION_MAX_DIMENSIONS.items(): + rendition_image = image.copy() + rendition_image.thumbnail((max_dimension, max_dimension)) + rgb_image = rendition_image.convert("RGB") + buffer = io.BytesIO() + rgb_image.save(buffer, format="JPEG", quality=85) + MediaRendition.objects.update_or_create( + library_item=item, + kind=kind, + defaults={ + "width": rendition_image.width, + "height": rendition_image.height, + "file": ContentFile(buffer.getvalue(), name=f"{item.id}_{kind}.jpg"), + }, + ) + + +def _ffprobe(path): + try: + result = subprocess.run( + [ + "ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height:format=duration", + "-of", "json", path, + ], + capture_output=True, text=True, timeout=30, check=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + return None + data = json.loads(result.stdout) + streams = data.get("streams") or [{}] + duration = (data.get("format") or {}).get("duration") + return { + "width": streams[0].get("width"), + "height": streams[0].get("height"), + "duration": float(duration) if duration else None, + } + + +def _extract_poster_frame(path, duration_seconds): + timestamp = min(1.0, duration_seconds / 2) if duration_seconds else 0.1 + with tempfile.NamedTemporaryFile(suffix=".jpg") as out: + try: + subprocess.run( + ["ffmpeg", "-y", "-ss", str(timestamp), "-i", path, "-frames:v", "1", "-q:v", "3", out.name], + capture_output=True, timeout=30, check=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + return None + return out.read() + + +def _extract_exif(image): + result = {} + raw_exif = image.getexif() + if not raw_exif: + return result + tags = {ExifTags.TAGS.get(tag_id, tag_id): value for tag_id, value in raw_exif.items()} + + if tags.get("Make"): + result["camera_make"] = str(tags["Make"]).strip() + if tags.get("Model"): + result["camera_model"] = str(tags["Model"]).strip() + + date_str = tags.get("DateTimeOriginal") or tags.get("DateTime") + if date_str: + try: + result["captured_at"] = datetime.strptime(str(date_str), "%Y:%m:%d %H:%M:%S") + except ValueError: + pass + return result diff --git a/api/apps/library/urls.py b/api/apps/library/urls.py new file mode 100644 index 0000000..064702b --- /dev/null +++ b/api/apps/library/urls.py @@ -0,0 +1,20 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter + +from .views import AlbumCollaboratorViewSet, AlbumItemViewSet, AlbumViewSet, LibraryItemViewSet + +router = DefaultRouter() +router.register("items", LibraryItemViewSet, basename="library-item") +router.register("albums", AlbumViewSet, basename="album") + +album_items_list = AlbumItemViewSet.as_view({"get": "list", "post": "create"}) +album_items_detail = AlbumItemViewSet.as_view({"get": "retrieve", "delete": "destroy"}) +album_collaborators_list = AlbumCollaboratorViewSet.as_view({"get": "list", "post": "create"}) +album_collaborators_detail = AlbumCollaboratorViewSet.as_view({"get": "retrieve", "delete": "destroy"}) + +urlpatterns = [ + path("albums//items/", album_items_list, name="album-items-list"), + path("albums//items//", album_items_detail, name="album-items-detail"), + path("albums//collaborators/", album_collaborators_list, name="album-collaborators-list"), + path("albums//collaborators//", album_collaborators_detail, name="album-collaborators-detail"), +] + router.urls diff --git a/api/apps/library/views.py b/api/apps/library/views.py new file mode 100644 index 0000000..28bd553 --- /dev/null +++ b/api/apps/library/views.py @@ -0,0 +1,67 @@ +from rest_framework import permissions, viewsets +from rest_framework.exceptions import PermissionDenied + +from .models import Album, AlbumCollaborator, AlbumItem, LibraryItem +from .permissions import IsOwner +from .serializers import AlbumCollaboratorSerializer, AlbumItemSerializer, AlbumSerializer, LibraryItemSerializer +from .tasks import process_library_item + + +class LibraryItemViewSet(viewsets.ModelViewSet): + serializer_class = LibraryItemSerializer + permission_classes = [permissions.IsAuthenticated, IsOwner] + + def get_queryset(self): + return LibraryItem.objects.filter(owner=self.request.user) + + def perform_create(self, serializer): + item = serializer.save() + process_library_item.delay(str(item.id)) + + +class AlbumViewSet(viewsets.ModelViewSet): + serializer_class = AlbumSerializer + permission_classes = [permissions.IsAuthenticated, IsOwner] + + def get_queryset(self): + return Album.objects.filter(owner=self.request.user).prefetch_related("items", "collaborators") + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + +class AlbumItemViewSet(viewsets.ModelViewSet): + serializer_class = AlbumItemSerializer + permission_classes = [permissions.IsAuthenticated] + http_method_names = ["get", "post", "delete", "head", "options"] + + def _get_album(self): + album = Album.objects.get(pk=self.kwargs["album_pk"]) + allowed = album.owner_id == self.request.user.id or album.collaborators.filter( + user=self.request.user, role=AlbumCollaborator.Role.CONTRIBUTOR, status=AlbumCollaborator.Status.ACCEPTED + ).exists() + if not allowed: + raise PermissionDenied("Not a contributor on this album.") + return album + + def get_queryset(self): + return AlbumItem.objects.filter(album=self._get_album()) + + def perform_create(self, serializer): + serializer.save(album=self._get_album(), added_by=self.request.user) + + +class AlbumCollaboratorViewSet(viewsets.ModelViewSet): + serializer_class = AlbumCollaboratorSerializer + permission_classes = [permissions.IsAuthenticated] + http_method_names = ["get", "post", "delete", "head", "options"] + + def _get_album(self): + album = Album.objects.get(pk=self.kwargs["album_pk"], owner=self.request.user) + return album + + def get_queryset(self): + return AlbumCollaborator.objects.filter(album=self._get_album()) + + def perform_create(self, serializer): + serializer.save(album=self._get_album(), invited_by=self.request.user) diff --git a/api/apps/moments/__init__.py b/api/apps/moments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps/moments/admin.py b/api/apps/moments/admin.py new file mode 100644 index 0000000..5bf3826 --- /dev/null +++ b/api/apps/moments/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from .models import Comment, Moment, MomentItem, Reaction + + +class MomentItemInline(admin.TabularInline): + model = MomentItem + extra = 0 + + +@admin.register(Moment) +class MomentAdmin(admin.ModelAdmin): + list_display = ("id", "owner", "audience", "created_at") + list_filter = ("audience",) + inlines = [MomentItemInline] + + +admin.site.register(Comment) +admin.site.register(Reaction) diff --git a/api/apps/moments/apps.py b/api/apps/moments/apps.py new file mode 100644 index 0000000..0a635ab --- /dev/null +++ b/api/apps/moments/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class MomentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.moments" + label = "moments" diff --git a/api/apps/moments/migrations/0001_initial.py b/api/apps/moments/migrations/0001_initial.py new file mode 100644 index 0000000..47479f1 --- /dev/null +++ b/api/apps/moments/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# Generated by Django 5.0.9 on 2026-08-09 17:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('library', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Moment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('caption', models.TextField(blank=True, max_length=2200)), + ('location', models.CharField(blank=True, max_length=255)), + ('audience', models.CharField(choices=[('public', 'Public'), ('followers', 'Followers'), ('friends', 'Friends'), ('family', 'Family'), ('custom', 'Custom'), ('only_me', 'Only Me')], default='public', max_length=10)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moments', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('body', models.TextField(max_length=1000)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='moments.comment')), + ('moment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='moments.moment')), + ], + options={ + 'ordering': ['created_at'], + }, + ), + migrations.CreateModel( + name='MomentItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.PositiveIntegerField(default=0)), + ('library_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moment_items', to='library.libraryitem')), + ('moment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='moments.moment')), + ], + options={ + 'ordering': ['order'], + 'unique_together': {('moment', 'library_item')}, + }, + ), + migrations.CreateModel( + name='Reaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reaction_type', models.CharField(choices=[('like', 'Like')], default='like', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('moment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='moments.moment')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('moment', 'user')}, + }, + ), + ] diff --git a/api/apps/moments/migrations/__init__.py b/api/apps/moments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps/moments/models.py b/api/apps/moments/models.py new file mode 100644 index 0000000..a961bad --- /dev/null +++ b/api/apps/moments/models.py @@ -0,0 +1,85 @@ +from django.conf import settings +from django.db import models + +from apps.core.models import Audience, TimeStampedModel +from apps.library.models import LibraryItem + + +class MomentQuerySet(models.QuerySet): + def visible_to(self, user): + """FAMILY and CUSTOM audiences intentionally fall back to + owner-only visibility until circles/custom-audience membership + (V1.5) exists — better to under-share than leak content.""" + from apps.accounts.models import Follow + + following_ids = Follow.objects.filter( + follower=user, status=Follow.Status.ACCEPTED + ).values_list("following_id", flat=True) + follower_ids = set( + Follow.objects.filter( + following=user, status=Follow.Status.ACCEPTED + ).values_list("follower_id", flat=True) + ) + friend_ids = follower_ids & set(following_ids) + + return self.filter( + models.Q(owner=user) + | models.Q(audience=Audience.PUBLIC) + | models.Q(audience=Audience.FOLLOWERS, owner_id__in=following_ids) + | models.Q(audience=Audience.FRIENDS, owner_id__in=friend_ids) + ).distinct() + + +class Moment(TimeStampedModel): + """The social-media object: one or more LibraryItems shared with an + explicit audience. Sharing a Moment never moves or copies the original + file — it just makes the existing library item visible to others.""" + + owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="moments", on_delete=models.CASCADE) + caption = models.TextField(max_length=2200, blank=True) + location = models.CharField(max_length=255, blank=True) + audience = models.CharField(max_length=10, choices=Audience.choices, default=Audience.PUBLIC) + + objects = MomentQuerySet.as_manager() + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"Moment({self.id}) by {self.owner}" + + +class MomentItem(models.Model): + moment = models.ForeignKey(Moment, related_name="items", on_delete=models.CASCADE) + library_item = models.ForeignKey(LibraryItem, related_name="moment_items", on_delete=models.CASCADE) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order"] + unique_together = ("moment", "library_item") + + +class Comment(TimeStampedModel): + moment = models.ForeignKey(Moment, related_name="comments", on_delete=models.CASCADE) + author = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="comments", on_delete=models.CASCADE) + parent = models.ForeignKey("self", related_name="replies", on_delete=models.CASCADE, null=True, blank=True) + body = models.TextField(max_length=1000) + + class Meta: + ordering = ["created_at"] + + def __str__(self): + return f"{self.author}: {self.body[:40]}" + + +class Reaction(models.Model): + class ReactionType(models.TextChoices): + LIKE = "like", "Like" + + moment = models.ForeignKey(Moment, related_name="reactions", on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="reactions", on_delete=models.CASCADE) + reaction_type = models.CharField(max_length=10, choices=ReactionType.choices, default=ReactionType.LIKE) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("moment", "user") diff --git a/api/apps/moments/permissions.py b/api/apps/moments/permissions.py new file mode 100644 index 0000000..afc6fb9 --- /dev/null +++ b/api/apps/moments/permissions.py @@ -0,0 +1,8 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission + + +class IsOwnerOrReadOnly(BasePermission): + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + return obj.owner_id == request.user.id diff --git a/api/apps/moments/serializers.py b/api/apps/moments/serializers.py new file mode 100644 index 0000000..bd7f458 --- /dev/null +++ b/api/apps/moments/serializers.py @@ -0,0 +1,103 @@ +from django.db import transaction +from rest_framework import serializers + +from apps.accounts.serializers import UserSerializer +from apps.library.models import LibraryItem +from apps.library.serializers import LibraryItemSerializer + +from .models import Comment, Moment, MomentItem, Reaction + + +class MomentItemSerializer(serializers.ModelSerializer): + library_item = LibraryItemSerializer(read_only=True) + + class Meta: + model = MomentItem + fields = ["id", "library_item", "order"] + + +class CommentSerializer(serializers.ModelSerializer): + author = UserSerializer(read_only=True) + + class Meta: + model = Comment + fields = ["id", "moment", "author", "parent", "body", "created_at"] + read_only_fields = ["id", "moment", "author", "created_at"] + + def validate_parent(self, value): + if value is None: + return value + if value.parent_id is not None: + raise serializers.ValidationError("Cannot reply to a reply; reply to the original comment instead.") + moment = self.context.get("moment") + if moment is not None and value.moment_id != moment.id: + raise serializers.ValidationError("Reply must target a comment on the same Moment.") + return value + + +class ReactionSerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + + class Meta: + model = Reaction + fields = ["id", "moment", "user", "reaction_type", "created_at"] + read_only_fields = ["id", "moment", "user", "created_at"] + + +class MomentSerializer(serializers.ModelSerializer): + owner = UserSerializer(read_only=True) + items = MomentItemSerializer(many=True, read_only=True) + library_item_ids = serializers.PrimaryKeyRelatedField( + queryset=LibraryItem.objects.all(), many=True, write_only=True, source="library_items", required=False + ) + comment_count = serializers.IntegerField(source="comments.count", read_only=True) + reaction_count = serializers.IntegerField(source="reactions.count", read_only=True) + viewer_reaction_id = serializers.SerializerMethodField() + + class Meta: + model = Moment + fields = [ + "id", "owner", "caption", "location", "audience", "items", + "library_item_ids", "comment_count", "reaction_count", "viewer_reaction_id", + "created_at", "updated_at", + ] + read_only_fields = [ + "id", "owner", "items", "comment_count", "reaction_count", "viewer_reaction_id", + "created_at", "updated_at", + ] + + def get_viewer_reaction_id(self, obj): + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return None + # obj.reactions is prefetched by the view, so this doesn't re-query per moment. + reaction = next((r for r in obj.reactions.all() if r.user_id == request.user.id), None) + return reaction.id if reaction else None + + def validate_library_item_ids(self, library_items): + request = self.context["request"] + seen_ids = set() + for item in library_items: + if item.owner_id != request.user.id: + raise serializers.ValidationError("Can only share items from your own library.") + if item.id in seen_ids: + raise serializers.ValidationError("The same photo was selected more than once.") + seen_ids.add(item.id) + return library_items + + def validate(self, attrs): + library_items = attrs.get("library_items", []) + caption = attrs.get("caption", "") + if not library_items and not caption.strip(): + raise serializers.ValidationError("A Moment needs a caption or at least one photo.") + return attrs + + def create(self, validated_data): + library_items = validated_data.pop("library_items", []) + validated_data["owner"] = self.context["request"].user + with transaction.atomic(): + moment = Moment.objects.create(**validated_data) + MomentItem.objects.bulk_create( + [MomentItem(moment=moment, library_item=item, order=i) for i, item in enumerate(library_items)] + ) + return moment diff --git a/api/apps/moments/urls.py b/api/apps/moments/urls.py new file mode 100644 index 0000000..58888ca --- /dev/null +++ b/api/apps/moments/urls.py @@ -0,0 +1,19 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter + +from .views import CommentViewSet, MomentViewSet, ReactionViewSet + +router = DefaultRouter() +router.register("", MomentViewSet, basename="moment") + +comments_list = CommentViewSet.as_view({"get": "list", "post": "create"}) +comments_detail = CommentViewSet.as_view({"get": "retrieve", "delete": "destroy"}) +reactions_list = ReactionViewSet.as_view({"get": "list", "post": "create"}) +reactions_detail = ReactionViewSet.as_view({"get": "retrieve", "delete": "destroy"}) + +urlpatterns = [ + path("/comments/", comments_list, name="moment-comments-list"), + path("/comments//", comments_detail, name="moment-comments-detail"), + path("/reactions/", reactions_list, name="moment-reactions-list"), + path("/reactions//", reactions_detail, name="moment-reactions-detail"), +] + router.urls diff --git a/api/apps/moments/views.py b/api/apps/moments/views.py new file mode 100644 index 0000000..ec61009 --- /dev/null +++ b/api/apps/moments/views.py @@ -0,0 +1,69 @@ +from rest_framework import permissions, viewsets +from rest_framework.exceptions import PermissionDenied + +from .models import Comment, Moment, Reaction +from .permissions import IsOwnerOrReadOnly +from .serializers import CommentSerializer, MomentSerializer, ReactionSerializer + + +class MomentViewSet(viewsets.ModelViewSet): + """list() is the home feed: everything visible to the requesting user, + newest first. Filter to one user's posts (for a profile page) with + ?owner__username= — still scoped by visible_to() below, so a + private/followers-only post won't leak through the filter.""" + + serializer_class = MomentSerializer + permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly] + filterset_fields = ["owner__username"] + + def get_queryset(self): + return ( + Moment.objects.visible_to(self.request.user) + .select_related("owner") + .prefetch_related("items__library_item__renditions", "reactions") + ) + + +class CommentViewSet(viewsets.ModelViewSet): + serializer_class = CommentSerializer + permission_classes = [permissions.IsAuthenticated] + http_method_names = ["get", "post", "delete", "head", "options"] + + def _get_moment(self): + return Moment.objects.visible_to(self.request.user).get(pk=self.kwargs["moment_pk"]) + + def get_queryset(self): + return Comment.objects.filter(moment=self._get_moment()).select_related("author") + + def get_serializer_context(self): + context = super().get_serializer_context() + context["moment"] = self._get_moment() + return context + + def perform_create(self, serializer): + serializer.save(moment=self._get_moment(), author=self.request.user) + + def perform_destroy(self, instance): + if instance.author_id != self.request.user.id and instance.moment.owner_id != self.request.user.id: + raise PermissionDenied("Cannot delete another user's comment.") + instance.delete() + + +class ReactionViewSet(viewsets.ModelViewSet): + serializer_class = ReactionSerializer + permission_classes = [permissions.IsAuthenticated] + http_method_names = ["get", "post", "delete", "head", "options"] + + def _get_moment(self): + return Moment.objects.visible_to(self.request.user).get(pk=self.kwargs["moment_pk"]) + + def get_queryset(self): + return Reaction.objects.filter(moment=self._get_moment()).select_related("user") + + def perform_create(self, serializer): + serializer.save(moment=self._get_moment(), user=self.request.user) + + def perform_destroy(self, instance): + if instance.user_id != self.request.user.id: + raise PermissionDenied("Cannot remove another user's reaction.") + instance.delete() diff --git a/api/apps/notifications/__init__.py b/api/apps/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps/notifications/admin.py b/api/apps/notifications/admin.py new file mode 100644 index 0000000..4d85afa --- /dev/null +++ b/api/apps/notifications/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from .models import Notification + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ["id", "recipient", "actor", "verb", "moment", "is_read", "created_at"] + list_filter = ["verb", "is_read"] diff --git a/api/apps/notifications/apps.py b/api/apps/notifications/apps.py new file mode 100644 index 0000000..6dd96c5 --- /dev/null +++ b/api/apps/notifications/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.notifications" + label = "notifications" + + def ready(self): + from . import signals # noqa: F401 diff --git a/api/apps/notifications/migrations/0001_initial.py b/api/apps/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..2e93340 --- /dev/null +++ b/api/apps/notifications/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.0.9 on 2026-08-09 21:28 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('moments', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('verb', models.CharField(choices=[('like', 'Like'), ('comment', 'Comment'), ('reply', 'Reply')], max_length=10)), + ('is_read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='moments.comment')), + ('moment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='moments.moment')), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['recipient', 'is_read'], name='notificatio_recipie_4e3567_idx')], + }, + ), + ] diff --git a/api/apps/notifications/migrations/__init__.py b/api/apps/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps/notifications/models.py b/api/apps/notifications/models.py new file mode 100644 index 0000000..1e9dad3 --- /dev/null +++ b/api/apps/notifications/models.py @@ -0,0 +1,31 @@ +from django.conf import settings +from django.db import models + + +class Notification(models.Model): + """Tells `recipient` that `actor` did something (`verb`) involving + `moment` (and, for comment/reply verbs, the specific `comment`). + Created by signal handlers in signals.py — never directly by API + consumers.""" + + class Verb(models.TextChoices): + LIKE = "like", "Like" + COMMENT = "comment", "Comment" + REPLY = "reply", "Reply" + + recipient = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="notifications", on_delete=models.CASCADE) + actor = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="+", on_delete=models.CASCADE) + verb = models.CharField(max_length=10, choices=Verb.choices) + moment = models.ForeignKey("moments.Moment", related_name="notifications", on_delete=models.CASCADE) + comment = models.ForeignKey( + "moments.Comment", null=True, blank=True, related_name="notifications", on_delete=models.CASCADE + ) + is_read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + indexes = [models.Index(fields=["recipient", "is_read"])] + + def __str__(self): + return f"{self.actor} {self.verb} -> {self.recipient}" diff --git a/api/apps/notifications/serializers.py b/api/apps/notifications/serializers.py new file mode 100644 index 0000000..dc14f61 --- /dev/null +++ b/api/apps/notifications/serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from apps.accounts.serializers import UserSerializer + +from .models import Notification + + +class NotificationSerializer(serializers.ModelSerializer): + actor = UserSerializer(read_only=True) + moment_id = serializers.IntegerField(read_only=True) + comment_id = serializers.IntegerField(read_only=True) + + class Meta: + model = Notification + fields = ["id", "actor", "verb", "moment_id", "comment_id", "is_read", "created_at"] + read_only_fields = fields diff --git a/api/apps/notifications/signals.py b/api/apps/notifications/signals.py new file mode 100644 index 0000000..d1c1701 --- /dev/null +++ b/api/apps/notifications/signals.py @@ -0,0 +1,49 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from apps.moments.models import Comment, Reaction + +from .models import Notification + + +@receiver(post_save, sender=Reaction) +def notify_on_reaction(sender, instance, created, **kwargs): + if not created: + return + moment = instance.moment + if instance.user_id == moment.owner_id: + return + Notification.objects.create( + recipient_id=moment.owner_id, + actor=instance.user, + verb=Notification.Verb.LIKE, + moment=moment, + ) + + +@receiver(post_save, sender=Comment) +def notify_on_comment(sender, instance, created, **kwargs): + if not created: + return + moment = instance.moment + + if instance.parent_id is None: + if instance.author_id != moment.owner_id: + Notification.objects.create( + recipient_id=moment.owner_id, + actor=instance.author, + verb=Notification.Verb.COMMENT, + moment=moment, + comment=instance, + ) + return + + parent_author_id = instance.parent.author_id + if instance.author_id != parent_author_id: + Notification.objects.create( + recipient_id=parent_author_id, + actor=instance.author, + verb=Notification.Verb.REPLY, + moment=moment, + comment=instance, + ) diff --git a/api/apps/notifications/urls.py b/api/apps/notifications/urls.py new file mode 100644 index 0000000..6f3b42e --- /dev/null +++ b/api/apps/notifications/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter + +from .views import NotificationViewSet + +router = DefaultRouter() +router.register("", NotificationViewSet, basename="notification") + +urlpatterns = router.urls diff --git a/api/apps/notifications/views.py b/api/apps/notifications/views.py new file mode 100644 index 0000000..14a31a8 --- /dev/null +++ b/api/apps/notifications/views.py @@ -0,0 +1,24 @@ +from rest_framework import mixins, permissions, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from .models import Notification +from .serializers import NotificationSerializer + + +class NotificationViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): + serializer_class = NotificationSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Notification.objects.filter(recipient=self.request.user).select_related("actor", "moment") + + @action(detail=False, methods=["get"], url_path="unread-count") + def unread_count(self, request): + count = self.get_queryset().filter(is_read=False).count() + return Response({"unread_count": count}) + + @action(detail=False, methods=["post"], url_path="mark-read") + def mark_read(self, request): + self.get_queryset().filter(is_read=False).update(is_read=True) + return Response(status=204) diff --git a/api/config/__init__.py b/api/config/__init__.py new file mode 100644 index 0000000..53f4ccb --- /dev/null +++ b/api/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/api/config/asgi.py b/api/config/asgi.py new file mode 100644 index 0000000..856079b --- /dev/null +++ b/api/config/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/api/config/celery.py b/api/config/celery.py new file mode 100644 index 0000000..bd289b2 --- /dev/null +++ b/api/config/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +app = Celery("aurora_moments") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/api/config/settings.py b/api/config/settings.py new file mode 100644 index 0000000..37d2eb8 --- /dev/null +++ b/api/config/settings.py @@ -0,0 +1,146 @@ +from datetime import timedelta +from pathlib import Path + +import environ + +BASE_DIR = Path(__file__).resolve().parent.parent + +env = environ.Env( + DEBUG=(bool, False), +) +environ.Env.read_env(BASE_DIR / ".env") + +SECRET_KEY = env("DJANGO_SECRET_KEY", default="insecure-dev-key-change-me") +DEBUG = env("DEBUG") +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"]) + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework_simplejwt", + "corsheaders", + "django_filters", + "apps.core", + "apps.accounts", + "apps.library", + "apps.moments", + "apps.notifications", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" + +DATABASES = { + "default": env.db("DATABASE_URL", default="postgres://aurora:aurora@postgres:5432/aurora_moments"), +} + +AUTH_USER_MODEL = "accounts.User" + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +# --- Media storage --------------------------------------------------------- +# Default: local filesystem, mounted as a volume so it can point at a NAS +# path in self-hosted deployments. Set MEDIA_BACKEND=s3 to switch to any +# S3-compatible object store (AWS S3, MinIO, Backblaze B2, etc.) without +# code changes. +MEDIA_BACKEND = env("MEDIA_BACKEND", default="filesystem") + +if MEDIA_BACKEND == "s3": + STORAGES = { + "default": {"BACKEND": "storages.backends.s3.S3Storage"}, + "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, + } + AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID", default="") + AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY", default="") + AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME", default="aurora-moments") + AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", default=None) + AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME", default="us-east-1") + AWS_DEFAULT_ACL = None + AWS_QUERYSTRING_AUTH = False +else: + STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, + } + MEDIA_URL = "media/" + MEDIA_ROOT = env("MEDIA_ROOT", default=str(BASE_DIR / "media")) + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# --- DRF / auth -------------------------------------------------------- +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.IsAuthenticated", + ), + "DEFAULT_FILTER_BACKENDS": ( + "django_filters.rest_framework.DjangoFilterBackend", + ), + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 30, +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), + "REFRESH_TOKEN_LIFETIME": timedelta(days=14), + "ROTATE_REFRESH_TOKENS": True, +} + +CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=["http://localhost:5173"]) + +# --- Celery -------------------------------------------------------------- +CELERY_BROKER_URL = env("REDIS_URL", default="redis://redis:6379/0") +CELERY_RESULT_BACKEND = env("REDIS_URL", default="redis://redis:6379/0") +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = TIME_ZONE diff --git a/api/config/urls.py b/api/config/urls.py new file mode 100644 index 0000000..8e99b7c --- /dev/null +++ b/api/config/urls.py @@ -0,0 +1,18 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/auth/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("api/auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("api/accounts/", include("apps.accounts.urls")), + path("api/library/", include("apps.library.urls")), + path("api/moments/", include("apps.moments.urls")), + path("api/notifications/", include("apps.notifications.urls")), +] + +if settings.DEBUG and settings.MEDIA_BACKEND == "filesystem": + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/api/config/wsgi.py b/api/config/wsgi.py new file mode 100644 index 0000000..8509335 --- /dev/null +++ b/api/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/api/manage.py b/api/manage.py new file mode 100644 index 0000000..3e8774a --- /dev/null +++ b/api/manage.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..e5f2286 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,13 @@ +Django==5.0.9 +djangorestframework==3.15.2 +djangorestframework-simplejwt==5.3.1 +django-cors-headers==4.4.0 +django-environ==0.11.2 +django-filter==24.3 +psycopg[binary]==3.2.3 +celery==5.4.0 +redis==5.0.8 +Pillow==10.4.0 +django-storages==1.14.4 +boto3==1.35.36 +gunicorn==23.0.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4a61349 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-aurora_moments} + POSTGRES_USER: ${POSTGRES_USER:-aurora} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-aurora} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-aurora}"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "6380:6379" + + api: + build: ./api + command: sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000" + env_file: .env + volumes: + - ./api:/app + - media_data:/app/media + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + + worker: + build: ./api + command: celery -A config worker --loglevel=info + env_file: .env + volumes: + - ./api:/app + - media_data:/app/media + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + + web: + build: ./web + env_file: .env + environment: + - VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8000} + volumes: + - ./web:/app + - /app/node_modules + ports: + - "5173:5173" + depends_on: + - api + +volumes: + postgres_data: + media_data: diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..e4269e8 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,10 @@ +# Aurora Moments — Mobile (planned) + +Not started yet. Per the V1 plan, the responsive web app plus the REST API +are the priority; the React Native app (iOS first, then Android) starts +once the API in [`../api`](../api) is stable, since the app is mainly a +client for automatic photo backup and the two navigation surfaces +(Library / Home feed). + +When this starts, it should reuse the same `/api/*` endpoints as the web +app (see `api/apps/*/urls.py`) rather than growing a parallel API. diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..9c97bbd --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.env diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/.oxlintrc.json b/web/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/web/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..d2f0ec3 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host"] diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/web/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e4559ac --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Aurora Moments + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..b389676 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1385 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.1.1" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "oxlint": "^1.75.0", + "typescript": "~6.0.2", + "vite": "^8.2.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.77.0.tgz", + "integrity": "sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.77.0.tgz", + "integrity": "sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.77.0.tgz", + "integrity": "sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.77.0.tgz", + "integrity": "sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.77.0.tgz", + "integrity": "sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.77.0.tgz", + "integrity": "sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.77.0.tgz", + "integrity": "sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.77.0.tgz", + "integrity": "sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.77.0.tgz", + "integrity": "sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.77.0.tgz", + "integrity": "sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.77.0.tgz", + "integrity": "sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.77.0.tgz", + "integrity": "sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.77.0.tgz", + "integrity": "sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.77.0.tgz", + "integrity": "sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.77.0.tgz", + "integrity": "sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.77.0.tgz", + "integrity": "sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.77.0.tgz", + "integrity": "sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.77.0.tgz", + "integrity": "sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.77.0.tgz", + "integrity": "sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.77.0.tgz", + "integrity": "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.77.0", + "@oxlint/binding-android-arm64": "1.77.0", + "@oxlint/binding-darwin-arm64": "1.77.0", + "@oxlint/binding-darwin-x64": "1.77.0", + "@oxlint/binding-freebsd-x64": "1.77.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", + "@oxlint/binding-linux-arm-musleabihf": "1.77.0", + "@oxlint/binding-linux-arm64-gnu": "1.77.0", + "@oxlint/binding-linux-arm64-musl": "1.77.0", + "@oxlint/binding-linux-ppc64-gnu": "1.77.0", + "@oxlint/binding-linux-riscv64-gnu": "1.77.0", + "@oxlint/binding-linux-riscv64-musl": "1.77.0", + "@oxlint/binding-linux-s390x-gnu": "1.77.0", + "@oxlint/binding-linux-x64-gnu": "1.77.0", + "@oxlint/binding-linux-x64-musl": "1.77.0", + "@oxlint/binding-openharmony-arm64": "1.77.0", + "@oxlint/binding-win32-arm64-msvc": "1.77.0", + "@oxlint/binding-win32-ia32-msvc": "1.77.0", + "@oxlint/binding-win32-x64-msvc": "1.77.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..d453c1d --- /dev/null +++ b/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.1.1" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "oxlint": "^1.75.0", + "typescript": "~6.0.2", + "vite": "^8.2.0" + } +} diff --git a/web/public/favicon.png b/web/public/favicon.png new file mode 100644 index 0000000..4a30357 Binary files /dev/null and b/web/public/favicon.png differ diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons.svg b/web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/logo.png b/web/public/logo.png new file mode 100644 index 0000000..b124b22 Binary files /dev/null and b/web/public/logo.png differ diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..b48b270 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,81 @@ +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { ProtectedRoute } from './components/ProtectedRoute' +import { Sidebar } from './components/Sidebar' +import { AuthProvider } from './context/AuthContext' +import { CreatePage } from './pages/CreatePage' +import { FeedPage } from './pages/FeedPage' +import { LibraryPage } from './pages/LibraryPage' +import { LoginPage } from './pages/LoginPage' +import { NotificationsPage } from './pages/NotificationsPage' +import { ProfilePage } from './pages/ProfilePage' +import { RegisterPage } from './pages/RegisterPage' +import { SettingsPage } from './pages/SettingsPage' + +function App() { + return ( + + +
+ +
+ + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + +
+
+
+
+ ) +} + +export default App diff --git a/web/src/api/accounts.ts b/web/src/api/accounts.ts new file mode 100644 index 0000000..9ccd947 --- /dev/null +++ b/web/src/api/accounts.ts @@ -0,0 +1,22 @@ +import { api } from './client' +import type { Profile, User } from './types' + +export function getProfile(username: string): Promise { + return api.get(`/api/accounts/users/${username}/`) +} + +export function updateProfile(data: { display_name?: string; bio?: string; avatar?: File }): Promise { + const formData = new FormData() + if (data.display_name !== undefined) formData.append('display_name', data.display_name) + if (data.bio !== undefined) formData.append('bio', data.bio) + if (data.avatar) formData.append('avatar', data.avatar) + return api.patch('/api/accounts/me/', formData) +} + +export function followUser(userId: number): Promise<{ id: number }> { + return api.post<{ id: number }>('/api/accounts/follows/', { following_id: userId }) +} + +export function unfollowUser(followId: number): Promise { + return api.delete(`/api/accounts/follows/${followId}/`) +} diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts new file mode 100644 index 0000000..bf69d39 --- /dev/null +++ b/web/src/api/auth.ts @@ -0,0 +1,25 @@ +import { api, tokenStorage } from './client' +import type { User } from './types' + +interface TokenPair { + access: string + refresh: string +} + +export async function login(email: string, password: string): Promise { + const tokens = await api.post('/api/auth/token/', { email, password }) + tokenStorage.set(tokens.access, tokens.refresh) +} + +export async function register(email: string, username: string, password: string): Promise { + await api.post('/api/accounts/register/', { email, username, password }) + await login(email, password) +} + +export function logout(): void { + tokenStorage.clear() +} + +export function getMe(): Promise { + return api.get('/api/accounts/me/') +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..1386da6 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,99 @@ +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000' + +const ACCESS_TOKEN_KEY = 'aurora_access_token' +const REFRESH_TOKEN_KEY = 'aurora_refresh_token' + +export const tokenStorage = { + getAccess: () => localStorage.getItem(ACCESS_TOKEN_KEY), + getRefresh: () => localStorage.getItem(REFRESH_TOKEN_KEY), + set: (access: string, refresh: string) => { + localStorage.setItem(ACCESS_TOKEN_KEY, access) + localStorage.setItem(REFRESH_TOKEN_KEY, refresh) + }, + clear: () => { + localStorage.removeItem(ACCESS_TOKEN_KEY) + localStorage.removeItem(REFRESH_TOKEN_KEY) + }, +} + +export class ApiError extends Error { + status: number + body: unknown + + constructor(status: number, body: unknown) { + super(`API error ${status}`) + this.status = status + this.body = body + } +} + +// Called when a refresh attempt fails outright (refresh token missing, +// expired, or revoked) so the app can drop back to a logged-out state. +let onAuthFailure: (() => void) | null = null + +export function setAuthFailureHandler(handler: () => void): void { + onAuthFailure = handler +} + +// Access tokens are short-lived (30 min, see SIMPLE_JWT in api/config/settings.py). +// Concurrent requests that all hit a 401 at once should trigger a single +// refresh call, not one each — this promise is shared across them. +let refreshPromise: Promise | null = null + +async function refreshAccessToken(): Promise { + const refresh = tokenStorage.getRefresh() + if (!refresh) return null + + if (!refreshPromise) { + refreshPromise = fetch(`${API_BASE_URL}/api/auth/token/refresh/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh }), + }) + .then(async (response) => { + if (!response.ok) return null + const body = await response.json() + tokenStorage.set(body.access, body.refresh ?? refresh) + return body.access as string + }) + .catch(() => null) + .finally(() => { + refreshPromise = null + }) + } + + return refreshPromise +} + +async function request(path: string, options: RequestInit = {}, isRetry = false): Promise { + const access = tokenStorage.getAccess() + const headers = new Headers(options.headers) + if (access) headers.set('Authorization', `Bearer ${access}`) + const isFormData = options.body instanceof FormData + if (!isFormData && options.body) headers.set('Content-Type', 'application/json') + + const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers }) + + if (response.status === 401 && !isRetry) { + const newAccess = await refreshAccessToken() + if (newAccess) return request(path, options, true) + + tokenStorage.clear() + onAuthFailure?.() + } + + if (response.status === 204) return undefined as T + + const body = await response.json().catch(() => undefined) + if (!response.ok) throw new ApiError(response.status, body) + return body as T +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, data?: unknown) => + request(path, { method: 'POST', body: data instanceof FormData ? data : JSON.stringify(data) }), + patch: (path: string, data?: unknown) => + request(path, { method: 'PATCH', body: data instanceof FormData ? data : JSON.stringify(data) }), + delete: (path: string) => request(path, { method: 'DELETE' }), +} diff --git a/web/src/api/library.ts b/web/src/api/library.ts new file mode 100644 index 0000000..5cbfeb6 --- /dev/null +++ b/web/src/api/library.ts @@ -0,0 +1,14 @@ +import { api } from './client' +import type { LibraryItem, Paginated } from './types' + +export function listLibraryItems(): Promise> { + return api.get>('/api/library/items/') +} + +export function uploadLibraryItem(file: File): Promise { + const formData = new FormData() + formData.append('file', file) + formData.append('media_type', file.type.startsWith('video') ? 'video' : 'photo') + formData.append('original_filename', file.name) + return api.post('/api/library/items/', formData) +} diff --git a/web/src/api/moments.ts b/web/src/api/moments.ts new file mode 100644 index 0000000..6c45b57 --- /dev/null +++ b/web/src/api/moments.ts @@ -0,0 +1,47 @@ +import { api } from './client' +import type { Audience, Comment, Moment, Paginated, Reaction } from './types' + +export function listFeed(): Promise> { + return api.get>('/api/moments/') +} + +export function listUserMoments(username: string): Promise> { + return api.get>(`/api/moments/?owner__username=${encodeURIComponent(username)}`) +} + +export function shareMoment(params: { + caption: string + location?: string + audience: Audience + libraryItemIds: string[] +}): Promise { + return api.post('/api/moments/', { + caption: params.caption, + location: params.location ?? '', + audience: params.audience, + library_item_ids: params.libraryItemIds, + }) +} + +export function listComments(momentId: number): Promise> { + return api.get>(`/api/moments/${momentId}/comments/`) +} + +export function addComment(momentId: number, body: string, parentId?: number): Promise { + return api.post(`/api/moments/${momentId}/comments/`, { + body, + ...(parentId ? { parent: parentId } : {}), + }) +} + +export function deleteComment(momentId: number, commentId: number): Promise { + return api.delete(`/api/moments/${momentId}/comments/${commentId}/`) +} + +export function addReaction(momentId: number): Promise { + return api.post(`/api/moments/${momentId}/reactions/`, { reaction_type: 'like' }) +} + +export function removeReaction(momentId: number, reactionId: number): Promise { + return api.delete(`/api/moments/${momentId}/reactions/${reactionId}/`) +} diff --git a/web/src/api/notifications.ts b/web/src/api/notifications.ts new file mode 100644 index 0000000..2e1f7c4 --- /dev/null +++ b/web/src/api/notifications.ts @@ -0,0 +1,14 @@ +import { api } from './client' +import type { AppNotification, Paginated } from './types' + +export function listNotifications(): Promise> { + return api.get>('/api/notifications/') +} + +export function getUnreadCount(): Promise<{ unread_count: number }> { + return api.get<{ unread_count: number }>('/api/notifications/unread-count/') +} + +export function markAllNotificationsRead(): Promise { + return api.post('/api/notifications/mark-read/') +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..3a28365 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,100 @@ +export interface User { + id: number + username: string + display_name: string + bio: string + avatar: string | null + is_private: boolean + date_joined: string +} + +export interface MediaRendition { + kind: 'thumbnail' | 'feed' | 'large' + file: string + width: number + height: number +} + +export interface LibraryItem { + id: string + file: string + media_type: 'photo' | 'video' + original_filename: string + file_size: number + width: number | null + height: number | null + duration_seconds: number | null + captured_at: string | null + camera_make: string + camera_model: string + processing_status: 'pending' | 'processing' | 'ready' | 'failed' + renditions: MediaRendition[] + created_at: string +} + +export interface MomentItem { + id: number + library_item: LibraryItem + order: number +} + +export type Audience = 'public' | 'followers' | 'friends' | 'family' | 'custom' | 'only_me' + +export interface Moment { + id: number + owner: User + caption: string + location: string + audience: Audience + items: MomentItem[] + comment_count: number + reaction_count: number + viewer_reaction_id: number | null + created_at: string + updated_at: string +} + +export interface Comment { + id: number + moment: number + author: User + parent: number | null + body: string + created_at: string +} + +export interface Reaction { + id: number + moment: number + user: User + reaction_type: 'like' + created_at: string +} + +export type FollowStatus = 'pending' | 'accepted' + +export interface Profile extends User { + follower_count: number + following_count: number + viewer_follow_id: number | null + viewer_follow_status: FollowStatus | null +} + +export type NotificationVerb = 'like' | 'comment' | 'reply' + +export interface AppNotification { + id: number + actor: User + verb: NotificationVerb + moment_id: number + comment_id: number | null + is_read: boolean + created_at: string +} + +export interface Paginated { + count: number + next: string | null + previous: string | null + results: T[] +} diff --git a/web/src/assets/vite.svg b/web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/web/src/components/Avatar.tsx b/web/src/components/Avatar.tsx new file mode 100644 index 0000000..5b21100 --- /dev/null +++ b/web/src/components/Avatar.tsx @@ -0,0 +1,7 @@ +export function Avatar({ user }: { user: { username: string; avatar: string | null } }) { + return user.avatar ? ( + {user.username} + ) : ( + {user.username.slice(0, 1).toUpperCase()} + ) +} diff --git a/web/src/components/MomentCard.tsx b/web/src/components/MomentCard.tsx new file mode 100644 index 0000000..7a1f01d --- /dev/null +++ b/web/src/components/MomentCard.tsx @@ -0,0 +1,249 @@ +import { useState } from 'react' +import type { FormEvent } from 'react' +import { Link } from 'react-router-dom' +import { addComment, addReaction, listComments, removeReaction } from '../api/moments' +import type { Comment, Moment, MomentItem } from '../api/types' +import { Avatar } from './Avatar' +import { formatRelativeTime } from '../utils/formatRelativeTime' + +function itemImageUrl(item: MomentItem): string { + const rendition = item.library_item.renditions.find((r) => r.kind === 'feed') + return rendition?.file ?? item.library_item.file +} + +function itemPosterUrl(item: MomentItem): string | undefined { + return item.library_item.renditions.find((r) => r.kind === 'feed')?.file +} + +function MomentCarousel({ moment }: { moment: Moment }) { + const [index, setIndex] = useState(0) + const items = moment.items + + if (items.length === 0) return null + + const goTo = (next: number) => setIndex((next + items.length) % items.length) + const current = items[index] + + return ( +
+ {current.library_item.media_type === 'video' ? ( +
+ ) +} + +export function MomentCard({ moment }: { moment: Moment }) { + const [reactionId, setReactionId] = useState(moment.viewer_reaction_id) + const [reactionCount, setReactionCount] = useState(moment.reaction_count) + const [reacting, setReacting] = useState(false) + + const [commentsOpen, setCommentsOpen] = useState(false) + const [commentsLoaded, setCommentsLoaded] = useState(false) + const [comments, setComments] = useState([]) + const [commentCount, setCommentCount] = useState(moment.comment_count) + const [newComment, setNewComment] = useState('') + const [submittingComment, setSubmittingComment] = useState(false) + + const [replyTarget, setReplyTarget] = useState<{ parentId: number; username: string } | null>(null) + const [replyBody, setReplyBody] = useState('') + const [submittingReply, setSubmittingReply] = useState(false) + + const liked = reactionId !== null + + const toggleLike = async () => { + if (reacting) return + setReacting(true) + try { + if (liked && reactionId !== null) { + await removeReaction(moment.id, reactionId) + setReactionId(null) + setReactionCount((count) => count - 1) + } else { + const reaction = await addReaction(moment.id) + setReactionId(reaction.id) + setReactionCount((count) => count + 1) + } + } finally { + setReacting(false) + } + } + + const toggleComments = async () => { + const opening = !commentsOpen + setCommentsOpen(opening) + if (opening && !commentsLoaded) { + const page = await listComments(moment.id) + setComments(page.results) + setCommentsLoaded(true) + } + } + + const submitComment = async (event: FormEvent) => { + event.preventDefault() + const body = newComment.trim() + if (!body || submittingComment) return + setSubmittingComment(true) + try { + const comment = await addComment(moment.id, body) + setComments((existing) => [...existing, comment]) + setCommentCount((count) => count + 1) + setNewComment('') + } finally { + setSubmittingComment(false) + } + } + + const startReply = (parentId: number, username: string) => { + setReplyTarget({ parentId, username }) + setReplyBody(`@${username} `) + } + + const cancelReply = () => { + setReplyTarget(null) + setReplyBody('') + } + + const submitReply = async (event: FormEvent) => { + event.preventDefault() + const body = replyBody.trim() + if (!body || !replyTarget || submittingReply) return + setSubmittingReply(true) + try { + const reply = await addComment(moment.id, body, replyTarget.parentId) + setComments((existing) => [...existing, reply]) + setCommentCount((count) => count + 1) + cancelReply() + } finally { + setSubmittingReply(false) + } + } + + const topLevelComments = comments.filter((comment) => comment.parent === null) + const repliesByParent = new Map() + for (const comment of comments) { + if (comment.parent === null) continue + const existing = repliesByParent.get(comment.parent) ?? [] + existing.push(comment) + repliesByParent.set(comment.parent, existing) + } + + return ( +
+
+ + @{moment.owner.username} + + {formatRelativeTime(moment.created_at)} +
+ + {moment.caption &&

{moment.caption}

} +
+ + +
+ {commentsOpen && ( +
+ {topLevelComments.map((comment) => ( +
+

+ + @{comment.author.username} + {' '} + {comment.body} + +

+ {(repliesByParent.get(comment.id) ?? []).map((reply) => ( +

+ + @{reply.author.username} + {' '} + {reply.body} + +

+ ))} + {replyTarget?.parentId === comment.id && ( +
+ setReplyBody(e.target.value)} + disabled={submittingReply} + autoFocus + /> + + +
+ )} +
+ ))} + {comments.length === 0 &&

No comments yet.

} +
+ setNewComment(e.target.value)} + disabled={submittingComment} + /> + +
+
+ )} +
+ ) +} diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..3fdcebf --- /dev/null +++ b/web/src/components/ProtectedRoute.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react' +import { Navigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +export function ProtectedRoute({ children }: { children: ReactNode }) { + const { user, loading } = useAuth() + + if (loading) return
Loading…
+ if (!user) return + return <>{children} +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..3d52f8e --- /dev/null +++ b/web/src/components/Sidebar.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react' +import { Link, NavLink, useNavigate } from 'react-router-dom' +import { getUnreadCount } from '../api/notifications' +import { useAuth } from '../context/AuthContext' +import { Avatar } from './Avatar' +import { CreateIcon, HomeIcon, LibraryIcon, LogoutIcon, NotificationsIcon } from './icons' + +const linkClassName = ({ isActive }: { isActive: boolean }) => + isActive ? 'sidebar-link sidebar-link-active' : 'sidebar-link' + +export function Sidebar() { + const { user, logout } = useAuth() + const navigate = useNavigate() + const [unreadCount, setUnreadCount] = useState(0) + + useEffect(() => { + if (!user) return + let cancelled = false + const poll = () => { + getUnreadCount() + .then(({ unread_count }) => { + if (!cancelled) setUnreadCount(unread_count) + }) + .catch(() => {}) + } + poll() + const interval = setInterval(poll, 15000) + return () => { + cancelled = true + clearInterval(interval) + } + }, [user]) + + if (!user) return null + + return ( + + ) +} diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx new file mode 100644 index 0000000..4ceb35f --- /dev/null +++ b/web/src/components/icons.tsx @@ -0,0 +1,59 @@ +type IconProps = { className?: string } + +export function HomeIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function CreateIcon({ className }: IconProps) { + return ( + + + + + ) +} + +export function LibraryIcon({ className }: IconProps) { + return ( + + + + + + ) +} + +export function NotificationsIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function VideoIcon({ className }: IconProps) { + return ( + + + + + ) +} + +export function LogoutIcon({ className }: IconProps) { + return ( + + + + + + ) +} diff --git a/web/src/context/AuthContext.tsx b/web/src/context/AuthContext.tsx new file mode 100644 index 0000000..8299746 --- /dev/null +++ b/web/src/context/AuthContext.tsx @@ -0,0 +1,77 @@ +import { createContext, useCallback, useContext, useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import * as authApi from '../api/auth' +import { setAuthFailureHandler, tokenStorage } from '../api/client' +import type { User } from '../api/types' + +interface AuthContextValue { + user: User | null + loading: boolean + login: (email: string, password: string) => Promise + register: (email: string, username: string, password: string) => Promise + logout: () => void + refreshUser: () => Promise +} + +const AuthContext = createContext(undefined) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + const refreshMe = useCallback(async () => { + if (!tokenStorage.getAccess()) { + setUser(null) + setLoading(false) + return + } + try { + setUser(await authApi.getMe()) + } catch { + tokenStorage.clear() + setUser(null) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + refreshMe() + }, [refreshMe]) + + // Fires when a request's 401 survives a refresh attempt (refresh token + // missing/expired/revoked) — drop back to logged-out state so + // ProtectedRoute sends the user to /login instead of the app silently + // failing every request. + useEffect(() => { + setAuthFailureHandler(() => setUser(null)) + return () => setAuthFailureHandler(() => {}) + }, []) + + const login = async (email: string, password: string) => { + await authApi.login(email, password) + await refreshMe() + } + + const register = async (email: string, username: string, password: string) => { + await authApi.register(email, username, password) + await refreshMe() + } + + const logout = () => { + authApi.logout() + setUser(null) + } + + return ( + + {children} + + ) +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..7601537 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,766 @@ +:root { + color-scheme: dark; + --aurora-bg: #0b0d12; + --aurora-surface: #14161d; + --aurora-border: #262a35; + --aurora-text: #e8e9ee; + --aurora-text-dim: #9199a8; + --aurora-accent: #7c5cff; + --aurora-accent-2: #ff5c9e; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--aurora-bg); + color: var(--aurora-text); + min-height: 100vh; +} + +a { + color: var(--aurora-accent); + text-decoration: none; +} + +button { + background: linear-gradient(135deg, var(--aurora-accent), var(--aurora-accent-2)); + color: white; + border: none; + border-radius: 8px; + padding: 0.6rem 1.2rem; + font-weight: 600; + cursor: pointer; +} + +button:disabled { + opacity: 0.6; + cursor: default; +} + +input, +textarea { + background: var(--aurora-surface); + border: 1px solid var(--aurora-border); + border-radius: 8px; + padding: 0.6rem 0.8rem; + color: var(--aurora-text); + font-size: 1rem; + font-family: inherit; +} + +.app-shell { + min-height: 100vh; +} + +.sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 88px; + display: flex; + flex-direction: column; + padding: 1.5rem 0 1.5rem 1.75rem; + background: var(--aurora-bg); + overflow: hidden; + white-space: nowrap; + transition: width 0.2s ease; + z-index: 100; +} + +.sidebar:hover { + width: 260px; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: 0.9rem; + font-weight: 700; + font-size: 1.1rem; + margin-bottom: 2rem; + padding: 0 0.75rem; +} + +.sidebar-logo { + width: 32px; + height: 32px; + flex-shrink: 0; +} + +.sidebar-brand-full { + background: linear-gradient(135deg, var(--aurora-accent), var(--aurora-accent-2)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + opacity: 0; + transform: translateX(-8px); + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.sidebar:hover .sidebar-brand-full { + opacity: 1; + transform: translateX(0); +} + +.sidebar-links-center { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; + justify-content: center; +} + +.sidebar-bottom { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sidebar-link { + display: flex; + align-items: center; + gap: 0.9rem; + padding: 0.75rem; + border-radius: 10px; + background: transparent; + border: none; + color: var(--aurora-text); + font-weight: 500; + font-size: 1rem; + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.sidebar-link:hover { + background: var(--aurora-surface); +} + +.sidebar-link-active { + background: var(--aurora-surface); + font-weight: 700; +} + +.sidebar-link svg { + width: 24px; + height: 24px; + flex-shrink: 0; +} + +.sidebar-link span:not(.sidebar-icon-wrap):not(.sidebar-badge) { + opacity: 0; + transform: translateX(-8px); + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.sidebar:hover .sidebar-link span:not(.sidebar-icon-wrap):not(.sidebar-badge) { + opacity: 1; + transform: translateX(0); +} + +.sidebar-icon-wrap { + position: relative; + display: inline-flex; +} + +.sidebar-badge { + position: absolute; + top: -4px; + right: -6px; + background: var(--aurora-accent-2); + color: white; + font-size: 0.65rem; + font-weight: 700; + border-radius: 999px; + min-width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3px; + line-height: 1; +} + +.avatar { + width: 24px; + height: 24px; + border-radius: 50%; + flex-shrink: 0; + object-fit: cover; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--aurora-accent), var(--aurora-accent-2)); + color: white; + font-size: 0.7rem; + font-weight: 700; +} + +.app-main { + min-width: 0; + max-width: 720px; + margin-left: max(120px, calc(50% - 360px)); + margin-right: auto; + padding: 1.5rem; +} + +.create-panel { + margin: 0 auto; +} + +.page-loading, +.feed-empty { + text-align: center; + color: var(--aurora-text-dim); + padding: 4rem 1rem; +} + +.notifications-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +.notification-row-unread { + background: var(--aurora-surface); + border-radius: 10px; +} + +.notification-link { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + color: var(--aurora-text); +} + +.notification-link .avatar { + width: 36px; + height: 36px; +} + +.notification-text { + flex: 1; +} + +.notification-time { + color: var(--aurora-text-dim); + font-size: 0.8rem; + flex-shrink: 0; +} + +.auth-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 0.9rem; + width: 100%; + max-width: 360px; + padding: 2rem; + background: var(--aurora-surface); + border: 1px solid var(--aurora-border); + border-radius: 16px; +} + +.auth-form h1 { + margin: 0; + background: linear-gradient(135deg, var(--aurora-accent), var(--aurora-accent-2)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.auth-tagline { + margin: 0 0 0.5rem; + color: var(--aurora-text-dim); +} + +.auth-error { + color: var(--aurora-accent-2); + margin: 0; +} + +.library-header { + margin-bottom: 1.5rem; +} + +.library-subtitle { + color: var(--aurora-text-dim); +} + +.library-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.5rem; +} + +.library-tile { + position: relative; + aspect-ratio: 1; + border-radius: 8px; + overflow: hidden; + background: var(--aurora-surface); + cursor: pointer; + border: 2px solid transparent; +} + +.grid-video-badge { + position: absolute; + top: 6px; + right: 6px; + width: 18px; + height: 18px; + color: white; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8)); +} + +.library-tile-selected { + border-color: var(--aurora-accent); +} + +.library-tile img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.library-tile-pending { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--aurora-text-dim); + font-size: 0.8rem; +} + +.moment-card { + background: var(--aurora-surface); + border: 1px solid var(--aurora-border); + border-radius: 16px; + overflow: hidden; + margin-bottom: 1.5rem; +} + +.moment-card header { + display: flex; + justify-content: space-between; + padding: 0.9rem 1rem; + color: var(--aurora-text-dim); +} + +.moment-author { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.moment-carousel { + position: relative; +} + +.moment-carousel img, +.moment-carousel video { + width: 100%; + display: block; + background: #000; +} + +.carousel-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(11, 13, 18, 0.6); + color: var(--aurora-text); + border: none; + border-radius: 999px; + width: 32px; + height: 32px; + font-size: 1.2rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.carousel-arrow-prev { + left: 0.6rem; +} + +.carousel-arrow-next { + right: 0.6rem; +} + +.carousel-dots { + position: absolute; + bottom: 0.7rem; + left: 0; + right: 0; + display: flex; + justify-content: center; + gap: 0.35rem; +} + +.carousel-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.4); +} + +.carousel-dot-active { + background: var(--aurora-text); +} + +.moment-caption { + padding: 0.9rem 1rem 0; + margin: 0; +} + +.moment-card footer { + display: flex; + gap: 0.75rem; + padding: 0.9rem 1rem; +} + +.reaction-button { + display: flex; + align-items: center; + gap: 0.35rem; + background: transparent; + border: 1px solid var(--aurora-border); + border-radius: 999px; + padding: 0.35rem 0.8rem; + color: var(--aurora-text-dim); + font-weight: 500; +} + +.reaction-button-active { + color: var(--aurora-accent-2); + border-color: var(--aurora-accent-2); +} + +.moment-comments { + border-top: 1px solid var(--aurora-border); + padding: 0.9rem 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.moment-comment-thread { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.moment-comment { + margin: 0; + font-size: 0.9rem; +} + +.moment-comment-reply { + margin-left: 2rem; +} + +.moment-comment-reply-link { + background: none; + border: none; + color: var(--aurora-text-dim); + font-size: 0.8rem; + font-weight: 600; + font-family: inherit; + cursor: pointer; + padding: 0; + margin-left: 0.5rem; +} + +.moment-comment-reply-link:hover { + color: var(--aurora-text); +} + +.moment-reply-form { + margin-left: 2rem; +} + +.moment-comment-author { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--aurora-accent); + font-weight: 600; + vertical-align: middle; +} + +.moment-comments-empty { + margin: 0; + color: var(--aurora-text-dim); + font-size: 0.85rem; +} + +.moment-comment-form { + display: flex; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.moment-comment-form input { + flex: 1; +} + +.selection-bar { + position: sticky; + bottom: 1rem; + margin-top: 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + background: var(--aurora-surface); + border: 1px solid var(--aurora-border); + border-radius: 12px; + padding: 0.9rem 1.2rem; +} + +.selection-bar div { + display: flex; + gap: 0.6rem; +} + +.composer-cancel { + background: transparent; + border: 1px solid var(--aurora-border); + color: var(--aurora-text); +} + +.composer-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 10; +} + +.composer-panel { + display: flex; + flex-direction: column; + gap: 0.8rem; + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + background: var(--aurora-surface); + border: 1px solid var(--aurora-border); + border-radius: 16px; + padding: 1.5rem; +} + +.composer-panel h2 { + margin: 0; +} + +.composer-thumbs { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.composer-thumb { + position: relative; + width: 64px; + height: 64px; +} + +.composer-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 8px; +} + +.composer-panel textarea { + background: var(--aurora-bg); + border: 1px solid var(--aurora-border); + border-radius: 8px; + padding: 0.6rem 0.8rem; + color: var(--aurora-text); + font-family: inherit; + font-size: 1rem; + resize: vertical; +} + +.composer-audience-label { + display: flex; + flex-direction: column; + gap: 0.35rem; + color: var(--aurora-text-dim); + font-size: 0.9rem; +} + +.composer-audience-label select { + background: var(--aurora-bg); + border: 1px solid var(--aurora-border); + border-radius: 8px; + padding: 0.5rem 0.7rem; + color: var(--aurora-text); + font-size: 1rem; +} + +.composer-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; +} + +.profile-header { + display: flex; + gap: 1.5rem; + align-items: flex-start; + margin-bottom: 2rem; +} + +.profile-avatar { + flex-shrink: 0; + width: 84px; + height: 84px; + border-radius: 50%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + font-weight: 700; + color: white; + background: linear-gradient(135deg, var(--aurora-accent), var(--aurora-accent-2)); +} + +.profile-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-info { + flex: 1; + min-width: 0; +} + +.profile-name-row { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.profile-name-row h1 { + margin: 0; + font-size: 1.3rem; +} + +.profile-bio { + margin: 0.2rem 0 0; + color: var(--aurora-text-dim); + white-space: pre-wrap; +} + +.profile-counts { + display: flex; + gap: 1.5rem; + margin-top: 0.9rem; + color: var(--aurora-text-dim); +} + +.profile-counts strong { + color: var(--aurora-text); +} + +.profile-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.35rem; +} + +.profile-grid-tile { + position: relative; + aspect-ratio: 1; + padding: 0; + border: none; + border-radius: 4px; + overflow: hidden; + background: var(--aurora-surface); + cursor: pointer; +} + +.profile-grid-tile img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.profile-modal { + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + border-radius: 16px; +} + +.settings-avatar { + position: relative; + align-self: center; + width: 84px; + height: 84px; + border-radius: 50%; + overflow: hidden; + padding: 0; + border: none; + background: linear-gradient(135deg, var(--aurora-accent), var(--aurora-accent-2)); + color: white; + font-size: 2rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; +} + +.settings-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.settings-avatar-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); + color: white; + font-size: 0.6rem; + font-weight: 600; + text-align: center; + opacity: 0; + transition: opacity 0.15s; +} + +.settings-avatar:hover .settings-avatar-overlay { + opacity: 1; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/pages/CreatePage.tsx b/web/src/pages/CreatePage.tsx new file mode 100644 index 0000000..58fe846 --- /dev/null +++ b/web/src/pages/CreatePage.tsx @@ -0,0 +1,158 @@ +import { useState } from 'react' +import type { FormEvent } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { listLibraryItems } from '../api/library' +import { shareMoment } from '../api/moments' +import type { Audience, LibraryItem } from '../api/types' +import { VideoIcon } from '../components/icons' + +const AUDIENCE_OPTIONS: { value: Audience; label: string }[] = [ + { value: 'public', label: 'Public' }, + { value: 'followers', label: 'Followers' }, + { value: 'friends', label: 'Friends' }, + { value: 'family', label: 'Family' }, + { value: 'only_me', label: 'Only me' }, +] + +function thumbnailUrl(item: LibraryItem): string { + const thumb = item.renditions.find((r) => r.kind === 'thumbnail') + return thumb?.file ?? item.file +} + +interface CreatePageState { + preselectedItems?: LibraryItem[] +} + +export function CreatePage() { + const location = useLocation() + const navigate = useNavigate() + const preselected = (location.state as CreatePageState | null)?.preselectedItems ?? [] + + const [selectedIds, setSelectedIds] = useState>(new Set(preselected.map((item) => item.id))) + const [selectedItems, setSelectedItems] = useState(preselected) + + const [pickerOpen, setPickerOpen] = useState(false) + const [pickerItems, setPickerItems] = useState([]) + const [pickerLoaded, setPickerLoaded] = useState(false) + + const [caption, setCaption] = useState('') + const [locationText, setLocationText] = useState('') + const [audience, setAudience] = useState('public') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const openPicker = async () => { + setPickerOpen((open) => !open) + if (!pickerLoaded) { + const page = await listLibraryItems() + setPickerItems(page.results.filter((item) => item.processing_status === 'ready')) + setPickerLoaded(true) + } + } + + const toggleItem = (item: LibraryItem) => { + setSelectedIds((existing) => { + const next = new Set(existing) + if (next.has(item.id)) next.delete(item.id) + else next.add(item.id) + return next + }) + setSelectedItems((items) => { + const alreadySelected = items.some((existingItem) => existingItem.id === item.id) + return alreadySelected ? items.filter((existingItem) => existingItem.id !== item.id) : [...items, item] + }) + } + + const canSubmit = caption.trim().length > 0 || selectedItems.length > 0 + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + if (!canSubmit || submitting) return + setSubmitting(true) + setError(null) + try { + await shareMoment({ + caption, + location: locationText, + audience, + libraryItemIds: selectedItems.map((item) => item.id), + }) + navigate('/') + } catch { + setError('Could not share this Moment. Try again.') + } finally { + setSubmitting(false) + } + } + + return ( +
+

Create a Moment

+ {selectedItems.length > 0 && ( +
+ {selectedItems.map((item) => ( +
+ {item.original_filename} + {item.media_type === 'video' && } +
+ ))} +
+ )} +