fixed implementation of UGC content uploader

This commit is contained in:
2026-08-03 18:57:39 -05:00
parent 39e31d3980
commit 473b3c811b
40 changed files with 4137 additions and 87 deletions
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class CommonConfig(AppConfig):
name = 'common'
+14
View File
@@ -0,0 +1,14 @@
from markdown_it import MarkdownIt
_markdown = MarkdownIt('js-default', {'html': False, 'breaks': True})
def render_markdown(text):
"""Render markdown text to safe HTML.
Raw HTML is escaped and dangerous link schemes (javascript:, data:,
vbscript:) are rejected by markdown-it with html=False.
"""
if not text:
return ''
return _markdown.render(text).rstrip('\n')
+1
View File
@@ -38,6 +38,7 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'common',
'api', 'api',
'landing', 'landing',
'library', 'library',
+11
View File
@@ -0,0 +1,11 @@
from django import template
from django.utils.safestring import mark_safe
from common.markdown import render_markdown
register = template.Library()
@register.filter
def markdown(text):
return mark_safe(render_markdown(text))
+1 -1
View File
@@ -7,6 +7,6 @@ from django.urls import include, path
urlpatterns = [ urlpatterns = [
path('', include('landing.urls')), path('', include('landing.urls')),
path('', include('profiles.urls')), path('', include('profiles.urls')),
path('api/', include('library.urls')), path('', include('library.urls')),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
] ]
+22 -1
View File
@@ -3,9 +3,12 @@ import random
import time import time
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models import Sum
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from common.middleware import gate_whitelist from common.middleware import gate_whitelist
from library.models import Project, Version
def _under_lockout(request): def _under_lockout(request):
@@ -47,4 +50,22 @@ def gate(request):
def home(request): def home(request):
return render(request, 'landing/home.html') latest_projects = (
Project.objects.select_related('owner', 'thumbnail')
.prefetch_related('tag_links__tag__category', 'versions')
.order_by('-created_at')[:4]
)
total_downloads = Version.objects.aggregate(total=Sum('downloads'))['total'] or 0
creators = (
get_user_model().objects.filter(projects__isnull=False).distinct().count()
)
context = {
'latest_projects': latest_projects,
'stats': {
'creators': creators,
'packs': Project.objects.count(),
'downloads': total_downloads,
'versions': Version.objects.count(),
},
}
return render(request, 'landing/home.html', context)
+57 -1
View File
@@ -1,3 +1,59 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from .models import (
FileIndex,
Project,
ProjectAsset,
ProjectContributor,
Tag,
TagCategory,
TagList,
Version,
)
@admin.register(FileIndex)
class FileIndexAdmin(admin.ModelAdmin):
list_display = ('uuid', 'kind', 'owner', 'original_filename', 'size', 'created_at')
search_fields = ('uuid', 'original_filename', 'stored_path')
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ('slug', 'title', 'category', 'owner', 'created_at', 'updated_at')
search_fields = ('slug', 'title', 'summary')
list_filter = ('category', 'created_at')
@admin.register(Version)
class VersionAdmin(admin.ModelAdmin):
list_display = ('project', 'version_name', 'downloads', 'created_at')
search_fields = ('project__title', 'version_name')
@admin.register(ProjectAsset)
class ProjectAssetAdmin(admin.ModelAdmin):
list_display = ('project', 'caption', 'uploaded_by', 'created_at')
@admin.register(ProjectContributor)
class ProjectContributorAdmin(admin.ModelAdmin):
list_display = ('project', 'user', 'added_by', 'added_at')
@admin.register(TagCategory)
class TagCategoryAdmin(admin.ModelAdmin):
list_display = ('slug', 'name', 'color')
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ('name', 'category', 'created_by', 'created_at')
list_filter = ('category',)
search_fields = ('name',)
@admin.register(TagList)
class TagListAdmin(admin.ModelAdmin):
list_display = ('project', 'tag', 'added_by', 'added_at')
search_fields = ('project__title', 'tag__name')
+64
View File
@@ -0,0 +1,64 @@
from django import forms
from .models import Project
class ProjectForm(forms.Form):
title = forms.CharField(
max_length=128,
widget=forms.TextInput(attrs={'placeholder': 'Project title'}),
)
summary = forms.CharField(
max_length=255,
required=False,
widget=forms.TextInput(attrs={'placeholder': 'One-line summary shown on cards'}),
)
category = forms.ChoiceField(choices=Project.CATEGORY_CHOICES)
description = forms.CharField(
required=False,
widget=forms.Textarea(attrs={
'rows': 8,
'placeholder': 'Write the description in Markdown…',
}),
)
thumbnail = forms.ImageField(required=False)
caption = forms.CharField(
max_length=128,
required=False,
widget=forms.TextInput(attrs={'placeholder': 'Optional caption for the gallery media'}),
)
tags = forms.CharField(
required=False,
widget=forms.HiddenInput(),
label='',
)
class VersionForm(forms.Form):
def __init__(self, *args, required_file=True, **kwargs):
# The create flow uploads the version file out-of-band (temp upload),
# so there the `file` field is optional in the bound form.
super().__init__(*args, **kwargs)
if not required_file:
self.fields['file'].required = False
version_name = forms.CharField(
max_length=64,
label='Version',
widget=forms.TextInput(attrs={'placeholder': 'e.g. 1.0.0'}),
)
file = forms.FileField(label='File')
changelog = forms.CharField(
required=False,
widget=forms.Textarea(attrs={
'rows': 4,
'placeholder': 'What changed in this version? (Markdown)',
}),
)
class ContributorForm(forms.Form):
username = forms.CharField(
max_length=150,
widget=forms.TextInput(attrs={'placeholder': 'Start typing a username…', 'autocomplete': 'off'}),
)
@@ -0,0 +1,110 @@
# Generated by Django 6.0.3 on 2026-08-03 20:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TagCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=64, unique=True)),
('name', models.CharField(max_length=64)),
('color', models.CharField(default='#666666', max_length=7)),
],
),
migrations.CreateModel(
name='Project',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=128, unique=True)),
('title', models.CharField(max_length=128)),
('summary', models.CharField(blank=True, default='', max_length=255)),
('description', models.TextField(blank=True, default='')),
('category', models.CharField(choices=[('skin', 'Skin'), ('non_pack', 'NoN Pack'), ('model', 'Model'), ('guide', 'Guide'), ('mod', 'Mod'), ('modpack', 'Modpack')], default='mod', max_length=32)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to=settings.AUTH_USER_MODEL)),
('thumbnail', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_thumb', to='library.fileindex')),
],
),
migrations.CreateModel(
name='ProjectAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('caption', models.CharField(blank=True, default='', max_length=128)),
('created_at', models.DateTimeField(auto_now_add=True)),
('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_asset', to='library.fileindex')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='library.project')),
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-pk'],
},
),
migrations.CreateModel(
name='Tag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_tags', to=settings.AUTH_USER_MODEL)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='library.tagcategory')),
],
),
migrations.CreateModel(
name='TagList',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('added_at', models.DateTimeField(auto_now_add=True)),
('added_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tag_links', to='library.project')),
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_links', to='library.tag')),
],
),
migrations.CreateModel(
name='Version',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version_name', models.CharField(max_length=64)),
('changelog', models.TextField(blank=True, default='')),
('downloads', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_version', to='library.fileindex')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='library.project')),
],
options={
'ordering': ['-created_at', '-pk'],
},
),
migrations.CreateModel(
name='ProjectContributor',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('added_at', models.DateTimeField(auto_now_add=True)),
('added_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contributors', to='library.project')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ugc_contributions', to=settings.AUTH_USER_MODEL)),
],
options={
'constraints': [models.UniqueConstraint(fields=('project', 'user'), name='uniq_project_contributor')],
},
),
migrations.AddConstraint(
model_name='tag',
constraint=models.UniqueConstraint(fields=('name', 'category'), name='uniq_tag_name_category'),
),
migrations.AddConstraint(
model_name='taglist',
constraint=models.UniqueConstraint(fields=('project', 'tag'), name='uniq_project_tag'),
),
]
@@ -0,0 +1,33 @@
from django.db import migrations
CATEGORIES = [
('loader', 'Loader', '#3a86ff'),
('version', 'Version', '#8338ec'),
('general', 'General', '#666666'),
('creator', 'Creator', '#fb5607'),
('content', 'Content', '#d62828'),
('species', 'Species', '#06a77d'),
('non_version', 'NoN Version', '#9d4edd'),
]
def seed_categories(apps, schema_editor):
TagCategory = apps.get_model('library', 'TagCategory')
for slug, name, color in CATEGORIES:
TagCategory.objects.update_or_create(slug=slug, defaults={'name': name, 'color': color})
def unseed_categories(apps, schema_editor):
TagCategory = apps.get_model('library', 'TagCategory')
TagCategory.objects.filter(slug__in=[c[0] for c in CATEGORIES]).delete()
class Migration(migrations.Migration):
dependencies = [
('library', '0002_tagcategory_project_projectasset_tag_taglist_version_and_more'),
]
operations = [
migrations.RunPython(seed_categories, unseed_categories),
]
@@ -0,0 +1,41 @@
# Generated by Django 6.0.3 on 2026-08-03 23:20
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0003_seed_tag_categories'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ProjectDraft',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('data', models.JSONField(blank=True, default=dict)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='project_draft', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='TempUpload',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('kind', models.CharField(choices=[('thumbnail', 'Thumbnail'), ('version', 'Version file'), ('media', 'Gallery media')], db_index=True, max_length=16)),
('status', models.CharField(choices=[('pending', 'Pending'), ('used', 'Used'), ('discarded', 'Discarded')], db_index=True, default='pending', max_length=16)),
('created_at', models.DateTimeField(auto_now_add=True)),
('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='temp_upload', to='library.fileindex')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='temp_uploads', to=settings.AUTH_USER_MODEL)),
],
options={
'indexes': [models.Index(fields=['user', 'status', 'kind'], name='library_tem_user_id_fb5c3f_idx')],
},
),
]
+261
View File
@@ -1,7 +1,17 @@
import re
import uuid import uuid
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.urls import reverse
def slugify_tag(value):
"""Normalize a user-entered tag name to its canonical form."""
value = (value or '').strip().lower()
value = re.sub(r'\s+', '_', value)
value = re.sub(r'[^a-z0-9_.+-]', '', value)
return value.strip('_')
class FileIndex(models.Model): class FileIndex(models.Model):
@@ -35,3 +45,254 @@ class FileIndex(models.Model):
@property @property
def file_path(self): def file_path(self):
return self.stored_path return self.stored_path
class TagCategory(models.Model):
"""Tag namespaces: Loader, Version, General, Creator, Content, Species, NoN Version."""
slug = models.SlugField(max_length=64, unique=True)
name = models.CharField(max_length=64)
color = models.CharField(max_length=7, default='#666666')
def __str__(self):
return self.name
class Tag(models.Model):
"""A single tag within a category. Auto-created on first use (e621-style)."""
name = models.CharField(max_length=64)
category = models.ForeignKey(
TagCategory,
on_delete=models.CASCADE,
related_name='tags',
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_tags',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['name', 'category'], name='uniq_tag_name_category'),
]
def __str__(self):
return f'{self.category.slug}:{self.name}'
class Project(models.Model):
"""A UGC page: versioned downloads (tracked) + gallery media (untracked)."""
CATEGORY_CHOICES = [
('skin', 'Skin'),
('non_pack', 'NoN Pack'),
('model', 'Model'),
('guide', 'Guide'),
('mod', 'Mod'),
('modpack', 'Modpack'),
]
slug = models.SlugField(max_length=128, unique=True)
title = models.CharField(max_length=128)
summary = models.CharField(max_length=255, blank=True, default='')
description = models.TextField(blank=True, default='')
category = models.CharField(max_length=32, choices=CATEGORY_CHOICES, default='mod')
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='projects',
)
thumbnail = models.OneToOneField(
FileIndex,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='project_thumb',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
@property
def thumbnail_url(self):
if self.thumbnail_id is None:
return None
return reverse('library:file_request', args=[self.thumbnail.uuid])
@property
def downloads(self):
return self.versions.aggregate(total=models.Sum('downloads'))['total'] or 0
def can_edit(self, user):
if not user.is_authenticated:
return False
if user.pk == self.owner_id:
return True
return self.contributors.filter(user=user).exists()
def sync_creator_tags(self, actor=None):
"""Ensure Creator tags exist + are attached for the owner and contributors."""
creator_cat = TagCategory.objects.filter(slug='creator').first()
if creator_cat is None:
return
actor = actor or self.owner
user_ids = {self.owner_id}
user_ids.update(self.contributors.values_list('user_id', flat=True))
for user_id in user_ids:
user = self.owner.__class__.objects.filter(pk=user_id).first()
if user is None:
continue
name = slugify_tag(user.username)
if not name:
continue
tag, _ = Tag.objects.get_or_create(
name=name, category=creator_cat, defaults={'created_by': user},
)
TagList.objects.get_or_create(project=self, tag=tag, defaults={'added_by': actor})
def detach_creator_tag(self, user):
creator_cat = TagCategory.objects.filter(slug='creator').first()
if creator_cat is None:
return
TagList.objects.filter(
project=self, tag__category=creator_cat, tag__name=slugify_tag(user.username),
).delete()
class ProjectContributor(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='contributors')
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='ugc_contributions',
)
added_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='+',
)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['project', 'user'], name='uniq_project_contributor'),
]
def __str__(self):
return f'{self.user.username} on {self.project.title}'
class TagList(models.Model):
"""Tag <-> Project link. Also records who applied the tag."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='tag_links')
tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name='project_links')
added_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='+',
)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['project', 'tag'], name='uniq_project_tag'),
]
def __str__(self):
return f'{self.project.title} #{self.tag}'
class Version(models.Model):
"""A tracked-file release. Every upload is stored; latest = newest created_at."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='versions')
version_name = models.CharField(max_length=64)
file = models.ForeignKey(
FileIndex, on_delete=models.SET_NULL, null=True, blank=True, related_name='project_version',
)
changelog = models.TextField(blank=True, default='')
downloads = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at', '-pk']
def __str__(self):
return f'{self.project.title} {self.version_name}'
class ProjectAsset(models.Model):
"""Untracked gallery media (image/video/gif) displayed on the project page."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='assets')
file = models.ForeignKey(FileIndex, on_delete=models.CASCADE, related_name='project_asset')
caption = models.CharField(max_length=128, blank=True, default='')
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='+',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at', '-pk']
def __str__(self):
return f'{self.project.title} asset'
@property
def file_url(self):
return reverse('library:file_request', args=[self.file.uuid])
@property
def is_video(self):
return self.file.content_type.startswith('video/')
class TempUpload(models.Model):
"""J621-style draft upload. Stored under media/uploads/user_<id>/<uuid><ext>
and tracked here until the project/gallery accepts it (status 'used') or it
is discarded. Previews are served through the gated file_request endpoint."""
KIND_CHOICES = [
('thumbnail', 'Thumbnail'),
('version', 'Version file'),
('media', 'Gallery media'),
]
STATUS_CHOICES = [
('pending', 'Pending'),
('used', 'Used'),
('discarded', 'Discarded'),
]
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='temp_uploads',
)
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)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['user', 'status', 'kind']),
]
def __str__(self):
return f'{self.kind} {self.uuid} ({self.status})'
@property
def url(self):
return reverse('library:file_request', args=[self.file.uuid])
@property
def is_video(self):
return self.file.content_type.startswith('video/')
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',
)
data = models.JSONField(default=dict, blank=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f'Draft for {self.user.username}'
+101
View File
@@ -0,0 +1,101 @@
"""Shared file-persistence helpers for UGC uploads.
Stored under MEDIA_ROOT/project_<pk>/... mirroring the avatar layout
(avatars/user_<id>/picture.jpeg). Draft uploads live under
MEDIA_ROOT/uploads/user_<id>/ until a project adopts them. All files are
indexed in FileIndex and served only through the gated file_request endpoint.
"""
import hashlib
import os
import uuid
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from .models import FileIndex
def _compute_md5_size(uploaded):
md5 = hashlib.md5()
size = 0
uploaded.seek(0)
while True:
chunk = uploaded.read(1024 * 1024)
if not chunk:
break
md5.update(chunk)
size += len(chunk)
uploaded.seek(0)
return md5.hexdigest(), size
def store_file(owner, kind, uploaded, project_pk, subdir='', original_filename=''):
"""Persist an uploaded file and index it.
Returns the FileIndex row. ``subdir`` is e.g. 'versions' or 'gallery'.
"""
name = getattr(uploaded, 'name', '') or original_filename or 'file'
content_type = getattr(uploaded, 'content_type', '') or 'application/octet-stream'
md5, size = _compute_md5_size(uploaded)
ext = os.path.splitext(name)[1] or ''
if subdir:
stored_path = f'project_{project_pk}/{subdir}/{uuid.uuid4().hex}{ext}'
else:
stored_path = f'project_{project_pk}/{uuid.uuid4().hex}{ext}'
actual_path = default_storage.save(stored_path, ContentFile(uploaded.read()))
return FileIndex.objects.create(
owner=owner,
kind=kind,
stored_path=actual_path,
original_filename=name,
content_type=content_type,
size=size,
md5=md5,
)
def store_temp_file(owner, kind, uploaded):
"""Persist a draft upload under uploads/user_<id>/ and return its FileIndex."""
name = getattr(uploaded, 'name', '') or 'file'
content_type = getattr(uploaded, 'content_type', '') or 'application/octet-stream'
md5, size = _compute_md5_size(uploaded)
ext = os.path.splitext(name)[1] or ''
stored_path = f'uploads/user_{owner.pk}/{uuid.uuid4().hex}{ext}'
actual_path = default_storage.save(stored_path, ContentFile(uploaded.read()))
return FileIndex.objects.create(
owner=owner,
kind=f'temp_{kind}',
stored_path=actual_path,
original_filename=name,
content_type=content_type,
size=size,
md5=md5,
)
def move_file_index(file_index, new_stored_path, kind=None):
"""Move an indexed file to a new stored path (chunked copy + delete old),
updating the index. Used when a project adopts a temp upload."""
if file_index.stored_path and file_index.stored_path != new_stored_path:
with default_storage.open(file_index.stored_path, 'rb') as src:
actual_path = default_storage.save(new_stored_path, src)
if actual_path != file_index.stored_path and default_storage.exists(file_index.stored_path):
default_storage.delete(file_index.stored_path)
file_index.stored_path = actual_path
if kind:
file_index.kind = kind
file_index.save(update_fields=['stored_path'] if not kind else ['stored_path', 'kind'])
return file_index
def delete_file_index(file_index):
"""Remove an indexed file from disk and DB (safe no-op when None)."""
if file_index is None:
return
if file_index.stored_path and default_storage.exists(file_index.stored_path):
default_storage.delete(file_index.stored_path)
file_index.delete()
+732 -2
View File
@@ -1,3 +1,733 @@
from django.test import TestCase import io
import secrets
import shutil
import tempfile
# Create your tests here. from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from profiles.models import ApiToken, UserProfile
from .models import (
Project,
ProjectAsset,
ProjectContributor,
ProjectDraft,
Tag,
TagCategory,
TagList,
TempUpload,
Version,
)
class UGCMediaTestCase(TestCase):
"""Base class that redirects uploaded media to a temp directory."""
@classmethod
def setUpClass(cls):
cls._media_root = tempfile.mkdtemp(prefix='packs_ugc_test_media_')
cls.override = override_settings(MEDIA_ROOT=cls._media_root)
cls.override.enable()
super().setUpClass()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
cls.override.disable()
shutil.rmtree(cls._media_root, ignore_errors=True)
class UGCGatedTestCase(TestCase):
"""Base class with a helper to authorize the client past the gate."""
def gate(self):
session = self.client.session
session['authorized'] = True
session.save()
def make_png_bytes(size=(8, 8), color='red'):
import PIL.Image
buf = io.BytesIO()
PIL.Image.new('RGB', size, color).save(buf, format='PNG')
return buf.getvalue()
def make_zip_bytes():
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('pack.mcmeta', '{"pack": {"pack_format": 15}}')
return buf.getvalue()
class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase):
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 _upload_temp(self, kind, name, content, content_type):
return self.client.post(
reverse('library:api_upload_temp'),
{'kind': kind, 'file': SimpleUploadedFile(name, content, content_type=content_type)},
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')
data = {
'title': title,
'summary': 'A test pack.',
'category': 'mod',
'description': '# Hello\n\nWorld.',
'tags': tags,
'version_name': '1.0.0',
'changelog': 'First release.',
}
return self.client.post(reverse('library:project_create'), data)
def test_create_requires_login(self):
self.gate()
resp = self.client.get(reverse('library:project_create'))
self.assertEqual(resp.status_code, 302)
def test_create_project(self):
self.gate()
resp = self._create_project(self.alice)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
project = Project.objects.get(slug='test-pack')
self.assertEqual(project.owner, self.alice)
self.assertEqual(project.category, 'mod')
# First version + thumbnail stored and adopted (moved under project_<pk>/).
self.assertEqual(project.versions.count(), 1)
self.assertIsNotNone(project.thumbnail)
self.assertTrue(project.thumbnail.stored_path.startswith('project_'))
self.assertTrue(project.versions.first().file.stored_path.startswith('project_'))
# Temp uploads marked used.
self.assertEqual(
TempUpload.objects.filter(user=self.alice, status='used').count(), 2
)
# Tags auto-created and linked.
names = set(TagList.objects.filter(project=project).values_list('tag__name', flat=True))
self.assertIn('furry', names)
self.assertIn('threesome', names)
self.assertIn('alice', names) # creator tag auto-synced
# Creator tag exists with category creator.
creator_cat = TagCategory.objects.get(slug='creator')
self.assertTrue(Tag.objects.filter(name='alice', category=creator_cat).exists())
def test_create_requires_version_file(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:project_create'), {
'title': 'No File',
'category': 'mod',
'version_name': '1.0.0',
})
self.assertEqual(resp.status_code, 200)
self.assertFalse(Project.objects.filter(slug='no-file').exists())
def test_create_adopts_media_and_caption(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')
resp = self.client.post(reverse('library:project_create'), {
'title': 'Media Pack',
'category': 'skin',
'version_name': '1.0.0',
'caption': 'Showcase',
})
self.assertRedirects(resp, reverse('library:project_detail', args=['media-pack']))
project = Project.objects.get(slug='media-pack')
self.assertEqual(project.assets.count(), 2)
self.assertEqual(project.assets.first().caption, 'Showcase')
for asset in project.assets.all():
self.assertTrue(asset.file.stored_path.startswith('project_'))
def test_detail_requires_gate(self):
self._create_project(self.alice)
self.client.logout()
resp = self.client.get(reverse('library:project_detail', args=['test-pack']))
self.assertEqual(resp.status_code, 302) # gate redirects HTML pages
def test_detail_gated_ok(self):
self._create_project(self.alice)
self.client.logout()
self.gate()
resp = self.client.get(reverse('library:project_detail', args=['test-pack']))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'Test Pack')
self.assertContains(resp, '<h1>Hello</h1>', html=True)
def test_detail_unknown_404(self):
self.gate()
resp = self.client.get(reverse('library:project_detail', args=['nope']))
self.assertEqual(resp.status_code, 404)
class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase):
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)
self.project = Project.objects.create(
slug='test-pack', title='Test Pack', category='mod', owner=self.alice,
)
self.project.sync_creator_tags()
def test_upload_version_owner(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{
'version_name': '2.0.0',
'changelog': 'More stuff.',
'file': SimpleUploadedFile('pack2.zip', make_zip_bytes(), content_type='application/zip'),
},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.assertEqual(self.project.versions.count(), 1)
self.assertEqual(self.project.versions.first().version_name, '2.0.0')
# Newest is latest (ordering).
self.assertTrue(self.project.versions.first().file_id)
def test_upload_version_non_contributor_denied(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{
'version_name': '2.0.0',
'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
},
)
self.assertEqual(resp.status_code, 403)
self.assertEqual(self.project.versions.count(), 0)
def test_contributor_can_upload(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.project.sync_creator_tags()
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{
'version_name': '2.0.0',
'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
},
)
self.assertEqual(resp.status_code, 302)
self.assertEqual(self.project.versions.count(), 1)
def test_download_bumps_counter_and_serves(self):
from library.storage import store_file
index = store_file(
self.alice, 'version',
SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
self.project.pk, subdir='versions',
)
version = Version.objects.create(project=self.project, version_name='1.0.0', file=index)
self.gate()
resp = self.client.get(reverse('library:version_download', args=['test-pack', version.pk]))
self.assertEqual(resp.status_code, 302)
version.refresh_from_db()
self.assertEqual(version.downloads, 1)
# Follow redirect → file served.
resp = self.client.get(resp.url)
self.assertEqual(resp.status_code, 200)
self.assertEqual(b''.join(resp.streaming_content), make_zip_bytes())
class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase):
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)
self.project = Project.objects.create(
slug='test-pack', title='Test Pack', category='skin', owner=self.alice,
)
def test_upload_image_asset(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
resp = self.client.post(reverse('library:asset_upload', args=['test-pack']), {'caption': 'Preview'})
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
asset = ProjectAsset.objects.get()
self.assertEqual(asset.caption, 'Preview')
self.assertEqual(asset.uploaded_by, self.alice)
self.assertFalse(asset.is_video)
def test_reject_non_media_file(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('evil.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 400)
self.assertEqual(ProjectAsset.objects.count(), 0)
def test_gallery_upload_requires_pending_media(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:asset_upload', args=['test-pack']), {'caption': 'none'})
self.assertEqual(resp.status_code, 200)
self.assertEqual(ProjectAsset.objects.count(), 0)
def test_asset_delete_removes_index(self):
from library.storage import store_file
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='gallery',
)
asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:asset_delete', args=['test-pack', asset.pk]))
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.assertEqual(ProjectAsset.objects.count(), 0)
self.assertFalse(Project.objects.filter(pk=self.project.pk).exists() is False) # project intact
self.assertTrue(Project.objects.filter(pk=self.project.pk).exists())
def test_asset_delete_non_contributor_denied(self):
from library.storage import store_file
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='gallery',
)
asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(reverse('library:asset_delete', args=['test-pack', asset.pk]))
self.assertEqual(resp.status_code, 403)
self.assertEqual(ProjectAsset.objects.count(), 1)
class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase):
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)
def _post_json(self, url, payload):
import json
return self.client.post(url, json.dumps(payload), content_type='application/json')
def test_upload_api_requires_auth(self):
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 401)
def test_temp_upload_media_accepts_video(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()['uuid'])
upload = TempUpload.objects.get()
self.assertEqual(upload.kind, 'media')
self.assertEqual(upload.status, 'pending')
self.assertTrue(upload.file.stored_path.startswith('uploads/user_'))
def test_temp_upload_thumbnail_rejects_video(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 400)
self.assertEqual(TempUpload.objects.count(), 0)
def test_thumbnail_and_version_are_single_slot(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('b.png', make_png_bytes(color='blue'), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('v1.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('v2.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
# Only the latest thumbnail + version remain; media unaffected.
self.assertEqual(
TempUpload.objects.filter(kind='thumbnail', status='pending').count(), 1
)
self.assertEqual(
TempUpload.objects.filter(kind='version', status='pending').count(), 1
)
def test_temp_upload_delete(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
upload = TempUpload.objects.get()
resp = self.client.post(
reverse('library:api_delete_temp', args=[upload.uuid]), HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(TempUpload.objects.count(), 0)
def test_delete_others_upload_forbidden(self):
User = get_user_model()
bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=bob)
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
upload = TempUpload.objects.get()
self.client.logout()
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:api_delete_temp', args=[upload.uuid]), HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 404)
self.assertEqual(TempUpload.objects.count(), 1)
def test_draft_save_and_restore(self):
self.gate()
self.client.login(username='Alice', password='pw')
self._post_json(reverse('library:api_draft'), {'data': {
'title': 'Draft Title',
'summary': 'Draft summary',
'category': 'mod',
'description': 'Hello draft',
'tags': 'species:dragon',
'version_name': '2.0.0',
}})
draft = ProjectDraft.objects.get(user=self.alice)
self.assertEqual(draft.data['title'], 'Draft Title')
resp = self.client.get(reverse('library:api_draft'))
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'))
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'}})
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
resp = self.client.post(reverse('library:project_create'), {
'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())
class UGCPermissionTests(UGCMediaTestCase, UGCGatedTestCase):
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)
self.carol = User.objects.create_user(username='Carol', password='pw')
UserProfile.objects.get_or_create(user=self.carol)
self.project = Project.objects.create(
slug='test-pack', title='Test Pack', category='mod', owner=self.alice,
)
def test_edit_owner_ok(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:project_edit', args=['test-pack']),
{
'title': 'Renamed Pack',
'summary': 'New summary',
'description': 'Updated.',
'category': 'mod',
},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.project.refresh_from_db()
self.assertEqual(self.project.title, 'Renamed Pack')
def test_edit_non_contributor_denied(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:project_edit', args=['test-pack']),
{'title': 'Hijacked', 'category': 'mod'},
)
self.assertEqual(resp.status_code, 403)
self.project.refresh_from_db()
self.assertEqual(self.project.title, 'Test Pack')
def test_edit_contributor_ok(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:project_edit', args=['test-pack']),
{
'title': 'Test Pack',
'summary': 'Edits by contributor.',
'description': 'Changed.',
'category': 'mod',
},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.project.refresh_from_db()
self.assertEqual(self.project.summary, 'Edits by contributor.')
def test_delete_owner_only(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(reverse('library:project_delete', args=['test-pack']))
self.assertEqual(resp.status_code, 403)
self.assertTrue(Project.objects.filter(pk=self.project.pk).exists())
self.client.logout()
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:project_delete', args=['test-pack']))
self.assertRedirects(resp, reverse('library:browse'))
self.assertFalse(Project.objects.filter(pk=self.project.pk).exists())
def test_contributor_management_owner_only(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:contributors', args=['test-pack']),
{'action': 'add', 'username': 'Carol'},
)
self.assertEqual(resp.status_code, 403)
self.assertEqual(ProjectContributor.objects.count(), 0)
self.client.logout()
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:contributors', args=['test-pack']),
{'action': 'add', 'username': 'Carol'},
)
self.assertRedirects(resp, reverse('library:contributors', args=['test-pack']))
self.assertTrue(
ProjectContributor.objects.filter(project=self.project, user=self.carol).exists()
)
def test_contributor_creator_tag_sync_and_detach(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.project.sync_creator_tags()
creator_cat = TagCategory.objects.get(slug='creator')
self.assertTrue(Tag.objects.filter(name='alice', category=creator_cat).exists())
self.assertTrue(Tag.objects.filter(name='bob', category=creator_cat).exists())
self.assertEqual(
TagList.objects.filter(project=self.project, tag__category=creator_cat).count(), 2
)
# Remove Bob as a contributor → his creator tag detaches.
ProjectContributor.objects.filter(project=self.project, user=self.bob).delete()
self.project.detach_creator_tag(self.bob)
self.project.sync_creator_tags(actor=self.alice)
self.assertEqual(
TagList.objects.filter(project=self.project, tag__category=creator_cat).count(), 1
)
self.assertFalse(
TagList.objects.filter(project=self.project, tag__category=creator_cat, tag__name='bob').exists()
)
def test_edit_syncs_creator_tags(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:project_edit', args=['test-pack']),
{
'title': 'Test Pack',
'description': 'x',
'category': 'mod',
'tags': 'species:dragon',
},
)
creator_cat = TagCategory.objects.get(slug='creator')
names = set(TagList.objects.filter(project=self.project, tag__category=creator_cat).values_list('tag__name', flat=True))
self.assertIn('alice', names)
self.assertIn('bob', names)
# Manual species tag applied.
self.assertTrue(TagList.objects.filter(project=self.project, tag__name='dragon').exists())
class UGCBrowseTests(UGCGatedTestCase):
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 _seed(self):
p1 = Project.objects.create(slug='furry-mod', title='Furry Mod', category='mod', owner=self.alice, summary='Dragon fun')
p2 = Project.objects.create(slug='skin-pack', title='Skin Pack', category='skin', owner=self.bob)
p1.sync_creator_tags()
# Tag p1: species:dragon, content:threesome. Tag p2: species:dragon only.
for cat_slug, name in [('species', 'dragon'), ('content', 'threesome')]:
cat = TagCategory.objects.get(slug=cat_slug)
tag = Tag.objects.get_or_create(name=name, category=cat)[0]
TagList.objects.create(project=p1, tag=tag, added_by=self.alice)
cat = TagCategory.objects.get(slug='species')
tag = Tag.objects.get_or_create(name='dragon', category=cat)[0]
TagList.objects.create(project=p2, tag=tag, added_by=self.bob)
return p1, p2
def test_browse_gated(self):
resp = self.client.get(reverse('library:browse'))
self.assertEqual(resp.status_code, 302)
def test_browse_lists_projects(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'Furry Mod')
self.assertContains(resp, 'Skin Pack')
def test_browse_search(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'), {'q': 'Furry'})
self.assertContains(resp, 'Furry Mod')
self.assertNotContains(resp, 'Skin Pack')
def test_browse_category_filter(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'), {'category': 'skin'})
self.assertContains(resp, 'Skin Pack')
self.assertNotContains(resp, 'Furry Mod')
def test_browse_tag_filter_and(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'), {'tag': ['dragon', 'threesome']})
self.assertContains(resp, 'Furry Mod')
self.assertNotContains(resp, 'Skin Pack')
def test_browse_tag_filter_single(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'), {'tag': ['dragon']})
self.assertContains(resp, 'Furry Mod')
self.assertContains(resp, 'Skin Pack')
def test_browse_sort_recent(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'))
self.assertEqual(resp.status_code, 200)
# Skin Pack created last → first card.
html = resp.content.decode()
self.assertLess(html.find('Skin Pack'), html.find('Furry Mod'))
def test_home_shows_real_projects(self):
self._seed()
self.gate()
resp = self.client.get(reverse('landing:home'))
self.assertContains(resp, 'Furry Mod')
self.assertContains(resp, 'Skin Pack')
class UGCApiTests(UGCMediaTestCase, UGCGatedTestCase):
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)
cat = TagCategory.objects.get(slug='species')
Tag.objects.create(name='dragon', category=cat, created_by=self.alice)
Tag.objects.create(name='dragoness', category=cat, created_by=self.alice)
self.token = secrets.token_urlsafe(48)
ApiToken.objects.create(user=self.alice, token=self.token, key_prefix=self.token[:8], label='test')
def test_tags_autocomplete_gated(self):
resp = self.client.get(reverse('library:api_tags_autocomplete'), {'q': 'dra'})
self.assertEqual(resp.status_code, 401)
def test_tags_autocomplete_with_token(self):
resp = self.client.get(
reverse('library:api_tags_autocomplete'), {'q': 'dra'},
HTTP_AUTHORIZATION=f'Bearer Alice {self.token}',
)
self.assertEqual(resp.status_code, 200)
self.assertJSONEqual(resp.content, [
{'name': 'dragon', 'category': 'species', 'category_name': 'Species', 'color': '#06a77d'},
{'name': 'dragoness', 'category': 'species', 'category_name': 'Species', 'color': '#06a77d'},
])
def test_users_autocomplete(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:api_users_autocomplete'), {'q': 'Ali'})
self.assertEqual(resp.status_code, 200)
self.assertJSONEqual(resp.content, [{'username': 'Alice'}])
+16 -1
View File
@@ -5,5 +5,20 @@ from . import views
app_name = 'library' app_name = 'library'
urlpatterns = [ urlpatterns = [
path('files/<uuid:file_id>/', views.file_request, name='file_request'), path('browse/', views.browse, name='browse'),
path('packs/create/', views.project_create, name='project_create'),
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>/delete/', views.project_delete, name='project_delete'),
path('packs/<slug:slug>/versions/upload/', views.version_upload, name='version_upload'),
path('packs/<slug:slug>/versions/<int:version_id>/download/', views.version_download, name='version_download'),
path('packs/<slug:slug>/gallery/upload/', views.asset_upload, name='asset_upload'),
path('packs/<slug:slug>/gallery/<int:asset_id>/delete/', views.asset_delete, name='asset_delete'),
path('packs/<slug:slug>/contributors/', views.contributors, name='contributors'),
path('api/files/<uuid:file_id>/', views.file_request, name='file_request'),
path('api/uploads/', views.api_upload_temp, name='api_upload_temp'),
path('api/uploads/<uuid:upload_uuid>/delete/', views.api_delete_temp, name='api_delete_temp'),
path('api/draft/', views.api_draft, name='api_draft'),
path('api/tags/autocomplete/', views.api_tags_autocomplete, name='api_tags_autocomplete'),
path('api/users/autocomplete/', views.api_users_autocomplete, name='api_users_autocomplete'),
] ]
+599 -3
View File
@@ -1,11 +1,33 @@
import json
import posixpath import posixpath
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
from django.http import FileResponse, Http404, HttpResponse, StreamingHttpResponse from django.contrib import messages
from django.shortcuts import get_object_or_404 from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Count, F, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.text import slugify
from .models import FileIndex from .forms import ContributorForm, ProjectForm, VersionForm
from .models import (
FileIndex,
Project,
ProjectAsset,
ProjectContributor,
ProjectDraft,
Tag,
TagCategory,
TagList,
TempUpload,
Version,
slugify_tag,
)
from .storage import delete_file_index, move_file_index, store_file, store_temp_file
def _open_indexed_file(file_index): def _open_indexed_file(file_index):
@@ -101,3 +123,577 @@ def file_request(request, file_id):
if disposition: if disposition:
part['Content-Disposition'] = disposition part['Content-Disposition'] = disposition
return part return part
def _require_can_edit(request, project):
if not project.can_edit(request.user):
raise HttpResponseForbidden('You do not have permission to edit this project.')
return True
def _unique_project_slug(title):
base = slugify(title)[:100] or 'project'
slug, n = base, 1
while Project.objects.filter(slug=slug).exists():
n += 1
slug = f'{base}-{n}'
return slug
def _tags_from_text(text):
"""Parse a raw tag string ('general:furry loader:fabric 1.20.1') into
(category_slug, tag_name) tuples. Unprefixed names default to general."""
pairs = []
for part in (text or '').replace(',', ' ').split():
if ':' in part:
category_slug, _, name = part.partition(':')
else:
category_slug, name = 'general', part
name = slugify_tag(name)
if not name or not category_slug:
continue
pairs.append((category_slug, name))
return pairs
def _apply_tags(project, raw_tags, actor):
"""Replace a project's manual tags (auto-creating new ones). Creator tags
are managed separately by sync_creator_tags."""
creator_cat = TagCategory.objects.filter(slug='creator').first()
to_delete = project.tag_links.all()
if creator_cat is not None:
to_delete = to_delete.exclude(tag__category=creator_cat)
to_delete.delete()
for category_slug, name in _tags_from_text(raw_tags):
category = TagCategory.objects.filter(slug=category_slug).first()
if category is None:
category = TagCategory.objects.filter(slug='general').first()
if category is None:
continue
tag, _ = Tag.objects.get_or_create(
name=name, category=category, defaults={'created_by': actor},
)
TagList.objects.get_or_create(project=project, tag=tag, defaults={'added_by': actor})
def _current_tags(project):
"""Serialize a project's non-creator tags back to 'category:name' form."""
creator_cat = TagCategory.objects.filter(slug='creator').first()
qs = project.tag_links.select_related('tag__category').all()
if creator_cat is not None:
qs = qs.exclude(tag__category=creator_cat)
return ' '.join(f'{tl.tag.category.slug}:{tl.tag.name}' for tl in qs)
def browse(request):
q = request.GET.get('q', '').strip()
category = request.GET.get('category', '').strip()
sort = request.GET.get('sort', 'recent')
tag_names = [slugify_tag(t) for t in request.GET.getlist('tag') if t.strip()]
projects = (
Project.objects.select_related('owner', 'thumbnail')
.prefetch_related('tag_links__tag__category', 'versions')
)
if q:
projects = projects.filter(
Q(title__icontains=q)
| Q(summary__icontains=q)
| Q(description__icontains=q)
| Q(tag_links__tag__name__icontains=q)
).distinct()
if category and category in dict(Project.CATEGORY_CHOICES):
projects = projects.filter(category=category)
else:
category = ''
for name in tag_names:
projects = projects.filter(tag_links__tag__name=name)
if sort == 'downloads':
projects = projects.annotate(_downloads=Sum('versions__downloads')).order_by(
F('_downloads').desc(nulls_last=True)
)
elif sort == 'name':
projects = projects.order_by('title')
else:
projects = projects.order_by('-created_at')
# Preserve the query params when toggling filters.
def _qs(**overrides):
params = request.GET.copy()
for key, value in overrides.items():
if value is None:
params.pop(key, None)
else:
params.setlist(key, value if isinstance(value, list) else [value])
return params.urlencode()
category_menu = [('', 'All', f'?{_qs(category=None)}')]
for value, label in Project.CATEGORY_CHOICES:
category_menu.append((value, label, f'?{_qs(category=value)}'))
tag_groups = []
for cat in TagCategory.objects.all().order_by('slug'):
tags = (
Tag.objects.filter(category=cat)
.annotate(count=Count('project_links'))
.filter(count__gt=0)
.order_by('-count')[:25]
)
if not tags:
continue
items = []
for tag in tags:
active = tag.name in tag_names
if active:
remaining = [t for t in tag_names if t != tag.name]
url = f'?{_qs(tag=remaining)}'
else:
url = f'?{_qs(tag=tag_names + [tag.name])}'
items.append({
'name': tag.name,
'color': cat.color,
'count': tag.count,
'active': active,
'url': url,
})
tag_groups.append({'category': cat, 'items': items})
paginator = Paginator(projects, 12)
page_obj = paginator.get_page(request.GET.get('page'))
context = {
'projects': page_obj,
'q': q,
'category': category,
'sort': sort,
'active_tags': tag_names,
'category_menu': category_menu,
'tag_groups': tag_groups,
'query_string': _qs(),
}
return render(request, 'library/browse.html', context)
def project_detail(request, slug):
project = get_object_or_404(
Project.objects.select_related('owner', 'thumbnail'), slug=slug,
)
versions = project.versions.select_related('file').all()
assets = project.assets.select_related('file').all()
tags = project.tag_links.select_related('tag__category').all()
can_edit = project.can_edit(request.user)
context = {
'project': project,
'versions': versions,
'assets': assets,
'tags': tags,
'can_edit': can_edit,
}
return render(request, 'library/project_detail.html', context)
DRAFT_KEYS = (
'title', 'summary', 'category', 'description',
'tags', 'caption', 'version_name', 'changelog',
)
def _serialize_temp(upload):
return {
'uuid': str(upload.uuid),
'kind': upload.kind,
'filename': upload.file.original_filename,
'content_type': upload.file.content_type,
'url': reverse('library:file_request', args=[upload.file.uuid]),
}
def _pending_uploads(user):
return list(
TempUpload.objects.filter(user=user, status='pending')
.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 _adopt_temp(upload, project_pk, subdir, kind):
"""Move a temp upload under project_<pk>/ and return the FileIndex."""
base = posixpath.basename(upload.file.stored_path)
new_path = f'project_{project_pk}/{subdir}/{base}' if subdir else f'project_{project_pk}/{base}'
return move_file_index(upload.file, new_path, kind=kind)
def _form_values(*forms):
out = {}
for form in forms:
for name in form.fields:
out[name] = form[name].value() or ''
return out
@login_required
def project_create(request):
project_form = ProjectForm()
version_form = VersionForm(required_file=False)
if request.method == 'POST':
project_form = ProjectForm(request.POST)
version_form = VersionForm(request.POST, required_file=False)
if project_form.is_valid() and version_form.is_valid():
data = project_form.cleaned_data
version_file = _latest_pending(request.user, 'version')
if version_file is None:
version_form.add_error('version_name', 'Upload the version file before creating the project.')
else:
project = Project.objects.create(
slug=_unique_project_slug(data['title']),
title=data['title'],
summary=data['summary'],
description=data['description'],
category=data['category'],
owner=request.user,
)
thumb = _latest_pending(request.user, 'thumbnail')
if thumb is not None:
project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail')
thumb.status = 'used'
thumb.save(update_fields=['status'])
project.save(update_fields=['thumbnail'])
version_index = _adopt_temp(version_file, project.pk, 'versions', 'version')
version_file.status = 'used'
version_file.save(update_fields=['status'])
Version.objects.create(
project=project,
version_name=version_form.cleaned_data['version_name'],
file=version_index,
changelog=version_form.cleaned_data['changelog'],
)
caption = data.get('caption', '')
for temp in TempUpload.objects.filter(
user=request.user, status='pending', kind='media',
).select_related('file').order_by('created_at'):
index = _adopt_temp(temp, project.pk, 'gallery', 'asset')
ProjectAsset.objects.create(
project=project, file=index,
caption=caption, uploaded_by=request.user,
)
temp.status = 'used'
temp.save(update_fields=['status'])
_apply_tags(project, data['tags'], request.user)
project.sync_creator_tags(actor=request.user)
ProjectDraft.objects.filter(user=request.user).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
},
required_file=False,
)
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_data': _form_values(project_form, version_form),
})
@login_required
def project_edit(request, slug):
project = get_object_or_404(Project, slug=slug)
if not project.can_edit(request.user):
return HttpResponseForbidden('You do not have permission to edit this project.')
if request.method == 'POST':
form = ProjectForm(request.POST, request.FILES)
if form.is_valid():
data = form.cleaned_data
project.title = data['title']
project.summary = data['summary']
project.description = data['description']
project.category = data['category']
thumbnail = data.get('thumbnail')
if thumbnail:
delete_file_index(project.thumbnail)
project.thumbnail = store_file(
request.user, 'thumbnail', thumbnail, project.pk, subdir='',
)
project.save()
_apply_tags(project, data['tags'], request.user)
project.sync_creator_tags(actor=request.user)
messages.success(request, 'Project updated.')
return redirect('library:project_detail', slug=project.slug)
else:
form = ProjectForm(initial={
'title': project.title,
'summary': project.summary,
'description': project.description,
'category': project.category,
'tags': _current_tags(project),
})
return render(request, 'library/project_edit.html', {
'project': project,
'project_form': form,
})
@login_required
def project_delete(request, slug):
project = get_object_or_404(Project, slug=slug)
if request.user != project.owner:
return HttpResponseForbidden('Only the owner can delete this project.')
if request.method == 'POST':
indices = []
if project.thumbnail_id:
indices.append(project.thumbnail)
indices += [v.file for v in project.versions.all() if v.file_id]
indices += [a.file for a in project.assets.all() if a.file_id]
project.delete()
for index in indices:
delete_file_index(index)
messages.success(request, 'Project deleted.')
return redirect('library:browse')
return render(request, 'library/project_delete.html', {'project': project})
@login_required
def version_upload(request, slug):
project = get_object_or_404(Project, slug=slug)
if not project.can_edit(request.user):
return HttpResponseForbidden('You do not have permission to upload versions.')
form = VersionForm()
if request.method == 'POST':
form = VersionForm(request.POST, request.FILES)
if form.is_valid():
index = store_file(
request.user, 'version', form.cleaned_data['file'],
project.pk, subdir='versions',
)
Version.objects.create(
project=project,
version_name=form.cleaned_data['version_name'],
file=index,
changelog=form.cleaned_data['changelog'],
)
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
return redirect('library:project_detail', slug=project.slug)
return render(request, 'library/version_upload.html', {
'project': project,
'version_form': form,
})
def version_download(request, slug, version_id):
version = get_object_or_404(
Version.objects.select_related('file', 'project'),
pk=version_id, project__slug=slug,
)
if version.file_id:
Version.objects.filter(pk=version.pk).update(downloads=F('downloads') + 1)
return redirect(
reverse('library:file_request', args=[version.file.uuid]) + '?download=1'
)
raise Http404
@login_required
def asset_upload(request, slug):
project = get_object_or_404(Project, slug=slug)
if not project.can_edit(request.user):
return HttpResponseForbidden('You do not have permission to add media.')
if request.method == 'POST':
caption = request.POST.get('caption', '')
media = TempUpload.objects.filter(user=request.user, status='pending', kind='media')
if not media.exists():
messages.error(request, 'No media uploaded yet.')
else:
count = 0
for temp in media.select_related('file'):
index = _adopt_temp(temp, project.pk, 'gallery', 'asset')
ProjectAsset.objects.create(
project=project, file=index,
caption=caption, uploaded_by=request.user,
)
temp.status = 'used'
temp.save(update_fields=['status'])
count += 1
messages.success(request, f'{count} media file{"s" if count != 1 else ""} added to the gallery.')
return redirect('library:project_detail', slug=project.slug)
return render(request, 'library/asset_upload.html', {
'project': project,
'caption': request.POST.get('caption', ''),
'pending_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)],
})
@login_required
def asset_delete(request, slug, asset_id):
project = get_object_or_404(Project, slug=slug)
asset = get_object_or_404(ProjectAsset, pk=asset_id, project=project)
if not project.can_edit(request.user):
return HttpResponseForbidden('You do not have permission to delete media.')
delete_file_index(asset.file)
asset.delete()
return redirect('library:project_detail', slug=project.slug)
@login_required
def contributors(request, slug):
project = get_object_or_404(Project, slug=slug)
if request.user != project.owner:
return HttpResponseForbidden('Only the owner can manage contributors.')
if request.method == 'POST':
action = request.POST.get('action')
if action == 'add':
username = request.POST.get('username', '').strip()
user = get_user_model().objects.filter(username__iexact=username).first()
if user is None:
messages.error(request, f'No account named "{username}".')
elif user == project.owner:
messages.error(request, 'The owner is already a contributor.')
else:
_, created = ProjectContributor.objects.get_or_create(
project=project, user=user, defaults={'added_by': request.user},
)
project.sync_creator_tags(actor=request.user)
if created:
messages.success(request, f'{user.username} added as a contributor.')
else:
messages.info(request, f'{user.username} is already a contributor.')
elif action == 'remove':
contributor = ProjectContributor.objects.filter(
pk=request.POST.get('contributor_id'), project=project,
).first()
if contributor is not None:
user = contributor.user
contributor.delete()
project.detach_creator_tag(user)
project.sync_creator_tags(actor=request.user)
messages.success(request, f'{user.username} removed.')
return redirect('library:contributors', slug=project.slug)
return render(request, 'library/contributors.html', {
'project': project,
'contributors': project.contributors.select_related('user').all(),
'contributor_form': ContributorForm(),
})
# ---------- API (autocomplete) ----------
def api_tags_autocomplete(request):
q = request.GET.get('q', '').strip()
tags = Tag.objects.select_related('category').order_by('name')
if q:
tags = tags.filter(Q(name__icontains=q) | Q(category__name__icontains=q))
data = [
{'name': t.name, 'category': t.category.slug, 'category_name': t.category.name, 'color': t.category.color}
for t in tags[:20]
]
return JsonResponse(data, safe=False)
def api_users_autocomplete(request):
q = request.GET.get('q', '').strip()
users = get_user_model().objects.filter(username__icontains=q).order_by('username')[:20]
return JsonResponse([{'username': u.username} for u in users], safe=False)
# ---------- API (draft uploads + autosave) ----------
MEDIA_PREFIXES = ('image/', 'video/')
@login_required
def api_upload_temp(request):
"""Accept one file (J621-style: the JS uploads each file individually)."""
if request.method != 'POST':
return JsonResponse({'error': 'Method not allowed'}, status=405)
uploaded = request.FILES.get('file')
if uploaded is None:
return JsonResponse({'error': 'No file'}, status=400)
kind = request.POST.get('kind', 'media')
if kind not in dict(TempUpload.KIND_CHOICES):
kind = 'media'
content_type = getattr(uploaded, 'content_type', '') or ''
if kind in ('thumbnail', 'media'):
if not content_type.startswith(MEDIA_PREFIXES):
return JsonResponse({'error': 'Only images, videos and gifs are allowed here.'}, status=400)
if kind == 'thumbnail' and not content_type.startswith('image/'):
return JsonResponse({'error': 'The thumbnail must be an image.'}, status=400)
# Thumbnail and version are single slots: discard any earlier pending one.
if kind in ('thumbnail', 'version'):
for old in TempUpload.objects.filter(user=request.user, status='pending', kind=kind):
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)
return JsonResponse(_serialize_temp(upload))
@login_required
def api_delete_temp(request, upload_uuid):
upload = get_object_or_404(TempUpload, uuid=upload_uuid, user=request.user)
if request.method != 'POST':
return JsonResponse({'error': 'Method not allowed'}, status=405)
if upload.status == 'pending':
delete_file_index(upload.file)
upload.delete()
return JsonResponse({'success': True})
@login_required
def api_draft(request):
if request.method == 'GET':
draft = ProjectDraft.objects.filter(user=request.user).first()
return JsonResponse({
'data': draft.data if draft else {},
'uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)],
})
if request.method == 'POST':
try:
payload = json.loads(request.body or b'{}')
except json.JSONDecodeError:
payload = {}
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({
'saved': True,
'updated_at': draft.updated_at.isoformat(),
})
if request.method == 'DELETE':
ProjectDraft.objects.filter(user=request.user).delete()
return JsonResponse({'success': True})
return JsonResponse({'error': 'Method not allowed'}, status=405)
+50
View File
@@ -298,6 +298,56 @@ class ProfilePageTests(GatedTestCase):
self.alice.userprofile.refresh_from_db() self.alice.userprofile.refresh_from_db()
self.assertEqual(self.alice.userprofile.bio, fancy) self.assertEqual(self.alice.userprofile.bio, fancy)
def _set_bio(self, text):
self.alice.userprofile.bio = text
self.alice.userprofile.save()
def test_bio_renders_markdown(self):
self._set_bio('# Hi\n\n**bold** and *italic* and `code`')
self.gate()
resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
self.assertContains(resp, '<h1>Hi</h1>', html=True)
self.assertContains(resp, '<strong>bold</strong>', html=True)
self.assertContains(resp, '<em>italic</em>', html=True)
def test_bio_raw_html_escaped(self):
self._set_bio('<script>alert(1)</script>')
self.gate()
resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
self.assertNotContains(resp, '<script>alert(1)</script>', html=True)
self.assertContains(resp, '&lt;script&gt;alert(1)&lt;/script&gt;')
def test_bio_dangerous_link_scheme_rejected(self):
self._set_bio('[x](javascript:alert(1))')
self.gate()
resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
self.assertNotContains(resp, 'href="javascript:alert(1)"')
def test_bio_gfm_features(self):
self._set_bio('| a | b |\n|---|---|\n| 1 | 2 |\n\n~~gone~~')
self.gate()
resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
self.assertContains(resp, '<table>', html=False)
self.assertContains(resp, '<s>gone</s>', html=False)
def test_bio_preview_requires_login(self):
self.gate()
resp = self.client.post(reverse('profiles:bio_preview'), {'bio': '# x'})
self.assertEqual(resp.status_code, 302)
def test_bio_preview_renders(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('profiles:bio_preview'), {'bio': '**hi** *there*'})
self.assertEqual(resp.status_code, 200)
self.assertJSONEqual(resp.content, {'html': '<p><strong>hi</strong> <em>there</em></p>'})
def test_bio_preview_get_rejected(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('profiles:bio_preview'))
self.assertEqual(resp.status_code, 405)
class FileRequestTests(MediaTestCase, GatedTestCase): class FileRequestTests(MediaTestCase, GatedTestCase):
def setUp(self): def setUp(self):
+1
View File
@@ -13,4 +13,5 @@ urlpatterns = [
path('settings/tokens/create/', views.token_create, name='token_create'), path('settings/tokens/create/', views.token_create, name='token_create'),
path('settings/tokens/<int:token_id>/delete/', views.token_delete, name='token_delete'), path('settings/tokens/<int:token_id>/delete/', views.token_delete, name='token_delete'),
path('profiles/<str:username>/', views.user_profile, name='user_profile'), path('profiles/<str:username>/', views.user_profile, name='user_profile'),
path('settings/account/bio/preview/', views.bio_preview, name='bio_preview'),
] ]
+15 -1
View File
@@ -8,8 +8,10 @@ from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from common.markdown import render_markdown
from library.models import FileIndex from library.models import FileIndex
from .forms import AvatarForm, BioForm, EmailForm, UsernameForm from .forms import AvatarForm, BioForm, EmailForm, UsernameForm
@@ -200,4 +202,16 @@ def user_profile(request, username):
'profile_user': user, 'profile_user': user,
'is_owner': is_owner, 'is_owner': is_owner,
'bio_form': bio_form, 'bio_form': bio_form,
}) 'projects': user.projects.select_related('owner', 'thumbnail').prefetch_related(
'tag_links__tag__category', 'versions',
).all(),
})
@login_required
def bio_preview(request):
"""Return the markdown-rendered HTML for a bio draft (editor live preview)."""
if request.method != 'POST':
return JsonResponse({'detail': 'Method not allowed'}, status=405)
text = request.POST.get('bio', '')
return JsonResponse({'html': render_markdown(text)})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 134 KiB

+772 -1
View File
@@ -1840,7 +1840,6 @@ a.deletelink {
font-size: 1.05rem; font-size: 1.05rem;
} }
.profile-bio { .profile-bio {
white-space: pre-wrap;
line-height: 1.6; line-height: 1.6;
margin: 0; margin: 0;
} }
@@ -1848,6 +1847,110 @@ a.deletelink {
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
margin: 0; margin: 0;
} }
/* ========== Markdown ========== */
.markdown-body {
line-height: 1.6;
color: var(--md-sys-color-on-surface);
overflow-wrap: break-word;
}
.markdown-body > *:first-child {
margin-top: 0;
}
.markdown-body > *:last-child {
margin-bottom: 0;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin: 1em 0 0.4em;
line-height: 1.3;
}
.markdown-body h1 { font-size: 1.6rem; }
.markdown-body h2 { font-size: 1.35rem; }
.markdown-body h3 { font-size: 1.15rem; }
.markdown-body h4 { font-size: 1rem; }
.markdown-body p {
margin: 0.5em 0;
}
.markdown-body ul,
.markdown-body ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.markdown-body li {
margin: 0.15em 0;
}
.markdown-body a {
color: var(--md-sys-color-primary);
text-decoration: underline;
}
.markdown-body code {
background: var(--md-sys-color-surface-variant);
padding: 2px 5px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.markdown-body pre {
background: var(--md-sys-color-surface-variant);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 0.6em 0;
}
.markdown-body pre code {
background: none;
padding: 0;
font-size: 0.85rem;
}
.markdown-body blockquote {
border-left: 3px solid var(--md-sys-color-primary);
margin: 0.6em 0;
padding: 0.2em 1em;
color: var(--md-sys-color-on-surface-variant);
}
.markdown-body table {
border-collapse: collapse;
margin: 0.6em 0;
width: 100%;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--md-sys-color-outline);
padding: 6px 10px;
text-align: left;
}
.markdown-body th {
background: var(--md-sys-color-surface-variant);
}
.markdown-body img {
max-width: 100%;
border-radius: 8px;
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--md-sys-color-outline);
margin: 1em 0;
}
.bio-help {
font-size: 0.8rem;
color: var(--md-sys-color-on-surface-variant);
margin: 4px 0 8px;
}
.bio-actions {
display: flex;
gap: 8px;
}
.markdown-preview {
margin-top: 12px;
padding: 16px;
background: var(--md-sys-color-surface-variant);
border-radius: 12px;
}
.pack-card-empty { .pack-card-empty {
opacity: 0.75; opacity: 0.75;
pointer-events: none; pointer-events: none;
@@ -1860,3 +1963,671 @@ a.deletelink {
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant); background: var(--md-sys-color-surface-variant);
} }
/* ========== UGC / Browse ========== */
.pack-card-thumb-icon {
font-size: 3rem;
color: var(--md-sys-color-on-surface-variant);
}
.pack-card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--md-sys-color-outline);
border-radius: 999px;
padding: 1px 8px;
font-size: 0.75rem;
text-decoration: none;
line-height: 1.5;
}
.tag-chip.active {
background: rgba(255, 255, 255, 0.08);
}
.tag-chip-remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 0.85rem;
padding: 0 2px;
}
.tag-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.tag-count {
opacity: 0.7;
font-size: 0.7rem;
}
/* Browse layout */
.browse-header h1 {
margin-bottom: 16px;
}
.browse-search-form {
margin-bottom: 16px;
}
.browse-search-row {
display: flex;
gap: 8px;
max-width: 560px;
}
.browse-search-input {
flex: 1;
}
.browse-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 16px;
align-items: start;
}
.browse-filters {
display: flex;
flex-direction: column;
gap: 12px;
position: sticky;
top: 16px;
}
.filter-card h3 {
margin-top: 0;
font-size: 0.95rem;
}
.filter-category {
display: flex;
flex-direction: column;
gap: 4px;
}
.filter-link {
padding: 6px 10px;
border-radius: 8px;
text-decoration: none;
color: var(--md-sys-color-on-surface-variant);
}
.filter-link:hover {
background: var(--md-sys-color-surface-variant);
}
.filter-link.active {
background: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-primary);
font-weight: 500;
}
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.filter-empty {
color: var(--md-sys-color-on-surface-variant);
font-size: 0.85rem;
}
.browse-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.browse-count {
color: var(--md-sys-color-on-surface-variant);
font-size: 0.9rem;
}
.browse-empty {
grid-column: 1 / -1;
text-align: center;
padding: 48px 16px;
color: var(--md-sys-color-on-surface-variant);
}
.browse-empty i {
font-size: 3rem;
margin-bottom: 12px;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
}
.page-info {
color: var(--md-sys-color-on-surface-variant);
font-size: 0.9rem;
}
/* Project detail */
.project-header {
display: flex;
gap: 20px;
margin-bottom: 16px;
}
.project-avatar img {
width: 128px;
height: 128px;
border-radius: 12px;
object-fit: cover;
border: 2px solid var(--md-sys-color-outline);
}
.project-avatar .default-avatar {
width: 128px;
height: 128px;
font-size: 3rem;
border-radius: 12px;
}
.project-header-main {
flex: 1;
}
.project-title-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.project-title-row h1 {
margin: 0;
}
.category-badge {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-radius: 999px;
padding: 2px 12px;
font-size: 0.8rem;
text-transform: capitalize;
}
.project-summary {
color: var(--md-sys-color-on-surface-variant);
margin: 8px 0;
}
.project-meta {
display: flex;
gap: 16px;
align-items: center;
font-size: 0.9rem;
color: var(--md-sys-color-on-surface-variant);
flex-wrap: wrap;
}
.project-owner a {
color: var(--md-sys-color-primary);
text-decoration: none;
}
.project-actions {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.project-tags {
margin-bottom: 16px;
padding: 12px 16px;
}
.tag-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin: 4px 0;
}
.tag-group-label {
font-size: 0.8rem;
color: var(--md-sys-color-on-surface-variant);
font-weight: 500;
margin-right: 4px;
}
/* Tabs */
.tab-bar {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--md-sys-color-outline);
margin-bottom: 16px;
}
.tab-btn {
background: none;
border: none;
color: var(--md-sys-color-on-surface-variant);
padding: 10px 16px;
font-size: 0.95rem;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tab-btn:hover {
color: var(--md-sys-color-on-surface);
}
.tab-btn.active {
color: var(--md-sys-color-primary);
border-bottom-color: var(--md-sys-color-primary);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Versions */
.version-row {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.version-row:last-child {
border-bottom: none;
}
.version-info {
min-width: 120px;
}
.version-name-row {
display: flex;
align-items: center;
gap: 8px;
}
.version-name {
font-size: 1rem;
}
.latest-badge {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-radius: 999px;
padding: 1px 8px;
font-size: 0.7rem;
text-transform: uppercase;
}
.version-date {
color: var(--md-sys-color-on-surface-variant);
font-size: 0.8rem;
}
.version-changelog {
flex: 1;
font-size: 0.9rem;
}
.version-changelog > *:first-child {
margin-top: 0;
}
.version-changelog > *:last-child {
margin-bottom: 0;
}
/* Gallery */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.gallery-item {
position: relative;
margin: 0;
background: var(--md-sys-color-surface-variant);
border-radius: 12px;
overflow: hidden;
}
.gallery-item img,
.gallery-item video {
width: 100%;
display: block;
object-fit: cover;
max-height: 260px;
}
.gallery-item figcaption {
padding: 8px 12px;
font-size: 0.85rem;
color: var(--md-sys-color-on-surface-variant);
}
.gallery-delete {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.gallery-delete:hover {
background: var(--md-sys-color-error);
}
.empty-hint {
color: var(--md-sys-color-on-surface-variant);
margin: 8px 0;
}
/* Forms */
.project-form {
padding: 24px;
max-width: 720px;
}
.form-section-title {
margin: 24px 0 8px;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.btn-sm {
padding: 4px 10px;
font-size: 0.8rem;
}
.btn-danger {
background: var(--md-sys-color-error-container);
color: var(--md-sys-color-on-error-container);
}
/* Tag editor */
.tag-editor {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
}
.tag-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-suggestions {
position: absolute;
z-index: 50;
background: var(--md-sys-color-surface);
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
max-height: 240px;
overflow-y: auto;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
min-width: 220px;
}
.tag-suggestion {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
padding: 8px 12px;
cursor: pointer;
color: var(--md-sys-color-on-surface);
}
.tag-suggestion:hover {
background: var(--md-sys-color-surface-variant);
}
.tag-suggestion small {
color: var(--md-sys-color-on-surface-variant);
}
/* Contributors */
.contributor-form {
display: flex;
gap: 12px;
align-items: flex-end;
flex-wrap: wrap;
}
.contributor-input-group {
position: relative;
flex: 1;
min-width: 220px;
}
.contributor-list {
list-style: none;
margin: 0;
padding: 0;
}
.contributor-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.contributor-row:last-child {
border-bottom: none;
}
.contributor-name i {
margin-right: 8px;
}
.inline-form {
margin: 0;
}
.user-suggestions {
position: absolute;
z-index: 50;
top: 100%;
left: 0;
right: 0;
background: var(--md-sys-color-surface);
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
max-height: 240px;
overflow-y: auto;
}
@media (max-width: 768px) {
.browse-layout {
grid-template-columns: 1fr;
}
.browse-filters {
position: static;
}
.project-header {
flex-direction: column;
}
.version-row {
flex-direction: column;
gap: 8px;
}
}
/* ========== Horizontal project form ========== */
.project-form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 24px;
}
.project-form-main {
display: flex;
flex-direction: column;
gap: 12px;
}
.project-form-side {
display: flex;
flex-direction: column;
gap: 12px;
align-self: start;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
@media (min-width: 992px) {
.project-form-grid {
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
align-items: start;
}
.project-form-side {
position: sticky;
top: 16px;
}
}
/* ========== Multi-upload drop zone ========== */
.drop-zone {
border: 2px dashed var(--md-sys-color-outline);
border-radius: 12px;
padding: 40px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
color: var(--md-sys-color-on-surface-variant);
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: var(--md-sys-color-primary);
background: var(--md-sys-color-surface-variant);
}
.drop-zone-icon {
font-size: 2.5rem;
margin-bottom: 8px;
color: var(--md-sys-color-primary);
}
.drop-zone-text {
margin: 0 0 4px;
font-weight: 500;
}
.drop-zone-sub {
margin: 0;
font-size: 0.85rem;
}
.file-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
margin-top: 16px;
}
.file-preview-card {
position: relative;
border-radius: 10px;
overflow: hidden;
background: var(--md-sys-color-surface-variant);
aspect-ratio: 1 / 1;
}
.file-preview-card img,
.file-preview-card video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.file-preview-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: var(--md-sys-color-on-surface-variant);
}
.file-preview-name {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 0.7rem;
padding: 3px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-preview-remove {
position: absolute;
top: 6px;
right: 6px;
width: 26px;
height: 26px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.file-preview-remove:hover {
background: var(--md-sys-color-error);
}
/* ========== Horizontal create form ========== */
.create-form {
max-width: none;
width: 100%;
}
.create-form input:not([type='hidden']):not([type='file']),
.create-form select,
.create-form textarea {
width: 100%;
}
.create-form .form-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.create-grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
min-width: 0;
}
.create-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border-radius: 12px;
background: var(--md-sys-color-surface-container-low);
border: 1px solid var(--md-sys-color-outline-variant);
align-self: start;
min-width: 0;
}
.create-panel .form-section-title {
margin: 0 0 4px;
font-size: 1rem;
}
@media (min-width: 992px) {
.create-grid { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
.create-panel-main { grid-row: span 2; }
}
@media (min-width: 1280px) {
.create-grid { grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1.2fr); }
}
/* ========== Upload zones ========== */
.upload-zone .drop-zone { padding: 24px 16px; }
.upload-zone .drop-zone-icon { font-size: 1.8rem; margin-bottom: 4px; }
.upload-preview { margin-top: 12px; }
.upload-preview .file-preview-card { max-width: 200px; }
.upload-batch[hidden] {
display: none;
}
.upload-batch {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 8px 12px;
border-radius: 8px;
background: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
font-size: 0.85rem;
}
/* ========== Autosave status ========== */
.autosave-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: var(--md-sys-color-on-surface-variant);
}
.autosave-status.saved { color: var(--md-sys-color-primary); }
.autosave-status.error { color: var(--md-sys-color-error); }
.autosave-status.idle { color: var(--md-sys-color-on-surface-variant); }
+153
View File
@@ -0,0 +1,153 @@
// J621-style multi-upload for the gallery "add media" page. Files are
// uploaded one at a time to /api/uploads/ (kind=media) and previewed; on
// submit the pending uploads are moved into the project's gallery.
(function () {
const zone = document.querySelector('.upload-zone[data-upload="media"]');
if (!zone) return;
const csrf = getCookie('csrftoken');
const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {};
const preview = document.getElementById('media-preview');
const batch = document.getElementById('media-batch');
const input = document.getElementById('media-input');
const drop = zone.querySelector('.drop-zone');
const pending = JSON.parse(
(document.getElementById('packs-pending-uploads') || { textContent: '[]' }).textContent
);
pending.forEach(renderCard);
function renderCard(upload) {
const card = document.createElement('div');
card.className = 'file-preview-card';
card.dataset.uuid = upload.uuid;
if ((upload.content_type || '').startsWith('image/')) {
const img = document.createElement('img');
img.src = upload.url;
img.alt = upload.filename;
card.appendChild(img);
} else if ((upload.content_type || '').startsWith('video/')) {
const video = document.createElement('video');
video.src = upload.url;
video.muted = true;
video.autoplay = true;
video.loop = true;
card.appendChild(video);
} else {
const icon = document.createElement('div');
icon.className = 'file-preview-icon';
icon.innerHTML = '<i class="fas fa-file"></i>';
card.appendChild(icon);
}
const name = document.createElement('span');
name.className = 'file-preview-name';
name.textContent = upload.filename;
card.appendChild(name);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'file-preview-remove';
remove.innerHTML = '<i class="fas fa-times"></i>';
remove.addEventListener('click', () => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/uploads/' + upload.uuid + '/delete/');
for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]);
xhr.onload = () => { if (card.parentNode) card.parentNode.removeChild(card); };
xhr.onerror = () => alert('Network error.');
xhr.send();
});
card.appendChild(remove);
preview.appendChild(card);
}
function uploadFile(file) {
const fd = new FormData();
fd.append('file', file);
fd.append('kind', 'media');
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/uploads/');
for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]);
xhr.onload = function () {
if (xhr.status !== 200) {
try {
alert(JSON.parse(xhr.responseText).error || 'Upload failed.');
} catch (e) {
alert('Upload failed.');
}
} else {
renderCard(JSON.parse(xhr.responseText));
}
batchDone++;
updateBatch();
};
xhr.onerror = function () {
alert('Network error during upload.');
batchDone++;
updateBatch();
};
xhr.send(fd);
}
let batchTotal = 0;
let batchDone = 0;
function updateBatch() {
if (batchTotal === 0) { batch.hidden = true; return; }
batch.hidden = false;
document.getElementById('media-batch-status').textContent =
'Uploading ' + batchDone + ' of ' + batchTotal + ' files';
if (batchDone >= batchTotal && batchTotal > 0) {
setTimeout(() => { batch.hidden = true; batchTotal = 0; batchDone = 0; }, 2500);
}
}
function handleFiles(files) {
batchTotal += files.length;
updateBatch();
files.forEach((file) => uploadFile(file));
}
drop.addEventListener('click', () => input.click());
drop.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); }
});
['dragenter', 'dragover'].forEach((evt) => drop.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
drop.classList.add('dragover');
}));
['dragleave', 'drop'].forEach((evt) => drop.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
drop.classList.remove('dragover');
}));
drop.addEventListener('drop', (e) => {
if (e.dataTransfer && e.dataTransfer.files.length) {
handleFiles(Array.from(e.dataTransfer.files));
}
});
input.addEventListener('change', () => {
handleFiles(Array.from(input.files));
input.value = '';
});
['dragover', 'drop'].forEach((evt) => window.addEventListener(evt, (e) => e.preventDefault()));
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
})();
+266
View File
@@ -0,0 +1,266 @@
// J621-style upload + real-time autosave for the "create project" form.
// Each selected file is uploaded individually (XHR + FormData) to /api/uploads/
// and stored under media/uploads/user_<id>/. Typed fields autosave to the
// ProjectDraft via /api/draft/ (debounced, no explicit Save Draft button).
(function () {
const form = document.getElementById('project-form');
if (!form) return;
const csrf = getCookie('csrftoken');
const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {};
const savedUploads = JSON.parse(
(document.getElementById('packs-draft-uploads') || { textContent: '[]' }).textContent
);
// ----- Multi-upload (per-file XHR, thumbnails) -----
const zones = {};
document.querySelectorAll('.upload-zone').forEach((zone) => {
const kind = zone.dataset.upload;
zones[kind] = {
zone,
drop: zone.querySelector('.drop-zone'),
input: zone.querySelector('input[type=file]'),
preview: zone.querySelector('.upload-preview, #media-preview'),
};
if (kind === 'media') zones[kind].batch = document.getElementById('media-batch');
});
// Re-render the pending uploads that survived from a previous session.
savedUploads.forEach((u) => {
const z = zones[u.kind];
if (z && !z.current) renderUpload(u, z);
});
function renderUpload(upload, z) {
if (z.kind === 'thumbnail' || z.kind === 'version') {
z.preview.innerHTML = '';
z.preview.appendChild(previewCard(upload));
z.current = upload;
} else {
z.preview.appendChild(previewCard(upload));
}
}
function previewCard(upload) {
const card = document.createElement('div');
card.className = 'file-preview-card';
card.dataset.uuid = upload.uuid;
if ((upload.content_type || '').startsWith('image/')) {
const img = document.createElement('img');
img.src = upload.url;
img.alt = upload.filename;
card.appendChild(img);
} else if ((upload.content_type || '').startsWith('video/')) {
const video = document.createElement('video');
video.src = upload.url;
video.muted = true;
video.autoplay = true;
video.loop = true;
card.appendChild(video);
} else {
const icon = document.createElement('div');
icon.className = 'file-preview-icon';
icon.innerHTML = '<i class="fas fa-file-archive"></i>';
card.appendChild(icon);
}
const name = document.createElement('span');
name.className = 'file-preview-name';
name.textContent = upload.filename;
card.appendChild(name);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'file-preview-remove';
remove.innerHTML = '<i class="fas fa-times"></i>';
remove.addEventListener('click', () => deleteUpload(upload.uuid, card));
card.appendChild(remove);
return card;
}
function uploadFile(file, kind, z) {
const fd = new FormData();
fd.append('file', file);
fd.append('kind', kind);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/uploads/');
for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]);
xhr.onload = function () {
z.busy = false;
if (xhr.status !== 200) {
try {
alert(JSON.parse(xhr.responseText).error || 'Upload failed.');
} catch (e) {
alert('Upload failed.');
}
return;
}
const data = JSON.parse(xhr.responseText);
renderUpload(data, z);
};
xhr.onerror = function () {
z.busy = false;
alert('Network error during upload.');
};
xhr.send(fd);
}
function deleteUpload(uuid, card) {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/uploads/' + uuid + '/delete/');
for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]);
xhr.onload = function () {
if (card && card.parentNode) card.parentNode.removeChild(card);
};
xhr.onerror = function () {
alert('Network error.');
};
xhr.send();
}
Object.keys(zones).forEach((kind) => {
const z = zones[kind];
z.drop.addEventListener('click', () => z.input.click());
z.drop.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); z.input.click(); }
});
['dragenter', 'dragover'].forEach((evt) => z.drop.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
z.drop.classList.add('dragover');
}));
['dragleave', 'drop'].forEach((evt) => z.drop.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
z.drop.classList.remove('dragover');
}));
z.drop.addEventListener('drop', (e) => {
if (e.dataTransfer && e.dataTransfer.files.length) {
handleFiles(Array.from(e.dataTransfer.files), kind, z);
}
});
z.input.addEventListener('change', () => {
handleFiles(Array.from(z.input.files), kind, z);
z.input.value = '';
});
});
// Prevent the browser opening files dropped outside a zone.
['dragover', 'drop'].forEach((evt) => window.addEventListener(evt, (e) => e.preventDefault()));
let batchTotal = 0;
let batchDone = 0;
function handleFiles(files, kind, z) {
// Thumbnail/version are single slots: take one file, replace any
// current upload, and ignore further drops while one is in flight.
if (kind === 'thumbnail' || kind === 'version') {
if (z.busy) return;
const file = files[0];
if (z.current) deleteUpload(z.current.uuid, null);
z.busy = true;
uploadFile(file, kind, z);
return;
}
batchTotal += files.length;
updateBatch();
files.forEach((file) => uploadFile(file, kind, z));
}
function updateBatch() {
const z = zones.media;
if (!z || !z.batch) return;
if (batchTotal === 0) { z.batch.hidden = true; return; }
z.batch.hidden = false;
document.getElementById('media-batch-status').textContent =
'Uploading ' + batchDone + ' of ' + batchTotal + ' files';
if (batchDone >= batchTotal && batchTotal > 0) {
setTimeout(() => {
z.batch.hidden = true;
batchTotal = 0;
batchDone = 0;
}, 2500);
}
}
// ----- Real-time draft autosave -----
const autosaveStatus = document.getElementById('autosave-status');
const FIELDS = ['title', 'summary', 'category', 'description', 'caption', 'version_name', 'changelog'];
function setStatus(text, state) {
autosaveStatus.innerHTML = text;
autosaveStatus.className = 'autosave-status ' + (state || '');
}
let saveTimer = null;
let lastSavedAt = null;
function scheduleSave() {
setStatus('<i class="fas fa-circle-notch fa-spin"></i> Saving…', 'saving');
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 600);
}
function saveDraft() {
const data = {};
FIELDS.forEach((name) => {
const el = document.getElementById('id_' + name);
if (el) data[name] = el.value;
});
const tags = document.getElementById('id_tags');
if (tags) data.tags = tags.value;
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/draft/');
xhr.setRequestHeader('Content-Type', 'application/json');
for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]);
xhr.onload = function () {
if (xhr.status === 200) {
const r = JSON.parse(xhr.responseText);
lastSavedAt = r.updated_at;
setStatus('<i class="fas fa-check"></i> Saved just now', 'saved');
} else {
setStatus('<i class="fas fa-exclamation-triangle"></i> Save failed', 'error');
}
};
xhr.onerror = function () {
setStatus('<i class="fas fa-exclamation-triangle"></i> Save failed', 'error');
};
xhr.send(JSON.stringify({ data }));
}
FIELDS.forEach((name) => {
const el = document.getElementById('id_' + name);
if (el) {
el.addEventListener('input', scheduleSave);
if (el.tagName === 'SELECT') el.addEventListener('change', scheduleSave);
}
});
document.addEventListener('tags-changed', scheduleSave);
// Autosave is always live; indicate it once the page settles.
setStatus('<i class="fas fa-cloud-upload-alt"></i> Autosave on', 'idle');
setTimeout(() => setStatus('<i class="fas fa-check"></i> Saved just now', 'saved'), 2000);
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
})();
+91
View File
@@ -0,0 +1,91 @@
// Tag editor shared between create/edit project pages.
// Autocompletes against /api/tags/autocomplete/ and lets users create new
// tags (unprefixed → general). Populates the hidden `tags` input as
// "category:name category:name …" on submit.
(function () {
const input = document.getElementById('tag-input');
if (!input) return;
const suggestions = document.getElementById('tag-suggestions');
const chips = document.getElementById('tag-chips');
const hidden = document.getElementById('id_tags');
const tags = new Map(); // "category:name" -> {category, name, color}
function render() {
chips.innerHTML = '';
tags.forEach((tag) => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.style.borderColor = tag.color;
chip.style.color = tag.color;
chip.textContent = tag.name;
const x = document.createElement('button');
x.type = 'button';
x.className = 'tag-chip-remove';
x.textContent = '×';
x.addEventListener('click', () => { tags.delete(tag.category + ':' + tag.name); render(); });
chip.appendChild(x);
chips.appendChild(chip);
});
hidden.value = Array.from(tags.values()).map(t => t.category + ':' + t.name).join(' ');
document.dispatchEvent(new CustomEvent('tags-changed', { detail: { tags: hidden.value } }));
}
function addTag(category, name, color) {
const canonical = category.toLowerCase();
tags.set(canonical + ':' + name, { category: canonical, name, color: color || '#666666' });
render();
}
(hidden.value || '').split(' ').filter(Boolean).forEach(raw => {
const [category, ...rest] = raw.split(':');
addTag(category, rest.join(':'));
});
let timer = null;
input.addEventListener('input', () => {
clearTimeout(timer);
const q = input.value.trim();
if (!q) { suggestions.hidden = true; return; }
timer = setTimeout(async () => {
try {
const resp = await fetch(
"/api/tags/autocomplete/?q=" + encodeURIComponent(q),
{ headers: { 'Accept': 'application/json' } }
);
const results = await resp.json();
suggestions.innerHTML = '';
for (const t of results.slice(0, 8)) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'tag-suggestion';
item.innerHTML = '<span class="tag-dot" style="background:' + t.color + ';"></span> ' +
'<strong>' + t.name + '</strong> <small>(' + t.category_name + ')</small>';
item.addEventListener('click', () => { addTag(t.category, t.name, t.color); input.value = ''; suggestions.hidden = true; });
suggestions.appendChild(item);
}
suggestions.hidden = results.length === 0;
} catch (e) {
suggestions.hidden = true;
}
}, 200);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const q = input.value.trim();
if (!q) return;
const [category, ...rest] = q.includes(':') ? q.split(':') : ['general', q];
const name = rest.join(':').trim();
if (name) addTag(category, name);
input.value = '';
suggestions.hidden = true;
}
if (e.key === 'Escape') suggestions.hidden = true;
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.tag-editor')) suggestions.hidden = true;
});
})();
+3
View File
@@ -38,6 +38,9 @@
<a href="{% url 'landing:home' %}"> <a href="{% url 'landing:home' %}">
<i class="fas fa-home"></i><span class="nav-text"> Home</span> <i class="fas fa-home"></i><span class="nav-text"> Home</span>
</a> </a>
<a href="{% url 'library:browse' %}">
<i class="fas fa-compass"></i><span class="nav-text"> Browse</span>
</a>
</nav> </nav>
</div> </div>
<main class="main-content"> <main class="main-content">
+18 -65
View File
@@ -10,8 +10,8 @@
<p>Hosted datapacks for Needs Of Nature &amp; Animation Director.</p> <p>Hosted datapacks for Needs Of Nature &amp; Animation Director.</p>
<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="#" class="btn btn-primary"><i class="fas fa-download"></i> Browse packs</a> <a href="{% url 'library:browse' %}" class="btn btn-primary"><i class="fas fa-download"></i> Browse packs</a>
<a href="#" class="btn btn-secondary"><i class="fas fa-upload"></i> Upload a pack</a> <a href="{% url 'library:project_create' %}" class="btn btn-secondary"><i class="fas fa-upload"></i> Upload a pack</a>
</div> </div>
</div> </div>
</div> </div>
@@ -19,22 +19,22 @@
<div class="home-stats"> <div class="home-stats">
<div class="stat-card"> <div class="stat-card">
<i class="fas fa-users"></i> <i class="fas fa-users"></i>
<span class="stat-value">0</span> <span class="stat-value">{{ stats.creators }}</span>
<span class="stat-label">Creators</span> <span class="stat-label">Creators</span>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<i class="fas fa-box-open"></i> <i class="fas fa-box-open"></i>
<span class="stat-value">0</span> <span class="stat-value">{{ stats.packs }}</span>
<span class="stat-label">Packs</span> <span class="stat-label">Packs</span>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<i class="fas fa-download"></i> <i class="fas fa-download"></i>
<span class="stat-value">0</span> <span class="stat-value">{{ stats.downloads }}</span>
<span class="stat-label">Downloads</span> <span class="stat-label">Downloads</span>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<i class="fas fa-tag"></i> <i class="fas fa-tag"></i>
<span class="stat-value">0</span> <span class="stat-value">{{ stats.versions }}</span>
<span class="stat-label">Versions</span> <span class="stat-label">Versions</span>
</div> </div>
</div> </div>
@@ -42,70 +42,23 @@
<div class="home-section"> <div class="home-section">
<div class="home-section-header"> <div class="home-section-header">
<h2>Latest packs</h2> <h2>Latest packs</h2>
<a href="#" class="home-section-link">View all <i class="fas fa-arrow-right"></i></a> <a href="{% url 'library:browse' %}" class="home-section-link">View all <i class="fas fa-arrow-right"></i></a>
</div> </div>
<div class="pack-grid"> <div class="pack-grid">
{% comment %} Example cards — replace with real Pack objects when the library model lands. {% endcomment %} {% for project in latest_projects %}
<a href="#" class="pack-card"> {% include 'library/_project_card.html' %}
<div class="pack-card-thumb"> {% empty %}
<img src="{% static 'Logo.png' %}" alt=""> <div class="pack-card pack-card-empty">
</div> <div class="pack-card-thumb">
<div class="pack-card-body"> <i class="fas fa-box-open"></i>
<h3>Example Pack</h3> </div>
<p class="pack-card-author"><i class="fas fa-user"></i> a_creator</p> <div class="pack-card-body">
<p class="pack-card-desc">Placeholder description for a datapack.</p> <h3>No packs yet</h3>
<div class="pack-card-meta"> <p class="pack-card-desc">Be the first to share a pack.</p>
<span class="pack-card-version"><i class="fas fa-tag"></i> v1.0.0</span>
<span class="pack-card-downloads"><i class="fas fa-download"></i> 0</span>
</div> </div>
</div> </div>
</a> {% endfor %}
<a href="#" class="pack-card">
<div class="pack-card-thumb">
<img src="{% static 'Logo.png' %}" alt="">
</div>
<div class="pack-card-body">
<h3>Another Pack</h3>
<p class="pack-card-author"><i class="fas fa-user"></i> someone</p>
<p class="pack-card-desc">This card shows what the library grid will look like.</p>
<div class="pack-card-meta">
<span class="pack-card-version"><i class="fas fa-tag"></i> v0.9.2</span>
<span class="pack-card-downloads"><i class="fas fa-download"></i> 0</span>
</div>
</div>
</a>
<a href="#" class="pack-card">
<div class="pack-card-thumb">
<img src="{% static 'Logo.png' %}" alt="">
</div>
<div class="pack-card-body">
<h3>Third Example</h3>
<p class="pack-card-author"><i class="fas fa-user"></i> dev</p>
<p class="pack-card-desc">Static assets, profile pictures and zip downloads land under /media/&lt;user&gt;/&lt;pack&gt;/.</p>
<div class="pack-card-meta">
<span class="pack-card-version"><i class="fas fa-tag"></i> v1.1.0</span>
<span class="pack-card-downloads"><i class="fas fa-download"></i> 0</span>
</div>
</div>
</a>
<a href="#" class="pack-card">
<div class="pack-card-thumb">
<img src="{% static 'Logo.png' %}" alt="">
</div>
<div class="pack-card-body">
<h3>And Another</h3>
<p class="pack-card-author"><i class="fas fa-user"></i> tester</p>
<p class="pack-card-desc">Responsive grid, Catppuccin-styled, ready for real data.</p>
<div class="pack-card-meta">
<span class="pack-card-version"><i class="fas fa-tag"></i> v0.1.0</span>
<span class="pack-card-downloads"><i class="fas fa-download"></i> 0</span>
</div>
</div>
</a>
</div> </div>
</div> </div>
@@ -0,0 +1,24 @@
{% load static %}
<a href="{% url 'library:project_detail' project.slug %}" class="pack-card">
<div class="pack-card-thumb">
{% if project.thumbnail %}
<img src="{{ project.thumbnail_url }}" alt="">
{% else %}
<i class="fas fa-box-open pack-card-thumb-icon"></i>
{% endif %}
</div>
<div class="pack-card-body">
<h3>{{ project.title }}</h3>
<p class="pack-card-author"><i class="fas fa-user"></i> {{ project.owner.username }}</p>
<p class="pack-card-desc">{{ project.summary|default:"No description yet." }}</p>
<div class="pack-card-tags">
{% for link in project.tag_links.all|slice:":4" %}
<span class="tag-chip" style="border-color: {{ link.tag.category.color }}; color: {{ link.tag.category.color }};">{{ link.tag.name }}</span>
{% endfor %}
</div>
<div class="pack-card-meta">
<span class="pack-card-version"><i class="fas fa-tag"></i> {{ project.versions.0.version_name|default:"no versions" }}</span>
<span class="pack-card-downloads"><i class="fas fa-download"></i> {{ project.downloads }}</span>
</div>
</div>
</a>
@@ -0,0 +1,26 @@
<div class="form-group">
{{ form.title.errors }}
<label for="{{ form.title.id_for_label }}">Title</label>
{{ form.title }}
</div>
<div class="form-group">
{{ form.summary.errors }}
<label for="{{ form.summary.id_for_label }}">Summary</label>
{{ form.summary }}
</div>
<div class="form-row">
<div class="form-group">
{{ form.category.errors }}
<label for="{{ form.category.id_for_label }}">Category</label>
{{ form.category }}
</div>
</div>
<div class="form-group">
{{ form.description.errors }}
<label for="{{ form.description.id_for_label }}">Description</label>
{{ form.description }}
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown — headings, lists, links, code, tables and more.</p>
</div>
@@ -0,0 +1,17 @@
<div class="form-group">
{{ form.thumbnail.errors }}
<label for="{{ form.thumbnail.id_for_label }}">Thumbnail</label>
{{ form.thumbnail }}
<p class="bio-help"><i class="fas fa-info-circle"></i> Square image shown on cards and the project header.</p>
</div>
<div class="form-group">
<label>Tags</label>
<div class="tag-editor">
<input type="text" id="tag-input" placeholder="Type a tag and press Enter… (e.g. content:threesome, species:dragon)" autocomplete="off">
<div class="tag-suggestions" id="tag-suggestions" hidden></div>
<div class="tag-chips" id="tag-chips"></div>
{{ form.tags }}
</div>
<p class="bio-help"><i class="fas fa-info-circle"></i> Categories: loader, version, general, creator, content, species, non_version. Unprefixed tags go to General. Creator tags are added automatically.</p>
</div>
@@ -0,0 +1,18 @@
<div class="form-group">
{{ form.version_name.errors }}
<label for="{{ form.version_name.id_for_label }}">{{ form.version_name.label }}</label>
{{ form.version_name }}
</div>
<div class="form-group">
{{ form.file.errors }}
<label for="{{ form.file.id_for_label }}">{{ form.file.label }}</label>
{{ form.file }}
</div>
<div class="form-group">
{{ form.changelog.errors }}
<label for="{{ form.changelog.id_for_label }}">{{ form.changelog.label }}</label>
{{ form.changelog }}
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown.</p>
</div>
@@ -0,0 +1,45 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Add Media - {{ project.title }}{% endblock %}
{% block content %}
<h1><i class="fas fa-images"></i> Add media — {{ project.title }}</h1>
<p><a href="{% url 'library:project_detail' project.slug %}">&larr; Back to project</a></p>
<form method="post" class="card project-form" id="asset-form">
{% csrf_token %}
<div class="upload-zone" data-upload="media">
<div class="drop-zone" role="button" tabindex="0">
<div class="drop-zone-inner">
<i class="fas fa-cloud-upload-alt drop-zone-icon"></i>
<p class="drop-zone-text">Drag &amp; drop media here</p>
<p class="drop-zone-sub">or click to browse — images, videos or gifs, several at once</p>
</div>
</div>
<input type="file" id="media-input" accept="image/*,video/*" multiple hidden>
<div class="upload-batch" id="media-batch" hidden>
<i class="fas fa-circle-notch fa-spin"></i>
<span id="media-batch-status"></span>
</div>
<div class="file-preview-grid" id="media-preview"></div>
</div>
<div class="form-group">
<label for="caption">Caption</label>
<input type="text" id="caption" name="caption" maxlength="128"
placeholder="Optional caption applied to all files" value="{{ caption }}">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary"><i class="fas fa-check"></i> Add to gallery</button>
</div>
</form>
{{ pending_uploads|json_script:'packs-pending-uploads' }}
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/asset_upload.js' %}"></script>
{% endblock %}
+89
View File
@@ -0,0 +1,89 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Browse - Packs Site{% endblock %}
{% block content %}
<div class="browse-header">
<h1><i class="fas fa-compass"></i> Browse</h1>
<form method="get" action="{% url 'library:browse' %}" class="browse-search-form">
<div class="browse-search-row">
<input type="text" name="q" value="{{ q }}" placeholder="Search packs, tags…" class="browse-search-input">
<button type="submit" class="btn btn-primary"><i class="fas fa-search"></i></button>
</div>
</form>
</div>
<div class="browse-layout">
<aside class="browse-filters">
<div class="card filter-card">
<h3>Category</h3>
<div class="filter-category">
{% for value, label, url in category_menu %}
<a href="{{ url }}" class="filter-link {% if category == value %}active{% endif %}">{{ label }}</a>
{% endfor %}
</div>
</div>
{% for group in tag_groups %}
<div class="card filter-card">
<h3>
<span class="tag-dot" style="background: {{ group.category.color }};"></span>
{{ group.category.name }}
</h3>
<div class="filter-tags">
{% for item in group.items %}
<a href="{{ item.url }}" class="tag-chip filter-tag {% if item.active %}active{% endif %}"
style="border-color: {{ item.color }}; color: {{ item.color }};">
{{ item.name }} <span class="tag-count">{{ item.count }}</span>
</a>
{% endfor %}
</div>
</div>
{% empty %}
<div class="card filter-card">
<p class="filter-empty">No tags yet. Create a project to get started!</p>
</div>
{% endfor %}
</aside>
<div class="browse-main">
<div class="browse-toolbar">
<span class="browse-count">{{ projects.paginator.count }} project{{ projects.paginator.count|pluralize }}</span>
<form method="get" action="{% url 'library:browse' %}" class="browse-sort-form">
{% if q %}<input type="hidden" name="q" value="{{ q }}">{% endif %}
{% if category %}<input type="hidden" name="category" value="{{ category }}">{% endif %}
{% for t in active_tags %}<input type="hidden" name="tag" value="{{ t }}">{% endfor %}
<select name="sort" onchange="this.form.submit()">
<option value="recent" {% if sort == 'recent' %}selected{% endif %}>Newest</option>
<option value="downloads" {% if sort == 'downloads' %}selected{% endif %}>Most downloaded</option>
<option value="name" {% if sort == 'name' %}selected{% endif %}>Name A-Z</option>
</select>
</form>
</div>
<div class="pack-grid">
{% for project in projects %}
{% include 'library/_project_card.html' %}
{% empty %}
<div class="browse-empty">
<i class="fas fa-box-open"></i>
<p>No projects match your filters.</p>
</div>
{% endfor %}
</div>
{% if projects.paginator.num_pages > 1 %}
<div class="pagination">
{% if projects.has_previous %}
<a class="btn btn-secondary" href="?{{ query_string }}&page={{ projects.previous_page_number }}">Previous</a>
{% endif %}
<span class="page-info">Page {{ projects.number }} of {{ projects.paginator.num_pages }}</span>
{% if projects.has_next %}
<a class="btn btn-secondary" href="?{{ query_string }}&page={{ projects.next_page_number }}">Next</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,86 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Contributors - {{ project.title }}{% endblock %}
{% block content %}
<h1><i class="fas fa-users"></i> Contributors — {{ project.title }}</h1>
<p><a href="{% url 'library:project_detail' project.slug %}">&larr; Back to project</a></p>
<div class="card">
<h2>Add contributor</h2>
<form method="post" class="contributor-form" id="contributor-form">
{% csrf_token %}
<input type="hidden" name="action" value="add">
<div class="form-group contributor-input-group">
{{ contributor_form.username.errors }}
<label for="{{ contributor_form.username.id_for_label }}">Username</label>
{{ contributor_form.username }}
<div class="user-suggestions" id="user-suggestions" hidden></div>
</div>
<button type="submit" class="btn btn-primary"><i class="fas fa-user-plus"></i> Add</button>
</form>
</div>
<div class="card">
<h2>Current contributors</h2>
{% if contributors %}
<ul class="contributor-list">
<li class="contributor-row">
<span class="contributor-name"><i class="fas fa-crown"></i> {{ project.owner.username }} <small>(owner)</small></span>
</li>
{% for contributor in contributors %}
<li class="contributor-row">
<span class="contributor-name"><i class="fas fa-user"></i> {{ contributor.user.username }}</span>
<form method="post" class="inline-form">
{% csrf_token %}
<input type="hidden" name="action" value="remove">
<input type="hidden" name="contributor_id" value="{{ contributor.pk }}">
<button type="submit" class="btn btn-danger btn-sm"><i class="fas fa-user-minus"></i> Remove</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-hint">No additional contributors yet.</p>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
const input = document.getElementById('{{ contributor_form.username.id_for_label }}');
if (!input) return;
const suggestions = document.getElementById('user-suggestions');
let timer = null;
input.addEventListener('input', () => {
clearTimeout(timer);
const q = input.value.trim();
if (!q) { suggestions.hidden = true; return; }
timer = setTimeout(async () => {
try {
const resp = await fetch("{% url 'library:api_users_autocomplete' %}?q=" + encodeURIComponent(q), { headers: { 'Accept': 'application/json' } });
const results = await resp.json();
suggestions.innerHTML = '';
for (const u of results.slice(0, 8)) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'tag-suggestion';
item.textContent = u.username;
item.addEventListener('click', () => { input.value = u.username; suggestions.hidden = true; });
suggestions.appendChild(item);
}
suggestions.hidden = results.length === 0;
} catch (e) {
suggestions.hidden = true;
}
}, 200);
});
input.addEventListener('keydown', (e) => { if (e.key === 'Escape') suggestions.hidden = true; });
document.addEventListener('click', (e) => {
if (!e.target.closest('.contributor-input-group')) suggestions.hidden = true;
});
})();
</script>
{% endblock %}
@@ -0,0 +1,122 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Create Project - Packs Site{% endblock %}
{% block content %}
<h1><i class="fas fa-plus-circle"></i> New Project</h1>
<form method="post" class="card project-form create-form" id="project-form">
{% csrf_token %}
<div class="create-grid">
<section class="create-panel create-panel-main">
<h2 class="form-section-title"><i class="fas fa-folder-open"></i> Project</h2>
<div class="form-group">
{{ project_form.title.errors }}
<label for="{{ project_form.title.id_for_label }}">Title</label>
{{ project_form.title }}
</div>
<div class="form-group">
{{ project_form.summary.errors }}
<label for="{{ project_form.summary.id_for_label }}">Summary</label>
{{ project_form.summary }}
</div>
<div class="form-group">
{{ project_form.category.errors }}
<label for="{{ project_form.category.id_for_label }}">Category</label>
{{ project_form.category }}
</div>
<div class="form-group">
{{ project_form.description.errors }}
<label for="{{ project_form.description.id_for_label }}">Description</label>
{{ project_form.description }}
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown — headings, lists, links, code, tables and more.</p>
</div>
</section>
<section class="create-panel">
<h2 class="form-section-title"><i class="fas fa-image"></i> Thumbnail</h2>
<div class="upload-zone" data-upload="thumbnail">
<div class="drop-zone" role="button" tabindex="0">
<div class="drop-zone-inner">
<i class="fas fa-cloud-upload-alt drop-zone-icon"></i>
<p class="drop-zone-text">Drag &amp; drop an image</p>
<p class="drop-zone-sub">or click to browse</p>
</div>
</div>
<input type="file" id="thumb-input" accept="image/*" hidden>
<div class="upload-preview" id="thumb-preview"></div>
</div>
<h2 class="form-section-title" style="margin-top:16px;"><i class="fas fa-tags"></i> Tags</h2>
<div class="tag-editor">
<input type="text" id="tag-input" placeholder="Type a tag and press Enter… (e.g. content:threesome, species:dragon)" autocomplete="off">
<div class="tag-suggestions" id="tag-suggestions" hidden></div>
<div class="tag-chips" id="tag-chips"></div>
{{ project_form.tags }}
</div>
<p class="bio-help"><i class="fas fa-info-circle"></i> Categories: loader, version, general, creator, content, species, non_version. Unprefixed tags go to General.</p>
</section>
<section class="create-panel">
<h2 class="form-section-title"><i class="fas fa-box-open"></i> First version</h2>
<div class="form-group">
{{ version_form.version_name.errors }}
<label for="{{ version_form.version_name.id_for_label }}">{{ version_form.version_name.label }}</label>
{{ version_form.version_name }}
</div>
<div class="upload-zone" data-upload="version">
<div class="drop-zone" role="button" tabindex="0">
<div class="drop-zone-inner">
<i class="fas fa-cloud-upload-alt drop-zone-icon"></i>
<p class="drop-zone-text">Drag &amp; drop the pack file</p>
<p class="drop-zone-sub">or click to browse</p>
</div>
</div>
<input type="file" id="version-input" hidden>
<div class="upload-preview" id="version-preview"></div>
</div>
<div class="form-group">
{{ version_form.changelog.errors }}
<label for="{{ version_form.changelog.id_for_label }}">{{ version_form.changelog.label }}</label>
{{ version_form.changelog }}
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown.</p>
</div>
<h2 class="form-section-title" style="margin-top:16px;"><i class="fas fa-images"></i> Gallery media</h2>
<div class="upload-zone" data-upload="media">
<div class="drop-zone" role="button" tabindex="0">
<div class="drop-zone-inner">
<i class="fas fa-cloud-upload-alt drop-zone-icon"></i>
<p class="drop-zone-text">Drag &amp; drop media</p>
<p class="drop-zone-sub">or click to browse — several at once</p>
</div>
</div>
<input type="file" id="media-input" accept="image/*,video/*" multiple hidden>
<div class="upload-batch" id="media-batch" hidden>
<i class="fas fa-circle-notch fa-spin"></i>
<span id="media-batch-status"></span>
</div>
<div class="file-preview-grid" id="media-preview"></div>
</div>
<div class="form-group">
{{ project_form.caption.errors }}
<label for="{{ project_form.caption.id_for_label }}">{{ project_form.caption.label }}</label>
{{ project_form.caption }}
</div>
</section>
</div>
<div class="form-actions">
<span class="autosave-status" id="autosave-status"><i class="fas fa-spinner fa-spin"></i></span>
<button type="submit" class="btn btn-primary" id="create-submit"><i class="fas fa-check"></i> Create project</button>
</div>
</form>
{% endblock %}
{% block extra_js %}
{{ draft_uploads|json_script:'packs-draft-uploads' }}
<script src="{% static 'js/tags.js' %}"></script>
<script src="{% static 'js/create.js' %}"></script>
{% endblock %}
@@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% block title %}Delete {{ project.title }} - Packs Site{% endblock %}
{% block content %}
<div class="card">
<h1><i class="fas fa-trash"></i> Delete {{ project.title }}?</h1>
<p class="empty-hint">This permanently deletes the project, all its versions and gallery media. This cannot be undone.</p>
<form method="post">
{% csrf_token %}
<div class="form-actions">
<a href="{% url 'library:project_detail' project.slug %}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-danger"><i class="fas fa-trash"></i> Delete project</button>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,140 @@
{% extends 'base.html' %}
{% load static %}
{% load markdown %}
{% block title %}{{ project.title }} - Packs Site{% endblock %}
{% block content %}
<div class="project-header card">
<div class="project-avatar">
{% if project.thumbnail %}
<img src="{{ project.thumbnail_url }}" alt="{{ project.title }}">
{% else %}
<div class="default-avatar large">{{ project.title|first|upper }}</div>
{% endif %}
</div>
<div class="project-header-main">
<div class="project-title-row">
<h1>{{ project.title }}</h1>
<span class="category-badge">{{ project.get_category_display }}</span>
</div>
{% if project.summary %}<p class="project-summary">{{ project.summary }}</p>{% endif %}
<div class="project-meta">
<span class="project-owner"><i class="fas fa-user"></i>
<a href="{% url 'profiles:user_profile' project.owner.username %}">{{ project.owner.username }}</a>
</span>
<span class="project-stat"><i class="fas fa-download"></i> {{ project.downloads }}</span>
<span class="project-stat"><i class="fas fa-tag"></i> {{ versions|length }} version{{ versions|length|pluralize }}</span>
</div>
{% if can_edit %}
<div class="project-actions">
<a href="{% url 'library:version_upload' project.slug %}" class="btn btn-primary"><i class="fas fa-upload"></i> New version</a>
<a href="{% url 'library:asset_upload' project.slug %}" class="btn btn-secondary"><i class="fas fa-images"></i> Add media</a>
<a href="{% url 'library:project_edit' project.slug %}" class="btn btn-secondary"><i class="fas fa-pen"></i> Edit</a>
{% if request.user == project.owner %}
<a href="{% url 'library:contributors' project.slug %}" class="btn btn-secondary"><i class="fas fa-users"></i> Contributors</a>
<a href="{% url 'library:project_delete' project.slug %}" class="btn btn-danger"><i class="fas fa-trash"></i></a>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% if tags %}
<div class="project-tags card">
{% regroup tags by tag.category as tag_groups %}
{% for group in tag_groups %}
<div class="tag-group">
<span class="tag-group-label">
<span class="tag-dot" style="background: {{ group.grouper.color }};"></span>
{{ group.grouper.name }}
</span>
{% for link in group.list %}
<a href="{% url 'library:browse' %}?tag={{ link.tag.name }}" class="tag-chip"
style="border-color: {{ group.grouper.color }}; color: {{ group.grouper.color }};">{{ link.tag.name }}</a>
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
<div class="tab-bar" role="tablist">
<button class="tab-btn active" data-tab="description" role="tab">Description</button>
<button class="tab-btn" data-tab="versions" role="tab">Versions</button>
<button class="tab-btn" data-tab="gallery" role="tab">Gallery</button>
</div>
<section class="tab-panel active" id="tab-description">
<div class="card">
{% if project.description %}
<div class="markdown-body project-description">{{ project.description|markdown }}</div>
{% else %}
<p class="empty-hint">No description yet.</p>
{% endif %}
</div>
</section>
<section class="tab-panel" id="tab-versions">
<div class="card">
<h2><i class="fas fa-history"></i> Versions</h2>
{% for version in versions %}
<div class="version-row">
<div class="version-info">
<div class="version-name-row">
<strong class="version-name">{{ version.version_name }}</strong>
{% if forloop.first %}<span class="latest-badge">latest</span>{% endif %}
</div>
<span class="version-date"><i class="fas fa-clock"></i> {{ version.created_at|date:"M j, Y" }}</span>
</div>
{% if version.changelog %}
<div class="markdown-body version-changelog">{{ version.changelog|markdown }}</div>
{% endif %}
<a class="btn btn-primary btn-sm" href="{% url 'library:version_download' project.slug version.pk %}">
<i class="fas fa-download"></i> {{ version.downloads }}
</a>
</div>
{% empty %}
<p class="empty-hint">No versions yet.</p>
{% endfor %}
</div>
</section>
<section class="tab-panel" id="tab-gallery">
<div class="card">
<h2><i class="fas fa-images"></i> Gallery</h2>
<div class="gallery-grid">
{% for asset in assets %}
<figure class="gallery-item">
{% if asset.is_video %}
<video controls preload="metadata" src="{{ asset.file_url }}"></video>
{% else %}
<a href="{{ asset.file_url }}" target="_blank"><img src="{{ asset.file_url }}" alt="{{ asset.caption }}"></a>
{% endif %}
{% if asset.caption %}<figcaption>{{ asset.caption }}</figcaption>{% endif %}
{% if can_edit %}
<a href="{% url 'library:asset_delete' project.slug asset.pk %}" class="gallery-delete" title="Delete media"><i class="fas fa-trash"></i></a>
{% endif %}
</figure>
{% empty %}
<p class="empty-hint">No media yet.</p>
{% endfor %}
</div>
</div>
</section>
{% endblock %}
{% block extra_js %}
<script>
(function () {
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
buttons.forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
});
});
})();
</script>
{% endblock %}
@@ -0,0 +1,31 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Edit {{ project.title }} - Packs Site{% endblock %}
{% block content %}
<h1><i class="fas fa-pen"></i> Edit {{ project.title }}</h1>
<p><a href="{% url 'library:project_detail' project.slug %}">&larr; Back to project</a></p>
<form method="post" enctype="multipart/form-data" class="card project-form" id="project-form">
{% csrf_token %}
<div class="project-form-grid">
<div class="project-form-main">
{% include 'library/_project_form_fields.html' with form=project_form %}
</div>
<aside class="project-form-side">
{% include 'library/_project_form_side.html' with form=project_form %}
</aside>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary"><i class="fas fa-check"></i> Save changes</button>
</div>
</form>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/tags.js' %}"></script>
{% endblock %}
@@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}New Version - {{ project.title }}{% endblock %}
{% block content %}
<h1><i class="fas fa-upload"></i> New version — {{ project.title }}</h1>
<p><a href="{% url 'library:project_detail' project.slug %}">&larr; Back to project</a></p>
<form method="post" enctype="multipart/form-data" class="card project-form">
{% csrf_token %}
{% include 'library/_version_form_fields.html' with form=version_form %}
<div class="form-actions">
<button type="submit" class="btn btn-primary"><i class="fas fa-upload"></i> Upload version</button>
</div>
</form>
{% endblock %}
+68 -11
View File
@@ -1,5 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% load static %}
{% load markdown %}
{% block title %}{{ profile_user.username }} - Packs{% endblock %} {% block title %}{{ profile_user.username }} - Packs{% endblock %}
@@ -38,11 +39,16 @@
{{ bio_form.bio.errors }} {{ bio_form.bio.errors }}
{{ bio_form.bio }} {{ bio_form.bio }}
</div> </div>
<button type="submit" class="btn btn-primary">Save bio</button> <p class="bio-help"><i class="fas fa-info-circle"></i> Supports <a href="#" data-md-help>Markdown</a> — headings, lists, links, code, tables and more.</p>
<div class="bio-actions">
<button type="submit" class="btn btn-primary">Save bio</button>
<button type="button" class="btn btn-secondary" data-bio-toggle>Preview</button>
</div>
</form> </form>
<div class="markdown-preview markdown-body" id="bio-preview" hidden></div>
{% else %} {% else %}
{% if profile.bio %} {% if profile.bio %}
<p class="profile-bio">{{ profile.bio }}</p> <div class="profile-bio markdown-body">{{ profile.bio|markdown }}</div>
{% else %} {% else %}
<p class="profile-bio-empty"><i class="fas fa-user"></i> {{ profile_user.username }} hasn't written a bio yet.</p> <p class="profile-bio-empty"><i class="fas fa-user"></i> {{ profile_user.username }} hasn't written a bio yet.</p>
{% endif %} {% endif %}
@@ -54,16 +60,67 @@
<h2>Packs by {{ profile_user.username }}</h2> <h2>Packs by {{ profile_user.username }}</h2>
</div> </div>
<div class="pack-grid"> <div class="pack-grid">
{% comment %} Placeholder — replace with real Pack objects when the library model lands. {% endcomment %} {% for project in projects %}
<div class="pack-card pack-card-empty"> {% include 'library/_project_card.html' %}
<div class="pack-card-thumb"> {% empty %}
<i class="fas fa-box-open"></i> <div class="pack-card pack-card-empty">
<div class="pack-card-thumb">
<i class="fas fa-box-open"></i>
</div>
<div class="pack-card-body">
<h3>No packs yet</h3>
<p class="pack-card-desc">This creator hasn't published anything yet.</p>
</div>
</div> </div>
<div class="pack-card-body"> {% endfor %}
<h3>No packs yet</h3>
<p class="pack-card-desc">This creator hasn't published any datapacks.</p>
</div>
</div>
</div> </div>
</section> </section>
{% endblock %} {% endblock %}
{% block extra_js %}
{% if is_owner %}
<script>
(function () {
const textarea = document.getElementById('{{ bio_form.bio.id_for_label }}');
const preview = document.getElementById('bio-preview');
const toggleBtn = document.querySelector('[data-bio-toggle]');
if (!textarea || !preview || !toggleBtn) return;
const url = "{% url 'profiles:bio_preview' %}";
let timer = null;
function refreshPreview() {
fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'bio=' + encodeURIComponent(textarea.value),
})
.then(r => r.json())
.then(data => { preview.innerHTML = data.html; })
.catch(() => { preview.innerHTML = '<p><em>Preview unavailable.</em></p>'; });
}
toggleBtn.addEventListener('click', () => {
if (preview.hidden) {
preview.hidden = false;
toggleBtn.textContent = 'Hide preview';
refreshPreview();
} else {
preview.hidden = true;
toggleBtn.textContent = 'Preview';
}
});
textarea.addEventListener('input', () => {
if (preview.hidden) return;
clearTimeout(timer);
timer = setTimeout(refreshPreview, 300);
});
})();
</script>
{% endif %}
{% endblock %}