From 49c4ff681f08d180bc0a376948c89ab1496da1c8 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Wed, 5 Aug 2026 17:16:09 -0500 Subject: [PATCH] Drafts system implemented --- .../0015_projectdraft_uuid_and_more.py | 34 ++++ .../0016_alter_projectdraft_options.py | 17 ++ nonpacks/library/models.py | 19 +- nonpacks/library/tests.py | 172 +++++++++++++++--- nonpacks/library/urls.py | 5 +- nonpacks/library/views.py | 157 ++++++++++++---- nonpacks/static/css/style.css | 20 ++ nonpacks/static/js/create.js | 11 +- nonpacks/static/js/uploads.js | 2 + nonpacks/templates/base.html | 4 +- nonpacks/templates/landing/home.html | 2 +- nonpacks/templates/library/my_projects.html | 47 +++++ .../templates/library/project_create.html | 4 + 13 files changed, 419 insertions(+), 75 deletions(-) create mode 100644 nonpacks/library/migrations/0015_projectdraft_uuid_and_more.py create mode 100644 nonpacks/library/migrations/0016_alter_projectdraft_options.py create mode 100644 nonpacks/templates/library/my_projects.html diff --git a/nonpacks/library/migrations/0015_projectdraft_uuid_and_more.py b/nonpacks/library/migrations/0015_projectdraft_uuid_and_more.py new file mode 100644 index 0000000..d209ef1 --- /dev/null +++ b/nonpacks/library/migrations/0015_projectdraft_uuid_and_more.py @@ -0,0 +1,34 @@ +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0014_alter_comment_created_at_alter_comment_updated_at'), + ] + + operations = [ + migrations.AddField( + model_name='projectdraft', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AlterField( + model_name='projectdraft', + name='user', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='project_drafts', + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name='tempupload', + name='draft_uuid', + field=models.UUIDField(blank=True, db_index=True, null=True), + ), + ] diff --git a/nonpacks/library/migrations/0016_alter_projectdraft_options.py b/nonpacks/library/migrations/0016_alter_projectdraft_options.py new file mode 100644 index 0000000..3899522 --- /dev/null +++ b/nonpacks/library/migrations/0016_alter_projectdraft_options.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.3 on 2026-08-05 21:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0015_projectdraft_uuid_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='projectdraft', + options={'ordering': ['-updated_at', '-pk']}, + ), + ] diff --git a/nonpacks/library/models.py b/nonpacks/library/models.py index 9f65d4f..c6e9b5b 100644 --- a/nonpacks/library/models.py +++ b/nonpacks/library/models.py @@ -314,6 +314,7 @@ class TempUpload(models.Model): kind = models.CharField(max_length=16, choices=KIND_CHOICES, db_index=True) file = models.ForeignKey(FileIndex, on_delete=models.CASCADE, related_name='temp_upload') status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='pending', db_index=True) + draft_uuid = models.UUIDField(null=True, blank=True, db_index=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: @@ -334,16 +335,24 @@ class TempUpload(models.Model): class ProjectDraft(models.Model): - """Autosaved 'create project' form state. One per user; no explicit - Save Draft button — every keystroke is persisted in real time.""" - user = models.OneToOneField( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='project_draft', + """Autosaved 'new project' form state. Many per user, each keyed by a UUID; + there is no explicit Save Draft button — every keystroke is persisted.""" + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='project_drafts', ) data = models.JSONField(default=dict, blank=True) updated_at = models.DateTimeField(auto_now=True) + class Meta: + ordering = ['-updated_at', '-pk'] + def __str__(self): - return f'Draft for {self.user.username}' + return f'Draft {self.uuid} for {self.user.username}' + + @property + def title(self): + return self.data.get('title') or 'Untitled draft' class Rating(models.Model): diff --git a/nonpacks/library/tests.py b/nonpacks/library/tests.py index 720f5f5..5d4f75e 100644 --- a/nonpacks/library/tests.py +++ b/nonpacks/library/tests.py @@ -79,18 +79,21 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase): self.bob = User.objects.create_user(username='Bob', password='pw') UserProfile.objects.get_or_create(user=self.bob) - def _upload_temp(self, kind, name, content, content_type): + def _upload_temp(self, kind, name, content, content_type, draft_uuid=None): + data = {'kind': kind, 'file': SimpleUploadedFile(name, content, content_type=content_type)} + if draft_uuid is not None: + data['draft_uuid'] = str(draft_uuid) return self.client.post( - reverse('library:api_upload_temp'), - {'kind': kind, 'file': SimpleUploadedFile(name, content, content_type=content_type)}, + reverse('library:api_upload_temp'), data, HTTP_X_REQUESTED_WITH='XMLHttpRequest', ) def _create_project(self, user, title='Test Pack', tags='general:furry content:threesome'): self.gate() self.client.login(username=user.username, password='pw') - self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png') - self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip') + uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] + self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png', draft_uuid=uuid) + self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid) data = { 'title': title, 'summary': 'A test pack.', @@ -100,11 +103,11 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase): 'version_name': '1.0.0', 'changelog': 'First release.', } - return self.client.post(reverse('library:project_create'), data) + return self.client.post(reverse('library:project_create', args=[uuid]), data) def test_create_requires_login(self): self.gate() - resp = self.client.get(reverse('library:project_create')) + resp = self.client.get(reverse('library:project_create_new')) self.assertEqual(resp.status_code, 302) def test_create_project(self): @@ -135,7 +138,8 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase): def test_create_requires_version_file(self): self.gate() self.client.login(username='Alice', password='pw') - resp = self.client.post(reverse('library:project_create'), { + uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] + resp = self.client.post(reverse('library:project_create', args=[uuid]), { 'title': 'No File', 'category': 'mod', 'version_name': '1.0.0', @@ -146,16 +150,17 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase): def test_create_adopts_media_and_captions(self): self.gate() self.client.login(username='Alice', password='pw') - self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png') - self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip') - self._upload_temp('media', 'pic1.png', make_png_bytes(), 'image/png') - self._upload_temp('media', 'pic2.png', make_png_bytes(), 'image/png') - media = TempUpload.objects.filter(kind='media').order_by('created_at') + uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] + self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png', draft_uuid=uuid) + self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid) + self._upload_temp('media', 'pic1.png', make_png_bytes(), 'image/png', draft_uuid=uuid) + self._upload_temp('media', 'pic2.png', make_png_bytes(), 'image/png', draft_uuid=uuid) + media = TempUpload.objects.filter(kind='media', draft_uuid=uuid).order_by('created_at') caption_payload = { f'caption_{media[0].uuid}': 'Showcase', f'caption_{media[1].uuid}': 'Second angle', } - resp = self.client.post(reverse('library:project_create'), { + resp = self.client.post(reverse('library:project_create', args=[uuid]), { 'title': 'Media Pack', 'category': 'skin', 'version_name': '1.0.0', @@ -192,6 +197,119 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase): self.assertEqual(resp.status_code, 404) +class UGCProjectDraftTests(UGCMediaTestCase, UGCGatedTestCase): + """Multiple per-user drafts, keyed by UUID, with upload scoping.""" + + def setUp(self): + User = get_user_model() + self.alice = User.objects.create_user(username='Alice', password='pw') + UserProfile.objects.get_or_create(user=self.alice) + self.bob = User.objects.create_user(username='Bob', password='pw') + UserProfile.objects.get_or_create(user=self.bob) + + def _auth(self, user): + self.client.force_login(user) + self.gate() + + def _upload_temp(self, kind, name, content, content_type, draft_uuid=None): + data = {'kind': kind, 'file': SimpleUploadedFile(name, content, content_type=content_type)} + if draft_uuid is not None: + data['draft_uuid'] = str(draft_uuid) + return self.client.post( + reverse('library:api_upload_temp'), data, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) + + def _new_draft(self, user=None): + self._auth(user or self.alice) + resp = self.client.get(reverse('library:project_create_new')) + uuid = resp.url.rstrip('/').split('/')[-1] + return uuid, resp + + def test_new_project_creates_draft_and_redirects(self): + uuid, resp = self._new_draft() + self.assertRedirects( + resp, reverse('library:project_create', args=[uuid]), + fetch_redirect_response=False, + ) + self.assertTrue(ProjectDraft.objects.filter(uuid=uuid, user=self.alice).exists()) + + def test_my_projects_lists_projects_and_drafts(self): + project = Project.objects.create( + slug='mine', title='Mine', category='mod', owner=self.alice, + ) + uuid, _ = self._new_draft() + self._auth(self.alice) + resp = self.client.get(reverse('library:my_projects')) + self.assertContains(resp, 'Mine') + self.assertContains(resp, 'Untitled draft') + self.assertEqual(Project.objects.get(pk=project.pk).title, 'Mine') + + def test_wrong_user_cannot_access_draft(self): + uuid, _ = self._new_draft() + self._auth(self.bob) + resp = self.client.get(reverse('library:project_create', args=[uuid])) + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp.url, reverse('library:my_projects')) + # API save also refused. + resp = self.client.post( + reverse('library:api_draft'), + data=json.dumps({'draft': uuid, 'data': {'title': 'hack'}}), + content_type='application/json', + ) + self.assertEqual(resp.status_code, 404) + draft = ProjectDraft.objects.get(uuid=uuid) + self.assertNotEqual(draft.data.get('title'), 'hack') + + def test_autosave_scopes_to_its_draft(self): + uuid_a, _ = self._new_draft() + uuid_b, _ = self._new_draft() + self._auth(self.alice) + self.client.post( + reverse('library:api_draft'), + data=json.dumps({'draft': uuid_a, 'data': {'title': 'Draft A'}}), + content_type='application/json', + ) + self.assertEqual(ProjectDraft.objects.get(uuid=uuid_a).data.get('title'), 'Draft A') + self.assertEqual(ProjectDraft.objects.get(uuid=uuid_b).data.get('title'), None) + + def test_uploads_scoped_per_draft(self): + uuid_a, _ = self._new_draft() + uuid_b, _ = self._new_draft() + self._auth(self.alice) + self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid_a) + self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid_a).count(), 1) + self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid_b).count(), 0) + self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid_a, status='pending').count(), 1) + self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid_b, status='pending').count(), 0) + + def test_submit_consumes_draft_uploads_and_deletes_draft(self): + uuid, _ = self._new_draft() + self._auth(self.alice) + self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid) + resp = self.client.post( + reverse('library:project_create', args=[uuid]), + { + 'title': 'From Draft', 'summary': 's', 'description': 'd', + 'category': 'mod', 'tags': 'general:test', + 'version_name': '1.0.0', 'changelog': 'c', + }, + ) + self.assertRedirects(resp, reverse('library:project_detail', args=['from-draft'])) + self.assertTrue(Project.objects.filter(title='From Draft', owner=self.alice).exists()) + self.assertFalse(ProjectDraft.objects.filter(uuid=uuid).exists()) + self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid, status='used').count(), 1) + + def test_draft_delete_purges_uploads(self): + uuid, _ = self._new_draft() + self._auth(self.alice) + self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid) + resp = self.client.post(reverse('library:project_draft_delete', args=[uuid])) + self.assertEqual(resp.status_code, 302) + self.assertFalse(ProjectDraft.objects.filter(uuid=uuid).exists()) + self.assertFalse(TempUpload.objects.filter(draft_uuid=uuid).exists()) + + class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase): def setUp(self): User = get_user_model() @@ -585,7 +703,8 @@ class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase): def test_draft_save_and_restore(self): self.gate() self.client.login(username='Alice', password='pw') - self._post_json(reverse('library:api_draft'), {'data': { + uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] + self._post_json(reverse('library:api_draft'), {'draft': uuid, 'data': { 'title': 'Draft Title', 'summary': 'Draft summary', 'category': 'mod', @@ -593,34 +712,36 @@ class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase): 'tags': 'species:dragon', 'version_name': '2.0.0', }}) - draft = ProjectDraft.objects.get(user=self.alice) + draft = ProjectDraft.objects.get(uuid=uuid, user=self.alice) self.assertEqual(draft.data['title'], 'Draft Title') - resp = self.client.get(reverse('library:api_draft')) + resp = self.client.get(reverse('library:api_draft'), {'draft': uuid}) self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json()['data']['title'], 'Draft Title') def test_create_form_prefills_draft(self): self.gate() self.client.login(username='Alice', password='pw') - self._post_json(reverse('library:api_draft'), {'data': {'title': 'My Draft Pack'}}) - resp = self.client.get(reverse('library:project_create')) + uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] + self._post_json(reverse('library:api_draft'), {'draft': uuid, 'data': {'title': 'My Draft Pack'}}) + resp = self.client.get(reverse('library:project_create', args=[uuid])) self.assertContains(resp, 'value="My Draft Pack"') def test_create_clears_draft(self): self.gate() self.client.login(username='Alice', password='pw') - self._post_json(reverse('library:api_draft'), {'data': {'title': 'My Draft Pack'}}) + uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] + self._post_json(reverse('library:api_draft'), {'draft': uuid, 'data': {'title': 'My Draft Pack'}}) self.client.post( reverse('library:api_upload_temp'), - {'kind': 'version', 'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip')}, + {'kind': 'version', 'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'), 'draft_uuid': uuid}, HTTP_X_REQUESTED_WITH='XMLHttpRequest', ) - resp = self.client.post(reverse('library:project_create'), { + resp = self.client.post(reverse('library:project_create', args=[uuid]), { 'title': 'My Draft Pack', 'category': 'mod', 'version_name': '1.0.0', }) self.assertRedirects(resp, reverse('library:project_detail', args=['my-draft-pack'])) - self.assertFalse(ProjectDraft.objects.filter(user=self.alice).exists()) + self.assertFalse(ProjectDraft.objects.filter(uuid=uuid).exists()) class UGCPermissionTests(UGCMediaTestCase, UGCGatedTestCase): @@ -1825,10 +1946,11 @@ class UGCAnimationIdTests(UGCMediaTestCase, UGCGatedTestCase): def test_create_injects_defaults(self): self.gate() self.client.login(username='Alice', password='pw') + uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] self.client.post(reverse('library:api_upload_temp'), - {'kind': 'version', 'file': SimpleUploadedFile('pack.zip', _logical_zip(), content_type='application/zip')}, + {'kind': 'version', 'file': SimpleUploadedFile('pack.zip', _logical_zip(), content_type='application/zip'), 'draft_uuid': uuid}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - resp = self.client.post(reverse('library:project_create'), { + resp = self.client.post(reverse('library:project_create', args=[uuid]), { 'title': 'New Pack', 'category': 'non_pack', 'version_name': '1.0.0', }) self.assertRedirects(resp, reverse('library:project_detail', args=['new-pack'])) diff --git a/nonpacks/library/urls.py b/nonpacks/library/urls.py index 6f1d528..610404c 100644 --- a/nonpacks/library/urls.py +++ b/nonpacks/library/urls.py @@ -6,7 +6,10 @@ app_name = 'library' urlpatterns = [ path('browse/', views.browse, name='browse'), - path('packs/create/', views.project_create, name='project_create'), + path('packs/my/', views.my_projects, name='my_projects'), + path('packs/create/', views.project_create_new, name='project_create_new'), + path('packs/create//', views.project_create, name='project_create'), + path('packs/create//delete/', views.project_draft_delete, name='project_draft_delete'), path('packs//', views.project_detail, name='project_detail'), path('packs//edit/', views.project_edit, name='project_edit'), path('packs//edit/regenerate/', views.regenerate_metadata, name='regenerate_metadata'), diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index c2b4bb3..3169feb 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -4,6 +4,7 @@ import posixpath import re import zipfile from pathlib import Path +from uuid import UUID from django.conf import settings from django.contrib import messages @@ -707,25 +708,31 @@ def _serialize_temp(upload): } -def _pending_uploads(user): - return list( - TempUpload.objects.filter(user=user, status='pending') - .select_related('file').order_by('created_at') - ) +def _pending_uploads(user, draft_uuid=None): + qs = TempUpload.objects.filter(user=user, status='pending') + if draft_uuid is not None: + qs = qs.filter(draft_uuid=draft_uuid) + else: + qs = qs.filter(draft_uuid__isnull=True) + return list(qs.select_related('file').order_by('created_at')) -def _pending_kind(user, kind): - return list( - TempUpload.objects.filter(user=user, status='pending', kind=kind) - .select_related('file').order_by('created_at') - ) +def _pending_kind(user, kind, draft_uuid=None): + qs = TempUpload.objects.filter(user=user, status='pending', kind=kind) + if draft_uuid is not None: + qs = qs.filter(draft_uuid=draft_uuid) + else: + qs = qs.filter(draft_uuid__isnull=True) + return list(qs.select_related('file').order_by('created_at')) -def _latest_pending(user, kind): - return ( - TempUpload.objects.filter(user=user, status='pending', kind=kind) - .select_related('file').order_by('created_at').last() - ) +def _latest_pending(user, kind, draft_uuid=None): + qs = TempUpload.objects.filter(user=user, status='pending', kind=kind) + if draft_uuid is not None: + qs = qs.filter(draft_uuid=draft_uuid) + else: + qs = qs.filter(draft_uuid__isnull=True) + return qs.select_related('file').order_by('created_at').last() def _adopt_temp(upload, project_pk, subdir, kind): @@ -1123,7 +1130,48 @@ def _form_values(*forms): @login_required -def project_create(request): +@login_required +def my_projects(request): + """The user's own projects and saved drafts (two-column page).""" + projects = Project.objects.filter(owner=request.user).order_by('-created_at') + drafts = ProjectDraft.objects.filter(user=request.user) + return render(request, 'library/my_projects.html', { + 'projects': projects, + 'drafts': drafts, + }) + + +@login_required +def project_create_new(request): + """Entry point for a new project: create a fresh draft UUID, then edit it.""" + if request.method != 'GET': + return HttpResponse(status=405) + draft = ProjectDraft.objects.create(user=request.user) + return redirect('library:project_create', uuid=draft.uuid) + + +@login_required +def project_draft_delete(request, uuid): + """Delete one draft (owner only) and purge its pending uploads.""" + draft = ProjectDraft.objects.filter(uuid=uuid, user=request.user).first() + if draft is None: + return redirect('library:my_projects') + if request.method == 'POST': + for temp in TempUpload.objects.filter(draft_uuid=draft.uuid): + delete_file_index(temp.file) + TempUpload.objects.filter(draft_uuid=draft.uuid).delete() + draft.delete() + messages.success(request, 'Draft deleted.') + return redirect('library:my_projects') + + +@login_required +def project_create(request, uuid): + """Create-page for one specific draft. Only its owner may access it.""" + draft = ProjectDraft.objects.filter(uuid=uuid, user=request.user).first() + if draft is None: + return redirect('library:my_projects') + project_form = ProjectForm() version_form = VersionForm() @@ -1132,7 +1180,7 @@ def project_create(request): version_form = VersionForm(request.POST) if project_form.is_valid() and version_form.is_valid(): data = project_form.cleaned_data - version_files = _pending_kind(request.user, 'version') + version_files = _pending_kind(request.user, 'version', draft.uuid) if not version_files: version_form.add_error('version_name', 'Upload at least one version file before creating the project.') else: @@ -1145,7 +1193,7 @@ def project_create(request): owner=request.user, ) - thumb = _latest_pending(request.user, 'thumbnail') + thumb = _latest_pending(request.user, 'thumbnail', draft.uuid) if thumb is not None: project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail') thumb.status = 'used' @@ -1166,7 +1214,7 @@ def project_create(request): messages.warning(request, ANIMATION_API_WARNING) for temp in TempUpload.objects.filter( - user=request.user, status='pending', kind='media', + user=request.user, status='pending', kind='media', draft_uuid=draft.uuid, ).select_related('file').order_by('created_at'): index = _adopt_temp(temp, project.pk, 'gallery', 'asset') ProjectAsset.objects.create( @@ -1179,24 +1227,23 @@ def project_create(request): _apply_tags(project, data['tags'], request.user) project.sync_creator_tags(actor=request.user) - ProjectDraft.objects.filter(user=request.user).delete() + draft.delete() messages.success(request, 'Project created.') return redirect('library:project_detail', slug=project.slug) else: - draft = ProjectDraft.objects.filter(user=request.user).first() - if draft: - initial = {k: draft.data.get(k, '') for k in DRAFT_KEYS} - project_form = ProjectForm(initial=initial) - version_form = VersionForm( - initial={ - k: initial[k] for k in ('version_name', 'changelog') if k in initial - }, - ) + initial = {k: draft.data.get(k, '') for k in DRAFT_KEYS} + project_form = ProjectForm(initial=initial) + version_form = VersionForm( + initial={ + k: initial[k] for k in ('version_name', 'changelog') if k in initial + }, + ) return render(request, 'library/project_create.html', { 'project_form': project_form, 'version_form': version_form, - 'draft_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)], + 'draft_uuid': draft.uuid, + 'draft_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user, draft.uuid)], 'draft_data': _form_values(project_form, version_form), 'tag_categories': list(TagCategory.objects.order_by('slug').values('slug', 'color')), }) @@ -1689,15 +1736,30 @@ def api_upload_temp(request): if kind == 'thumbnail' and not content_type.startswith('image/'): return JsonResponse({'error': 'The thumbnail must be an image.'}, status=400) - # Thumbnail is a single slot: discard any earlier pending one. Version and - # media accept multiple files (one Version can hold several files). + draft_uuid_raw = request.POST.get('draft_uuid', '').strip() + draft_uuid = draft_uuid_raw or None + if draft_uuid is not None: + try: + draft_uuid = UUID(draft_uuid_raw) + except (ValueError, TypeError): + draft_uuid = None + + # Thumbnail is a single slot: discard any earlier pending one for this + # draft (or non-draft flow). Version and media accept multiple files. if kind == 'thumbnail': - for old in TempUpload.objects.filter(user=request.user, status='pending', kind='thumbnail'): + old_qs = TempUpload.objects.filter(user=request.user, status='pending', kind='thumbnail') + if draft_uuid is not None: + old_qs = old_qs.filter(draft_uuid=draft_uuid) + else: + old_qs = old_qs.filter(draft_uuid__isnull=True) + for old in old_qs: delete_file_index(old.file) old.delete() index = store_temp_file(request.user, kind, uploaded) - upload = TempUpload.objects.create(user=request.user, kind=kind, file=index) + upload = TempUpload.objects.create( + user=request.user, kind=kind, file=index, draft_uuid=draft_uuid, + ) return JsonResponse(_serialize_temp(upload)) @@ -1714,20 +1776,31 @@ def api_delete_temp(request, upload_uuid): @login_required def api_draft(request): + """Autosave endpoint for a single draft, keyed by its UUID.""" + def _draft_for(uuid_raw): + try: + return ProjectDraft.objects.filter(uuid=UUID(uuid_raw), user=request.user).first() + except (ValueError, TypeError): + return None + if request.method == 'GET': - draft = ProjectDraft.objects.filter(user=request.user).first() + draft = _draft_for(request.GET.get('draft', '')) + if draft is None: + return JsonResponse({'error': 'Draft not found'}, status=404) return JsonResponse({ - 'data': draft.data if draft else {}, - 'uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)], + 'data': draft.data, + 'uploads': [_serialize_temp(u) for u in _pending_uploads(request.user, draft.uuid)], }) if request.method == 'POST': try: payload = json.loads(request.body or b'{}') except json.JSONDecodeError: payload = {} + draft = _draft_for(payload.get('draft', '')) + if draft is None: + return JsonResponse({'error': 'Draft not found'}, status=404) data = payload.get('data') or {} clean = {k: str(data.get(k, '')) for k in DRAFT_KEYS if k in data} - draft, _ = ProjectDraft.objects.get_or_create(user=request.user) draft.data = clean draft.save() return JsonResponse({ @@ -1735,6 +1808,12 @@ def api_draft(request): 'updated_at': draft.updated_at.isoformat(), }) if request.method == 'DELETE': - ProjectDraft.objects.filter(user=request.user).delete() + draft = _draft_for(request.GET.get('draft', '')) + if draft is None: + return JsonResponse({'error': 'Draft not found'}, status=404) + for temp in TempUpload.objects.filter(draft_uuid=draft.uuid): + delete_file_index(temp.file) + TempUpload.objects.filter(draft_uuid=draft.uuid).delete() + draft.delete() return JsonResponse({'success': True}) return JsonResponse({'error': 'Method not allowed'}, status=405) diff --git a/nonpacks/static/css/style.css b/nonpacks/static/css/style.css index b246a0a..aee87e3 100644 --- a/nonpacks/static/css/style.css +++ b/nonpacks/static/css/style.css @@ -3870,3 +3870,23 @@ a.deletelink { .notify-item a { color: var(--md-sys-color-on-surface, #cdd6f4); } .notify-item.notify-unread a { color: var(--ctp-mocha-blue, #1e66f5); font-weight: 600; } .notify-new { color: var(--ctp-mocha-red); font-weight: 600; } + +/* My Projects page */ +.myprojects-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; } +.myprojects-header h1 { margin: 0; } +.myprojects-layout { display: grid; grid-template-columns: 1fr 1px 1fr; gap: 24px; align-items: start; } +.myprojects-divider { background: var(--md-sys-color-outline-variant, #45475a); align-self: stretch; } +.myprojects-col h2 { margin-top: 0; font-size: 1.05rem; } +@media (max-width: 900px) { + .myprojects-layout { grid-template-columns: 1fr; } + .myprojects-divider { height: 1px; width: 100%; } +} +.draft-list { display: flex; flex-direction: column; gap: 0.6rem; } +.draft-card { + background: var(--md-sys-color-surface-variant, #45475a); + border-radius: 10px; padding: 0.7rem 0.8rem; + display: flex; flex-direction: column; gap: 0.3rem; +} +.draft-title { font-weight: 600; color: var(--md-sys-color-on-surface, #cdd6f4); text-decoration: none; } +.draft-meta { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.75rem; } +.draft-actions { display: flex; gap: 0.4rem; align-items: center; } diff --git a/nonpacks/static/js/create.js b/nonpacks/static/js/create.js index 9ff31a8..7f9fd2c 100644 --- a/nonpacks/static/js/create.js +++ b/nonpacks/static/js/create.js @@ -9,6 +9,13 @@ (document.getElementById('packs-draft-uploads') || { textContent: '[]' }).textContent ); + const draftUuidEl = document.getElementById('packs-draft-uuid'); + let draftUuid = ''; + if (draftUuidEl) { + try { draftUuid = JSON.parse(draftUuidEl.textContent || '""'); } + catch (e) { draftUuid = draftUuidEl.textContent; } + } + const autosaveStatus = document.getElementById('autosave-status'); const csrf = getCookie('csrftoken'); const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {}; @@ -50,7 +57,7 @@ xhr.onerror = function () { setStatus(' Save failed', 'error'); }; - xhr.send(JSON.stringify({ data })); + xhr.send(JSON.stringify({ draft: draftUuid, data })); } function initAutosave() { @@ -67,7 +74,7 @@ setTimeout(() => setStatus(' Saved just now', 'saved'), 2000); } - PacksUploads.init({ pending: pending, onReady: initAutosave }); + PacksUploads.init({ pending: pending, draftUuid: draftUuid, onReady: initAutosave }); function getCookie(name) { let cookieValue = null; diff --git a/nonpacks/static/js/uploads.js b/nonpacks/static/js/uploads.js index e1bf20f..9059d5d 100644 --- a/nonpacks/static/js/uploads.js +++ b/nonpacks/static/js/uploads.js @@ -21,6 +21,7 @@ function init(options) { const opts = options || {}; const pending = opts.pending || []; + const draftUuid = opts.draftUuid || ''; const csrf = getCookie('csrftoken'); const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {}; @@ -112,6 +113,7 @@ const fd = new FormData(); fd.append('file', file); fd.append('kind', z.kind); + if (draftUuid) fd.append('draft_uuid', draftUuid); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/uploads/'); diff --git a/nonpacks/templates/base.html b/nonpacks/templates/base.html index 20fb5a0..7e73476 100644 --- a/nonpacks/templates/base.html +++ b/nonpacks/templates/base.html @@ -49,8 +49,8 @@ Announcements {% if user.is_authenticated %} - - Upload + + My Projects {% endif %} {% endif %} diff --git a/nonpacks/templates/landing/home.html b/nonpacks/templates/landing/home.html index 2a3c76d..fa64057 100644 --- a/nonpacks/templates/landing/home.html +++ b/nonpacks/templates/landing/home.html @@ -12,7 +12,7 @@

Browse and download packs shared by the community. Upload your own once you have a creator account.

diff --git a/nonpacks/templates/library/my_projects.html b/nonpacks/templates/library/my_projects.html new file mode 100644 index 0000000..3459c24 --- /dev/null +++ b/nonpacks/templates/library/my_projects.html @@ -0,0 +1,47 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}My Projects - Packs Site{% endblock %} + +{% block content %} +
+

My Projects

+ New Project +
+ +
+
+

Projects

+
+ {% for project in projects %} + {% include 'library/_project_card.html' %} + {% empty %} +

You haven't created any projects yet.

+ {% endfor %} +
+
+ + + +
+

Drafts

+
+ {% for draft in drafts %} +
+ {{ draft.title }} + Edited {{ draft.updated_at|timesince }} ago +
+ Continue +
+ {% csrf_token %} + +
+
+
+ {% empty %} +

No drafts yet — start one with the "+ New Project" button.

+ {% endfor %} +
+
+
+{% endblock %} diff --git a/nonpacks/templates/library/project_create.html b/nonpacks/templates/library/project_create.html index a91b099..29710c8 100644 --- a/nonpacks/templates/library/project_create.html +++ b/nonpacks/templates/library/project_create.html @@ -5,6 +5,9 @@ {% block content %}

New Project

+

+ Back to My Projects +

{% csrf_token %} @@ -111,6 +114,7 @@ {% endblock %} {% block extra_js %} +{{ draft_uuid|json_script:'packs-draft-uuid' }} {{ draft_uploads|json_script:'packs-draft-uploads' }} {{ tag_categories|json_script:'packs-tag-categories' }}