fixed implementation of UGC content uploader

This commit is contained in:
JakeBreath
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.messages',
'django.contrib.staticfiles',
'common',
'api',
'landing',
'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 = [
path('', include('landing.urls')),
path('', include('profiles.urls')),
path('api/', include('library.urls')),
path('', include('library.urls')),
path('admin/', admin.site.urls),
]
+22 -1
View File
@@ -3,9 +3,12 @@ import random
import time
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 common.middleware import gate_whitelist
from library.models import Project, Version
def _under_lockout(request):
@@ -47,4 +50,22 @@ def gate(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
# 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
from django.conf import settings
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):
@@ -35,3 +45,254 @@ class FileIndex(models.Model):
@property
def file_path(self):
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'
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
from pathlib import Path
from django.conf import settings
from django.http import FileResponse, Http404, HttpResponse, StreamingHttpResponse
from django.shortcuts import get_object_or_404
from django.contrib import messages
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):
@@ -101,3 +123,577 @@ def file_request(request, file_id):
if disposition:
part['Content-Disposition'] = disposition
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.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):
def setUp(self):
+1
View File
@@ -13,4 +13,5 @@ urlpatterns = [
path('settings/tokens/create/', views.token_create, name='token_create'),
path('settings/tokens/<int:token_id>/delete/', views.token_delete, name='token_delete'),
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.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from common.markdown import render_markdown
from library.models import FileIndex
from .forms import AvatarForm, BioForm, EmailForm, UsernameForm
@@ -200,4 +202,16 @@ def user_profile(request, username):
'profile_user': user,
'is_owner': is_owner,
'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;
}
.profile-bio {
white-space: pre-wrap;
line-height: 1.6;
margin: 0;
}
@@ -1848,6 +1847,110 @@ a.deletelink {
color: var(--md-sys-color-on-surface-variant);
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 {
opacity: 0.75;
pointer-events: none;
@@ -1860,3 +1963,671 @@ a.deletelink {
color: var(--md-sys-color-on-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' %}">
<i class="fas fa-home"></i><span class="nav-text"> Home</span>
</a>
<a href="{% url 'library:browse' %}">
<i class="fas fa-compass"></i><span class="nav-text"> Browse</span>
</a>
</nav>
</div>
<main class="main-content">
+18 -65
View File
@@ -10,8 +10,8 @@
<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>
<div class="home-hero-actions">
<a href="#" 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:browse' %}" class="btn btn-primary"><i class="fas fa-download"></i> Browse packs</a>
<a href="{% url 'library:project_create' %}" class="btn btn-secondary"><i class="fas fa-upload"></i> Upload a pack</a>
</div>
</div>
</div>
@@ -19,22 +19,22 @@
<div class="home-stats">
<div class="stat-card">
<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>
</div>
<div class="stat-card">
<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>
</div>
<div class="stat-card">
<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>
</div>
<div class="stat-card">
<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>
</div>
</div>
@@ -42,70 +42,23 @@
<div class="home-section">
<div class="home-section-header">
<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 class="pack-grid">
{% comment %} Example cards — replace with real Pack objects when the library model lands. {% endcomment %}
<a href="#" class="pack-card">
<div class="pack-card-thumb">
<img src="{% static 'Logo.png' %}" alt="">
</div>
<div class="pack-card-body">
<h3>Example Pack</h3>
<p class="pack-card-author"><i class="fas fa-user"></i> a_creator</p>
<p class="pack-card-desc">Placeholder description for a datapack.</p>
<div class="pack-card-meta">
<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>
{% for project in latest_projects %}
{% include 'library/_project_card.html' %}
{% empty %}
<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">Be the first to share a pack.</p>
</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>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>
{% endfor %}
</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' %}
{% load static %}
{% load markdown %}
{% block title %}{{ profile_user.username }} - Packs{% endblock %}
@@ -38,11 +39,16 @@
{{ bio_form.bio.errors }}
{{ bio_form.bio }}
</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>
<div class="markdown-preview markdown-body" id="bio-preview" hidden></div>
{% else %}
{% if profile.bio %}
<p class="profile-bio">{{ profile.bio }}</p>
<div class="profile-bio markdown-body">{{ profile.bio|markdown }}</div>
{% else %}
<p class="profile-bio-empty"><i class="fas fa-user"></i> {{ profile_user.username }} hasn't written a bio yet.</p>
{% endif %}
@@ -54,16 +60,67 @@
<h2>Packs by {{ profile_user.username }}</h2>
</div>
<div class="pack-grid">
{% comment %} Placeholder — replace with real Pack objects when the library model lands. {% endcomment %}
<div class="pack-card pack-card-empty">
<div class="pack-card-thumb">
<i class="fas fa-box-open"></i>
{% for project in projects %}
{% include 'library/_project_card.html' %}
{% empty %}
<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 class="pack-card-body">
<h3>No packs yet</h3>
<p class="pack-card-desc">This creator hasn't published any datapacks.</p>
</div>
</div>
{% endfor %}
</div>
</section>
{% 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 %}