fixed implementation of UGC content uploader
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommonConfig(AppConfig):
|
||||
name = 'common'
|
||||
@@ -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')
|
||||
@@ -38,6 +38,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'common',
|
||||
'api',
|
||||
'landing',
|
||||
'library',
|
||||
|
||||
@@ -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))
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
|
||||
@@ -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'}),
|
||||
)
|
||||
+110
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}'
|
||||
|
||||
@@ -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
@@ -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'}])
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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, '<script>alert(1)</script>')
|
||||
|
||||
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):
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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 |
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
});
|
||||
})();
|
||||
@@ -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">
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
<p>Hosted datapacks for Needs Of Nature & 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/<user>/<pack>/.</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 %}">← 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 & 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 %}
|
||||
@@ -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 %}">← 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 & 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 & 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 & 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 %}">← 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 %}">← 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user