Drafts system implemented

This commit is contained in:
JakeBreath
2026-08-05 17:16:09 -05:00
parent 203ff88af1
commit 49c4ff681f
13 changed files with 419 additions and 75 deletions
@@ -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),
),
]
@@ -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']},
),
]
+14 -5
View File
@@ -314,6 +314,7 @@ class TempUpload(models.Model):
kind = models.CharField(max_length=16, choices=KIND_CHOICES, db_index=True) kind = models.CharField(max_length=16, choices=KIND_CHOICES, db_index=True)
file = models.ForeignKey(FileIndex, on_delete=models.CASCADE, related_name='temp_upload') 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) 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) created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
@@ -334,16 +335,24 @@ class TempUpload(models.Model):
class ProjectDraft(models.Model): class ProjectDraft(models.Model):
"""Autosaved 'create project' form state. One per user; no explicit """Autosaved 'new project' form state. Many per user, each keyed by a UUID;
Save Draft button — every keystroke is persisted in real time.""" there is no explicit Save Draft button — every keystroke is persisted."""
user = models.OneToOneField( uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='project_draft', user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='project_drafts',
) )
data = models.JSONField(default=dict, blank=True) data = models.JSONField(default=dict, blank=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-updated_at', '-pk']
def __str__(self): 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): class Rating(models.Model):
+147 -25
View File
@@ -79,18 +79,21 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase):
self.bob = User.objects.create_user(username='Bob', password='pw') self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob) 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( return self.client.post(
reverse('library:api_upload_temp'), reverse('library:api_upload_temp'), data,
{'kind': kind, 'file': SimpleUploadedFile(name, content, content_type=content_type)},
HTTP_X_REQUESTED_WITH='XMLHttpRequest', HTTP_X_REQUESTED_WITH='XMLHttpRequest',
) )
def _create_project(self, user, title='Test Pack', tags='general:furry content:threesome'): def _create_project(self, user, title='Test Pack', tags='general:furry content:threesome'):
self.gate() self.gate()
self.client.login(username=user.username, password='pw') self.client.login(username=user.username, password='pw')
self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png') uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1]
self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip') 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 = { data = {
'title': title, 'title': title,
'summary': 'A test pack.', 'summary': 'A test pack.',
@@ -100,11 +103,11 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase):
'version_name': '1.0.0', 'version_name': '1.0.0',
'changelog': 'First release.', '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): def test_create_requires_login(self):
self.gate() 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) self.assertEqual(resp.status_code, 302)
def test_create_project(self): def test_create_project(self):
@@ -135,7 +138,8 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase):
def test_create_requires_version_file(self): def test_create_requires_version_file(self):
self.gate() self.gate()
self.client.login(username='Alice', password='pw') 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', 'title': 'No File',
'category': 'mod', 'category': 'mod',
'version_name': '1.0.0', 'version_name': '1.0.0',
@@ -146,16 +150,17 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase):
def test_create_adopts_media_and_captions(self): def test_create_adopts_media_and_captions(self):
self.gate() self.gate()
self.client.login(username='Alice', password='pw') self.client.login(username='Alice', password='pw')
self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png') uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1]
self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip') self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png', draft_uuid=uuid)
self._upload_temp('media', 'pic1.png', make_png_bytes(), 'image/png') self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid)
self._upload_temp('media', 'pic2.png', make_png_bytes(), 'image/png') self._upload_temp('media', 'pic1.png', make_png_bytes(), 'image/png', draft_uuid=uuid)
media = TempUpload.objects.filter(kind='media').order_by('created_at') 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 = { caption_payload = {
f'caption_{media[0].uuid}': 'Showcase', f'caption_{media[0].uuid}': 'Showcase',
f'caption_{media[1].uuid}': 'Second angle', 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', 'title': 'Media Pack',
'category': 'skin', 'category': 'skin',
'version_name': '1.0.0', 'version_name': '1.0.0',
@@ -192,6 +197,119 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase):
self.assertEqual(resp.status_code, 404) 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): class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self): def setUp(self):
User = get_user_model() User = get_user_model()
@@ -585,7 +703,8 @@ class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase):
def test_draft_save_and_restore(self): def test_draft_save_and_restore(self):
self.gate() self.gate()
self.client.login(username='Alice', password='pw') 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', 'title': 'Draft Title',
'summary': 'Draft summary', 'summary': 'Draft summary',
'category': 'mod', 'category': 'mod',
@@ -593,34 +712,36 @@ class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase):
'tags': 'species:dragon', 'tags': 'species:dragon',
'version_name': '2.0.0', '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') 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.status_code, 200)
self.assertEqual(resp.json()['data']['title'], 'Draft Title') self.assertEqual(resp.json()['data']['title'], 'Draft Title')
def test_create_form_prefills_draft(self): def test_create_form_prefills_draft(self):
self.gate() self.gate()
self.client.login(username='Alice', password='pw') 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]
resp = self.client.get(reverse('library:project_create')) 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"') self.assertContains(resp, 'value="My Draft Pack"')
def test_create_clears_draft(self): def test_create_clears_draft(self):
self.gate() self.gate()
self.client.login(username='Alice', password='pw') 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( self.client.post(
reverse('library:api_upload_temp'), 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', 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', 'title': 'My Draft Pack', 'category': 'mod', 'version_name': '1.0.0',
}) })
self.assertRedirects(resp, reverse('library:project_detail', args=['my-draft-pack'])) 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): class UGCPermissionTests(UGCMediaTestCase, UGCGatedTestCase):
@@ -1825,10 +1946,11 @@ class UGCAnimationIdTests(UGCMediaTestCase, UGCGatedTestCase):
def test_create_injects_defaults(self): def test_create_injects_defaults(self):
self.gate() self.gate()
self.client.login(username='Alice', password='pw') 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'), 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') 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', 'title': 'New Pack', 'category': 'non_pack', 'version_name': '1.0.0',
}) })
self.assertRedirects(resp, reverse('library:project_detail', args=['new-pack'])) self.assertRedirects(resp, reverse('library:project_detail', args=['new-pack']))
+4 -1
View File
@@ -6,7 +6,10 @@ app_name = 'library'
urlpatterns = [ urlpatterns = [
path('browse/', views.browse, name='browse'), 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/<uuid:uuid>/', views.project_create, name='project_create'),
path('packs/create/<uuid:uuid>/delete/', views.project_draft_delete, name='project_draft_delete'),
path('packs/<slug:slug>/', views.project_detail, name='project_detail'), path('packs/<slug:slug>/', views.project_detail, name='project_detail'),
path('packs/<slug:slug>/edit/', views.project_edit, name='project_edit'), path('packs/<slug:slug>/edit/', views.project_edit, name='project_edit'),
path('packs/<slug:slug>/edit/regenerate/', views.regenerate_metadata, name='regenerate_metadata'), path('packs/<slug:slug>/edit/regenerate/', views.regenerate_metadata, name='regenerate_metadata'),
+118 -39
View File
@@ -4,6 +4,7 @@ import posixpath
import re import re
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from uuid import UUID
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@@ -707,25 +708,31 @@ def _serialize_temp(upload):
} }
def _pending_uploads(user): def _pending_uploads(user, draft_uuid=None):
return list( qs = TempUpload.objects.filter(user=user, status='pending')
TempUpload.objects.filter(user=user, status='pending') if draft_uuid is not None:
.select_related('file').order_by('created_at') 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): def _pending_kind(user, kind, draft_uuid=None):
return list( qs = TempUpload.objects.filter(user=user, status='pending', kind=kind)
TempUpload.objects.filter(user=user, status='pending', kind=kind) if draft_uuid is not None:
.select_related('file').order_by('created_at') 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): def _latest_pending(user, kind, draft_uuid=None):
return ( qs = TempUpload.objects.filter(user=user, status='pending', kind=kind)
TempUpload.objects.filter(user=user, status='pending', kind=kind) if draft_uuid is not None:
.select_related('file').order_by('created_at').last() 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): def _adopt_temp(upload, project_pk, subdir, kind):
@@ -1123,7 +1130,48 @@ def _form_values(*forms):
@login_required @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() project_form = ProjectForm()
version_form = VersionForm() version_form = VersionForm()
@@ -1132,7 +1180,7 @@ def project_create(request):
version_form = VersionForm(request.POST) version_form = VersionForm(request.POST)
if project_form.is_valid() and version_form.is_valid(): if project_form.is_valid() and version_form.is_valid():
data = project_form.cleaned_data 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: if not version_files:
version_form.add_error('version_name', 'Upload at least one version file before creating the project.') version_form.add_error('version_name', 'Upload at least one version file before creating the project.')
else: else:
@@ -1145,7 +1193,7 @@ def project_create(request):
owner=request.user, owner=request.user,
) )
thumb = _latest_pending(request.user, 'thumbnail') thumb = _latest_pending(request.user, 'thumbnail', draft.uuid)
if thumb is not None: if thumb is not None:
project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail') project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail')
thumb.status = 'used' thumb.status = 'used'
@@ -1166,7 +1214,7 @@ def project_create(request):
messages.warning(request, ANIMATION_API_WARNING) messages.warning(request, ANIMATION_API_WARNING)
for temp in TempUpload.objects.filter( 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'): ).select_related('file').order_by('created_at'):
index = _adopt_temp(temp, project.pk, 'gallery', 'asset') index = _adopt_temp(temp, project.pk, 'gallery', 'asset')
ProjectAsset.objects.create( ProjectAsset.objects.create(
@@ -1179,24 +1227,23 @@ def project_create(request):
_apply_tags(project, data['tags'], request.user) _apply_tags(project, data['tags'], request.user)
project.sync_creator_tags(actor=request.user) project.sync_creator_tags(actor=request.user)
ProjectDraft.objects.filter(user=request.user).delete() draft.delete()
messages.success(request, 'Project created.') messages.success(request, 'Project created.')
return redirect('library:project_detail', slug=project.slug) return redirect('library:project_detail', slug=project.slug)
else: else:
draft = ProjectDraft.objects.filter(user=request.user).first() initial = {k: draft.data.get(k, '') for k in DRAFT_KEYS}
if draft: project_form = ProjectForm(initial=initial)
initial = {k: draft.data.get(k, '') for k in DRAFT_KEYS} version_form = VersionForm(
project_form = ProjectForm(initial=initial) initial={
version_form = VersionForm( k: initial[k] for k in ('version_name', 'changelog') if k in initial
initial={ },
k: initial[k] for k in ('version_name', 'changelog') if k in initial )
},
)
return render(request, 'library/project_create.html', { return render(request, 'library/project_create.html', {
'project_form': project_form, 'project_form': project_form,
'version_form': version_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), 'draft_data': _form_values(project_form, version_form),
'tag_categories': list(TagCategory.objects.order_by('slug').values('slug', 'color')), '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/'): if kind == 'thumbnail' and not content_type.startswith('image/'):
return JsonResponse({'error': 'The thumbnail must be an image.'}, status=400) return JsonResponse({'error': 'The thumbnail must be an image.'}, status=400)
# Thumbnail is a single slot: discard any earlier pending one. Version and draft_uuid_raw = request.POST.get('draft_uuid', '').strip()
# media accept multiple files (one Version can hold several files). 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': 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) delete_file_index(old.file)
old.delete() old.delete()
index = store_temp_file(request.user, kind, uploaded) 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)) return JsonResponse(_serialize_temp(upload))
@@ -1714,20 +1776,31 @@ def api_delete_temp(request, upload_uuid):
@login_required @login_required
def api_draft(request): 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': 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({ return JsonResponse({
'data': draft.data if draft else {}, 'data': draft.data,
'uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)], 'uploads': [_serialize_temp(u) for u in _pending_uploads(request.user, draft.uuid)],
}) })
if request.method == 'POST': if request.method == 'POST':
try: try:
payload = json.loads(request.body or b'{}') payload = json.loads(request.body or b'{}')
except json.JSONDecodeError: except json.JSONDecodeError:
payload = {} payload = {}
draft = _draft_for(payload.get('draft', ''))
if draft is None:
return JsonResponse({'error': 'Draft not found'}, status=404)
data = payload.get('data') or {} data = payload.get('data') or {}
clean = {k: str(data.get(k, '')) for k in DRAFT_KEYS if k in data} 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.data = clean
draft.save() draft.save()
return JsonResponse({ return JsonResponse({
@@ -1735,6 +1808,12 @@ def api_draft(request):
'updated_at': draft.updated_at.isoformat(), 'updated_at': draft.updated_at.isoformat(),
}) })
if request.method == 'DELETE': 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({'success': True})
return JsonResponse({'error': 'Method not allowed'}, status=405) return JsonResponse({'error': 'Method not allowed'}, status=405)
+20
View File
@@ -3870,3 +3870,23 @@ a.deletelink {
.notify-item a { color: var(--md-sys-color-on-surface, #cdd6f4); } .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-item.notify-unread a { color: var(--ctp-mocha-blue, #1e66f5); font-weight: 600; }
.notify-new { color: var(--ctp-mocha-red); 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; }
+9 -2
View File
@@ -9,6 +9,13 @@
(document.getElementById('packs-draft-uploads') || { textContent: '[]' }).textContent (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 autosaveStatus = document.getElementById('autosave-status');
const csrf = getCookie('csrftoken'); const csrf = getCookie('csrftoken');
const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {}; const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {};
@@ -50,7 +57,7 @@
xhr.onerror = function () { xhr.onerror = function () {
setStatus('<i class="fas fa-exclamation-triangle"></i> Save failed', 'error'); setStatus('<i class="fas fa-exclamation-triangle"></i> Save failed', 'error');
}; };
xhr.send(JSON.stringify({ data })); xhr.send(JSON.stringify({ draft: draftUuid, data }));
} }
function initAutosave() { function initAutosave() {
@@ -67,7 +74,7 @@
setTimeout(() => setStatus('<i class="fas fa-check"></i> Saved just now', 'saved'), 2000); setTimeout(() => setStatus('<i class="fas fa-check"></i> Saved just now', 'saved'), 2000);
} }
PacksUploads.init({ pending: pending, onReady: initAutosave }); PacksUploads.init({ pending: pending, draftUuid: draftUuid, onReady: initAutosave });
function getCookie(name) { function getCookie(name) {
let cookieValue = null; let cookieValue = null;
+2
View File
@@ -21,6 +21,7 @@
function init(options) { function init(options) {
const opts = options || {}; const opts = options || {};
const pending = opts.pending || []; const pending = opts.pending || [];
const draftUuid = opts.draftUuid || '';
const csrf = getCookie('csrftoken'); const csrf = getCookie('csrftoken');
const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {}; const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {};
@@ -112,6 +113,7 @@
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
fd.append('kind', z.kind); fd.append('kind', z.kind);
if (draftUuid) fd.append('draft_uuid', draftUuid);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/uploads/'); xhr.open('POST', '/api/uploads/');
+2 -2
View File
@@ -49,8 +49,8 @@
<i class="fas fa-bullhorn"></i><span class="nav-text"> Announcements</span> <i class="fas fa-bullhorn"></i><span class="nav-text"> Announcements</span>
</a> </a>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="{% url 'library:project_create' %}"> <a href="{% url 'library:my_projects' %}">
<i class="fas fa-upload"></i><span class="nav-text"> Upload</span> <i class="fas fa-folder-open"></i><span class="nav-text"> My Projects</span>
</a> </a>
{% endif %} {% endif %}
{% endif %} {% endif %}
+1 -1
View File
@@ -12,7 +12,7 @@
<p class="home-hero-sub">Browse and download packs shared by the community. Upload your own once you have a creator account.</p> <p class="home-hero-sub">Browse and download packs shared by the community. Upload your own once you have a creator account.</p>
<div class="home-hero-actions"> <div class="home-hero-actions">
<a href="{% url 'library:browse' %}" class="btn btn-primary"><i class="fas fa-search"></i> Browse packs</a> <a href="{% url 'library:browse' %}" class="btn btn-primary"><i class="fas fa-search"></i> Browse packs</a>
<a href="{% url 'library:project_create' %}" class="btn btn-secondary"><i class="fas fa-upload"></i> Upload a pack</a> <a href="{% url 'library:my_projects' %}" class="btn btn-secondary"><i class="fas fa-folder-open"></i> My Projects</a>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,47 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}My Projects - Packs Site{% endblock %}
{% block content %}
<div class="myprojects-header">
<h1><i class="fas fa-folder-open"></i> My Projects</h1>
<a href="{% url 'library:project_create_new' %}" class="btn btn-primary"><i class="fas fa-plus"></i> New Project</a>
</div>
<div class="myprojects-layout">
<section class="myprojects-col">
<h2><i class="fas fa-box-open"></i> Projects</h2>
<div class="pack-grid">
{% for project in projects %}
{% include 'library/_project_card.html' %}
{% empty %}
<p class="empty-hint">You haven't created any projects yet.</p>
{% endfor %}
</div>
</section>
<div class="myprojects-divider" role="separator"></div>
<section class="myprojects-col">
<h2><i class="fas fa-pen-nib"></i> Drafts</h2>
<div class="draft-list">
{% for draft in drafts %}
<div class="draft-card">
<a href="{% url 'library:project_create' draft.uuid %}" class="draft-title">{{ draft.title }}</a>
<span class="draft-meta">Edited {{ draft.updated_at|timesince }} ago</span>
<div class="draft-actions">
<a href="{% url 'library:project_create' draft.uuid %}" class="btn btn-secondary btn-sm"><i class="fas fa-pen"></i> Continue</a>
<form method="post" action="{% url 'library:project_draft_delete' draft.uuid %}" class="inline" onsubmit="return confirm('Delete this draft and its uploads?');">
{% csrf_token %}
<button type="submit" class="btn btn-secondary btn-sm" title="Delete draft"><i class="fas fa-trash"></i></button>
</form>
</div>
</div>
{% empty %}
<p class="empty-hint">No drafts yet — start one with the "+ New Project" button.</p>
{% endfor %}
</div>
</section>
</div>
{% endblock %}
@@ -5,6 +5,9 @@
{% block content %} {% block content %}
<h1><i class="fas fa-plus-circle"></i> New Project</h1> <h1><i class="fas fa-plus-circle"></i> New Project</h1>
<p class="bio-help">
<a href="{% url 'library:my_projects' %}"><i class="fas fa-arrow-left"></i> Back to My Projects</a>
</p>
<form method="post" class="card project-form create-form" id="project-form"> <form method="post" class="card project-form create-form" id="project-form">
{% csrf_token %} {% csrf_token %}
@@ -111,6 +114,7 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{{ draft_uuid|json_script:'packs-draft-uuid' }}
{{ draft_uploads|json_script:'packs-draft-uploads' }} {{ draft_uploads|json_script:'packs-draft-uploads' }}
{{ tag_categories|json_script:'packs-tag-categories' }} {{ tag_categories|json_script:'packs-tag-categories' }}
<script src="{% static 'js/tags.js' %}"></script> <script src="{% static 'js/tags.js' %}"></script>