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.sessions',
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
|
'common',
|
||||||
'api',
|
'api',
|
||||||
'landing',
|
'landing',
|
||||||
'library',
|
'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 = [
|
urlpatterns = [
|
||||||
path('', include('landing.urls')),
|
path('', include('landing.urls')),
|
||||||
path('', include('profiles.urls')),
|
path('', include('profiles.urls')),
|
||||||
path('api/', include('library.urls')),
|
path('', include('library.urls')),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import random
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Sum
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
|
|
||||||
from common.middleware import gate_whitelist
|
from common.middleware import gate_whitelist
|
||||||
|
from library.models import Project, Version
|
||||||
|
|
||||||
|
|
||||||
def _under_lockout(request):
|
def _under_lockout(request):
|
||||||
@@ -47,4 +50,22 @@ def gate(request):
|
|||||||
|
|
||||||
|
|
||||||
def home(request):
|
def home(request):
|
||||||
return render(request, 'landing/home.html')
|
latest_projects = (
|
||||||
|
Project.objects.select_related('owner', 'thumbnail')
|
||||||
|
.prefetch_related('tag_links__tag__category', 'versions')
|
||||||
|
.order_by('-created_at')[:4]
|
||||||
|
)
|
||||||
|
total_downloads = Version.objects.aggregate(total=Sum('downloads'))['total'] or 0
|
||||||
|
creators = (
|
||||||
|
get_user_model().objects.filter(projects__isnull=False).distinct().count()
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
'latest_projects': latest_projects,
|
||||||
|
'stats': {
|
||||||
|
'creators': creators,
|
||||||
|
'packs': Project.objects.count(),
|
||||||
|
'downloads': total_downloads,
|
||||||
|
'versions': Version.objects.count(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return render(request, 'landing/home.html', context)
|
||||||
@@ -1,3 +1,59 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
# Register your models here.
|
from .models import (
|
||||||
|
FileIndex,
|
||||||
|
Project,
|
||||||
|
ProjectAsset,
|
||||||
|
ProjectContributor,
|
||||||
|
Tag,
|
||||||
|
TagCategory,
|
||||||
|
TagList,
|
||||||
|
Version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FileIndex)
|
||||||
|
class FileIndexAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('uuid', 'kind', 'owner', 'original_filename', 'size', 'created_at')
|
||||||
|
search_fields = ('uuid', 'original_filename', 'stored_path')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Project)
|
||||||
|
class ProjectAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('slug', 'title', 'category', 'owner', 'created_at', 'updated_at')
|
||||||
|
search_fields = ('slug', 'title', 'summary')
|
||||||
|
list_filter = ('category', 'created_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Version)
|
||||||
|
class VersionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('project', 'version_name', 'downloads', 'created_at')
|
||||||
|
search_fields = ('project__title', 'version_name')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ProjectAsset)
|
||||||
|
class ProjectAssetAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('project', 'caption', 'uploaded_by', 'created_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ProjectContributor)
|
||||||
|
class ProjectContributorAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('project', 'user', 'added_by', 'added_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TagCategory)
|
||||||
|
class TagCategoryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('slug', 'name', 'color')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Tag)
|
||||||
|
class TagAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'category', 'created_by', 'created_at')
|
||||||
|
list_filter = ('category',)
|
||||||
|
search_fields = ('name',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TagList)
|
||||||
|
class TagListAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('project', 'tag', 'added_by', 'added_at')
|
||||||
|
search_fields = ('project__title', 'tag__name')
|
||||||
|
|||||||
@@ -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
|
import uuid
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
|
def slugify_tag(value):
|
||||||
|
"""Normalize a user-entered tag name to its canonical form."""
|
||||||
|
value = (value or '').strip().lower()
|
||||||
|
value = re.sub(r'\s+', '_', value)
|
||||||
|
value = re.sub(r'[^a-z0-9_.+-]', '', value)
|
||||||
|
return value.strip('_')
|
||||||
|
|
||||||
|
|
||||||
class FileIndex(models.Model):
|
class FileIndex(models.Model):
|
||||||
@@ -35,3 +45,254 @@ class FileIndex(models.Model):
|
|||||||
@property
|
@property
|
||||||
def file_path(self):
|
def file_path(self):
|
||||||
return self.stored_path
|
return self.stored_path
|
||||||
|
|
||||||
|
|
||||||
|
class TagCategory(models.Model):
|
||||||
|
"""Tag namespaces: Loader, Version, General, Creator, Content, Species, NoN Version."""
|
||||||
|
slug = models.SlugField(max_length=64, unique=True)
|
||||||
|
name = models.CharField(max_length=64)
|
||||||
|
color = models.CharField(max_length=7, default='#666666')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(models.Model):
|
||||||
|
"""A single tag within a category. Auto-created on first use (e621-style)."""
|
||||||
|
name = models.CharField(max_length=64)
|
||||||
|
category = models.ForeignKey(
|
||||||
|
TagCategory,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='tags',
|
||||||
|
)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='created_tags',
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=['name', 'category'], name='uniq_tag_name_category'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.category.slug}:{self.name}'
|
||||||
|
|
||||||
|
|
||||||
|
class Project(models.Model):
|
||||||
|
"""A UGC page: versioned downloads (tracked) + gallery media (untracked)."""
|
||||||
|
CATEGORY_CHOICES = [
|
||||||
|
('skin', 'Skin'),
|
||||||
|
('non_pack', 'NoN Pack'),
|
||||||
|
('model', 'Model'),
|
||||||
|
('guide', 'Guide'),
|
||||||
|
('mod', 'Mod'),
|
||||||
|
('modpack', 'Modpack'),
|
||||||
|
]
|
||||||
|
|
||||||
|
slug = models.SlugField(max_length=128, unique=True)
|
||||||
|
title = models.CharField(max_length=128)
|
||||||
|
summary = models.CharField(max_length=255, blank=True, default='')
|
||||||
|
description = models.TextField(blank=True, default='')
|
||||||
|
category = models.CharField(max_length=32, choices=CATEGORY_CHOICES, default='mod')
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='projects',
|
||||||
|
)
|
||||||
|
thumbnail = models.OneToOneField(
|
||||||
|
FileIndex,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='project_thumb',
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def thumbnail_url(self):
|
||||||
|
if self.thumbnail_id is None:
|
||||||
|
return None
|
||||||
|
return reverse('library:file_request', args=[self.thumbnail.uuid])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def downloads(self):
|
||||||
|
return self.versions.aggregate(total=models.Sum('downloads'))['total'] or 0
|
||||||
|
|
||||||
|
def can_edit(self, user):
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
if user.pk == self.owner_id:
|
||||||
|
return True
|
||||||
|
return self.contributors.filter(user=user).exists()
|
||||||
|
|
||||||
|
def sync_creator_tags(self, actor=None):
|
||||||
|
"""Ensure Creator tags exist + are attached for the owner and contributors."""
|
||||||
|
creator_cat = TagCategory.objects.filter(slug='creator').first()
|
||||||
|
if creator_cat is None:
|
||||||
|
return
|
||||||
|
actor = actor or self.owner
|
||||||
|
user_ids = {self.owner_id}
|
||||||
|
user_ids.update(self.contributors.values_list('user_id', flat=True))
|
||||||
|
for user_id in user_ids:
|
||||||
|
user = self.owner.__class__.objects.filter(pk=user_id).first()
|
||||||
|
if user is None:
|
||||||
|
continue
|
||||||
|
name = slugify_tag(user.username)
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
tag, _ = Tag.objects.get_or_create(
|
||||||
|
name=name, category=creator_cat, defaults={'created_by': user},
|
||||||
|
)
|
||||||
|
TagList.objects.get_or_create(project=self, tag=tag, defaults={'added_by': actor})
|
||||||
|
|
||||||
|
def detach_creator_tag(self, user):
|
||||||
|
creator_cat = TagCategory.objects.filter(slug='creator').first()
|
||||||
|
if creator_cat is None:
|
||||||
|
return
|
||||||
|
TagList.objects.filter(
|
||||||
|
project=self, tag__category=creator_cat, tag__name=slugify_tag(user.username),
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectContributor(models.Model):
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='contributors')
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='ugc_contributions',
|
||||||
|
)
|
||||||
|
added_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='+',
|
||||||
|
)
|
||||||
|
added_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=['project', 'user'], name='uniq_project_contributor'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user.username} on {self.project.title}'
|
||||||
|
|
||||||
|
|
||||||
|
class TagList(models.Model):
|
||||||
|
"""Tag <-> Project link. Also records who applied the tag."""
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='tag_links')
|
||||||
|
tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name='project_links')
|
||||||
|
added_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='+',
|
||||||
|
)
|
||||||
|
added_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=['project', 'tag'], name='uniq_project_tag'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.project.title} #{self.tag}'
|
||||||
|
|
||||||
|
|
||||||
|
class Version(models.Model):
|
||||||
|
"""A tracked-file release. Every upload is stored; latest = newest created_at."""
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='versions')
|
||||||
|
version_name = models.CharField(max_length=64)
|
||||||
|
file = models.ForeignKey(
|
||||||
|
FileIndex, on_delete=models.SET_NULL, null=True, blank=True, related_name='project_version',
|
||||||
|
)
|
||||||
|
changelog = models.TextField(blank=True, default='')
|
||||||
|
downloads = models.PositiveIntegerField(default=0)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at', '-pk']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.project.title} {self.version_name}'
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectAsset(models.Model):
|
||||||
|
"""Untracked gallery media (image/video/gif) displayed on the project page."""
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='assets')
|
||||||
|
file = models.ForeignKey(FileIndex, on_delete=models.CASCADE, related_name='project_asset')
|
||||||
|
caption = models.CharField(max_length=128, blank=True, default='')
|
||||||
|
uploaded_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='+',
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at', '-pk']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.project.title} asset'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_url(self):
|
||||||
|
return reverse('library:file_request', args=[self.file.uuid])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_video(self):
|
||||||
|
return self.file.content_type.startswith('video/')
|
||||||
|
|
||||||
|
|
||||||
|
class TempUpload(models.Model):
|
||||||
|
"""J621-style draft upload. Stored under media/uploads/user_<id>/<uuid><ext>
|
||||||
|
and tracked here until the project/gallery accepts it (status 'used') or it
|
||||||
|
is discarded. Previews are served through the gated file_request endpoint."""
|
||||||
|
|
||||||
|
KIND_CHOICES = [
|
||||||
|
('thumbnail', 'Thumbnail'),
|
||||||
|
('version', 'Version file'),
|
||||||
|
('media', 'Gallery media'),
|
||||||
|
]
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('used', 'Used'),
|
||||||
|
('discarded', 'Discarded'),
|
||||||
|
]
|
||||||
|
|
||||||
|
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='temp_uploads',
|
||||||
|
)
|
||||||
|
kind = models.CharField(max_length=16, choices=KIND_CHOICES, db_index=True)
|
||||||
|
file = models.ForeignKey(FileIndex, on_delete=models.CASCADE, related_name='temp_upload')
|
||||||
|
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='pending', db_index=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['user', 'status', 'kind']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.kind} {self.uuid} ({self.status})'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return reverse('library:file_request', args=[self.file.uuid])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_video(self):
|
||||||
|
return self.file.content_type.startswith('video/')
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectDraft(models.Model):
|
||||||
|
"""Autosaved 'create project' form state. One per user; no explicit
|
||||||
|
Save Draft button — every keystroke is persisted in real time."""
|
||||||
|
user = models.OneToOneField(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='project_draft',
|
||||||
|
)
|
||||||
|
data = models.JSONField(default=dict, blank=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Draft for {self.user.username}'
|
||||||
|
|||||||
@@ -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'
|
app_name = 'library'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('files/<uuid:file_id>/', views.file_request, name='file_request'),
|
path('browse/', views.browse, name='browse'),
|
||||||
|
path('packs/create/', views.project_create, name='project_create'),
|
||||||
|
path('packs/<slug:slug>/', views.project_detail, name='project_detail'),
|
||||||
|
path('packs/<slug:slug>/edit/', views.project_edit, name='project_edit'),
|
||||||
|
path('packs/<slug:slug>/delete/', views.project_delete, name='project_delete'),
|
||||||
|
path('packs/<slug:slug>/versions/upload/', views.version_upload, name='version_upload'),
|
||||||
|
path('packs/<slug:slug>/versions/<int:version_id>/download/', views.version_download, name='version_download'),
|
||||||
|
path('packs/<slug:slug>/gallery/upload/', views.asset_upload, name='asset_upload'),
|
||||||
|
path('packs/<slug:slug>/gallery/<int:asset_id>/delete/', views.asset_delete, name='asset_delete'),
|
||||||
|
path('packs/<slug:slug>/contributors/', views.contributors, name='contributors'),
|
||||||
|
path('api/files/<uuid:file_id>/', views.file_request, name='file_request'),
|
||||||
|
path('api/uploads/', views.api_upload_temp, name='api_upload_temp'),
|
||||||
|
path('api/uploads/<uuid:upload_uuid>/delete/', views.api_delete_temp, name='api_delete_temp'),
|
||||||
|
path('api/draft/', views.api_draft, name='api_draft'),
|
||||||
|
path('api/tags/autocomplete/', views.api_tags_autocomplete, name='api_tags_autocomplete'),
|
||||||
|
path('api/users/autocomplete/', views.api_users_autocomplete, name='api_users_autocomplete'),
|
||||||
]
|
]
|
||||||
|
|||||||
+599
-3
@@ -1,11 +1,33 @@
|
|||||||
|
import json
|
||||||
import posixpath
|
import posixpath
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import FileResponse, Http404, HttpResponse, StreamingHttpResponse
|
from django.contrib import messages
|
||||||
from django.shortcuts import get_object_or_404
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.db.models import Count, F, Q, Sum
|
||||||
|
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse, StreamingHttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from .models import FileIndex
|
from .forms import ContributorForm, ProjectForm, VersionForm
|
||||||
|
from .models import (
|
||||||
|
FileIndex,
|
||||||
|
Project,
|
||||||
|
ProjectAsset,
|
||||||
|
ProjectContributor,
|
||||||
|
ProjectDraft,
|
||||||
|
Tag,
|
||||||
|
TagCategory,
|
||||||
|
TagList,
|
||||||
|
TempUpload,
|
||||||
|
Version,
|
||||||
|
slugify_tag,
|
||||||
|
)
|
||||||
|
from .storage import delete_file_index, move_file_index, store_file, store_temp_file
|
||||||
|
|
||||||
|
|
||||||
def _open_indexed_file(file_index):
|
def _open_indexed_file(file_index):
|
||||||
@@ -101,3 +123,577 @@ def file_request(request, file_id):
|
|||||||
if disposition:
|
if disposition:
|
||||||
part['Content-Disposition'] = disposition
|
part['Content-Disposition'] = disposition
|
||||||
return part
|
return part
|
||||||
|
|
||||||
|
|
||||||
|
def _require_can_edit(request, project):
|
||||||
|
if not project.can_edit(request.user):
|
||||||
|
raise HttpResponseForbidden('You do not have permission to edit this project.')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_project_slug(title):
|
||||||
|
base = slugify(title)[:100] or 'project'
|
||||||
|
slug, n = base, 1
|
||||||
|
while Project.objects.filter(slug=slug).exists():
|
||||||
|
n += 1
|
||||||
|
slug = f'{base}-{n}'
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
def _tags_from_text(text):
|
||||||
|
"""Parse a raw tag string ('general:furry loader:fabric 1.20.1') into
|
||||||
|
(category_slug, tag_name) tuples. Unprefixed names default to general."""
|
||||||
|
pairs = []
|
||||||
|
for part in (text or '').replace(',', ' ').split():
|
||||||
|
if ':' in part:
|
||||||
|
category_slug, _, name = part.partition(':')
|
||||||
|
else:
|
||||||
|
category_slug, name = 'general', part
|
||||||
|
name = slugify_tag(name)
|
||||||
|
if not name or not category_slug:
|
||||||
|
continue
|
||||||
|
pairs.append((category_slug, name))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_tags(project, raw_tags, actor):
|
||||||
|
"""Replace a project's manual tags (auto-creating new ones). Creator tags
|
||||||
|
are managed separately by sync_creator_tags."""
|
||||||
|
creator_cat = TagCategory.objects.filter(slug='creator').first()
|
||||||
|
to_delete = project.tag_links.all()
|
||||||
|
if creator_cat is not None:
|
||||||
|
to_delete = to_delete.exclude(tag__category=creator_cat)
|
||||||
|
to_delete.delete()
|
||||||
|
|
||||||
|
for category_slug, name in _tags_from_text(raw_tags):
|
||||||
|
category = TagCategory.objects.filter(slug=category_slug).first()
|
||||||
|
if category is None:
|
||||||
|
category = TagCategory.objects.filter(slug='general').first()
|
||||||
|
if category is None:
|
||||||
|
continue
|
||||||
|
tag, _ = Tag.objects.get_or_create(
|
||||||
|
name=name, category=category, defaults={'created_by': actor},
|
||||||
|
)
|
||||||
|
TagList.objects.get_or_create(project=project, tag=tag, defaults={'added_by': actor})
|
||||||
|
|
||||||
|
|
||||||
|
def _current_tags(project):
|
||||||
|
"""Serialize a project's non-creator tags back to 'category:name' form."""
|
||||||
|
creator_cat = TagCategory.objects.filter(slug='creator').first()
|
||||||
|
qs = project.tag_links.select_related('tag__category').all()
|
||||||
|
if creator_cat is not None:
|
||||||
|
qs = qs.exclude(tag__category=creator_cat)
|
||||||
|
return ' '.join(f'{tl.tag.category.slug}:{tl.tag.name}' for tl in qs)
|
||||||
|
|
||||||
|
|
||||||
|
def browse(request):
|
||||||
|
q = request.GET.get('q', '').strip()
|
||||||
|
category = request.GET.get('category', '').strip()
|
||||||
|
sort = request.GET.get('sort', 'recent')
|
||||||
|
tag_names = [slugify_tag(t) for t in request.GET.getlist('tag') if t.strip()]
|
||||||
|
|
||||||
|
projects = (
|
||||||
|
Project.objects.select_related('owner', 'thumbnail')
|
||||||
|
.prefetch_related('tag_links__tag__category', 'versions')
|
||||||
|
)
|
||||||
|
|
||||||
|
if q:
|
||||||
|
projects = projects.filter(
|
||||||
|
Q(title__icontains=q)
|
||||||
|
| Q(summary__icontains=q)
|
||||||
|
| Q(description__icontains=q)
|
||||||
|
| Q(tag_links__tag__name__icontains=q)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
if category and category in dict(Project.CATEGORY_CHOICES):
|
||||||
|
projects = projects.filter(category=category)
|
||||||
|
else:
|
||||||
|
category = ''
|
||||||
|
|
||||||
|
for name in tag_names:
|
||||||
|
projects = projects.filter(tag_links__tag__name=name)
|
||||||
|
|
||||||
|
if sort == 'downloads':
|
||||||
|
projects = projects.annotate(_downloads=Sum('versions__downloads')).order_by(
|
||||||
|
F('_downloads').desc(nulls_last=True)
|
||||||
|
)
|
||||||
|
elif sort == 'name':
|
||||||
|
projects = projects.order_by('title')
|
||||||
|
else:
|
||||||
|
projects = projects.order_by('-created_at')
|
||||||
|
|
||||||
|
# Preserve the query params when toggling filters.
|
||||||
|
def _qs(**overrides):
|
||||||
|
params = request.GET.copy()
|
||||||
|
for key, value in overrides.items():
|
||||||
|
if value is None:
|
||||||
|
params.pop(key, None)
|
||||||
|
else:
|
||||||
|
params.setlist(key, value if isinstance(value, list) else [value])
|
||||||
|
return params.urlencode()
|
||||||
|
|
||||||
|
category_menu = [('', 'All', f'?{_qs(category=None)}')]
|
||||||
|
for value, label in Project.CATEGORY_CHOICES:
|
||||||
|
category_menu.append((value, label, f'?{_qs(category=value)}'))
|
||||||
|
|
||||||
|
tag_groups = []
|
||||||
|
for cat in TagCategory.objects.all().order_by('slug'):
|
||||||
|
tags = (
|
||||||
|
Tag.objects.filter(category=cat)
|
||||||
|
.annotate(count=Count('project_links'))
|
||||||
|
.filter(count__gt=0)
|
||||||
|
.order_by('-count')[:25]
|
||||||
|
)
|
||||||
|
if not tags:
|
||||||
|
continue
|
||||||
|
items = []
|
||||||
|
for tag in tags:
|
||||||
|
active = tag.name in tag_names
|
||||||
|
if active:
|
||||||
|
remaining = [t for t in tag_names if t != tag.name]
|
||||||
|
url = f'?{_qs(tag=remaining)}'
|
||||||
|
else:
|
||||||
|
url = f'?{_qs(tag=tag_names + [tag.name])}'
|
||||||
|
items.append({
|
||||||
|
'name': tag.name,
|
||||||
|
'color': cat.color,
|
||||||
|
'count': tag.count,
|
||||||
|
'active': active,
|
||||||
|
'url': url,
|
||||||
|
})
|
||||||
|
tag_groups.append({'category': cat, 'items': items})
|
||||||
|
|
||||||
|
paginator = Paginator(projects, 12)
|
||||||
|
page_obj = paginator.get_page(request.GET.get('page'))
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'projects': page_obj,
|
||||||
|
'q': q,
|
||||||
|
'category': category,
|
||||||
|
'sort': sort,
|
||||||
|
'active_tags': tag_names,
|
||||||
|
'category_menu': category_menu,
|
||||||
|
'tag_groups': tag_groups,
|
||||||
|
'query_string': _qs(),
|
||||||
|
}
|
||||||
|
return render(request, 'library/browse.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def project_detail(request, slug):
|
||||||
|
project = get_object_or_404(
|
||||||
|
Project.objects.select_related('owner', 'thumbnail'), slug=slug,
|
||||||
|
)
|
||||||
|
versions = project.versions.select_related('file').all()
|
||||||
|
assets = project.assets.select_related('file').all()
|
||||||
|
tags = project.tag_links.select_related('tag__category').all()
|
||||||
|
can_edit = project.can_edit(request.user)
|
||||||
|
context = {
|
||||||
|
'project': project,
|
||||||
|
'versions': versions,
|
||||||
|
'assets': assets,
|
||||||
|
'tags': tags,
|
||||||
|
'can_edit': can_edit,
|
||||||
|
}
|
||||||
|
return render(request, 'library/project_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
DRAFT_KEYS = (
|
||||||
|
'title', 'summary', 'category', 'description',
|
||||||
|
'tags', 'caption', 'version_name', 'changelog',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_temp(upload):
|
||||||
|
return {
|
||||||
|
'uuid': str(upload.uuid),
|
||||||
|
'kind': upload.kind,
|
||||||
|
'filename': upload.file.original_filename,
|
||||||
|
'content_type': upload.file.content_type,
|
||||||
|
'url': reverse('library:file_request', args=[upload.file.uuid]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_uploads(user):
|
||||||
|
return list(
|
||||||
|
TempUpload.objects.filter(user=user, status='pending')
|
||||||
|
.select_related('file').order_by('created_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_pending(user, kind):
|
||||||
|
return (
|
||||||
|
TempUpload.objects.filter(user=user, status='pending', kind=kind)
|
||||||
|
.select_related('file').order_by('created_at').last()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _adopt_temp(upload, project_pk, subdir, kind):
|
||||||
|
"""Move a temp upload under project_<pk>/ and return the FileIndex."""
|
||||||
|
base = posixpath.basename(upload.file.stored_path)
|
||||||
|
new_path = f'project_{project_pk}/{subdir}/{base}' if subdir else f'project_{project_pk}/{base}'
|
||||||
|
return move_file_index(upload.file, new_path, kind=kind)
|
||||||
|
|
||||||
|
|
||||||
|
def _form_values(*forms):
|
||||||
|
out = {}
|
||||||
|
for form in forms:
|
||||||
|
for name in form.fields:
|
||||||
|
out[name] = form[name].value() or ''
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def project_create(request):
|
||||||
|
project_form = ProjectForm()
|
||||||
|
version_form = VersionForm(required_file=False)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
project_form = ProjectForm(request.POST)
|
||||||
|
version_form = VersionForm(request.POST, required_file=False)
|
||||||
|
if project_form.is_valid() and version_form.is_valid():
|
||||||
|
data = project_form.cleaned_data
|
||||||
|
version_file = _latest_pending(request.user, 'version')
|
||||||
|
if version_file is None:
|
||||||
|
version_form.add_error('version_name', 'Upload the version file before creating the project.')
|
||||||
|
else:
|
||||||
|
project = Project.objects.create(
|
||||||
|
slug=_unique_project_slug(data['title']),
|
||||||
|
title=data['title'],
|
||||||
|
summary=data['summary'],
|
||||||
|
description=data['description'],
|
||||||
|
category=data['category'],
|
||||||
|
owner=request.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
thumb = _latest_pending(request.user, 'thumbnail')
|
||||||
|
if thumb is not None:
|
||||||
|
project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail')
|
||||||
|
thumb.status = 'used'
|
||||||
|
thumb.save(update_fields=['status'])
|
||||||
|
project.save(update_fields=['thumbnail'])
|
||||||
|
|
||||||
|
version_index = _adopt_temp(version_file, project.pk, 'versions', 'version')
|
||||||
|
version_file.status = 'used'
|
||||||
|
version_file.save(update_fields=['status'])
|
||||||
|
Version.objects.create(
|
||||||
|
project=project,
|
||||||
|
version_name=version_form.cleaned_data['version_name'],
|
||||||
|
file=version_index,
|
||||||
|
changelog=version_form.cleaned_data['changelog'],
|
||||||
|
)
|
||||||
|
|
||||||
|
caption = data.get('caption', '')
|
||||||
|
for temp in TempUpload.objects.filter(
|
||||||
|
user=request.user, status='pending', kind='media',
|
||||||
|
).select_related('file').order_by('created_at'):
|
||||||
|
index = _adopt_temp(temp, project.pk, 'gallery', 'asset')
|
||||||
|
ProjectAsset.objects.create(
|
||||||
|
project=project, file=index,
|
||||||
|
caption=caption, uploaded_by=request.user,
|
||||||
|
)
|
||||||
|
temp.status = 'used'
|
||||||
|
temp.save(update_fields=['status'])
|
||||||
|
|
||||||
|
_apply_tags(project, data['tags'], request.user)
|
||||||
|
project.sync_creator_tags(actor=request.user)
|
||||||
|
ProjectDraft.objects.filter(user=request.user).delete()
|
||||||
|
messages.success(request, 'Project created.')
|
||||||
|
return redirect('library:project_detail', slug=project.slug)
|
||||||
|
else:
|
||||||
|
draft = ProjectDraft.objects.filter(user=request.user).first()
|
||||||
|
if draft:
|
||||||
|
initial = {k: draft.data.get(k, '') for k in DRAFT_KEYS}
|
||||||
|
project_form = ProjectForm(initial=initial)
|
||||||
|
version_form = VersionForm(
|
||||||
|
initial={
|
||||||
|
k: initial[k] for k in ('version_name', 'changelog') if k in initial
|
||||||
|
},
|
||||||
|
required_file=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(request, 'library/project_create.html', {
|
||||||
|
'project_form': project_form,
|
||||||
|
'version_form': version_form,
|
||||||
|
'draft_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)],
|
||||||
|
'draft_data': _form_values(project_form, version_form),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def project_edit(request, slug):
|
||||||
|
project = get_object_or_404(Project, slug=slug)
|
||||||
|
if not project.can_edit(request.user):
|
||||||
|
return HttpResponseForbidden('You do not have permission to edit this project.')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ProjectForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
data = form.cleaned_data
|
||||||
|
project.title = data['title']
|
||||||
|
project.summary = data['summary']
|
||||||
|
project.description = data['description']
|
||||||
|
project.category = data['category']
|
||||||
|
thumbnail = data.get('thumbnail')
|
||||||
|
if thumbnail:
|
||||||
|
delete_file_index(project.thumbnail)
|
||||||
|
project.thumbnail = store_file(
|
||||||
|
request.user, 'thumbnail', thumbnail, project.pk, subdir='',
|
||||||
|
)
|
||||||
|
project.save()
|
||||||
|
_apply_tags(project, data['tags'], request.user)
|
||||||
|
project.sync_creator_tags(actor=request.user)
|
||||||
|
messages.success(request, 'Project updated.')
|
||||||
|
return redirect('library:project_detail', slug=project.slug)
|
||||||
|
else:
|
||||||
|
form = ProjectForm(initial={
|
||||||
|
'title': project.title,
|
||||||
|
'summary': project.summary,
|
||||||
|
'description': project.description,
|
||||||
|
'category': project.category,
|
||||||
|
'tags': _current_tags(project),
|
||||||
|
})
|
||||||
|
return render(request, 'library/project_edit.html', {
|
||||||
|
'project': project,
|
||||||
|
'project_form': form,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def project_delete(request, slug):
|
||||||
|
project = get_object_or_404(Project, slug=slug)
|
||||||
|
if request.user != project.owner:
|
||||||
|
return HttpResponseForbidden('Only the owner can delete this project.')
|
||||||
|
if request.method == 'POST':
|
||||||
|
indices = []
|
||||||
|
if project.thumbnail_id:
|
||||||
|
indices.append(project.thumbnail)
|
||||||
|
indices += [v.file for v in project.versions.all() if v.file_id]
|
||||||
|
indices += [a.file for a in project.assets.all() if a.file_id]
|
||||||
|
project.delete()
|
||||||
|
for index in indices:
|
||||||
|
delete_file_index(index)
|
||||||
|
messages.success(request, 'Project deleted.')
|
||||||
|
return redirect('library:browse')
|
||||||
|
return render(request, 'library/project_delete.html', {'project': project})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def version_upload(request, slug):
|
||||||
|
project = get_object_or_404(Project, slug=slug)
|
||||||
|
if not project.can_edit(request.user):
|
||||||
|
return HttpResponseForbidden('You do not have permission to upload versions.')
|
||||||
|
|
||||||
|
form = VersionForm()
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = VersionForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
index = store_file(
|
||||||
|
request.user, 'version', form.cleaned_data['file'],
|
||||||
|
project.pk, subdir='versions',
|
||||||
|
)
|
||||||
|
Version.objects.create(
|
||||||
|
project=project,
|
||||||
|
version_name=form.cleaned_data['version_name'],
|
||||||
|
file=index,
|
||||||
|
changelog=form.cleaned_data['changelog'],
|
||||||
|
)
|
||||||
|
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
|
||||||
|
return redirect('library:project_detail', slug=project.slug)
|
||||||
|
return render(request, 'library/version_upload.html', {
|
||||||
|
'project': project,
|
||||||
|
'version_form': form,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def version_download(request, slug, version_id):
|
||||||
|
version = get_object_or_404(
|
||||||
|
Version.objects.select_related('file', 'project'),
|
||||||
|
pk=version_id, project__slug=slug,
|
||||||
|
)
|
||||||
|
if version.file_id:
|
||||||
|
Version.objects.filter(pk=version.pk).update(downloads=F('downloads') + 1)
|
||||||
|
return redirect(
|
||||||
|
reverse('library:file_request', args=[version.file.uuid]) + '?download=1'
|
||||||
|
)
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def asset_upload(request, slug):
|
||||||
|
project = get_object_or_404(Project, slug=slug)
|
||||||
|
if not project.can_edit(request.user):
|
||||||
|
return HttpResponseForbidden('You do not have permission to add media.')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
caption = request.POST.get('caption', '')
|
||||||
|
media = TempUpload.objects.filter(user=request.user, status='pending', kind='media')
|
||||||
|
if not media.exists():
|
||||||
|
messages.error(request, 'No media uploaded yet.')
|
||||||
|
else:
|
||||||
|
count = 0
|
||||||
|
for temp in media.select_related('file'):
|
||||||
|
index = _adopt_temp(temp, project.pk, 'gallery', 'asset')
|
||||||
|
ProjectAsset.objects.create(
|
||||||
|
project=project, file=index,
|
||||||
|
caption=caption, uploaded_by=request.user,
|
||||||
|
)
|
||||||
|
temp.status = 'used'
|
||||||
|
temp.save(update_fields=['status'])
|
||||||
|
count += 1
|
||||||
|
messages.success(request, f'{count} media file{"s" if count != 1 else ""} added to the gallery.')
|
||||||
|
return redirect('library:project_detail', slug=project.slug)
|
||||||
|
|
||||||
|
return render(request, 'library/asset_upload.html', {
|
||||||
|
'project': project,
|
||||||
|
'caption': request.POST.get('caption', ''),
|
||||||
|
'pending_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def asset_delete(request, slug, asset_id):
|
||||||
|
project = get_object_or_404(Project, slug=slug)
|
||||||
|
asset = get_object_or_404(ProjectAsset, pk=asset_id, project=project)
|
||||||
|
if not project.can_edit(request.user):
|
||||||
|
return HttpResponseForbidden('You do not have permission to delete media.')
|
||||||
|
delete_file_index(asset.file)
|
||||||
|
asset.delete()
|
||||||
|
return redirect('library:project_detail', slug=project.slug)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def contributors(request, slug):
|
||||||
|
project = get_object_or_404(Project, slug=slug)
|
||||||
|
if request.user != project.owner:
|
||||||
|
return HttpResponseForbidden('Only the owner can manage contributors.')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
action = request.POST.get('action')
|
||||||
|
if action == 'add':
|
||||||
|
username = request.POST.get('username', '').strip()
|
||||||
|
user = get_user_model().objects.filter(username__iexact=username).first()
|
||||||
|
if user is None:
|
||||||
|
messages.error(request, f'No account named "{username}".')
|
||||||
|
elif user == project.owner:
|
||||||
|
messages.error(request, 'The owner is already a contributor.')
|
||||||
|
else:
|
||||||
|
_, created = ProjectContributor.objects.get_or_create(
|
||||||
|
project=project, user=user, defaults={'added_by': request.user},
|
||||||
|
)
|
||||||
|
project.sync_creator_tags(actor=request.user)
|
||||||
|
if created:
|
||||||
|
messages.success(request, f'{user.username} added as a contributor.')
|
||||||
|
else:
|
||||||
|
messages.info(request, f'{user.username} is already a contributor.')
|
||||||
|
elif action == 'remove':
|
||||||
|
contributor = ProjectContributor.objects.filter(
|
||||||
|
pk=request.POST.get('contributor_id'), project=project,
|
||||||
|
).first()
|
||||||
|
if contributor is not None:
|
||||||
|
user = contributor.user
|
||||||
|
contributor.delete()
|
||||||
|
project.detach_creator_tag(user)
|
||||||
|
project.sync_creator_tags(actor=request.user)
|
||||||
|
messages.success(request, f'{user.username} removed.')
|
||||||
|
return redirect('library:contributors', slug=project.slug)
|
||||||
|
|
||||||
|
return render(request, 'library/contributors.html', {
|
||||||
|
'project': project,
|
||||||
|
'contributors': project.contributors.select_related('user').all(),
|
||||||
|
'contributor_form': ContributorForm(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- API (autocomplete) ----------
|
||||||
|
|
||||||
|
def api_tags_autocomplete(request):
|
||||||
|
q = request.GET.get('q', '').strip()
|
||||||
|
tags = Tag.objects.select_related('category').order_by('name')
|
||||||
|
if q:
|
||||||
|
tags = tags.filter(Q(name__icontains=q) | Q(category__name__icontains=q))
|
||||||
|
data = [
|
||||||
|
{'name': t.name, 'category': t.category.slug, 'category_name': t.category.name, 'color': t.category.color}
|
||||||
|
for t in tags[:20]
|
||||||
|
]
|
||||||
|
return JsonResponse(data, safe=False)
|
||||||
|
|
||||||
|
|
||||||
|
def api_users_autocomplete(request):
|
||||||
|
q = request.GET.get('q', '').strip()
|
||||||
|
users = get_user_model().objects.filter(username__icontains=q).order_by('username')[:20]
|
||||||
|
return JsonResponse([{'username': u.username} for u in users], safe=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- API (draft uploads + autosave) ----------
|
||||||
|
|
||||||
|
MEDIA_PREFIXES = ('image/', 'video/')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def api_upload_temp(request):
|
||||||
|
"""Accept one file (J621-style: the JS uploads each file individually)."""
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'error': 'Method not allowed'}, status=405)
|
||||||
|
uploaded = request.FILES.get('file')
|
||||||
|
if uploaded is None:
|
||||||
|
return JsonResponse({'error': 'No file'}, status=400)
|
||||||
|
|
||||||
|
kind = request.POST.get('kind', 'media')
|
||||||
|
if kind not in dict(TempUpload.KIND_CHOICES):
|
||||||
|
kind = 'media'
|
||||||
|
|
||||||
|
content_type = getattr(uploaded, 'content_type', '') or ''
|
||||||
|
if kind in ('thumbnail', 'media'):
|
||||||
|
if not content_type.startswith(MEDIA_PREFIXES):
|
||||||
|
return JsonResponse({'error': 'Only images, videos and gifs are allowed here.'}, status=400)
|
||||||
|
if kind == 'thumbnail' and not content_type.startswith('image/'):
|
||||||
|
return JsonResponse({'error': 'The thumbnail must be an image.'}, status=400)
|
||||||
|
|
||||||
|
# Thumbnail and version are single slots: discard any earlier pending one.
|
||||||
|
if kind in ('thumbnail', 'version'):
|
||||||
|
for old in TempUpload.objects.filter(user=request.user, status='pending', kind=kind):
|
||||||
|
delete_file_index(old.file)
|
||||||
|
old.delete()
|
||||||
|
|
||||||
|
index = store_temp_file(request.user, kind, uploaded)
|
||||||
|
upload = TempUpload.objects.create(user=request.user, kind=kind, file=index)
|
||||||
|
return JsonResponse(_serialize_temp(upload))
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def api_delete_temp(request, upload_uuid):
|
||||||
|
upload = get_object_or_404(TempUpload, uuid=upload_uuid, user=request.user)
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'error': 'Method not allowed'}, status=405)
|
||||||
|
if upload.status == 'pending':
|
||||||
|
delete_file_index(upload.file)
|
||||||
|
upload.delete()
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def api_draft(request):
|
||||||
|
if request.method == 'GET':
|
||||||
|
draft = ProjectDraft.objects.filter(user=request.user).first()
|
||||||
|
return JsonResponse({
|
||||||
|
'data': draft.data if draft else {},
|
||||||
|
'uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)],
|
||||||
|
})
|
||||||
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
|
payload = json.loads(request.body or b'{}')
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
payload = {}
|
||||||
|
data = payload.get('data') or {}
|
||||||
|
clean = {k: str(data.get(k, '')) for k in DRAFT_KEYS if k in data}
|
||||||
|
draft, _ = ProjectDraft.objects.get_or_create(user=request.user)
|
||||||
|
draft.data = clean
|
||||||
|
draft.save()
|
||||||
|
return JsonResponse({
|
||||||
|
'saved': True,
|
||||||
|
'updated_at': draft.updated_at.isoformat(),
|
||||||
|
})
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
ProjectDraft.objects.filter(user=request.user).delete()
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
return JsonResponse({'error': 'Method not allowed'}, status=405)
|
||||||
|
|||||||
@@ -298,6 +298,56 @@ class ProfilePageTests(GatedTestCase):
|
|||||||
self.alice.userprofile.refresh_from_db()
|
self.alice.userprofile.refresh_from_db()
|
||||||
self.assertEqual(self.alice.userprofile.bio, fancy)
|
self.assertEqual(self.alice.userprofile.bio, fancy)
|
||||||
|
|
||||||
|
def _set_bio(self, text):
|
||||||
|
self.alice.userprofile.bio = text
|
||||||
|
self.alice.userprofile.save()
|
||||||
|
|
||||||
|
def test_bio_renders_markdown(self):
|
||||||
|
self._set_bio('# Hi\n\n**bold** and *italic* and `code`')
|
||||||
|
self.gate()
|
||||||
|
resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
|
||||||
|
self.assertContains(resp, '<h1>Hi</h1>', html=True)
|
||||||
|
self.assertContains(resp, '<strong>bold</strong>', html=True)
|
||||||
|
self.assertContains(resp, '<em>italic</em>', html=True)
|
||||||
|
|
||||||
|
def test_bio_raw_html_escaped(self):
|
||||||
|
self._set_bio('<script>alert(1)</script>')
|
||||||
|
self.gate()
|
||||||
|
resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
|
||||||
|
self.assertNotContains(resp, '<script>alert(1)</script>', html=True)
|
||||||
|
self.assertContains(resp, '<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):
|
class FileRequestTests(MediaTestCase, GatedTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ urlpatterns = [
|
|||||||
path('settings/tokens/create/', views.token_create, name='token_create'),
|
path('settings/tokens/create/', views.token_create, name='token_create'),
|
||||||
path('settings/tokens/<int:token_id>/delete/', views.token_delete, name='token_delete'),
|
path('settings/tokens/<int:token_id>/delete/', views.token_delete, name='token_delete'),
|
||||||
path('profiles/<str:username>/', views.user_profile, name='user_profile'),
|
path('profiles/<str:username>/', views.user_profile, name='user_profile'),
|
||||||
|
path('settings/account/bio/preview/', views.bio_preview, name='bio_preview'),
|
||||||
]
|
]
|
||||||
@@ -8,8 +8,10 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm
|
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
|
||||||
|
from common.markdown import render_markdown
|
||||||
from library.models import FileIndex
|
from library.models import FileIndex
|
||||||
|
|
||||||
from .forms import AvatarForm, BioForm, EmailForm, UsernameForm
|
from .forms import AvatarForm, BioForm, EmailForm, UsernameForm
|
||||||
@@ -200,4 +202,16 @@ def user_profile(request, username):
|
|||||||
'profile_user': user,
|
'profile_user': user,
|
||||||
'is_owner': is_owner,
|
'is_owner': is_owner,
|
||||||
'bio_form': bio_form,
|
'bio_form': bio_form,
|
||||||
|
'projects': user.projects.select_related('owner', 'thumbnail').prefetch_related(
|
||||||
|
'tag_links__tag__category', 'versions',
|
||||||
|
).all(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def bio_preview(request):
|
||||||
|
"""Return the markdown-rendered HTML for a bio draft (editor live preview)."""
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'detail': 'Method not allowed'}, status=405)
|
||||||
|
text = request.POST.get('bio', '')
|
||||||
|
return JsonResponse({'html': render_markdown(text)})
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 134 KiB |
@@ -1840,7 +1840,6 @@ a.deletelink {
|
|||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
.profile-bio {
|
.profile-bio {
|
||||||
white-space: pre-wrap;
|
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -1848,6 +1847,110 @@ a.deletelink {
|
|||||||
color: var(--md-sys-color-on-surface-variant);
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== Markdown ========== */
|
||||||
|
.markdown-body {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
.markdown-body > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.markdown-body > *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.markdown-body h1,
|
||||||
|
.markdown-body h2,
|
||||||
|
.markdown-body h3,
|
||||||
|
.markdown-body h4,
|
||||||
|
.markdown-body h5,
|
||||||
|
.markdown-body h6 {
|
||||||
|
margin: 1em 0 0.4em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.markdown-body h1 { font-size: 1.6rem; }
|
||||||
|
.markdown-body h2 { font-size: 1.35rem; }
|
||||||
|
.markdown-body h3 { font-size: 1.15rem; }
|
||||||
|
.markdown-body h4 { font-size: 1rem; }
|
||||||
|
.markdown-body p {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.markdown-body ul,
|
||||||
|
.markdown-body ol {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
.markdown-body li {
|
||||||
|
margin: 0.15em 0;
|
||||||
|
}
|
||||||
|
.markdown-body a {
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.markdown-body code {
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.markdown-body pre {
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.6em 0;
|
||||||
|
}
|
||||||
|
.markdown-body pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.markdown-body blockquote {
|
||||||
|
border-left: 3px solid var(--md-sys-color-primary);
|
||||||
|
margin: 0.6em 0;
|
||||||
|
padding: 0.2em 1em;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
.markdown-body table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.6em 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.markdown-body th,
|
||||||
|
.markdown-body td {
|
||||||
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.markdown-body th {
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
}
|
||||||
|
.markdown-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.markdown-body hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--md-sys-color-outline);
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
.bio-help {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
}
|
||||||
|
.bio-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.markdown-preview {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
.pack-card-empty {
|
.pack-card-empty {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -1860,3 +1963,671 @@ a.deletelink {
|
|||||||
color: var(--md-sys-color-on-surface-variant);
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
background: var(--md-sys-color-surface-variant);
|
background: var(--md-sys-color-surface-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== UGC / Browse ========== */
|
||||||
|
.pack-card-thumb-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
.pack-card-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.tag-chip.active {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.tag-chip-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
.tag-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.tag-count {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Browse layout */
|
||||||
|
.browse-header h1 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.browse-search-form {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.browse-search-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
.browse-search-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.browse-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.browse-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
}
|
||||||
|
.filter-card h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.filter-category {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.filter-link {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
.filter-link:hover {
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
}
|
||||||
|
.filter-link.active {
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.filter-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.filter-empty {
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.browse-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.browse-count {
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.browse-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
.browse-empty i {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
.page-info {
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Project detail */
|
||||||
|
.project-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.project-avatar img {
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid var(--md-sys-color-outline);
|
||||||
|
}
|
||||||
|
.project-avatar .default-avatar {
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
font-size: 3rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.project-header-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.project-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.project-title-row h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.category-badge {
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
color: var(--md-sys-color-on-primary);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.project-summary {
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.project-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.project-owner a {
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.project-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tags {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.tag-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.tag-group-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-outline);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
border-bottom-color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
.tab-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tab-panel.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Versions */
|
||||||
|
.version-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
}
|
||||||
|
.version-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.version-info {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
.version-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.version-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.latest-badge {
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
color: var(--md-sys-color-on-primary);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.version-date {
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.version-changelog {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.version-changelog > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.version-changelog > *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gallery */
|
||||||
|
.gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.gallery-item {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.gallery-item img,
|
||||||
|
.gallery-item video {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
max-height: 260px;
|
||||||
|
}
|
||||||
|
.gallery-item figcaption {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
.gallery-delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.gallery-delete:hover {
|
||||||
|
background: var(--md-sys-color-error);
|
||||||
|
}
|
||||||
|
.empty-hint {
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.project-form {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
.form-section-title {
|
||||||
|
margin: 24px 0 8px;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--md-sys-color-error-container);
|
||||||
|
color: var(--md-sys-color-on-error-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag editor */
|
||||||
|
.tag-editor {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.tag-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.tag-suggestions {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
.tag-suggestion {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
.tag-suggestion:hover {
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
}
|
||||||
|
.tag-suggestion small {
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contributors */
|
||||||
|
.contributor-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.contributor-input-group {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
.contributor-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.contributor-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
}
|
||||||
|
.contributor-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.contributor-name i {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.inline-form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.user-suggestions {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 50;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.browse-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.browse-filters {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
.project-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.version-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Horizontal project form ========== */
|
||||||
|
.project-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.project-form-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.project-form-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.project-form-grid {
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.project-form-side {
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Multi-upload drop zone ========== */
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed var(--md-sys-color-outline);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px 24px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
.drop-zone:hover,
|
||||||
|
.drop-zone.dragover {
|
||||||
|
border-color: var(--md-sys-color-primary);
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
}
|
||||||
|
.drop-zone-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
.drop-zone-text {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.drop-zone-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.file-preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.file-preview-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
.file-preview-card img,
|
||||||
|
.file-preview-card video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.file-preview-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
.file-preview-name {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 3px 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.file-preview-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.file-preview-remove:hover {
|
||||||
|
background: var(--md-sys-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Horizontal create form ========== */
|
||||||
|
.create-form {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.create-form input:not([type='hidden']):not([type='file']),
|
||||||
|
.create-form select,
|
||||||
|
.create-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.create-form .form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.create-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.create-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--md-sys-color-surface-container-low);
|
||||||
|
border: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
align-self: start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.create-panel .form-section-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.create-grid { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
|
||||||
|
.create-panel-main { grid-row: span 2; }
|
||||||
|
}
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.create-grid { grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1.2fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Upload zones ========== */
|
||||||
|
.upload-zone .drop-zone { padding: 24px 16px; }
|
||||||
|
.upload-zone .drop-zone-icon { font-size: 1.8rem; margin-bottom: 4px; }
|
||||||
|
.upload-preview { margin-top: 12px; }
|
||||||
|
.upload-preview .file-preview-card { max-width: 200px; }
|
||||||
|
|
||||||
|
.upload-batch[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.upload-batch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Autosave status ========== */
|
||||||
|
.autosave-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
.autosave-status.saved { color: var(--md-sys-color-primary); }
|
||||||
|
.autosave-status.error { color: var(--md-sys-color-error); }
|
||||||
|
.autosave-status.idle { color: var(--md-sys-color-on-surface-variant); }
|
||||||
|
|||||||
@@ -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' %}">
|
<a href="{% url 'landing:home' %}">
|
||||||
<i class="fas fa-home"></i><span class="nav-text"> Home</span>
|
<i class="fas fa-home"></i><span class="nav-text"> Home</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{% url 'library:browse' %}">
|
||||||
|
<i class="fas fa-compass"></i><span class="nav-text"> Browse</span>
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
<p>Hosted datapacks for Needs Of Nature & Animation Director.</p>
|
<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>
|
<p class="home-hero-sub">Browse and download packs shared by the community. Upload your own once you have a creator account.</p>
|
||||||
<div class="home-hero-actions">
|
<div class="home-hero-actions">
|
||||||
<a href="#" class="btn btn-primary"><i class="fas fa-download"></i> Browse packs</a>
|
<a href="{% url 'library:browse' %}" class="btn btn-primary"><i class="fas fa-download"></i> Browse packs</a>
|
||||||
<a href="#" class="btn btn-secondary"><i class="fas fa-upload"></i> Upload a pack</a>
|
<a href="{% url 'library:project_create' %}" class="btn btn-secondary"><i class="fas fa-upload"></i> Upload a pack</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -19,22 +19,22 @@
|
|||||||
<div class="home-stats">
|
<div class="home-stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<i class="fas fa-users"></i>
|
<i class="fas fa-users"></i>
|
||||||
<span class="stat-value">0</span>
|
<span class="stat-value">{{ stats.creators }}</span>
|
||||||
<span class="stat-label">Creators</span>
|
<span class="stat-label">Creators</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<i class="fas fa-box-open"></i>
|
<i class="fas fa-box-open"></i>
|
||||||
<span class="stat-value">0</span>
|
<span class="stat-value">{{ stats.packs }}</span>
|
||||||
<span class="stat-label">Packs</span>
|
<span class="stat-label">Packs</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
<span class="stat-value">0</span>
|
<span class="stat-value">{{ stats.downloads }}</span>
|
||||||
<span class="stat-label">Downloads</span>
|
<span class="stat-label">Downloads</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<i class="fas fa-tag"></i>
|
<i class="fas fa-tag"></i>
|
||||||
<span class="stat-value">0</span>
|
<span class="stat-value">{{ stats.versions }}</span>
|
||||||
<span class="stat-label">Versions</span>
|
<span class="stat-label">Versions</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,70 +42,23 @@
|
|||||||
<div class="home-section">
|
<div class="home-section">
|
||||||
<div class="home-section-header">
|
<div class="home-section-header">
|
||||||
<h2>Latest packs</h2>
|
<h2>Latest packs</h2>
|
||||||
<a href="#" class="home-section-link">View all <i class="fas fa-arrow-right"></i></a>
|
<a href="{% url 'library:browse' %}" class="home-section-link">View all <i class="fas fa-arrow-right"></i></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pack-grid">
|
<div class="pack-grid">
|
||||||
{% comment %} Example cards — replace with real Pack objects when the library model lands. {% endcomment %}
|
{% for project in latest_projects %}
|
||||||
<a href="#" class="pack-card">
|
{% include 'library/_project_card.html' %}
|
||||||
|
{% empty %}
|
||||||
|
<div class="pack-card pack-card-empty">
|
||||||
<div class="pack-card-thumb">
|
<div class="pack-card-thumb">
|
||||||
<img src="{% static 'Logo.png' %}" alt="">
|
<i class="fas fa-box-open"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="pack-card-body">
|
<div class="pack-card-body">
|
||||||
<h3>Example Pack</h3>
|
<h3>No packs yet</h3>
|
||||||
<p class="pack-card-author"><i class="fas fa-user"></i> a_creator</p>
|
<p class="pack-card-desc">Be the first to share a pack.</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
{% endfor %}
|
||||||
|
|
||||||
<a href="#" class="pack-card">
|
|
||||||
<div class="pack-card-thumb">
|
|
||||||
<img src="{% static 'Logo.png' %}" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="pack-card-body">
|
|
||||||
<h3>Another Pack</h3>
|
|
||||||
<p class="pack-card-author"><i class="fas fa-user"></i> someone</p>
|
|
||||||
<p class="pack-card-desc">This card shows what the library grid will look like.</p>
|
|
||||||
<div class="pack-card-meta">
|
|
||||||
<span class="pack-card-version"><i class="fas fa-tag"></i> v0.9.2</span>
|
|
||||||
<span class="pack-card-downloads"><i class="fas fa-download"></i> 0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="#" class="pack-card">
|
|
||||||
<div class="pack-card-thumb">
|
|
||||||
<img src="{% static 'Logo.png' %}" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="pack-card-body">
|
|
||||||
<h3>Third Example</h3>
|
|
||||||
<p class="pack-card-author"><i class="fas fa-user"></i> dev</p>
|
|
||||||
<p class="pack-card-desc">Static assets, profile pictures and zip downloads land under /media/<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% load static %}
|
||||||
|
<a href="{% url 'library:project_detail' project.slug %}" class="pack-card">
|
||||||
|
<div class="pack-card-thumb">
|
||||||
|
{% if project.thumbnail %}
|
||||||
|
<img src="{{ project.thumbnail_url }}" alt="">
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-box-open pack-card-thumb-icon"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="pack-card-body">
|
||||||
|
<h3>{{ project.title }}</h3>
|
||||||
|
<p class="pack-card-author"><i class="fas fa-user"></i> {{ project.owner.username }}</p>
|
||||||
|
<p class="pack-card-desc">{{ project.summary|default:"No description yet." }}</p>
|
||||||
|
<div class="pack-card-tags">
|
||||||
|
{% for link in project.tag_links.all|slice:":4" %}
|
||||||
|
<span class="tag-chip" style="border-color: {{ link.tag.category.color }}; color: {{ link.tag.category.color }};">{{ link.tag.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="pack-card-meta">
|
||||||
|
<span class="pack-card-version"><i class="fas fa-tag"></i> {{ project.versions.0.version_name|default:"no versions" }}</span>
|
||||||
|
<span class="pack-card-downloads"><i class="fas fa-download"></i> {{ project.downloads }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<div class="form-group">
|
||||||
|
{{ form.title.errors }}
|
||||||
|
<label for="{{ form.title.id_for_label }}">Title</label>
|
||||||
|
{{ form.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.summary.errors }}
|
||||||
|
<label for="{{ form.summary.id_for_label }}">Summary</label>
|
||||||
|
{{ form.summary }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.category.errors }}
|
||||||
|
<label for="{{ form.category.id_for_label }}">Category</label>
|
||||||
|
{{ form.category }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.description.errors }}
|
||||||
|
<label for="{{ form.description.id_for_label }}">Description</label>
|
||||||
|
{{ form.description }}
|
||||||
|
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown — headings, lists, links, code, tables and more.</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<div class="form-group">
|
||||||
|
{{ form.thumbnail.errors }}
|
||||||
|
<label for="{{ form.thumbnail.id_for_label }}">Thumbnail</label>
|
||||||
|
{{ form.thumbnail }}
|
||||||
|
<p class="bio-help"><i class="fas fa-info-circle"></i> Square image shown on cards and the project header.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Tags</label>
|
||||||
|
<div class="tag-editor">
|
||||||
|
<input type="text" id="tag-input" placeholder="Type a tag and press Enter… (e.g. content:threesome, species:dragon)" autocomplete="off">
|
||||||
|
<div class="tag-suggestions" id="tag-suggestions" hidden></div>
|
||||||
|
<div class="tag-chips" id="tag-chips"></div>
|
||||||
|
{{ form.tags }}
|
||||||
|
</div>
|
||||||
|
<p class="bio-help"><i class="fas fa-info-circle"></i> Categories: loader, version, general, creator, content, species, non_version. Unprefixed tags go to General. Creator tags are added automatically.</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<div class="form-group">
|
||||||
|
{{ form.version_name.errors }}
|
||||||
|
<label for="{{ form.version_name.id_for_label }}">{{ form.version_name.label }}</label>
|
||||||
|
{{ form.version_name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.file.errors }}
|
||||||
|
<label for="{{ form.file.id_for_label }}">{{ form.file.label }}</label>
|
||||||
|
{{ form.file }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.changelog.errors }}
|
||||||
|
<label for="{{ form.changelog.id_for_label }}">{{ form.changelog.label }}</label>
|
||||||
|
{{ form.changelog }}
|
||||||
|
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown.</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Add Media - {{ project.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1><i class="fas fa-images"></i> Add media — {{ project.title }}</h1>
|
||||||
|
<p><a href="{% url 'library:project_detail' project.slug %}">← 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' %}
|
{% extends 'base.html' %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load markdown %}
|
||||||
|
|
||||||
{% block title %}{{ profile_user.username }} - Packs{% endblock %}
|
{% block title %}{{ profile_user.username }} - Packs{% endblock %}
|
||||||
|
|
||||||
@@ -38,11 +39,16 @@
|
|||||||
{{ bio_form.bio.errors }}
|
{{ bio_form.bio.errors }}
|
||||||
{{ bio_form.bio }}
|
{{ bio_form.bio }}
|
||||||
</div>
|
</div>
|
||||||
|
<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="submit" class="btn btn-primary">Save bio</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bio-toggle>Preview</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<div class="markdown-preview markdown-body" id="bio-preview" hidden></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if profile.bio %}
|
{% if profile.bio %}
|
||||||
<p class="profile-bio">{{ profile.bio }}</p>
|
<div class="profile-bio markdown-body">{{ profile.bio|markdown }}</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="profile-bio-empty"><i class="fas fa-user"></i> {{ profile_user.username }} hasn't written a bio yet.</p>
|
<p class="profile-bio-empty"><i class="fas fa-user"></i> {{ profile_user.username }} hasn't written a bio yet.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -54,16 +60,67 @@
|
|||||||
<h2>Packs by {{ profile_user.username }}</h2>
|
<h2>Packs by {{ profile_user.username }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="pack-grid">
|
<div class="pack-grid">
|
||||||
{% comment %} Placeholder — replace with real Pack objects when the library model lands. {% endcomment %}
|
{% for project in projects %}
|
||||||
|
{% include 'library/_project_card.html' %}
|
||||||
|
{% empty %}
|
||||||
<div class="pack-card pack-card-empty">
|
<div class="pack-card pack-card-empty">
|
||||||
<div class="pack-card-thumb">
|
<div class="pack-card-thumb">
|
||||||
<i class="fas fa-box-open"></i>
|
<i class="fas fa-box-open"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="pack-card-body">
|
<div class="pack-card-body">
|
||||||
<h3>No packs yet</h3>
|
<h3>No packs yet</h3>
|
||||||
<p class="pack-card-desc">This creator hasn't published any datapacks.</p>
|
<p class="pack-card-desc">This creator hasn't published anything yet.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{% if is_owner %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const textarea = document.getElementById('{{ bio_form.bio.id_for_label }}');
|
||||||
|
const preview = document.getElementById('bio-preview');
|
||||||
|
const toggleBtn = document.querySelector('[data-bio-toggle]');
|
||||||
|
if (!textarea || !preview || !toggleBtn) return;
|
||||||
|
|
||||||
|
const url = "{% url 'profiles:bio_preview' %}";
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function refreshPreview() {
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCookie('csrftoken'),
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: 'bio=' + encodeURIComponent(textarea.value),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => { preview.innerHTML = data.html; })
|
||||||
|
.catch(() => { preview.innerHTML = '<p><em>Preview unavailable.</em></p>'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleBtn.addEventListener('click', () => {
|
||||||
|
if (preview.hidden) {
|
||||||
|
preview.hidden = false;
|
||||||
|
toggleBtn.textContent = 'Hide preview';
|
||||||
|
refreshPreview();
|
||||||
|
} else {
|
||||||
|
preview.hidden = true;
|
||||||
|
toggleBtn.textContent = 'Preview';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
textarea.addEventListener('input', () => {
|
||||||
|
if (preview.hidden) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(refreshPreview, 300);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user