Projects list, creation, settings pages (#542)

* add projects list

* new project page

* mypy

* allow '.' in github identifiers

* implement project create

* project settings

* disallow anons from being project members

* uploadable project icon

* docker attempt

* fix tests

* add tests

* add description form

* refactor to add useEntity and FieldSet

* move FieldSet out of subdirectory

* use same page for project tabs

* scroll up to UnderlineNav when tab changes

* stylelint

* configure vscode mypy extension

* mypy

* fix mypy and dmypy

dmypy does not support follow_imports=silent. Instead we explicitly
disable most checks for asm_differ and m2c, which
has the same effect

* remove redundant mypy flags

* FieldSet style tweaks

* give UnderlineNav horiz padding

* fix swr mutate of project header

* few tweaks to help docker (#550)

* eth changes

* use POST/DELETE rather than PUT for project members

* add migration

* fix pr creation

* simplify project platform derivation

Co-authored-by: Mark Street <22226349+mkst@users.noreply.github.com>
Co-authored-by: Ethan Roseman <ethteck@gmail.com>
This commit is contained in:
Alex Bates
2022-10-07 12:12:18 +01:00
committed by GitHub
parent 28fa3a4ab3
commit 2e96373ac7
75 changed files with 2602 additions and 510 deletions
+1
View File
@@ -6,3 +6,4 @@ API_BASE=http://127.0.0.1:8000/api
USE_SANDBOX_JAIL=off
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
FRONTEND_USE_IMAGE_PROXY=true
+1 -1
View File
@@ -3,8 +3,8 @@ __pycache__
node_modules
build
.snowpack
local_files/
backend/local_files/
backend/media/
backend/virtualenvs/
backend/docker.dev.env
postgres/
+6
View File
@@ -34,4 +34,10 @@
"editor.formatOnSave": true,
},
"python.formatting.provider": "black",
"mypy.configFile": "backend/mypy.ini",
"mypy.runUsingActiveInterpreter": true,
"mypy.targets": [
"backend/coreapp",
"backend/decompme"
],
}
+1 -2
View File
@@ -39,6 +39,7 @@ RUN apt-get -y update && apt-get install -y \
binutils-aarch64-linux-gnu \
curl \
gcc-mips-linux-gnu \
git \
libnl-route-3-200 \
libprotobuf-dev \
netcat \
@@ -122,5 +123,3 @@ RUN if [ "${ENABLE_PS1_SUPPORT}" = "YES" ] || \
ENV PATH="$PATH:/etc/poetry/bin"
ENTRYPOINT ["/backend/docker_entrypoint.sh"]
# TODO: nginx server to proxy-pass frontend/backend in order to preserve cookies
+9 -1
View File
@@ -1,8 +1,9 @@
from sqlite3 import IntegrityError
from subprocess import CalledProcessError
from typing import Any, ClassVar, Optional
from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR
from rest_framework.views import exception_handler
@@ -20,6 +21,13 @@ def custom_exception_handler(exc: Exception, context: Any) -> Optional[Response]
},
status=HTTP_400_BAD_REQUEST,
)
elif isinstance(exc, AssertionError) or isinstance(exc, IntegrityError):
response = Response(
data={
"detail": str(exc),
},
status=HTTP_500_INTERNAL_SERVER_ERROR,
)
if response is not None:
response.data["kind"] = exc.__class__.__name__
+1 -3
View File
@@ -68,10 +68,8 @@ def set_user_profile(
profile = Profile.objects.filter(id=id).first()
profile_user = User.objects.filter(profile=profile).first()
# If the request is logged out but the profile stored in their session
# references a user, don't use that profile
if profile_user and request.user.is_anonymous:
profile = None
request.user = profile_user
# If we still don't have a profile, create a new one
if not profile:
@@ -0,0 +1,74 @@
# Generated by Django 4.1 on 2022-09-18 22:47
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from django.apps.registry import Apps
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
import django.db.migrations.operations.special
def move_to_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
"""
For ProjectMembers with a non-anonymous profile, move the profile to the user field. Delete any anonymous ones.
"""
ProjectMember = apps.get_model("coreapp", "ProjectMember")
for row in ProjectMember.objects.all():
if row.profile.user:
row.user = row.profile.user
row.save()
else:
row.delete()
def delete_all_project_members(
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
"""
Delete all ProjectMembers.
"""
ProjectMember = apps.get_model("coreapp", "ProjectMember")
ProjectMember.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("coreapp", "0025_profile_pseudonym"),
]
operations = [
migrations.RemoveConstraint(
model_name="projectmember",
name="unique_project_member",
),
migrations.AddField(
model_name="projectmember",
name="user",
field=models.ForeignKey(
default=0,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
preserve_default=False,
),
migrations.RunPython(
code=move_to_user,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.RemoveField(
model_name="projectmember",
name="profile",
),
migrations.AddConstraint(
model_name="projectmember",
constraint=models.UniqueConstraint(
fields=("project", "user"), name="unique_project_member"
),
),
migrations.RunPython(
code=django.db.migrations.operations.special.RunPython.noop,
reverse_code=delete_all_project_members,
),
]
@@ -0,0 +1,32 @@
# Generated by Django 4.1 on 2022-09-19 01:27
from django.db import migrations
import django_resized.forms
class Migration(migrations.Migration):
dependencies = [
("coreapp", "0026_project_member_no_anons"),
]
operations = [
migrations.RemoveField(
model_name="project",
name="icon_url",
),
migrations.AddField(
model_name="project",
name="icon",
field=django_resized.forms.ResizedImageField(
crop=None,
force_format="WEBP",
keep_meta=False,
null=True,
quality=100,
scale=1.0,
size=[256, 256],
upload_to="project_icons",
),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 4.1 on 2022-10-06 11:22
import coreapp.models.project
from django.db import migrations
import django_resized.forms
class Migration(migrations.Migration):
dependencies = [
("coreapp", "0027_uploadable_project_icon"),
]
operations = [
migrations.AlterField(
model_name="project",
name="icon",
field=django_resized.forms.ResizedImageField(
crop=None,
force_format="WEBP",
keep_meta=False,
null=True,
quality=100,
scale=1.0,
size=[256, 256],
upload_to=coreapp.models.project.icon_path,
),
),
]
+22 -22
View File
@@ -6,6 +6,8 @@ from pathlib import Path
from typing import List, Optional, Tuple
from django.db import models, transaction
from django.contrib.auth.models import User
from django_resized import ResizedImageField
from ..context import c_file_to_context
from ..symbol_addrs import parse_symbol_addrs, symbol_name_from_asm_file
@@ -16,18 +18,25 @@ from .scratch import CompilerConfig, Scratch
logger = logging.getLogger(__name__)
def icon_path(instance: "Project", filename: str) -> str:
return instance.slug + "_" + filename
class Project(models.Model):
slug = models.SlugField(primary_key=True)
creation_time = models.DateTimeField(auto_now_add=True)
repo = models.OneToOneField("GithubRepo", on_delete=models.PROTECT)
icon_url = models.URLField(blank=False)
icon = ResizedImageField(size=[256, 256], upload_to=icon_path, null=True)
description = models.TextField(default="", blank=True, max_length=1000)
def __str__(self) -> str:
return self.slug
def get_url(self) -> str:
return f"/projects/{self.slug}"
def get_html_url(self) -> str:
return f"/{self.slug}"
return f"/projects/{self.slug}"
@transaction.atomic
def import_functions(self) -> None:
@@ -41,7 +50,10 @@ class Project(models.Model):
import_config.execute_import()
def is_member(self, profile: Profile) -> bool:
return ProjectMember.objects.filter(project=self, profile=profile).exists()
for member in self.members():
if member.user.profile == profile:
return True
return False
def members(self) -> List["ProjectMember"]:
return [m for m in ProjectMember.objects.filter(project=self)]
@@ -164,7 +176,7 @@ class ProjectFunction(models.Model):
]
def get_html_url(self) -> str:
return f"{self.project.get_html_url()}/{self.rom_address:X}"
return f"{self.project.get_html_url()}/functions/{self.rom_address:X}"
def __str__(self) -> str:
return f"{self.display_name} ({self.project})"
@@ -184,22 +196,7 @@ class ProjectFunction(models.Model):
"" # TODO: grab sourcecode from src_file's NON_MATCHING block, if any
)
# TODO: make this more configurable or something
cpp_flags = shlex.split(compiler_config.compiler_flags)
"""[
"-Iinclude",
"-Isrc",
"-Iver/current/build/include",
"-D_LANGUAGE_C",
"-DF3DEX_GBI_2",
"-D_MIPS_SZLONG=32",
"-DSCRIPT(...)={}" # only relevant for papermario. bad
"-D__attribute__(...)=",
"-D__asm__(...)=",
"-ffreestanding",
"-DM2CTX",
"-DPERMUTER",
]"""
# Attempt to generate context (TODO: #361 so we don't have to do this)
try:
@@ -232,14 +229,17 @@ class ProjectFunction(models.Model):
class ProjectMember(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE)
profile = models.ForeignKey(Profile, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["project", "profile"], name="unique_project_member"
fields=["project", "user"], name="unique_project_member"
),
]
def __str__(self) -> str:
return f"{self.profile} is a member of {self.project}"
return f"({self.project}, {self.user})"
def get_url(self) -> str:
return f"{self.project.get_url()}/members/{self.user.username}"
+50 -14
View File
@@ -1,18 +1,20 @@
from platform import platform
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from django.contrib.auth.models import User
from django.core.exceptions import ImproperlyConfigured
from rest_framework import serializers
from rest_framework.fields import SerializerMethodField
from rest_framework.relations import HyperlinkedIdentityField, HyperlinkedRelatedField
from rest_framework.relations import HyperlinkedRelatedField, SlugRelatedField
from rest_framework.reverse import reverse
from html_json_forms.serializers import JSONFormSerializer
from .middleware import Request
from .models.github import GitHubRepo, GitHubUser
from .models.profile import Profile
from .models.project import Project, ProjectFunction
from .models.scratch import Scratch
from .models.project import Project, ProjectFunction, ProjectImportConfig, ProjectMember
from .models.scratch import CompilerConfig, Scratch
def serialize_profile(
@@ -26,6 +28,7 @@ def serialize_profile(
"is_anonymous": True,
"id": profile.id,
"is_online": profile.is_online(),
"is_admin": False,
"username": f"{profile.pseudonym} (anon)",
"frog_color": profile.get_frog_color(),
}
@@ -40,8 +43,9 @@ def serialize_profile(
"html_url": profile.get_html_url(),
"is_you": user == request.user, # TODO(#245): remove
"is_anonymous": False,
"id": user.id,
"id": profile.id,
"is_online": profile.is_online(),
"is_admin": user.is_staff,
"username": user.username,
"avatar_url": github_details.avatar_url if github_details else None,
}
@@ -212,23 +216,42 @@ class GitHubRepoSerializer(serializers.ModelSerializer[GitHubRepo]):
read_only_fields = ["last_pulled", "is_pulling"]
class ProjectSerializer(serializers.ModelSerializer[Project]):
slug = serializers.SlugField(read_only=True)
url = HyperlinkedIdentityField(view_name="project-detail")
class ProjectSerializer(JSONFormSerializer, serializers.ModelSerializer[Project]):
slug = serializers.SlugField()
url = UrlField()
html_url = HtmlUrlField()
repo = GitHubRepoSerializer(read_only=True)
members = SerializerMethodField()
repo = GitHubRepoSerializer()
platform = SerializerMethodField()
unmatched_function_count = SerializerMethodField()
class Meta:
model = Project
exclude: List[str] = []
depth = 1 # repo
def get_members(self, project: Project) -> List[Dict[str, Any]]:
return [
serialize_profile(self.context["request"], member.profile, True)
for member in project.members()
]
def create(self, validated_data: Any) -> Project:
repo_data = validated_data.pop("repo")
repo = GitHubRepo.objects.create(**repo_data)
project = Project.objects.create(repo=repo, **validated_data)
return project
def update(self, instance: Project, validated_data: Any) -> Project:
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
def get_platform(self, project: Project) -> Optional[str]:
import_config = ProjectImportConfig.objects.filter(project=project).first()
if import_config:
return import_config.compiler_config.platform
else:
return None
def get_unmatched_function_count(self, project: Project) -> int:
return ProjectFunction.objects.filter(
is_matched_in_repo=False, project=project
).count()
class ProjectFunctionSerializer(serializers.ModelSerializer[ProjectFunction]):
@@ -251,3 +274,16 @@ class ProjectFunctionSerializer(serializers.ModelSerializer[ProjectFunction]):
def get_attempts_count(self, fn: ProjectFunction) -> int:
return Scratch.objects.filter(project_function=fn).count()
class ProjectMemberSerializer(serializers.ModelSerializer[ProjectMember]):
url = UrlField()
username = SlugRelatedField(
source="user",
slug_field="username",
queryset=User.objects.all(),
)
class Meta:
model = ProjectMember
fields = ["url", "username"]
+67 -3
View File
@@ -1151,9 +1151,10 @@ class ScratchPRTests(BaseTestCase):
# Give user membership of project
profile = Profile.objects.first()
assert profile is not None
assert profile.user is not None
ProjectMember.objects.create(
project=project,
profile=profile,
user=profile.user,
)
def test_pr_one_scratch(self, mock_get_repo: Mock) -> None:
@@ -1236,7 +1237,6 @@ class ProjectTests(TestCase):
project = Project(
slug=slug,
repo=repo,
icon_url="http://example.com",
)
project.save()
@@ -1266,6 +1266,66 @@ class ProjectTests(TestCase):
)
mock_mkdir.assert_called_once_with(parents=True)
@patch("coreapp.models.github.GitHubRepo.pull")
@patch.object(
GitHubRepo,
"details",
new=Mock(return_value=MockRepository("orig_repo")),
)
@patch.object(
GitHubUser,
"details",
new=Mock(return_value=None),
)
def test_create_api_json(self, mock_pull: Mock) -> None:
"""
Test that you can create a project via the JSON API, and that it only works when is_staff=True
"""
# Make a request so we can get a profile
response = self.client.get(reverse("project-list"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
profile = Profile.objects.first()
assert profile is not None
# Give the profile a User and GitHubUser
profile.user = User(username="test")
profile.user.save()
profile.save()
GitHubUser.objects.create(
user=profile.user, github_id=1234, access_token="__mock__"
)
data = {
"slug": "example-project",
"repo": {
"owner": "decompme",
"repo": "example-project",
"branch": "not_a_real_branch",
},
}
# Fail when not admin
response = self.client.post(
reverse("project-list"),
data,
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
# Succeed when admin
profile.user.is_staff = True
profile.user.save()
response = self.client.post(
reverse("project-list"),
data,
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
mock_pull.assert_called_once()
self.assertEqual(Project.objects.count(), 1)
@patch("coreapp.models.github.GitHubRepo.get_dir")
@patch("coreapp.models.github.shutil.rmtree")
def test_delete_repo_dir(self, mock_rmtree: Mock, mock_get_dir: Mock) -> None:
@@ -1379,7 +1439,11 @@ class ProjectTests(TestCase):
# add project member
profile = Profile.objects.first()
ProjectMember(project=project, profile=profile).save()
assert profile is not None
profile.user = User(username="test")
profile.user.save()
profile.save()
ProjectMember(project=project, user=profile.user).save()
# try again
response = self.client.patch(
+125 -17
View File
@@ -9,6 +9,7 @@ import django_filters
from django.db.models.query import QuerySet
from django.views import View
from django.contrib.auth.models import User
from django.db.utils import IntegrityError
from github import Github, UnknownObjectException
from github.Repository import Repository
@@ -20,8 +21,9 @@ from rest_framework.pagination import CursorPagination
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from rest_framework_extensions.routers import ExtendedSimpleRouter
from coreapp.middleware import Request
from rest_framework.request import Request
from rest_framework.parsers import JSONParser, MultiPartParser
from rest_framework.serializers import BaseSerializer
from ..models.github import (
GitHubRepo,
@@ -29,10 +31,12 @@ from ..models.github import (
GitHubUser,
MissingOAuthScopeException,
)
from ..models.project import Project, ProjectFunction
from ..models.project import Project, ProjectFunction, ProjectMember
from ..models.scratch import Scratch
from ..models.profile import Profile
from ..serializers import (
ProjectFunctionSerializer,
ProjectMemberSerializer,
ProjectSerializer,
ScratchSerializer,
TerseScratchSerializer,
@@ -52,15 +56,37 @@ class GithubLoginException(APIException):
class ScratchNotProjectFunctionException(APIException):
status_code = status.HTTP_400_BAD_REQUEST
status_code = status.HTTP_403_FORBIDDEN
default_detail = "Scratches given must be part of the project."
class PrMustHaveScratchesException(APIException):
status_code = status.HTTP_400_BAD_REQUEST
status_code = status.HTTP_403_FORBIDDEN
default_detail = "You must provide at least one scratch to create a PR."
class ProjectExistsException(APIException):
status_code = status.HTTP_403_FORBIDDEN
default_detail = "Project with this name already exists."
class ProjectMustHaveMembersException(APIException):
status_code = status.HTTP_403_FORBIDDEN
default_detail = "You must have at least one member in your project."
class ProjectMemberExists(APIException):
status_code = status.HTTP_409_CONFLICT
default_detail = "User is already a member of this project."
class TemporaryProjectCreationStaffOnlyException(APIException):
status_code = status.HTTP_403_FORBIDDEN
default_detail = (
"Project creation is currently experimental and limited to admins only."
)
class ProjectPagination(CursorPagination):
ordering = "-creation_time"
page_size = 20
@@ -80,8 +106,14 @@ class IsProjectMemberOrReadOnly(permissions.BasePermission):
return True
def has_object_permission(self, request: Any, view: View, obj: Any) -> bool:
assert isinstance(obj, Project)
return request.method in permissions.SAFE_METHODS or obj.is_member(
if isinstance(obj, Project):
project = obj
elif isinstance(obj, ProjectMember):
project = obj.project
else:
raise ValueError("Object must be a Project or ProjectMember")
return request.method in permissions.SAFE_METHODS or project.is_member(
request.profile
)
@@ -97,21 +129,59 @@ class ProjectViewSet(
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
mixins.UpdateModelMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
GenericViewSet,
):
queryset = Project.objects.all()
pagination_class = ProjectPagination
serializer_class = ProjectSerializer
permission_classes = [IsProjectMemberOrReadOnly]
parser_classes = [JSONParser, MultiPartParser]
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
user: Optional[User] = request.profile.user
if not user:
raise GithubLoginException()
gh_user: Optional[GitHubUser] = user.github
if not gh_user:
raise GithubLoginException()
if not user.is_staff:
raise TemporaryProjectCreationStaffOnlyException()
serializer = ProjectSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
slug = serializer.validated_data["slug"]
if slug == "new" or Project.objects.filter(slug=slug).exists():
raise ProjectExistsException()
project = serializer.save()
repo: GitHubRepo = project.repo
repo.pull()
ProjectMember(project=project, user=request.profile.user).save()
return Response(
ProjectSerializer(project, context={"request": request}).data,
status=status.HTTP_201_CREATED,
)
def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response:
project: Project = self.get_object()
repo: GitHubRepo = project.repo
project.delete()
repo.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["POST"])
def pull(self, request: Request, pk: str) -> Response:
project: Project = self.get_object()
repo: GitHubRepo = project.repo
if not project.is_member(request.profile):
raise NotProjectMaintainer()
if not repo.is_pulling:
t = Thread(target=GitHubRepo.pull, args=(project.repo,))
t.start()
@@ -322,12 +392,50 @@ class ProjectFunctionViewSet(
raise Exception("Unsupported method")
class ProjectMemberViewSet(
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
GenericViewSet,
):
serializer_class = ProjectMemberSerializer
permission_classes = [IsProjectMemberOrReadOnly]
def get_queryset(self) -> QuerySet[ProjectMember]:
return ProjectMember.objects.filter(project=self.kwargs["parent_lookup_slug"])
def get_object(self) -> ProjectMember:
return ProjectMember.objects.get(
project=self.kwargs["parent_lookup_slug"],
user__username=self.kwargs["pk"],
)
def perform_create(self, serializer: BaseSerializer[Any]) -> None:
project = Project.objects.get(slug=self.kwargs["parent_lookup_slug"])
try:
serializer.save(project=project)
except IntegrityError:
raise ProjectMemberExists()
def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response:
member: ProjectMember = self.get_object()
if ProjectMember.objects.filter(project=member.project).count() == 1:
raise ProjectMustHaveMembersException()
return super().destroy(request, *args, **kwargs)
router = ExtendedSimpleRouter(trailing_slash=False)
(
router.register(r"projects", ProjectViewSet).register(
r"functions",
ProjectFunctionViewSet,
basename="projectfunction",
parents_query_lookups=["slug"],
)
projects_router = router.register(r"projects", ProjectViewSet)
projects_router.register(
r"functions",
ProjectFunctionViewSet,
basename="projectfunction",
parents_query_lookups=["slug"],
)
projects_router.register(
r"members",
ProjectMemberViewSet,
basename="projectmember",
parents_query_lookups=["slug"],
)
+13
View File
@@ -22,6 +22,8 @@ env = environ.Env(
SECURE_HSTS_PRELOAD=(bool, False),
STATIC_URL=(str, "/static/"),
STATIC_ROOT=(str, BASE_DIR / "static"),
MEDIA_URL=(str, "/media/"),
MEDIA_ROOT=(str, BASE_DIR / "media"),
LOCAL_FILE_DIR=(str, BASE_DIR / "local_files"),
USE_SANDBOX_JAIL=(bool, False),
SESSION_COOKIE_SECURE=(bool, True),
@@ -58,6 +60,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django_filters",
"django_cleanup.apps.CleanupConfig",
]
MIDDLEWARE = [
@@ -130,6 +133,16 @@ USE_TZ = True
STATIC_URL = env("STATIC_URL")
STATIC_ROOT = env("STATIC_ROOT")
# Media files (user uploads)
MEDIA_ROOT = env("MEDIA_ROOT")
MEDIA_URL = env("MEDIA_URL")
DJANGORESIZED_DEFAULT_SCALE = 1.0
DJANGORESIZED_DEFAULT_QUALITY = 100
DJANGORESIZED_DEFAULT_KEEP_META = False
DJANGORESIZED_DEFAULT_FORCE_FORMAT = "WEBP"
DJANGORESIZED_DEFAULT_FORMAT_EXTENSIONS = {"WEBP": ".webp"}
DJANGORESIZED_DEFAULT_NORMALIZE_ROTATION = True
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+1 -1
View File
@@ -5,4 +5,4 @@ ALLOWED_HOSTS="backend,localhost,127.0.0.1"
USE_SANDBOX_JAIL="on"
SANDBOX_DISABLE_PROC="true"
COMPILER_BASE_PATH=/compilers
LOCAL_FILE_DIR=/local_files
MEDIA_URL=http://localhost/media/
View File
View File
+30 -1
View File
@@ -8,7 +8,6 @@ disallow_untyped_calls = True
disallow_untyped_decorators = True
ignore_errors = False
ignore_missing_imports = True
follow_imports = silent
implicit_reexport = False
strict_optional = True
strict_equality = True
@@ -29,5 +28,35 @@ plugins =
mypy_django_plugin.main,
mypy_drf_plugin.main
mypy_path = $MYPY_CONFIG_FILE_DIR
[mypy.plugins.django-stubs]
django_settings_module = decompme.settings
[mypy-m2c.*]
implicit_reexport = True
check_untyped_defs = True
disallow_any_generics = False
disallow_incomplete_defs = False
disallow_subclassing_any = False
disallow_untyped_calls = False
disallow_untyped_decorators = False
disallow_untyped_defs = False
no_implicit_optional = False
warn_unused_ignores = False
warn_unreachable = False
warn_no_return = False
[mypy-asm_differ.*]
implicit_reexport = True
check_untyped_defs = True
disallow_any_generics = False
disallow_incomplete_defs = False
disallow_subclassing_any = False
disallow_untyped_calls = False
disallow_untyped_decorators = False
disallow_untyped_defs = False
no_implicit_optional = False
warn_unused_ignores = False
warn_unreachable = False
warn_no_return = False
+240 -18
View File
@@ -191,6 +191,14 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"]
[[package]]
name = "django-cleanup"
version = "6.0.0"
description = "Deletes old files."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "django-cors-headers"
version = "3.13.0"
@@ -226,6 +234,14 @@ python-versions = ">=3.6"
[package.dependencies]
Django = ">=2.2"
[[package]]
name = "django-resized"
version = "1.0.2"
description = "Resizes image origin to specified size."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "django-stubs"
version = "1.9.0"
@@ -293,6 +309,14 @@ python-versions = "*"
[package.dependencies]
djangorestframework = ">=3.9.3"
[[package]]
name = "html-json-forms"
version = "1.1.1"
description = "Implementation of the HTML JSON Forms spec for use with the Django REST Framework."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "idna"
version = "3.3"
@@ -413,6 +437,18 @@ category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "pillow"
version = "9.2.0"
description = "Python Imaging Library (Fork)"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinxext-opengraph"]
tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
[[package]]
name = "platformdirs"
version = "2.5.2"
@@ -735,7 +771,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
content-hash = "220d633168ddbfd6bb9ea8ddec948934b4bd9a483afb9a2b8ade32fc784ce0e4"
content-hash = "6bb464a8f01e3e778318aa37e6e74aaf5ba34023072f17b48ee9aff1aa75f78c"
[metadata.files]
ansiwrap = [
@@ -746,8 +782,35 @@ asgiref = [
{file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"},
{file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"},
]
attrs = []
black = []
attrs = [
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
{file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
]
black = [
{file = "black-22.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce957f1d6b78a8a231b18e0dd2d94a33d2ba738cd88a7fe64f53f659eea49fdd"},
{file = "black-22.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5107ea36b2b61917956d018bd25129baf9ad1125e39324a9b18248d362156a27"},
{file = "black-22.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8166b7bfe5dcb56d325385bd1d1e0f635f24aae14b3ae437102dedc0c186747"},
{file = "black-22.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd82842bb272297503cbec1a2600b6bfb338dae017186f8f215c8958f8acf869"},
{file = "black-22.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d839150f61d09e7217f52917259831fe2b689f5c8e5e32611736351b89bb2a90"},
{file = "black-22.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a05da0430bd5ced89176db098567973be52ce175a55677436a271102d7eaa3fe"},
{file = "black-22.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a098a69a02596e1f2a58a2a1c8d5a05d5a74461af552b371e82f9fa4ada8342"},
{file = "black-22.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5594efbdc35426e35a7defa1ea1a1cb97c7dbd34c0e49af7fb593a36bd45edab"},
{file = "black-22.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a983526af1bea1e4cf6768e649990f28ee4f4137266921c2c3cee8116ae42ec3"},
{file = "black-22.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b2c25f8dea5e8444bdc6788a2f543e1fb01494e144480bc17f806178378005e"},
{file = "black-22.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:78dd85caaab7c3153054756b9fe8c611efa63d9e7aecfa33e533060cb14b6d16"},
{file = "black-22.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:cea1b2542d4e2c02c332e83150e41e3ca80dc0fb8de20df3c5e98e242156222c"},
{file = "black-22.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b879eb439094751185d1cfdca43023bc6786bd3c60372462b6f051efa6281a5"},
{file = "black-22.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a12e4e1353819af41df998b02c6742643cfef58282915f781d0e4dd7a200411"},
{file = "black-22.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3a73f66b6d5ba7288cd5d6dad9b4c9b43f4e8a4b789a94bf5abfb878c663eb3"},
{file = "black-22.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:e981e20ec152dfb3e77418fb616077937378b322d7b26aa1ff87717fb18b4875"},
{file = "black-22.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8ce13ffed7e66dda0da3e0b2eb1bdfc83f5812f66e09aca2b0978593ed636b6c"},
{file = "black-22.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:32a4b17f644fc288c6ee2bafdf5e3b045f4eff84693ac069d87b1a347d861497"},
{file = "black-22.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad827325a3a634bae88ae7747db1a395d5ee02cf05d9aa7a9bd77dfb10e940c"},
{file = "black-22.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53198e28a1fb865e9fe97f88220da2e44df6da82b18833b588b1883b16bb5d41"},
{file = "black-22.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:bc4d4123830a2d190e9cc42a2e43570f82ace35c3aeb26a512a2102bce5af7ec"},
{file = "black-22.8.0-py3-none-any.whl", hash = "sha256:d2c21d439b2baf7aa80d6dd4e3659259be64c6f49dfd0f32091063db0e006db4"},
{file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"},
]
certifi = [
{file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"},
{file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"},
@@ -818,7 +881,10 @@ cffi = [
{file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"},
{file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"},
]
charset-normalizer = []
charset-normalizer = [
{file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"},
{file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"},
]
click = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
@@ -835,7 +901,30 @@ coreschema = [
{file = "coreschema-0.0.4-py2-none-any.whl", hash = "sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f"},
{file = "coreschema-0.0.4.tar.gz", hash = "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607"},
]
cryptography = []
cryptography = [
{file = "cryptography-37.0.4-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:549153378611c0cca1042f20fd9c5030d37a72f634c9326e225c9f666d472884"},
{file = "cryptography-37.0.4-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:a958c52505c8adf0d3822703078580d2c0456dd1d27fabfb6f76fe63d2971cd6"},
{file = "cryptography-37.0.4-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f721d1885ecae9078c3f6bbe8a88bc0786b6e749bf32ccec1ef2b18929a05046"},
{file = "cryptography-37.0.4-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3d41b965b3380f10e4611dbae366f6dc3cefc7c9ac4e8842a806b9672ae9add5"},
{file = "cryptography-37.0.4-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80f49023dd13ba35f7c34072fa17f604d2f19bf0989f292cedf7ab5770b87a0b"},
{file = "cryptography-37.0.4-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2dcb0b3b63afb6df7fd94ec6fbddac81b5492513f7b0436210d390c14d46ee8"},
{file = "cryptography-37.0.4-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:b7f8dd0d4c1f21759695c05a5ec8536c12f31611541f8904083f3dc582604280"},
{file = "cryptography-37.0.4-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:30788e070800fec9bbcf9faa71ea6d8068f5136f60029759fd8c3efec3c9dcb3"},
{file = "cryptography-37.0.4-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:190f82f3e87033821828f60787cfa42bff98404483577b591429ed99bed39d59"},
{file = "cryptography-37.0.4-cp36-abi3-win32.whl", hash = "sha256:b62439d7cd1222f3da897e9a9fe53bbf5c104fff4d60893ad1355d4c14a24157"},
{file = "cryptography-37.0.4-cp36-abi3-win_amd64.whl", hash = "sha256:f7a6de3e98771e183645181b3627e2563dcde3ce94a9e42a3f427d2255190327"},
{file = "cryptography-37.0.4-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bc95ed67b6741b2607298f9ea4932ff157e570ef456ef7ff0ef4884a134cc4b"},
{file = "cryptography-37.0.4-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:f8c0a6e9e1dd3eb0414ba320f85da6b0dcbd543126e30fcc546e7372a7fbf3b9"},
{file = "cryptography-37.0.4-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:e007f052ed10cc316df59bc90fbb7ff7950d7e2919c9757fd42a2b8ecf8a5f67"},
{file = "cryptography-37.0.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bc997818309f56c0038a33b8da5c0bfbb3f1f067f315f9abd6fc07ad359398d"},
{file = "cryptography-37.0.4-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:d204833f3c8a33bbe11eda63a54b1aad7aa7456ed769a982f21ec599ba5fa282"},
{file = "cryptography-37.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:75976c217f10d48a8b5a8de3d70c454c249e4b91851f6838a4e48b8f41eb71aa"},
{file = "cryptography-37.0.4-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:7099a8d55cd49b737ffc99c17de504f2257e3787e02abe6d1a6d136574873441"},
{file = "cryptography-37.0.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2be53f9f5505673eeda5f2736bea736c40f051a739bfae2f92d18aed1eb54596"},
{file = "cryptography-37.0.4-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:91ce48d35f4e3d3f1d83e29ef4a9267246e6a3be51864a5b7d2247d5086fa99a"},
{file = "cryptography-37.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4c590ec31550a724ef893c50f9a97a0c14e9c851c85621c5650d699a7b88f7ab"},
{file = "cryptography-37.0.4.tar.gz", hash = "sha256:63f9c17c0e2474ccbebc9302ce2f07b55b3b3fcb211ded18a42d5764f5c10a82"},
]
cxxfilt = [
{file = "cxxfilt-0.3.0-py2.py3-none-any.whl", hash = "sha256:774e85a8d0157775ed43276d89397d924b104135762d86b3a95f81f203094e07"},
{file = "cxxfilt-0.3.0.tar.gz", hash = "sha256:7df6464ba5e8efbf0d8974c0b2c78b32546676f06059a83515dbdfa559b34214"},
@@ -844,7 +933,14 @@ deprecated = [
{file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"},
{file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"},
]
django = []
django = [
{file = "Django-4.1-py3-none-any.whl", hash = "sha256:031ccb717782f6af83a0063a1957686e87cb4581ea61b47b3e9addf60687989a"},
{file = "Django-4.1.tar.gz", hash = "sha256:032f8a6fc7cf05ccd1214e4a2e21dfcd6a23b9d575c6573cacc8c67828dbe642"},
]
django-cleanup = [
{file = "django-cleanup-6.0.0.tar.gz", hash = "sha256:922e06ef8839c92bd3ab37a84db6058b8764f3fe44dbb4487bbca941d288280a"},
{file = "django_cleanup-6.0.0-py2.py3-none-any.whl", hash = "sha256:997feab3b1f7a2e84f71c29e83b1d664459ec0d4b1924977b1fa25b5babb8703"},
]
django-cors-headers = [
{file = "django-cors-headers-3.13.0.tar.gz", hash = "sha256:f9dc6b4e3f611c3199700b3e5f3398c28757dcd559c2f82932687f3d0443cfdf"},
{file = "django_cors_headers-3.13.0-py3-none-any.whl", hash = "sha256:37e42883b5f1f2295df6b4bba96eb2417a14a03270cb24b2a07f021cd4487cf4"},
@@ -857,6 +953,10 @@ django-filter = [
{file = "django-filter-21.1.tar.gz", hash = "sha256:632a251fa8f1aadb4b8cceff932bb52fe2f826dd7dfe7f3eac40e5c463d6836e"},
{file = "django_filter-21.1-py3-none-any.whl", hash = "sha256:f4a6737a30104c98d2e2a5fb93043f36dd7978e0c7ddc92f5998e85433ea5063"},
]
django-resized = [
{file = "django-resized-1.0.2.tar.gz", hash = "sha256:52d727860f64ef4fdadbe2e74b66231c71c59df4d95949e338fcd320450f77fa"},
{file = "django_resized-1.0.2-py3-none-any.whl", hash = "sha256:d55a8d4125838486a1e76ffb689f8364f7d579bc7562b04400065602ec2ba7cc"},
]
django-stubs = [
{file = "django-stubs-1.9.0.tar.gz", hash = "sha256:664843091636a917faf5256d028476559dc360fdef9050b6df87ab61b21607bf"},
{file = "django_stubs-1.9.0-py3-none-any.whl", hash = "sha256:59c9f81af64d214b1954eaf90f037778c8d2b9c2de946a3cda177fefcf588fbd"},
@@ -877,6 +977,10 @@ drf-extensions = [
{file = "drf-extensions-0.7.1.tar.gz", hash = "sha256:90abfc11a2221e8daf4cd54457e41ed38cd71134678de9622e806193db027db1"},
{file = "drf_extensions-0.7.1-py2.py3-none-any.whl", hash = "sha256:007910437e64aa1d5ad6fc47266a4ac4280e31761e6458eb30fcac7494ac7d4e"},
]
html-json-forms = [
{file = "html-json-forms-1.1.1.tar.gz", hash = "sha256:16dc413dc858fcc53602ad509c1aef735534838e1bae888bf429e210a9c48f6b"},
{file = "html_json_forms-1.1.1-py3-none-any.whl", hash = "sha256:51e7e9088bc88e324027144ca25d8bcdd37da28f311a8436bfd88944138ed409"},
]
idna = [
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
@@ -892,7 +996,32 @@ jinja2 = [
jwt = [
{file = "jwt-1.3.1-py3-none-any.whl", hash = "sha256:61c9170f92e736b530655e75374681d4fcca9cfa8763ab42be57353b2b203494"},
]
libcst = []
libcst = [
{file = "libcst-0.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dc6f8965b6ca68d47e11321772887d81fa6fd8ea86e6ef87434ca2147de10747"},
{file = "libcst-0.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8f47d809df59fcd83058b777b86a300154ee3a1f1b0523a398a67b5f8affd4c"},
{file = "libcst-0.4.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0d19de56aa733b4ef024527e3ce4896d4b0e9806889797f409ec24caa651a44"},
{file = "libcst-0.4.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31da97bc986dc3f7a97f7d431fa911932aaf716d2f8bcda947fc964afd3b57cd"},
{file = "libcst-0.4.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71b2e2c5e33e53669c20de0853cecfac1ffb8657ee727ab8527140f39049b820"},
{file = "libcst-0.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:76fae68bd6b7ce069e267b3322c806b4305341cea78d161ae40e0ed641c8c660"},
{file = "libcst-0.4.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bac76d69980bb3254f503f52128c256ef4d1bcbaabe4a17c3a9ebcd1fc0472c0"},
{file = "libcst-0.4.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f86535271eaefe84a99736875566a038449f92e1a2a61ea0b588d8359fbefd"},
{file = "libcst-0.4.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:617f7fa2610a8c86cf22d8d03416f25391383d05bd0ad1ca8ef68023ddd6b4f6"},
{file = "libcst-0.4.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c3637fffe476c5b4ee2225c6474b83382518f2c1b2fe4771039e06bdd7835a4a"},
{file = "libcst-0.4.7-cp37-cp37m-win_amd64.whl", hash = "sha256:f56565124c2541adee0634e411b2126b3f335306d19e91ed2bfe52efa698b219"},
{file = "libcst-0.4.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0ca2771ff3cfdf1f148349f89fcae64afa365213ed5c2703a69a89319325d0c8"},
{file = "libcst-0.4.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:aa438131b7befc7e5a3cbadb5a7b1506305de5d62262ea0556add0152f40925e"},
{file = "libcst-0.4.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c6bd66a8be2ffad7b968d90dae86c62fd4739c0e011d71f3e76544a891ae743"},
{file = "libcst-0.4.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:214a9c4f4f90cd5b4bfa18e17877da4dd9a896821d9af9be86fa3effdc289b9b"},
{file = "libcst-0.4.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a37f2b459a8b51a41e260bd89c24ae41ab1d658f610c91650c79b1bbf27138"},
{file = "libcst-0.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:2f6766391d90472f036b88a95251c87d498ab068c377724f212ab0cc20509a68"},
{file = "libcst-0.4.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:234293aa8681a3d47fef1716c5622797a81cbe85a9381fe023815468cfe20eed"},
{file = "libcst-0.4.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fa618dc359663a0a097c633452b104c1ca93365da7a811e655c6944f6b323239"},
{file = "libcst-0.4.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3569d9901c18940632414fb7a0943bffd326db9f726a9c041664926820857815"},
{file = "libcst-0.4.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beb5347e46b419f782589da060e9300957e71d561aa5574309883b71f93c1dfe"},
{file = "libcst-0.4.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e541ccfeebda1ae5f005fc120a5bf3e8ac9ccfda405ec3efd3df54fc4688ac3"},
{file = "libcst-0.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:3a2b7253cd2e3f0f8a3e23b5c2acb492811d865ef36e0816091c925f32b713d2"},
{file = "libcst-0.4.7.tar.gz", hash = "sha256:95c52c2130531f6e726a3b077442cfd486975435fecf3db8224d43fba7b85099"},
]
markupsafe = [
{file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"},
{file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"},
@@ -965,8 +1094,74 @@ mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
parameterized = []
pathspec = []
parameterized = [
{file = "parameterized-0.8.1-py2.py3-none-any.whl", hash = "sha256:9cbb0b69a03e8695d68b3399a8a5825200976536fe1cb79db60ed6a4c8c9efe9"},
{file = "parameterized-0.8.1.tar.gz", hash = "sha256:41bbff37d6186430f77f900d777e5bb6a24928a1c46fb1de692f8b52b8833b5c"},
]
pathspec = [
{file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"},
{file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"},
]
pillow = [
{file = "Pillow-9.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb"},
{file = "Pillow-9.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:510cef4a3f401c246cfd8227b300828715dd055463cdca6176c2e4036df8bd4f"},
{file = "Pillow-9.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7888310f6214f19ab2b6df90f3f06afa3df7ef7355fc025e78a3044737fab1f5"},
{file = "Pillow-9.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831e648102c82f152e14c1a0938689dbb22480c548c8d4b8b248b3e50967b88c"},
{file = "Pillow-9.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cc1d2451e8a3b4bfdb9caf745b58e6c7a77d2e469159b0d527a4554d73694d1"},
{file = "Pillow-9.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:136659638f61a251e8ed3b331fc6ccd124590eeff539de57c5f80ef3a9594e58"},
{file = "Pillow-9.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6e8c66f70fb539301e064f6478d7453e820d8a2c631da948a23384865cd95544"},
{file = "Pillow-9.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:37ff6b522a26d0538b753f0b4e8e164fdada12db6c6f00f62145d732d8a3152e"},
{file = "Pillow-9.2.0-cp310-cp310-win32.whl", hash = "sha256:c79698d4cd9318d9481d89a77e2d3fcaeff5486be641e60a4b49f3d2ecca4e28"},
{file = "Pillow-9.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:254164c57bab4b459f14c64e93df11eff5ded575192c294a0c49270f22c5d93d"},
{file = "Pillow-9.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:adabc0bce035467fb537ef3e5e74f2847c8af217ee0be0455d4fec8adc0462fc"},
{file = "Pillow-9.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:336b9036127eab855beec9662ac3ea13a4544a523ae273cbf108b228ecac8437"},
{file = "Pillow-9.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50dff9cc21826d2977ef2d2a205504034e3a4563ca6f5db739b0d1026658e004"},
{file = "Pillow-9.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb6259196a589123d755380b65127ddc60f4c64b21fc3bb46ce3a6ea663659b0"},
{file = "Pillow-9.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b0554af24df2bf96618dac71ddada02420f946be943b181108cac55a7a2dcd4"},
{file = "Pillow-9.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:15928f824870535c85dbf949c09d6ae7d3d6ac2d6efec80f3227f73eefba741c"},
{file = "Pillow-9.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:bdd0de2d64688ecae88dd8935012c4a72681e5df632af903a1dca8c5e7aa871a"},
{file = "Pillow-9.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5b87da55a08acb586bad5c3aa3b86505f559b84f39035b233d5bf844b0834b1"},
{file = "Pillow-9.2.0-cp311-cp311-win32.whl", hash = "sha256:b6d5e92df2b77665e07ddb2e4dbd6d644b78e4c0d2e9272a852627cdba0d75cf"},
{file = "Pillow-9.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6bf088c1ce160f50ea40764f825ec9b72ed9da25346216b91361eef8ad1b8f8c"},
{file = "Pillow-9.2.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:2c58b24e3a63efd22554c676d81b0e57f80e0a7d3a5874a7e14ce90ec40d3069"},
{file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eef7592281f7c174d3d6cbfbb7ee5984a671fcd77e3fc78e973d492e9bf0eb3f"},
{file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd7b9c7139dc8258d164b55696ecd16c04607f1cc33ba7af86613881ffe4ac8"},
{file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a138441e95562b3c078746a22f8fca8ff1c22c014f856278bdbdd89ca36cff1b"},
{file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:93689632949aff41199090eff5474f3990b6823404e45d66a5d44304e9cdc467"},
{file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:f3fac744f9b540148fa7715a435d2283b71f68bfb6d4aae24482a890aed18b59"},
{file = "Pillow-9.2.0-cp37-cp37m-win32.whl", hash = "sha256:fa768eff5f9f958270b081bb33581b4b569faabf8774726b283edb06617101dc"},
{file = "Pillow-9.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:69bd1a15d7ba3694631e00df8de65a8cb031911ca11f44929c97fe05eb9b6c1d"},
{file = "Pillow-9.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:030e3460861488e249731c3e7ab59b07c7853838ff3b8e16aac9561bb345da14"},
{file = "Pillow-9.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:74a04183e6e64930b667d321524e3c5361094bb4af9083db5c301db64cd341f3"},
{file = "Pillow-9.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d33a11f601213dcd5718109c09a52c2a1c893e7461f0be2d6febc2879ec2402"},
{file = "Pillow-9.2.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fd6f5e3c0e4697fa7eb45b6e93996299f3feee73a3175fa451f49a74d092b9f"},
{file = "Pillow-9.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a647c0d4478b995c5e54615a2e5360ccedd2f85e70ab57fbe817ca613d5e63b8"},
{file = "Pillow-9.2.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:4134d3f1ba5f15027ff5c04296f13328fecd46921424084516bdb1b2548e66ff"},
{file = "Pillow-9.2.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:bc431b065722a5ad1dfb4df354fb9333b7a582a5ee39a90e6ffff688d72f27a1"},
{file = "Pillow-9.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:1536ad017a9f789430fb6b8be8bf99d2f214c76502becc196c6f2d9a75b01b76"},
{file = "Pillow-9.2.0-cp38-cp38-win32.whl", hash = "sha256:2ad0d4df0f5ef2247e27fc790d5c9b5a0af8ade9ba340db4a73bb1a4a3e5fb4f"},
{file = "Pillow-9.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:ec52c351b35ca269cb1f8069d610fc45c5bd38c3e91f9ab4cbbf0aebc136d9c8"},
{file = "Pillow-9.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ed2c4ef2451de908c90436d6e8092e13a43992f1860275b4d8082667fbb2ffc"},
{file = "Pillow-9.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ad2f835e0ad81d1689f1b7e3fbac7b01bb8777d5a985c8962bedee0cc6d43da"},
{file = "Pillow-9.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea98f633d45f7e815db648fd7ff0f19e328302ac36427343e4432c84432e7ff4"},
{file = "Pillow-9.2.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7761afe0126d046974a01e030ae7529ed0ca6a196de3ec6937c11df0df1bc91c"},
{file = "Pillow-9.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a54614049a18a2d6fe156e68e188da02a046a4a93cf24f373bffd977e943421"},
{file = "Pillow-9.2.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:5aed7dde98403cd91d86a1115c78d8145c83078e864c1de1064f52e6feb61b20"},
{file = "Pillow-9.2.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:13b725463f32df1bfeacbf3dd197fb358ae8ebcd8c5548faa75126ea425ccb60"},
{file = "Pillow-9.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:808add66ea764ed97d44dda1ac4f2cfec4c1867d9efb16a33d158be79f32b8a4"},
{file = "Pillow-9.2.0-cp39-cp39-win32.whl", hash = "sha256:337a74fd2f291c607d220c793a8135273c4c2ab001b03e601c36766005f36885"},
{file = "Pillow-9.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:fac2d65901fb0fdf20363fbd345c01958a742f2dc62a8dd4495af66e3ff502a4"},
{file = "Pillow-9.2.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:ad2277b185ebce47a63f4dc6302e30f05762b688f8dc3de55dbae4651872cdf3"},
{file = "Pillow-9.2.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c7b502bc34f6e32ba022b4a209638f9e097d7a9098104ae420eb8186217ebbb"},
{file = "Pillow-9.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d1f14f5f691f55e1b47f824ca4fdcb4b19b4323fe43cc7bb105988cad7496be"},
{file = "Pillow-9.2.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:dfe4c1fedfde4e2fbc009d5ad420647f7730d719786388b7de0999bf32c0d9fd"},
{file = "Pillow-9.2.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:f07f1f00e22b231dd3d9b9208692042e29792d6bd4f6639415d2f23158a80013"},
{file = "Pillow-9.2.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1802f34298f5ba11d55e5bb09c31997dc0c6aed919658dfdf0198a2fe75d5490"},
{file = "Pillow-9.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17d4cafe22f050b46d983b71c707162d63d796a1235cdf8b9d7a112e97b15bac"},
{file = "Pillow-9.2.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96b5e6874431df16aee0c1ba237574cb6dff1dcb173798faa6a9d8b399a05d0e"},
{file = "Pillow-9.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:0030fdbd926fb85844b8b92e2f9449ba89607231d3dd597a21ae72dc7fe26927"},
{file = "Pillow-9.2.0.tar.gz", hash = "sha256:75e636fd3e0fb872693f23ccb8a5ff2cd578801251f3a4f6854c6a5d437d3c04"},
]
platformdirs = [
{file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
{file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
@@ -1056,7 +1251,10 @@ pynacl = [
python-levenshtein = [
{file = "python-Levenshtein-0.12.2.tar.gz", hash = "sha256:dc2395fbd148a1ab31090dd113c366695934b9e85fe5a4b2a032745efd0346f6"},
]
pytz = []
pytz = [
{file = "pytz-2022.2.1-py2.py3-none-any.whl", hash = "sha256:220f481bdafa09c3955dfbdddb7b57780e9a94f5127e35456a48589b9e0c0197"},
{file = "pytz-2022.2.1.tar.gz", hash = "sha256:cea221417204f2d1a2aa03ddae3e867921971d0d76f14d87abb4414415bbdcf5"},
]
pyyaml = [
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
{file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
@@ -1128,22 +1326,46 @@ trailrunner = [
{file = "trailrunner-1.2.1-py3-none-any.whl", hash = "sha256:dff4ee07cf079bcd388585ee6f14911e2c9292d58bf7cc6047912e082ec21283"},
{file = "trailrunner-1.2.1.tar.gz", hash = "sha256:157de2b2d82139d9583ec1dbc1981c33b39e24c420bb5294f86083f1f4b44311"},
]
types-pytz = []
types-pyyaml = []
types-requests = []
types-urllib3 = []
types-pytz = [
{file = "types-pytz-2022.2.1.0.tar.gz", hash = "sha256:47cfb19c52b9f75896440541db392fd312a35b279c6307a531db71152ea63e2b"},
{file = "types_pytz-2022.2.1.0-py3-none-any.whl", hash = "sha256:50ead2254b524a3d4153bc65d00289b66898060d2938e586170dce918dbaf3b3"},
]
types-pyyaml = [
{file = "types-PyYAML-6.0.11.tar.gz", hash = "sha256:7f7da2fd11e9bc1e5e9eb3ea1be84f4849747017a59fc2eee0ea34ed1147c2e0"},
{file = "types_PyYAML-6.0.11-py3-none-any.whl", hash = "sha256:8f890028123607379c63550179ddaec4517dc751f4c527a52bb61934bf495989"},
]
types-requests = [
{file = "types-requests-2.28.9.tar.gz", hash = "sha256:feaf581bd580497a47fe845d506fa3b91b484cf706ff27774e87659837de9962"},
{file = "types_requests-2.28.9-py3-none-any.whl", hash = "sha256:86cb66d3de2f53eac5c09adc42cf6547eefbd0c7e1210beca1ee751c35d96083"},
]
types-urllib3 = [
{file = "types-urllib3-1.26.23.tar.gz", hash = "sha256:b78e819f0e350221d0689a5666162e467ba3910737bafda14b5c2c85e9bb1e56"},
{file = "types_urllib3-1.26.23-py3-none-any.whl", hash = "sha256:333e675b188a1c1fd980b4b352f9e40572413a4c1ac689c23cd546e96310070a"},
]
typing-extensions = [
{file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
{file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"},
]
typing-inspect = []
tzdata = []
typing-inspect = [
{file = "typing_inspect-0.8.0-py3-none-any.whl", hash = "sha256:5fbf9c1e65d4fa01e701fe12a5bca6c6e08a4ffd5bc60bfac028253a447c5188"},
{file = "typing_inspect-0.8.0.tar.gz", hash = "sha256:8b1ff0c400943b6145df8119c41c244ca8207f1f10c9c057aeed1560e4806e3d"},
]
tzdata = [
{file = "tzdata-2022.2-py2.py3-none-any.whl", hash = "sha256:c3119520447d68ef3eb8187a55a4f44fa455f30eb1b4238fa5691ba094f2b05b"},
{file = "tzdata-2022.2.tar.gz", hash = "sha256:21f4f0d7241572efa7f7a4fdabb052e61b55dc48274e6842697ccdf5253e5451"},
]
uritemplate = [
{file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"},
{file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"},
]
urllib3 = []
usort = []
urllib3 = [
{file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"},
{file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"},
]
usort = [
{file = "usort-1.0.4-py3-none-any.whl", hash = "sha256:a0c3f59c83231bcb3b0fb70f69834484d191c98ac66cfc12e03653a800e58f13"},
{file = "usort-1.0.4.tar.gz", hash = "sha256:74f5d20555b9df80ba00d21987279304983a254d2cbeb8ab46cc5b3f9c4d936a"},
]
watchdog = [
{file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"},
{file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b17d302850c8d412784d9246cfe8d7e3af6bcd45f958abb2d08a6f8bedf695d"},
+4
View File
@@ -26,6 +26,10 @@ PyGithub = "^1.55"
drf-extensions = "^0.7.1"
tqdm = "^4.62.3"
tzdata = "^2022.1"
Pillow = "^9.2.0"
html-json-forms = "^1.1.1"
django-resized = "^1.0.2"
django-cleanup = "^6.0.0"
[tool.poetry.dev-dependencies]
black = "^22.1.0"
+2 -1
View File
@@ -36,7 +36,6 @@ services:
- seccomp=unconfined
volumes:
- ./backend:/backend
- ./local_files:/local_files
tmpfs:
# Use a separate tmpfs to prevent a rogue jailed process
# from filling /tmp on the parent container
@@ -46,6 +45,7 @@ services:
environment:
API_BASE: http://localhost/api
INTERNAL_API_BASE: http://backend:8000/api
FRONTEND_USE_IMAGE_PROXY: "false"
ports:
- "8080:8080"
volumes:
@@ -57,3 +57,4 @@ services:
- "80:80"
volumes:
- ./nginx:/etc/nginx/conf.d
- ./backend/media:/media
+3
View File
@@ -26,6 +26,9 @@ server {
location /static {
try_files $uri @proxy_api;
}
location /media {
root /path/to/decomp.me/backend;
}
location @proxy_api {
proxy_set_header X-Forwarded-Proto https;
+4 -1
View File
@@ -29,6 +29,8 @@ const removeImports = require("next-remove-imports")({
})
const nextTranslate = require("next-translate")
const mediaUrl = new URL(process.env.MEDIA_URL ?? "http://localhost")
let app = withPlausibleProxy({
customDomain: "https://stats.decomp.me",
})(nextTranslate(removeImports(withPWA({
@@ -77,7 +79,8 @@ let app = withPlausibleProxy({
return config
},
images: {
domains: ["avatars.githubusercontent.com", "cdn.discordapp.com"],
domains: [mediaUrl.hostname, "avatars.githubusercontent.com"],
unoptimized: process.env.FRONTEND_USE_IMAGE_PROXY === "false",
},
swcMinify: false,
experimental: {},
+1 -1
View File
@@ -38,7 +38,7 @@
"react-window": "^1.8.6",
"sass": "^1.42.1",
"sharp": "^0.30.6",
"swr": "^1.2.1",
"swr": "^1.3.0",
"use-debounce": "^8.0.1",
"use-deep-compare-effect": "^1.6.1",
"use-persisted-state": "^0.3.3"
@@ -1,6 +1,5 @@
.breadcrumbs {
padding: 1em;
background: var(--g300);
> ol {
margin: 0;
+17 -12
View File
@@ -1,6 +1,6 @@
.btn {
border-radius: 4px;
padding: 0.6em 1em;
padding: 0.6em 1.5em;
font-size: 0.8rem;
font-weight: 500;
@@ -31,16 +31,21 @@
color: var(--a900);
border-color: var(--g800);
}
}
}
.primary {
background: var(--accent);
color: #fff;
border-color: rgba(255, 255, 255, 0.2);
&:not(:disabled):hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.3);
&.primary,
&.danger {
background: var(--accent);
color: #fff;
border-color: rgba(255, 255, 255, 0.3);
&:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.7);
}
}
&.danger {
background: var(--danger);
}
}
}
+3
View File
@@ -10,12 +10,14 @@ const Button = forwardRef(function Button({
className,
disabled,
primary,
danger,
title,
}: Props, ref: ForwardedRef<HTMLButtonElement>) {
return <button
ref={ref}
className={classNames(className, styles.btn, {
[styles.primary]: primary,
[styles.danger]: danger,
})}
onClick={event => {
if (!disabled && onClick) {
@@ -35,6 +37,7 @@ export type Props = {
className?: string
disabled?: boolean
primary?: boolean
danger?: boolean
title?: string
}
@@ -0,0 +1,58 @@
.fieldset {
border-radius: 6px;
border: 1px solid var(--g400);
overflow: hidden;
user-select: text;
font-size: 14px;
line-height: 1.6;
color: var(--g1600);
background: var(--g200);
> div {
display: flex;
flex-direction: column;
gap: 1em;
padding: 1.25em 1.5em 1.5em 1.5em;
}
> footer {
padding: 1em 1.5em;
color: var(--g1300);
background: var(--g250);
border-top: 1px solid var(--g300);
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
display: grid;
grid-template-columns: 1fr auto;
gap: 1em;
@media (max-width: 700px) {
grid-template-columns: 1fr;
// Get rid of the gap if there's only one row
> div:empty {
display: none;
}
}
> div {
display: flex;
align-items: center;
gap: 0.5em;
}
}
}
.label {
font-size: 1.25em;
font-weight: 500;
color: var(--g2000);
}
+38
View File
@@ -0,0 +1,38 @@
import { ReactNode, useId } from "react"
import classNames from "classnames"
import styles from "./FieldSet.module.scss"
export interface Props {
label: ReactNode
children: ReactNode
actions?: ReactNode // Right side of footer
status?: ReactNode // Left side of footer
className?: string
}
export default function FieldSet({ label, children, actions, status, className }: Props) {
const labelId = useId()
return <div
role="group"
aria-labelledby={labelId}
className={classNames(styles.fieldset, className)}
>
<div>
<h4 id={labelId} className={styles.label}>
{label}
</h4>
{children}
</div>
{(status || actions) && <footer role="group" aria-label="Actions">
<div>
{status}
</div>
<div>
{actions}
</div>
</footer>}
</div>
}
@@ -0,0 +1,110 @@
.container {
color: var(--g1800);
background: var(--g200);
border: 1px solid var(--g500);
border-radius: 8px;
}
.inputsContainer,
.suggestions {
padding: 10px;
}
.inputsContainer {
display: flex;
align-items: center;
gap: 8px;
> svg {
color: var(--g1000);
width: 1.25em;
height: 1.25em;
}
}
.input {
min-width: 4ch;
height: 1.5em;
&:last-of-type {
flex-grow: 1;
}
}
.end {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 1.25em;
height: 1.25em;
}
}
.red {
color: #ec3c3c !important;
}
.green {
color: #3cec3c !important;
}
.suggestions {
border-top: 1px solid var(--g400);
list-style: none;
font-size: 14px;
color: var(--g1600);
position: relative; // For .suggestionsLoading
min-height: 164px; // Avoid layout shift
li {
border-radius: 4px;
&:hover {
color: var(--g2000);
background: var(--g300);
}
button {
background: none;
color: inherit;
border: none;
width: 100%;
padding: 6px;
cursor: pointer;
text-align: left;
display: grid;
grid-template-columns: 1fr auto; // Put timeago on right
align-items: center;
gap: 8px;
time {
color: var(--g800);
}
}
}
.suggestionsLoading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--g1400);
// Loading spinner
svg {
height: 1.5em;
width: 1.5em;
}
}
}
@@ -0,0 +1,158 @@
import { useEffect, useState } from "react"
import { CheckCircleIcon, MarkGithubIcon, XCircleIcon } from "@primer/octicons-react"
import classNames from "classnames"
import TimeAgo from "react-timeago"
import * as api from "../lib/api"
import styles from "./GitHubRepoPicker.module.scss"
import Loading from "./loading.svg"
import StringInput from "./StringInput"
interface GitHubApiRepo {
name: string
pushed_at: string
default_branch: string
owner: {
login: string
}
}
enum ValidationState {
LOADING = "Loading",
VALID = "Repository exists",
INVALID = "Repository does not exist or is private",
IS_EMPTY = "",
RATE_LIMIT = "Rate limit exceeded",
}
export function isValidIdentifierKey(key: string): boolean {
return key.match(/^[a-zA-Z0-9-_.]$/) !== null
}
export interface Props {
owner?: string
repo?: string
onChangeValid: (obj: { owner: string, repo: string, defaultBranch?: string }) => void
className?: string
}
export default function GitHubRepoPicker(props: Props) {
const [owner, setOwner] = useState(props.owner ?? "")
const [repo, setRepo] = useState(props.repo ?? "")
// Default owner to the logged-in user
const user = api.useThisUser()
useEffect(() => {
if (owner === "" && user && !api.isAnonUser(user)) {
setOwner(user.username)
}
}, [user, owner])
// Validate the repo exists
const [validationState, setValidationState] = useState(ValidationState.IS_EMPTY)
const [hitRateLimit, setHitRateLimit] = useState(false)
const onChangeValid = props.onChangeValid
useEffect(() => {
if (owner === "" || repo === "") {
setValidationState(ValidationState.IS_EMPTY)
return
}
setValidationState(ValidationState.LOADING)
fetch(`https://api.github.com/repos/${owner}/${repo}`).then(response => {
if (response.status == 200) {
setValidationState(ValidationState.VALID)
response.json().then((ghRepo: GitHubApiRepo) => {
onChangeValid({
owner: ghRepo.owner.login,
repo: ghRepo.name,
defaultBranch: ghRepo.default_branch,
})
})
} else if (response.status == 404) {
setValidationState(ValidationState.INVALID)
} else if (response.status == 403) {
setValidationState(ValidationState.RATE_LIMIT)
onChangeValid({ owner, repo })
setHitRateLimit(true)
}
})
}, [owner, repo, onChangeValid])
// Get owner's repos to suggest
const [suggestions, setSuggestions] = useState<GitHubApiRepo[]>([])
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(true)
useEffect(() => {
setSuggestions([])
if (owner === "") {
setIsLoadingSuggestions(false)
return
}
setIsLoadingSuggestions(true)
fetch(`https://api.github.com/users/${owner}/repos`).then(response => {
if (response.status === 403) {
setHitRateLimit(true)
}
if (response.status !== 200) {
setSuggestions([])
setIsLoadingSuggestions(false)
return
}
response.json().then((repos: GitHubApiRepo[]) => {
// Sort by most recently updated
repos.sort((a, b) => {
return new Date(a.pushed_at) > new Date(b.pushed_at) ? -1 : 1
})
// Limit to 5
setSuggestions(repos.slice(0, 5))
setIsLoadingSuggestions(false)
})
})
}, [owner])
return <div className={classNames(styles.container, props.className)}>
<div className={styles.inputsContainer}>
<MarkGithubIcon />
<StringInput
className={styles.input}
label="Owner"
value={owner}
onChange={setOwner}
isValidKey={isValidIdentifierKey}
/>
/
<StringInput className={styles.input} label="Repository" value={repo} onChange={setRepo} isValidKey={isValidIdentifierKey} />
<div className={styles.end} title={validationState}>
{validationState == ValidationState.LOADING && <Loading />}
{validationState == ValidationState.VALID && <CheckCircleIcon className={styles.green} />}
{validationState == ValidationState.INVALID && <XCircleIcon className={styles.red} />}
</div>
</div>
{!hitRateLimit && <ul aria-label="Suggestions" className={styles.suggestions}>
{suggestions.length == 0 && <div className={styles.suggestionsLoading}>
{isLoadingSuggestions ? <Loading /> : "User has no repositories"}
</div>}
{suggestions.map(suggestion => {
return <li key={`${suggestion.owner.login}/${suggestion.name}`}>
<button
onClick={() => {
setOwner(suggestion.owner.login)
setRepo(suggestion.name)
}}
>
{suggestion.name}
<TimeAgo date={suggestion.pushed_at} />
</button>
</li>
})}
</ul>}
</div>
}
@@ -0,0 +1,50 @@
.container {
background: var(--g300);
border: 1px solid var(--g400);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
position: relative;
img {
width: 100%;
height: 100%;
}
input {
// Visually hidden, but still accessible to screen readers
position: absolute;
width: 0;
height: 0;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
&:has(img) .uploadIcon {
opacity: 0;
}
&:hover {
img {
filter: brightness(0.5);
}
.uploadIcon {
opacity: 1;
}
}
}
.uploadIcon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0.5;
}
+50
View File
@@ -0,0 +1,50 @@
/* eslint-disable @next/next/no-img-element */
import { useEffect, useRef, useState } from "react"
import { UploadIcon } from "@primer/octicons-react"
import classNames from "classnames"
import styles from "./ImageInput.module.scss"
export interface Props {
file: File
fallbackUrl?: string
onChange: (file: File) => void
className?: string
}
export default function ImageInput({ file, fallbackUrl, onChange, className }: Props) {
const input = useRef<HTMLInputElement>()
const [objectUrl, setObjectUrl] = useState<string>(fallbackUrl)
useEffect(() => {
if (file) {
const url = URL.createObjectURL(file)
setObjectUrl(url)
return () => URL.revokeObjectURL(url)
} else {
setObjectUrl(fallbackUrl)
}
}, [fallbackUrl, file])
return <div
className={classNames(styles.container, className)}
onClick={() => input.current.click()}
onDrop={evt => {
evt.preventDefault()
onChange(evt.dataTransfer.files[0])
}}
>
<input
ref={input}
type="file"
accept="image/*"
onChange={evt => {
onChange(evt.target.files[0])
}}
/>
{objectUrl && <img alt="" src={objectUrl} />}
<UploadIcon className={styles.uploadIcon} size={16} />
</div>
}
+6
View File
@@ -94,6 +94,9 @@ export default function Nav({ border, children }: Props) {
<li>
<Link href="/new">New scratch</Link>
</li>
<li>
<Link href="/projects">Projects</Link>
</li>
<li>
<Link href="/settings/appearance">Settings</Link>
</li>
@@ -112,6 +115,9 @@ export default function Nav({ border, children }: Props) {
<li>
<Link href="/new">New scratch</Link>
</li>
<li>
<Link href="/projects">Projects</Link>
</li>
<li>
<Link href="/settings/appearance">Settings</Link>
</li>
@@ -34,6 +34,7 @@ export default function UserMenuItems() {
Your profile
</LinkItem>
<hr />
{user.is_admin && <LinkItem href={"/admin"}>Admin</LinkItem>}
<ButtonItem
onTrigger={async () => {
plausible("logout")
@@ -4,6 +4,7 @@
padding: 0.08em;
cursor: text;
user-select: none;
&.disabled {
cursor: default;
@@ -12,7 +13,7 @@
&:focus,
&:not(.disabled):hover {
outline: none;
border-bottom: 0;
border-bottom-color: transparent;
background: var(--g300);
}
}
+3 -2
View File
@@ -6,12 +6,12 @@ import styles from "./NumberInput.module.scss"
export type Props = {
value?: number
onChange?: (duration: number) => void
onChange?: (value: number) => void
stringValue?: string
disabled?: boolean
}
export default function TimePeriodInput({ value, onChange, stringValue, disabled }: Props) {
export default function NumberInput({ value, onChange, stringValue, disabled }: Props) {
const [isEditing, setIsEditing] = useState(false)
const editableRef = useRef<HTMLSpanElement>()
@@ -30,6 +30,7 @@ export default function TimePeriodInput({ value, onChange, stringValue, disabled
return <span
ref={editableRef}
className={classNames(styles.numberInput, { [styles.disabled]: disabled })}
tabIndex={0}
contentEditable={isEditing && !disabled}
suppressContentEditableWarning={true}
onClick={() => setIsEditing(true)}
@@ -0,0 +1,60 @@
.header {
background: var(--g200);
user-select: text;
align-self: stretch;
.headerInner {
max-width: 50em;
padding: 1em;
margin: 0 auto;
display: grid;
grid-template-columns: auto 1fr;
gap: 16px;
}
h1 {
color: var(--g1900);
font-size: 1.7em;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.25em;
}
p {
padding-top: 4px;
color: var(--g1600);
font-size: 0.9em;
max-width: 50ch;
white-space: pre-line;
line-height: 1.6;
}
}
.metadata {
margin-top: 8px;
display: flex;
gap: 24px;
color: var(--g1400);
font-size: 12px;
a {
display: inline-flex;
align-items: center;
gap: 4px;
&:hover {
text-decoration: underline;
}
}
}
.platform {
display: inline-flex;
align-items: center;
gap: 4px;
}
+66
View File
@@ -0,0 +1,66 @@
import Link from "next/link"
import { DiamondIcon, GearIcon, MarkGithubIcon } from "@primer/octicons-react"
import UnderlineNav, { Counter } from "../components/UnderlineNav"
import * as api from "../lib/api"
import { Tab } from "../pages/projects/[...project]"
import PlatformIcon from "./PlatformSelect/PlatformIcon"
import PlatformName from "./PlatformSelect/PlatformName"
import styles from "./ProjectHeader.module.scss"
import ProjectIcon from "./ProjectIcon"
export interface Props {
project: api.Project
tab: Tab
}
export default function ProjectHeader({ project, tab }: Props) {
const isMember = api.useIsUserProjectMember(project)
return <>
<header className={styles.header}>
<div className={styles.headerInner}>
<ProjectIcon project={project} size={72} priority />
<div>
<h1>
{project.slug}
</h1>
<p>{project.description}</p>
<div className={styles.metadata}>
<Link href={project.repo.html_url}>
<a>
<MarkGithubIcon size={16} />
{project.repo.owner}/{project.repo.repo}
</a>
</Link>
{project.platform && <div className={styles.platform}>
<PlatformIcon platform={project.platform} size={16} />
<PlatformName platform={project.platform} />
</div>}
</div>
</div>
</div>
</header>
<UnderlineNav
maxWidth="50rem"
links={[
{
href: project.html_url,
selected: tab === Tab.FUNCTIONS,
label: <>
<DiamondIcon /> Functions <Counter>{project.unmatched_function_count}</Counter>
</>,
shallow: true,
},
isMember && {
href: project.html_url + "/settings",
selected: tab === Tab.SETTINGS,
label: <><GearIcon /> Settings</>,
shallow: true,
},
]}
/>
</>
}
@@ -1,8 +0,0 @@
.icon {
width: 1.2em;
height: 1.2em;
border-radius: 0.1em;
overflow: hidden;
position: relative;
}
+20 -20
View File
@@ -1,34 +1,34 @@
import Image from "next/future/image"
import Link from "next/link"
import classNames from "classnames"
import { ProjectIcon as ProjectOcticon } from "@primer/octicons-react"
import useSWR from "swr"
import * as api from "../lib/api"
import styles from "./ProjectIcon.module.scss"
export type Props = {
projectUrl: string
size?: string | number
project: api.Project | string
size: number
className?: string
priority?: boolean
}
export default function ProjectIcon({ projectUrl, size, className }: Props) {
const { data, error } = useSWR<api.Project>(projectUrl, api.get)
export default function ProjectIcon({ project, size, className, priority }: Props) {
const { data, error } = useSWR<api.Project>(typeof project === "string" ? project : project.url, api.get, {
fallbackData: typeof project === "string" ? undefined : project,
})
if (error)
throw error
console.error(error)
if (!data) {
return <a className={classNames(styles.icon, className)} />
}
const style = typeof size === "undefined" ? {} : { width: size, height: size }
return <Link href={data.html_url}>
<a className={classNames(styles.icon, className)} style={style}>
<Image src={data.icon_url} alt={data.slug} fill />
</a>
</Link>
return data?.icon
? <Image
className={className}
src={data.icon}
alt=""
width={size}
height={size}
priority={priority}
style={{ borderRadius: (size / 12) + "px" }}
/>
: <ProjectOcticon className={className} size={size} />
}
@@ -0,0 +1,36 @@
.list {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
> li {
padding: 6px;
display: grid;
grid-template-columns: 1fr auto;
justify-items: start;
color: var(--a800);
.removeBtn {
opacity: 0;
}
&:hover {
border-radius: 6px;
background: var(--g300);
.removeBtn {
opacity: 1;
}
}
}
}
.removeBtn {
padding: 4px;
border-radius: 4px;
border-color: transparent;
}
@@ -0,0 +1,71 @@
import router from "next/router"
import { XIcon } from "@primer/octicons-react"
import useSWR from "swr"
import * as api from "../lib/api"
import AsyncButton from "./AsyncButton"
import FieldSet from "./FieldSet"
import styles from "./ProjectMembers.module.scss"
import UserLink from "./user/UserLink"
function Member({ member, onRemove }: { member: api.ProjectMember, onRemove?: () => Promise<void> }) {
const { data, error } = useSWR<api.User>(`/users/${member.username}`, api.get)
if (error) {
throw error
}
return <li>
{data && <UserLink user={data} />}
{onRemove && <AsyncButton
title="Remove"
className={styles.removeBtn}
onClick={onRemove}
>
<XIcon />
</AsyncButton>}
</li>
}
export default function ProjectMembers({ project }: { project: api.Project }) {
const user = api.useThisUser()
const { members, addMember, removeMember } = api.useProjectMembers(project)
const canAct = api.useIsUserProjectMember(project)
return <FieldSet
label="Members"
status="Members can modify any project setting as well as delete the project."
actions={canAct && <AsyncButton
onClick={async () => {
const username = prompt("Enter username of new member:")
if (username && username.length > 0) {
await addMember(username)
}
}}
>
Add member..
</AsyncButton>}
>
<ul className={styles.list}>
{members.map(member => <Member
key={member.url}
member={member}
onRemove={canAct ? async () => {
if (member.username === user?.username) {
if (!confirm("Are you sure you want to remove yourself from this project?")) {
return
}
}
await removeMember(member.username)
if (member.username === user?.username) {
router.push(project.html_url)
}
} : undefined}
/>)}
</ul>
</FieldSet>
}
@@ -0,0 +1,44 @@
.container {
display: flex;
flex-direction: column;
gap: 1.5em;
}
.icon {
width: 64px;
height: 64px;
}
.descriptionTextarea {
padding: 6px 10px;
width: 100%;
max-width: 50ch;
border: 1px solid var(--g700);
border-radius: 4px;
background: transparent;
outline: none !important;
resize: none;
cursor: auto;
user-select: text;
font-size: inherit;
line-height: inherit;
&:not(:disabled) {
cursor: auto;
transition: border-color 0.1s ease;
&:focus {
border-color: currentcolor;
}
}
}
.borderDanger {
border-color: var(--danger);
border-style: dashed;
}
@@ -0,0 +1,98 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/router"
import * as api from "../lib/api"
import useEntity from "../lib/useEntity"
import AsyncButton from "./AsyncButton"
import FieldSet from "./FieldSet"
import ImageInput from "./ImageInput"
import ProjectMembers from "./ProjectMembers"
import styles from "./ProjectSettings.module.scss"
function ProjectIconForm({ project }: { project: api.Project }) {
const [file, setFile] = useState<File>()
useEffect(() => {
if (file) {
const data = new FormData()
data.append("icon", file)
api.patch(project.url, data).catch(console.error)
}
}, [file, project.url])
return <ImageInput
file={file}
onChange={setFile}
fallbackUrl={project.icon}
className={styles.icon}
/>
}
function ProjectDescriptionForm({ url }: { url: string }) {
const [project, actions] = useEntity<api.Project>(url)
return <FieldSet
label="Description"
actions={<AsyncButton
primary
disabled={actions.isSaved}
onClick={actions.save}
>
Save
</AsyncButton>}
>
<textarea
className={styles.descriptionTextarea}
value={project.description}
onChange={evt => actions.assign({ description: evt.currentTarget.value })}
maxLength={1000}
rows={(project.description.match(/\n/g)?.length ?? 0) + 1}
/>
</FieldSet>
}
export default function ProjectSettings({ project }: { project: api.Project }) {
const router = useRouter()
const userIsMember = api.useIsUserProjectMember(project)
if (!userIsMember) {
return <div className={styles.container}>
You must be a member of this project to view its settings.
</div>
}
return <div className={styles.container}>
<ProjectMembers project={project} />
<FieldSet label="Icon">
<ProjectIconForm project={project} />
</FieldSet>
<ProjectDescriptionForm url={project.url} />
<FieldSet
label="Delete Project"
className={styles.borderDanger}
actions={<AsyncButton
danger
onClick={async () => {
const msg = [
`Are you sure you want to permanently delete ${project.slug}?`,
`Type '${project.slug}' to continue.`,
].join("\n")
if (prompt(msg) == project.slug) {
await api.delete_(project.url, {})
router.push("/projects")
}
}}
>
Delete
</AsyncButton>}
>
<p>
The project will be permanently deleted. This action is irreversible and can not be undone.
Scratches associated with this project will not be deleted.
</p>
</FieldSet>
</div>
}
@@ -26,7 +26,7 @@ function htmlTextOnly(html: string): string {
}
function exportScratchZip(scratch: api.Scratch) {
const url = api.getURL(`${scratch.url}/export`)
const url = api.normalizeUrl(`${scratch.url}/export`)
const a = document.createElement("a")
a.href = url
a.download = scratch.name + ".zip"
+2 -2
View File
@@ -5,13 +5,13 @@ import ProjectIcon from "./ProjectIcon"
export type Props = {
scratch: api.TerseScratch
size?: string | number
size: number
className?: string
}
export default function ScratchIcon(props: Props) {
if (props.scratch.project) {
return <ProjectIcon {...props} projectUrl={props.scratch.project} />
return <ProjectIcon {...props} project={props.scratch.project} />
} else {
return <PlatformIcon {...props} platform={props.scratch.platform} />
}
+3 -3
View File
@@ -74,7 +74,7 @@ export function ScratchItem({ scratch, children }: { scratch: api.TerseScratch,
return <li className={styles.item}>
<div className={styles.scratch}>
<div className={styles.header}>
<ScratchIcon scratch={scratch} className={styles.icon} />
<ScratchIcon size={16} scratch={scratch} className={styles.icon} />
<Link href={scratch.html_url}>
<a className={classNames(styles.link, styles.name)}>
{scratch.name}
@@ -106,7 +106,7 @@ export function ScratchItemNoOwner({ scratch }: { scratch: api.TerseScratch }) {
return <li className={styles.item}>
<div className={styles.scratch}>
<div className={styles.header}>
<ScratchIcon scratch={scratch} className={styles.icon} />
<ScratchIcon size={16} scratch={scratch} className={styles.icon} />
<Link href={scratch.html_url}>
<a className={classNames(styles.link, styles.name)}>
{scratch.name}
@@ -125,7 +125,7 @@ export function SingleLineScratchItem({ scratch }: { scratch: api.TerseScratch }
const matchPercentString = isNaN(matchPercent) ? "0%" : percentToString(matchPercent)
return <li className={styles.singleLine}>
<ScratchIcon scratch={scratch} className={styles.icon} />
<ScratchIcon size={16} scratch={scratch} className={styles.icon} />
<Link href={scratch.html_url}>
<a className={classNames(styles.link, styles.name)}>
{scratch.name}
@@ -0,0 +1,22 @@
.input {
display: inline-block;
border: 0;
border-bottom: 1px dotted var(--g900);
padding: 0.08em;
cursor: text;
user-select: none;
&.disabled {
pointer-events: none;
opacity: 0.7;
}
&:focus,
&:not(.disabled):hover {
outline: none;
border-bottom-color: transparent;
background: var(--g300);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from "react"
import classNames from "classnames"
import styles from "./StringInput.module.scss"
export type Props = {
value: string
onChange: (value: string) => void
label: string
isValidKey?: (key: string) => boolean
disabled?: boolean
className?: string
}
export default function StringInput({ value, onChange, isValidKey, label, disabled, className }: Props) {
const [isEditing, setIsEditing] = useState(false)
const editableRef = useRef<HTMLSpanElement>()
useEffect(() => {
const el = editableRef.current
if (el) {
const range = document.createRange()
range.selectNodeContents(el)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
}
}, [isEditing])
return <span
ref={editableRef}
title={label}
tabIndex={0}
className={classNames(styles.input, { [styles.disabled]: disabled }, className)}
contentEditable={isEditing && !disabled}
suppressContentEditableWarning={true}
spellCheck={false}
onClick={() => setIsEditing(true)}
onBlur={evt => {
onChange(evt.currentTarget.textContent)
setIsEditing(false)
}}
onKeyPress={evt => {
const v = isValidKey ? isValidKey(evt.key) : true
if (!v || disabled) {
evt.preventDefault()
}
if (evt.key == "Enter") {
evt.currentTarget.blur() // submit
}
}}
onPaste={evt => evt.preventDefault()}
>
{isEditing ? editableRef.current.textContent : value}
</span>
}
+1 -1
View File
@@ -54,7 +54,7 @@ export class Tab extends Component<TabProps> {
return <div>Misplaced Tab (not in Tabs?)</div>
}
if (!key) {
if (typeof key !== "string") {
console.error("Misplaced Tab (no tabKey)")
return <div>Misplaced Tab (no tabKey?)</div>
}
@@ -0,0 +1,93 @@
.container {
width: 100%;
font-size: 14px;
background: var(--g200);
box-shadow: inset 0 -1px 0 var(--g400);
user-select: none;
position: sticky;
top: 0;
z-index: 99;
> ul {
list-style: none;
display: flex;
flex-direction: row;
align-items: stretch;
width: 100%;
margin: 0 auto;
padding: 0 1em;
> li {
position: relative;
padding: 12px 4px;
> a {
padding: 8px;
color: var(--g1600);
font-weight: 450;
background: transparent;
border-radius: 8px;
transition: background 0.1s ease;
&:hover {
background: var(--g300);
}
svg {
color: var(--g1200);
margin-right: 4px;
}
.counter {
margin-left: 4px;
}
}
&[data-selected="true"] {
> a {
color: var(--g2000);
}
&::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
right: 0;
height: 2px;
background: var(--g2000);
border-radius: 6px;
}
}
}
}
}
.counter {
display: inline-block;
min-width: 20px;
line-height: 20px;
padding: 0 6px;
font-size: 12px;
font-weight: 600;
text-align: center;
white-space: nowrap;
border-radius: 2em;
color: var(--g1600);
background: var(--g400);
}
+59
View File
@@ -0,0 +1,59 @@
import { ReactNode, useRef } from "react"
import Link from "next/link"
import { useRouter } from "next/router"
import styles from "./UnderlineNav.module.scss"
export function Counter({ children }: { children: ReactNode }) {
return <span className={styles.counter}>{children}</span>
}
export interface LinkConfig {
href: string
label: ReactNode
selected?: boolean
shallow?: boolean
}
export interface Props {
links: LinkConfig[]
maxWidth?: string
}
export default function UnderlineNav({ links, maxWidth }: Props) {
const router = useRouter()
const ref = useRef<HTMLDivElement>()
// When a shallow route change is made, we need to scroll up to whereever this component is.
const onClickShallow = () => {
if (ref.current) {
// Temporarily remove position:sticky so we can get its normal position.
ref.current.style.position = "initial"
requestAnimationFrame(() => {
const { offsetTop } = ref.current
// Only scroll up, not down.
if (offsetTop < window.scrollY) {
window.scroll({ top: offsetTop })
}
ref.current.style.position = "sticky"
})
}
}
return <nav ref={ref} className={styles.container}>
<ul style={{ maxWidth }}>
{links.filter(Boolean).map(({ href, label, selected, shallow }) => {
const isSelected = selected || router.asPath === href
return <li key={href} data-selected={isSelected}>
<Link href={href} shallow={shallow}>
<a onClick={shallow && onClickShallow}>{label}</a>
</Link>
</li>
})}
</ul>
</nav>
}
+7 -1
View File
@@ -1,3 +1,5 @@
import { useEffect, useState } from "react"
import Image from "next/future/image"
import classNames from "classnames"
@@ -15,8 +17,12 @@ export type Props = {
export default function UserAvatar({ user, className }: Props) {
const userIsYou = api.useUserIsYou()
// Avoid hydration mismatch
const [isMounted, setIsMounted] = useState(false)
useEffect(() => setIsMounted(true), [])
return <span className={classNames(styles.avatar, className)}>
{api.isAnonUser(user) ? <AnonymousFrogAvatar user={user}/> : user.avatar_url && <Image src={user.avatar_url} alt="" fill sizes="64px" />}
{!userIsYou(user) && user.is_online && <div className={styles.online} title="Online" />}
{isMounted && !userIsYou(user) && user.is_online && <div className={styles.online} title="Online" />}
</span>
}
@@ -12,5 +12,5 @@
}
.user:any-link:hover {
color: var(--link);
text-decoration: underline;
}
+84 -76
View File
@@ -56,7 +56,7 @@ export class ResponseError extends Error {
}
}
export function getURL(url: string) {
export function normalizeUrl(url: string) {
if (url.startsWith("/")) {
url = API_BASE + url
}
@@ -64,7 +64,7 @@ export function getURL(url: string) {
}
export async function get(url: string, useCacheIfFresh = false) {
url = getURL(url)
url = normalizeUrl(url)
const response = await fetch(url, {
...commonOpts,
@@ -91,18 +91,23 @@ export async function get(url: string, useCacheIfFresh = false) {
export const getCached = (url: string) => get(url, true)
export async function post(url: string, json: Json) {
url = getURL(url)
export async function post(url: string, data: Json | FormData, method = "POST") {
url = normalizeUrl(url)
const body: string = JSON.stringify(json)
console.info(method, url, data)
console.info("POST", url, JSON.parse(body))
let body: string | FormData
if (data instanceof FormData) {
body = data
} else {
body = JSON.stringify(data)
}
const response = await fetch(url, {
...commonOpts,
method: "POST",
method,
body,
headers: {
headers: body instanceof FormData ? {} : {
"Content-Type": "application/json",
},
})
@@ -111,63 +116,25 @@ export async function post(url: string, json: Json) {
throw new ResponseError(response, await response.json())
}
return await response.json()
}
export async function patch(url: string, json: Json) {
url = getURL(url)
const body = JSON.stringify(json)
console.info("PATCH", url, JSON.parse(body))
const response = await fetch(url, {
...commonOpts,
method: "PATCH",
body,
headers: {
"Content-Type": "application/json",
},
})
if (!response.ok) {
throw new ResponseError(response, await response.json())
}
const text = await response.text()
if (!text) {
return
}
return JSON.parse(text)
}
export async function delete_(url: string, json: Json) {
url = getURL(url)
const body: string = JSON.stringify(json)
console.info("DELETE", url, JSON.parse(body))
const response = await fetch(url, {
...commonOpts,
method: "DELETE",
body,
headers: {
"Content-Type": "application/json",
},
})
if (!response.ok) {
throw new ResponseError(response, await response.json())
}
if (response.status == 204) { // No Content
if (response.status == 204) {
return null
} else {
return await response.json()
}
}
export async function patch(url: string, data: Json | FormData) {
return await post(url, data, "PATCH")
}
export async function delete_(url: string, json: Json) {
return await post(url, json, "DELETE")
}
export async function put(url: string, json: Json) {
return await post(url, json, "PUT")
}
export interface Page<T> {
next: string | null
previous: string | null
@@ -180,6 +147,7 @@ export interface AnonymousUser {
is_anonymous: true
id: number
is_online: boolean
is_admin: boolean
username: string
frog_color: [number, number, number]
@@ -191,6 +159,7 @@ export interface User {
is_anonymous: false
id: number
is_online: boolean
is_admin: boolean
username: string
name: string
@@ -239,9 +208,10 @@ export interface Project {
last_pulled: string | null
}
creation_time: string
icon_url: string
members: User[]
icon?: string
description: string
platform?: string
unmatched_function_count: number
}
export interface ProjectFunction {
@@ -257,6 +227,11 @@ export interface ProjectFunction {
attempts_count: number
}
export interface ProjectMember {
url: string
username: string
}
export type Compilation = {
errors: string
diff_output: DiffOutput | null
@@ -567,7 +542,7 @@ export function useCompilers(): Record<string, Compiler> {
return data.compilers
}
export function usePaginated<T>(url: string): {
export function usePaginated<T>(url: string, firstPage?: Page<T>): {
results: T[]
hasNext: boolean
hasPrevious: boolean
@@ -575,24 +550,26 @@ export function usePaginated<T>(url: string): {
loadNext: () => Promise<void>
loadPrevious: () => Promise<void>
} {
const [results, setResults] = useState<T[]>([])
const [next, setNext] = useState<string | null>(url)
const [previous, setPrevious] = useState<string | null>(null)
const [results, setResults] = useState<T[]>(firstPage?.results ?? [])
const [next, setNext] = useState<string | null>(firstPage?.next)
const [previous, setPrevious] = useState<string | null>(firstPage?.previous)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
setResults([])
setNext(url)
setPrevious(null)
setIsLoading(true)
if (!firstPage) {
setResults([])
setNext(url)
setPrevious(null)
setIsLoading(true)
get(url).then((data: Page<T>) => {
setResults(data.results)
setNext(data.next)
setPrevious(data.previous)
setIsLoading(false)
})
}, [url])
get(url).then((page: Page<T>) => {
setResults(page.results)
setNext(page.next)
setPrevious(page.previous)
setIsLoading(false)
})
}
}, [url]) // eslint-disable-line react-hooks/exhaustive-deps
const loadNext = useCallback(async () => {
if (!next)
@@ -646,3 +623,34 @@ export function useStats(): Stats | undefined {
return data
}
export function useProjectMembers(project: Project): {
members: ProjectMember[]
addMember: (username: string) => Promise<void>
removeMember: (username: string) => Promise<void>
} {
const url = `${project.url}/members`
const { data, error, mutate } = useSWR<ProjectMember[]>(url, get)
if (error) {
throw error
}
return {
members: data || [],
async addMember(username: string) {
await mutate(() => post(url, { username }))
},
async removeMember(username: string) {
await delete_(`${url}/${username}`, {})
await mutate()
},
}
}
export function useIsUserProjectMember(project: Project): boolean {
const user = useThisUser()
const { members } = useProjectMembers(project)
return !!members.find(member => member.username === user?.username)
}
+68
View File
@@ -0,0 +1,68 @@
import { useState } from "react"
import useSWR, { SWRResponse } from "swr"
import { get, patch } from "./api"
import { useWarnBeforeUnload } from "./hooks"
export interface Actions<T> {
/** Direct access to SWR response */
swr: SWRResponse<T>
/** True if local data has been modified and not yet submitted. */
isModified: boolean
/** Negation of isModified. */
isSaved: boolean
/** Commit local data to the server. */
save: () => Promise<void>
/** Modify local data without submitting. */
assign: (partial: Partial<T>) => void
/** Resets local data to the server state. */
reset: () => void
}
export interface HasUrl {
url: string
}
export default function useEntity<T extends HasUrl>(entity: T | string): [T, Actions<T>] {
const url = typeof entity === "string" ? entity : entity.url
const swr = useSWR(url, get, { suspense: true })
const [localPatch, setLocalPatch] = useState<Partial<T> | null>(null)
const isModified = localPatch !== null
const data = isModified ? { ...swr.data, ...localPatch } : swr.data
useWarnBeforeUnload(isModified)
return [data, {
swr,
isModified,
isSaved: !isModified,
async save() {
if (!isModified) {
console.warn("Ignoring entity save() without changes")
return
}
setLocalPatch(null)
await swr.mutate(() => patch(data.url, localPatch), {
optimisticData: data,
rollbackOnError: true,
})
},
assign(partial: Partial<T>) {
if (localPatch) {
setLocalPatch({ ...localPatch, ...partial })
} else {
setLocalPatch(partial)
}
},
reset() {
setLocalPatch(null)
},
}]
}
-90
View File
@@ -1,90 +0,0 @@
.container {
display: flex;
flex-direction: column;
gap: 1em;
max-width: 50em;
padding: 1em;
margin: 0 auto;
cursor: default;
> h2 {
color: var(--g1400);
font-weight: 600;
svg {
margin-right: 0.4em;
}
}
}
.loadingContainer {
display: flex;
padding: 1em 0;
gap: 1em;
align-items: center;
justify-content: center;
}
.header {
background: var(--g300);
user-select: auto;
align-self: stretch;
.headerInner {
max-width: 50em;
padding: 1em;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.25em;
}
h1 {
color: var(--g1900);
font-size: 1.7em;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.25em;
}
p {
padding-top: 4px;
color: var(--g1000);
font-size: 0.9em;
max-width: 50ch;
}
}
.headerActions {
display: flex;
gap: 0.5em;
align-items: center;
small {
font-size: 0.8em;
color: var(--g1000);
}
}
.links {
display: flex;
align-items: center;
gap: 1em;
a {
display: inline-flex;
align-items: center;
gap: 0.5ch;
color: var(--g1700);
&:hover {
text-decoration: underline;
}
}
}
-119
View File
@@ -1,119 +0,0 @@
import { useEffect, useState } from "react"
import { GetStaticPaths, GetStaticProps } from "next"
import Image from "next/future/image"
import Link from "next/link"
import { MarkGithubIcon, RepoPullIcon } from "@primer/octicons-react"
import TimeAgo from "react-timeago"
import useSWR from "swr"
import AsyncButton from "../components/AsyncButton"
import ErrorBoundary from "../components/ErrorBoundary"
import Footer from "../components/Footer"
import LoadingSpinner from "../components/loading.svg"
import Nav from "../components/Nav"
import PageTitle from "../components/PageTitle"
import ProjectFunctionList from "../components/ProjectFunctionList"
import PrScratchBasket from "../components/PrScratchBasket"
import UserAvatarList from "../components/UserAvatarList"
import * as api from "../lib/api"
import styles from "./[project].module.scss"
export const getStaticPaths: GetStaticPaths = async () => {
const page: api.Page<api.Project> = await api.get("/projects")
return {
paths: page.results.map(project => "/" + project.slug),
fallback: "blocking",
}
}
export const getStaticProps: GetStaticProps = async context => {
try {
const project: api.Project = await api.get(`/projects/${context.params.project}`)
return {
props: {
project,
},
revalidate: 60, // cache for a minute
}
} catch (error) {
console.log(error)
return {
notFound: true,
revalidate: true,
}
}
}
export default function ProjectPage(props: { project: api.Project }) {
const { data: project, mutate } = useSWR<api.Project>(props.project.url, api.get, {
fallbackData: props.project,
// Refresh every 2s if the repo is busy being pulled
refreshInterval: p => (p.repo.is_pulling ? 2000 : 0),
})
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
const userIsYou = api.useUserIsYou()
const userIsMember = isMounted && !!project.members.find(userIsYou)
return <>
<PageTitle title={project.slug} />
<Nav />
<header className={styles.header}>
<div className={styles.headerInner}>
<h1>
<Image src={project.icon_url} alt="" width={32} height={32} />
{project.slug}
</h1>
<p>{project.description}</p>
<div className={styles.links}>
<Link href={project.repo.html_url}>
<a>
<MarkGithubIcon size={18} />
{project.repo.owner}/{project.repo.repo}
</a>
</Link>
<UserAvatarList users={project.members} />
</div>
</div>
</header>
<PrScratchBasket project={project} />
{project.repo.is_pulling ? <main className={styles.loadingContainer}>
<LoadingSpinner width={32} height={32} />
This project is being updated, please wait
</main> : <main>
<ErrorBoundary>
<div className={styles.container}>
<h2>Functions</h2>
<ProjectFunctionList projectUrl={project.url}>
<div className={styles.headerActions}>
{userIsMember && <AsyncButton
forceLoading={project.repo.is_pulling}
onClick={async () => {
mutate(await api.post(project.url + "/pull", {}))
}}
>
<RepoPullIcon /> Pull
</AsyncButton>}
<small>
Last pulled <TimeAgo date={project.repo.last_pulled} />
</small>
</div>
</ProjectFunctionList>
</div>
</ErrorBoundary>
</main>}
<Footer />
</>
}
+2 -1
View File
@@ -1,7 +1,8 @@
@use "./theme.scss" as theme;
:root {
--link: #3db8e9;
--link: #58a6ff;
--danger: #f00;
--monospace: "Menlo", "Monaco", monospace;
--font-ui: -apple-system, "BlinkMacSystemFont", "Segoe UI", "Helvetica", "Arial", sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
}
-35
View File
@@ -114,41 +114,6 @@
}
}
.projectList {
font-size: 0.9em;
max-width: 300px;
list-style: none;
> li {
padding: 1em 0;
&:last-child {
padding-bottom: 0;
}
}
}
.projectLink {
display: flex;
gap: 0.3em;
font-weight: 500;
&:hover {
color: var(--link);
}
}
.loadMoreLink {
font-size: 0.8em;
color: var(--g1000);
&:hover {
color: var(--link);
}
}
.aboutColumnsContainer {
display: grid;
grid-template-columns: 1fr auto;
-30
View File
@@ -1,4 +1,3 @@
import Image from "next/future/image"
import Link from "next/link"
import { ArrowRightIcon } from "@primer/octicons-react"
@@ -16,29 +15,6 @@ import * as api from "../lib/api"
import styles from "./index.module.scss"
const DECOMP_ME_DESCRIPTION = "decomp.me is a collaborative online space where you can contribute to ongoing decompilation projects."
const SHOW_PROJECT_LIST = false
function ProjectList() {
const { results, isLoading, hasNext, loadNext } = api.usePaginated<api.Project>("/projects")
return <ul className={styles.projectList}>
{results.map(project => (
<li key={project.url}>
<Link href={project.html_url}>
<a className={styles.projectLink}>
<Image src={project.icon_url} alt="" width={16} height={16} />
{project.slug}
</a>
</Link>
</li>
))}
{hasNext && <li className={styles.loadMoreLink}>
<a onClick={loadNext}>
{isLoading ? "Loading..." : "Show more"}
</a>
</li>}
</ul>
}
export default function IndexPage() {
const user = api.useThisUser()
@@ -94,12 +70,6 @@ export default function IndexPage() {
</section>
<section className={styles.projects}>
<ErrorBoundary>
{SHOW_PROJECT_LIST && <>
<h2>Projects</h2>
<ProjectList />
<br/>
</>}
<h2>Your scratches</h2>
<ScratchList
url={yourScratchesUrl}
+2 -2
View File
@@ -19,7 +19,7 @@
.heading {
background: var(--g300);
padding: 1em;
user-select: auto;
user-select: text;
align-self: stretch;
.headingInner {
@@ -29,7 +29,7 @@
h1 {
color: var(--g1900);
font-size: 1.25em;
font-size: 1.5em;
font-weight: 500;
}
+128
View File
@@ -0,0 +1,128 @@
.container {
margin: 0 auto;
max-width: 49em;
padding: 0 1em;
user-select: text;
h1 {
color: var(--g1900);
font-size: 1.5em;
font-weight: 500;
}
p {
padding-top: 4px;
color: var(--g1000);
font-size: 0.9em;
max-width: 50ch;
}
}
.header {
background: var(--g300);
padding: 1em;
align-self: stretch;
}
.projectList {
list-style: none;
> li {
margin: 1.5em 0;
border-radius: 6px;
border: 1px solid var(--g400);
overflow: hidden;
user-select: text;
font-size: 14px;
line-height: 1.6;
color: var(--g1700);
background: var(--g200);
a:hover {
text-decoration: underline;
}
.projectLink {
font-size: 24px;
font-weight: 500;
text-decoration: none;
display: flex;
align-items: center;
gap: 20px;
}
> div {
padding: 1rem;
font-size: 14px;
border-top: 1px solid var(--g300);
&:first-child {
border-top: 0;
background-color: var(--g250);
}
}
.metadata {
color: var(--g1400);
font-size: 12px;
display: flex;
gap: 24px;
@media (max-width: 700px) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
@media (max-width: 400px) {
grid-template-columns: 1fr;
}
.platform,
.repo {
display: flex;
align-items: center;
gap: 4px;
}
}
.description {
white-space: pre-line;
line-height: 1.6;
}
}
.loadMoreLink {
button {
border: 0;
background: var(--g200);
width: 100%;
padding: 0.5rem;
text-align: center;
font-size: 14px;
&[data-is-loading="false"] {
cursor: pointer;
&:hover {
background: var(--g300);
}
}
svg {
width: 1em;
height: 1em;
}
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import { GetStaticProps } from "next"
import Link from "next/link"
import { MarkGithubIcon } from "@primer/octicons-react"
import TimeAgo from "react-timeago"
import Footer from "../components/Footer"
import Loading from "../components/loading.svg"
import Nav from "../components/Nav"
import PageTitle from "../components/PageTitle"
import PlatformIcon from "../components/PlatformSelect/PlatformIcon"
import PlatformName from "../components/PlatformSelect/PlatformName"
import ProjectIcon from "../components/ProjectIcon"
import * as api from "../lib/api"
import styles from "./projects.module.scss"
function ProjectList({ initialPage }: { initialPage: api.Page<api.Project> }) {
const { results, isLoading, hasNext, loadNext } = api.usePaginated<api.Project>("/projects", initialPage)
return <ul className={styles.projectList} aria-label="Project list">
{results.map(project => (
<li key={project.url}>
<div>
<Link href={project.html_url}>
<a className={styles.projectLink}>
<ProjectIcon project={project} size={48} />
{project.slug}
</a>
</Link>
</div>
{project.description && <div className={styles.description}>
{project.description}
</div>}
<div className={styles.metadata}>
<div>
Updated <TimeAgo date={project.repo.last_pulled} />
</div>
<div>
{project.unmatched_function_count} functions
</div>
{project.platform && <div className={styles.platform}>
<PlatformIcon platform={project.platform} size={16} />
<PlatformName platform={project.platform} />
</div>}
<div>
<Link href={project.repo.html_url}>
<a className={styles.repo}>
<MarkGithubIcon size={16} />
{project.repo.owner}/{project.repo.repo}
</a>
</Link>
</div>
</div>
</li>
))}
{hasNext && <li className={styles.loadMoreLink}>
<button onClick={loadNext} data-is-loading={isLoading}>
{isLoading ? <Loading /> : "Show more"}
</button>
</li>}
</ul>
}
export const getStaticProps: GetStaticProps = async _context => {
const initialPage: api.Page<api.Project> = await api.get("/projects")
return {
props: {
initialPage,
},
revalidate: 60,
}
}
export default function ProjectsPage({ initialPage }: { initialPage: api.Page<api.Project> }) {
return <>
<PageTitle title="Projects" />
<Nav />
<main>
<header className={styles.header}>
<div className={styles.container}>
<h1>Projects</h1>
</div>
</header>
<div className={styles.container}>
<ProjectList initialPage={initialPage} />
</div>
</main>
<Footer />
</>
}
@@ -0,0 +1,39 @@
.container {
display: flex;
flex-direction: column;
gap: 1em;
max-width: 50em;
padding: 1em;
margin: 0 auto;
cursor: default;
> h2 {
color: var(--g1400);
font-weight: 600;
svg {
margin-right: 0.4em;
}
}
}
.loadingContainer {
display: flex;
padding: 1em 0;
gap: 1em;
align-items: center;
justify-content: center;
}
.headerActions {
display: flex;
gap: 0.5em;
align-items: center;
small {
font-size: 0.8em;
color: var(--g1000);
}
}
@@ -0,0 +1,122 @@
import { GetStaticPaths, GetStaticProps } from "next"
import { useRouter } from "next/router"
import { RepoPullIcon } from "@primer/octicons-react"
import { SWRConfig } from "swr"
import AsyncButton from "../../components/AsyncButton"
import ErrorBoundary from "../../components/ErrorBoundary"
import Footer from "../../components/Footer"
import LoadingSpinner from "../../components/loading.svg"
import Nav from "../../components/Nav"
import PageTitle from "../../components/PageTitle"
import ProjectFunctionList from "../../components/ProjectFunctionList"
import ProjectHeader from "../../components/ProjectHeader"
import ProjectSettings from "../../components/ProjectSettings"
import PrScratchBasket from "../../components/PrScratchBasket"
import * as api from "../../lib/api"
import useEntity from "../../lib/useEntity"
import Error404Page from "../404"
import styles from "./[...project].module.scss"
export enum Tab {
FUNCTIONS = "functions",
SETTINGS = "settings",
}
export const DEFAULT_TAB = Tab.FUNCTIONS
export function isValidTab(tab: string): tab is Tab {
return Object.values(Tab).includes(tab as Tab)
}
export const getStaticPaths: GetStaticPaths = async () => {
const page: api.Page<api.Project> = await api.get("/projects")
return {
paths: page.results.map(project => project.html_url),
fallback: "blocking",
}
}
export const getStaticProps: GetStaticProps = async context => {
const parts = context.params.project
if (parts.length == 0 || parts.length > 3) {
return {
notFound: true,
}
}
try {
const project: api.Project = await api.get(`/projects/${context.params.project[0]}`)
return {
props: {
project: project,
fallback: {
[api.normalizeUrl(project.url)]: project,
[api.normalizeUrl(project.url) + "/members"]: await api.get(project.url + "/members"),
"/compilers": await api.get("/compilers"),
},
},
revalidate: 60,
}
} catch (error) {
console.log(error)
return {
notFound: true,
revalidate: true,
}
}
}
export function Inner({ url, tab }: { url: string, tab: Tab }) {
const [project, actions] = useEntity<api.Project>(url)
const userIsMember = api.useIsUserProjectMember(project)
return <>
<PageTitle title={project.slug} description={project.description} />
<Nav />
<ProjectHeader project={project} tab={tab} />
<PrScratchBasket project={project} />
{project.repo.is_pulling ? <main className={styles.loadingContainer}>
<LoadingSpinner width={32} height={32} />
This project is being updated, please wait
</main> : <main>
<ErrorBoundary>
<div className={styles.container}>
{tab == Tab.FUNCTIONS && <ProjectFunctionList projectUrl={project.url}>
<div className={styles.headerActions}>
{userIsMember && <AsyncButton
forceLoading={project.repo.is_pulling}
onClick={async () => {
actions.swr.mutate(await api.post(project.url + "/pull", {}))
}}
>
<RepoPullIcon /> Pull
</AsyncButton>}
</div>
</ProjectFunctionList>}
{tab == Tab.SETTINGS && <ProjectSettings project={project} />}
</div>
</ErrorBoundary>
</main>}
<Footer />
</>
}
export default function ProjectPage(props: { project: api.Project, fallback: any }) {
const router = useRouter()
const [, maybeTab, ...rest] = router.query.project as string[]
const tab = maybeTab ?? DEFAULT_TAB
if (rest.length || !isValidTab(tab)) {
return <Error404Page />
}
return <SWRConfig value={{ fallback: props.fallback }}>
<Inner url={props.project.url} tab={tab} />
</SWRConfig>
}
@@ -1,5 +1,7 @@
.header {
background: var(--g300);
background: var(--g200);
border-bottom: 1px solid var(--g400);
user-select: auto;
align-self: stretch;
@@ -1,21 +1,21 @@
import { GetStaticPaths, GetStaticProps } from "next"
import Image from "next/future/image"
import Link from "next/link"
import { useRouter } from "next/router"
import { ArrowRightIcon, GitPullRequestIcon } from "@primer/octicons-react"
import AsyncButton from "../../components/AsyncButton"
import Breadcrumbs from "../../components/Breadcrumbs"
import Button from "../../components/Button"
import ErrorBoundary from "../../components/ErrorBoundary"
import Footer from "../../components/Footer"
import Nav from "../../components/Nav"
import PageTitle from "../../components/PageTitle"
import PrScratchBasket, { useBasket } from "../../components/PrScratchBasket"
import { ScratchItem } from "../../components/ScratchList"
import * as api from "../../lib/api"
import AsyncButton from "../../../../components/AsyncButton"
import Breadcrumbs from "../../../../components/Breadcrumbs"
import Button from "../../../../components/Button"
import ErrorBoundary from "../../../../components/ErrorBoundary"
import Footer from "../../../../components/Footer"
import Nav from "../../../../components/Nav"
import PageTitle from "../../../../components/PageTitle"
import ProjectIcon from "../../../../components/ProjectIcon"
import PrScratchBasket, { useBasket } from "../../../../components/PrScratchBasket"
import { ScratchItem } from "../../../../components/ScratchList"
import * as api from "../../../../lib/api"
import styles from "./[function].module.scss"
@@ -81,7 +81,7 @@ export default function ProjectFunctionPage({ project, func, attempts }: { proje
const userAttempt = attempts.find(scratch => userIsYou(scratch.owner))
const basket = useBasket(project)
const canCreatePr = !!project.members.find(userIsYou)
const canCreatePr = api.useIsUserProjectMember(project)
const basketHasThisFunc = basket.scratches.some(s => s.project_function == func.url)
return <>
@@ -92,7 +92,7 @@ export default function ProjectFunctionPage({ project, func, attempts }: { proje
<Breadcrumbs pages={[
{
label: <div className={styles.projectLink}>
<Image src={project.icon_url} alt="" width={24} height={24} />
<ProjectIcon project={project} size={24} />
{project.slug}
</div>,
href: project.html_url,
@@ -0,0 +1,66 @@
.container {
margin: 0 auto;
max-width: 49em;
padding: 0 1em;
> h1 {
color: var(--g1900);
font-size: 1.5em;
font-weight: 500;
}
> p {
padding-top: 4px;
color: var(--g1000);
font-size: 0.9em;
max-width: 50ch;
}
}
.header {
background: var(--g300);
padding: 1em;
align-self: stretch;
}
.label {
display: block;
margin-top: 1em;
padding: 10px 0;
color: var(--g1700);
font-weight: 600;
}
.branchInput {
min-width: 24ch;
max-width: 100%;
padding: 8px 10px;
}
.urlInput {
max-width: 100%;
padding: 8px 10px;
color: var(--g1200);
> span {
min-width: 4ch;
color: var(--g2000);
height: 100%;
}
}
.rule {
border: 0;
background: var(--g300);
height: 1px;
margin: 1em 0;
}
.icon {
width: 64px;
height: 64px;
}
+119
View File
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useState } from "react"
import router from "next/router"
import AsyncButton from "../../components/AsyncButton"
import Footer from "../../components/Footer"
import GitHubLoginButton from "../../components/GitHubLoginButton"
import GitHubRepoPicker, { isValidIdentifierKey } from "../../components/GitHubRepoPicker"
import ImageInput from "../../components/ImageInput"
import Nav from "../../components/Nav"
import PageTitle from "../../components/PageTitle"
import StringInput from "../../components/StringInput"
import * as api from "../../lib/api"
import styles from "./new.module.scss"
export default function NewProjectPage() {
const [repoOwner, setRepoOwner] = useState("")
const [repoName, setRepoName] = useState("")
const [defaultBranch, setDefaultBranch] = useState("main")
const [branch, setBranch] = useState(defaultBranch)
const [slug, setSlug] = useState("project")
const [icon, setIcon] = useState<File>()
// Default branch name
useEffect(() => {
if (branch.length == 0)
setBranch(defaultBranch)
}, [branch, defaultBranch])
// Default project name
useEffect(() => {
if (slug.length == 0)
setSlug(repoName)
}, [slug.length, repoName])
// Remove dots from slug
useEffect(() => {
setSlug(slug.replace(/\./g, "-"))
}, [slug])
const handleRepoChange = useCallback(({ owner, repo, defaultBranch }) => {
setRepoOwner(owner)
setRepoName(repo)
setSlug(repo)
setBranch(defaultBranch)
setDefaultBranch(defaultBranch)
}, [])
const submit = async () => {
const data = new FormData()
data.append("slug", slug)
data.append("icon", icon)
data.append("repo[owner]", repoOwner)
data.append("repo[repo]", repoName)
data.append("repo[branch]", branch)
const project: api.Project = await api.post("/projects", data)
router.push(project.html_url)
}
const user = api.useThisUser()
const isSignedIn = user && !api.isAnonUser(user)
return <>
<PageTitle title="New project" />
<Nav />
<main>
<header className={styles.header}>
<div className={styles.container}>
<h1>New project</h1>
</div>
</header>
<div className={styles.container}>
<h2 className={styles.label}>Repository</h2>
<GitHubRepoPicker
owner={repoOwner}
repo={repoName}
onChangeValid={handleRepoChange}
/>
<div style={repoName == "" ? { opacity: "0.5", pointerEvents: "none" } : {}}>
<h2 className={styles.label}>Branch</h2>
<StringInput
className={styles.branchInput}
label="Branch name"
value={branch}
onChange={setBranch}
isValidKey={isValidIdentifierKey}
/>
<h2 className={styles.label}>Project name</h2>
<div className={styles.urlInput}>
decomp.me/projects/<StringInput
label="Project name"
value={slug}
onChange={setSlug}
isValidKey={isValidIdentifierKey}
/>
</div>
<h2 className={styles.label}>Icon</h2>
<ImageInput className={styles.icon} file={icon} onChange={setIcon} />
<hr className={styles.rule} />
{isSignedIn ? <AsyncButton
primary
onClick={submit}
errorPlacement="right-center"
disabled={!isSignedIn}
>
Create project
</AsyncButton> : <GitHubLoginButton popup label="Sign in to create projects" />}
</div>
</div>
</main>
<Footer />
</>
}
@@ -7,7 +7,8 @@
.header {
width: 100%;
background: var(--g300);
background: var(--g200);
border-bottom: 1px solid var(--g400);
}
.actions {
+2 -2
View File
@@ -12,7 +12,7 @@ import Footer from "../../components/Footer"
import Nav from "../../components/Nav"
import NumberInput from "../../components/NumberInput"
import PageTitle from "../../components/PageTitle"
import Tabs, { Tab } from "../../components/Tabs"
import Tabs, { Tab } from "../../components/Tabs" // TODO: use UnderlineNav instead
import ThemePicker from "../../components/ThemePicker"
import basicSetup from "../../lib/codemirror/basic-setup"
import * as settings from "../../lib/settings"
@@ -221,7 +221,7 @@ function ScratchEditorSettings() {
checked={autoRecompile}
onChange={evt => setAutoRecompile(evt.target.checked)}
/>
Automatically compile after typing
Automatically compile after changes to scratch
</label>
<div className={classNames(styles.intPreference, { [styles.disabled]: !autoRecompile })}>
<input
+1 -1
View File
@@ -5779,7 +5779,7 @@ svgo@^2.5.0, svgo@^2.7.0:
picocolors "^1.0.0"
stable "^0.1.8"
swr@^1.2.1:
swr@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/swr/-/swr-1.3.0.tgz#c6531866a35b4db37b38b72c45a63171faf9f4e8"
integrity sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw==
+3
View File
@@ -20,6 +20,9 @@ server {
location /static {
try_files $uri @proxy_api;
}
location /media {
root /;
}
location @proxy_api {
proxy_set_header X-Forwarded-Proto https;