diff --git a/readthedocsext/theme/templates/includes/elements/chips/project.html b/readthedocsext/theme/templates/includes/elements/chips/project.html
index 8774d278..55a111f7 100644
--- a/readthedocsext/theme/templates/includes/elements/chips/project.html
+++ b/readthedocsext/theme/templates/includes/elements/chips/project.html
@@ -14,21 +14,11 @@
{% endblock chip_classes %}
{% block chip_icon %}
- {% if project.remote_repository %}
-
- {% else %}
- {% comment %}
- For now, this is using Dicebear, which has a few random generators for
- user/project icons. We don't need to use Dicebear, but could use this or
- similar to generate files on disk and use an md5 hash or similar to link
- to the same image always.
- {% endcomment %}
-
- {% endif %}
+
{% endblock chip_icon %}
-
+
{% block chip_text %}
- {{ project.name }}
+ {{ project.name }}
{% endblock chip_text %}
{% block chip_detail_text %}
@@ -39,21 +29,11 @@
{% endblock popupcard_image %}
{% block popupcard_header %}
- {{ project.name }}
+ {{ project.name }}
{% endblock popupcard_header %}
{% block popupcard_right %}
- {% if project.remote_repository %}
-
- {% else %}
- {% comment %}
- For now, this is using Dicebear, which has a few random generators for
- user/project icons. We don't need to use Dicebear, but could use this or
- similar to generate files on disk and use an md5 hash or similar to link
- to the same image always.
- {% endcomment %}
-
- {% endif %}
+
{% endblock popupcard_right %}
{% block popupcard_meta %}
@@ -63,7 +43,7 @@
{% block popupcard_content %}
-
+
diff --git a/readthedocsext/theme/templates/projects/includes/project_image.html b/readthedocsext/theme/templates/projects/includes/project_image.html
index ff434c2f..5f5a8672 100644
--- a/readthedocsext/theme/templates/projects/includes/project_image.html
+++ b/readthedocsext/theme/templates/projects/includes/project_image.html
@@ -5,8 +5,4 @@
to the same image always.
{% endcomment %}
-{% if project.remote_repository %}
-
-{% else %}
-
-{% endif %}
+
diff --git a/readthedocsext/theme/templates/theme/images/avatar.svg b/readthedocsext/theme/templates/theme/images/avatar.svg
new file mode 100644
index 00000000..c1c9bcd1
--- /dev/null
+++ b/readthedocsext/theme/templates/theme/images/avatar.svg
@@ -0,0 +1,25 @@
+
diff --git a/readthedocsext/theme/urls.py b/readthedocsext/theme/urls.py
new file mode 100644
index 00000000..da5e3689
--- /dev/null
+++ b/readthedocsext/theme/urls.py
@@ -0,0 +1,14 @@
+# pylint: disable=missing-docstring
+
+from django.urls import re_path
+from readthedocs.constants import pattern_opts
+
+from .views import AvatarImageProjectView
+
+urlpatterns = [
+ re_path(
+ r"avatar/project/(?P{project_slug})/$".format(**pattern_opts),
+ AvatarImageProjectView.as_view(),
+ name="theme_avatar_project",
+ ),
+]
diff --git a/readthedocsext/theme/views.py b/readthedocsext/theme/views.py
new file mode 100644
index 00000000..85c42a68
--- /dev/null
+++ b/readthedocsext/theme/views.py
@@ -0,0 +1,108 @@
+import random
+
+from django.http.response import HttpResponseRedirect
+from django.shortcuts import get_object_or_404
+from django.views.generic.base import TemplateView
+from readthedocs.projects.models import Project
+
+
+class AvatarImageBaseView(TemplateView):
+
+ """
+ Base view for project, organization, team or user avatars.
+ """
+
+ http_method_names = ["get", "head", "options"]
+
+ template_name = "theme/images/avatar.svg"
+ content_type = "image/svg+xml"
+
+ # Primary to secondary color gradient, generated using a gradient generator
+ COLORS = [
+ "#0993af",
+ "#0090b7",
+ "#008bbe",
+ "#0087c5",
+ "#0081cb",
+ "#007bcf",
+ "#1d73d1",
+ "#446bd0",
+ "#6060cc",
+ "#7854c5",
+ ]
+
+ def get(self, request, *args, **kwargs):
+ self.object = self.get_object()
+ remote_avatar_url = self.get_remote_avatar_url()
+ if remote_avatar_url:
+ return HttpResponseRedirect(remote_avatar_url)
+ else:
+ context = self.get_context_data()
+ return self.render_to_response(context)
+
+ def get_object(self):
+ raise NotImplementedError
+
+ def get_queryset(self):
+ raise NotImplementedError
+
+ def get_remote_avatar_url(self):
+ raise NotImplementedError
+
+ def get_avatar_color(self):
+ random.seed(self.object.pk)
+ return random.choice(self.COLORS)
+
+ def get_avatar_letters(self):
+ raise NotImplementedError
+
+ def get_context_data(self):
+ # Truncate letters, use a max of 5 letters for now. More than that and
+ # text is too tiny.
+ letters = self.get_avatar_letters()[0:5]
+ return {
+ "letters": letters.lower(),
+ # Font size is proportional to the number of letters
+ "font_size": 55 - (max(0, len(letters)) * 5),
+ "background_color": self.get_avatar_color(),
+ }
+
+
+class AvatarImageProjectView(AvatarImageBaseView):
+
+ """
+ Project avatar configuration.
+
+ Use the project name or slug for the image letters, and use the project pk
+ to seed the randomization for background color. A remote URL can be
+ specified by the attached remote repository, if any.
+ """
+
+ def get_queryset(self):
+ return Project.objects.public(self.request.user)
+
+ def get_object(self):
+ queryset = self.get_queryset()
+ project_slug = self.kwargs.get("project_slug")
+ return get_object_or_404(
+ queryset,
+ slug=project_slug,
+ )
+
+ def get_remote_avatar_url(self):
+ try:
+ return self.object.remote_repository.avatar_url
+ except (AttributeError, ValueError):
+ return
+
+ def get_avatar_letters(self):
+ # Try using the project name first, as the slug could have an organization slug
+ # prepended to the project slug (this leads to redundant acronyms).
+ words = self.object.name.split(" ")
+ # However, some project names are just something machine readable
+ # anyways. See if the slug produces a longer acronym
+ slug_words = self.object.slug.split("-")
+ if len(slug_words) > len(words):
+ words = slug_words
+
+ return "".join([word[0:1] for word in words])