Drafts system implemented
This commit is contained in:
@@ -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']},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
@@ -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']))
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
+111
-32
@@ -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,12 +1227,10 @@ 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()
|
|
||||||
if draft:
|
|
||||||
initial = {k: draft.data.get(k, '') for k in DRAFT_KEYS}
|
initial = {k: draft.data.get(k, '') for k in DRAFT_KEYS}
|
||||||
project_form = ProjectForm(initial=initial)
|
project_form = ProjectForm(initial=initial)
|
||||||
version_form = VersionForm(
|
version_form = VersionForm(
|
||||||
@@ -1196,7 +1242,8 @@ def project_create(request):
|
|||||||
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)
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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/');
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user