mirror of
https://github.com/decompme/decomp.me.git
synced 2026-04-27 02:18:05 -05:00
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:
@@ -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
@@ -3,8 +3,8 @@ __pycache__
|
||||
node_modules
|
||||
build
|
||||
.snowpack
|
||||
local_files/
|
||||
backend/local_files/
|
||||
backend/media/
|
||||
backend/virtualenvs/
|
||||
backend/docker.dev.env
|
||||
postgres/
|
||||
|
||||
Vendored
+6
@@ -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
@@ -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
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/
|
||||
|
||||
+30
-1
@@ -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
|
||||
|
||||
Generated
+240
-18
@@ -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"},
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
.header {
|
||||
background: var(--g300);
|
||||
background: var(--g200);
|
||||
border-bottom: 1px solid var(--g400);
|
||||
|
||||
user-select: auto;
|
||||
align-self: stretch;
|
||||
|
||||
+13
-13
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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==
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user