diff --git a/.eslintrc.yaml b/.eslintrc.yaml
index 989beb8c0..56959c746 100644
--- a/.eslintrc.yaml
+++ b/.eslintrc.yaml
@@ -15,6 +15,7 @@ rules:
'unicorn/prefer-query-selector': off # See https://github.com/sindresorhus/eslint-plugin-unicorn/issues/276
'unicorn/prefer-node-append': off
'unicorn/prefer-ternary': off
+ 'unicorn/no-lonely-if': off
env:
browser: true
es2024: true
diff --git a/concordia/admin/__init__.py b/concordia/admin/__init__.py
index 0462e8891..e6a53c48c 100644
--- a/concordia/admin/__init__.py
+++ b/concordia/admin/__init__.py
@@ -9,7 +9,6 @@
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.models import User
-from django.db.models import Count
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.template.defaultfilters import truncatechars
@@ -80,6 +79,7 @@
SubmittedFilter,
TagCampaignListFilter,
TagCampaignStatusListFilter,
+ TopicListFilter,
TranscriptionCampaignListFilter,
TranscriptionCampaignStatusListFilter,
TranscriptionProjectListFilter,
@@ -93,8 +93,9 @@
CampaignAdminForm,
CardAdminForm,
GuideAdminForm,
+ ItemAdminForm,
ProjectAdminForm,
- SanitizedDescriptionAdminForm,
+ TopicAdminForm,
)
logger = logging.getLogger(__name__)
@@ -110,13 +111,18 @@ class ConcordiaUserAdmin(UserAdmin):
)
def get_queryset(self, request):
- qs = super().get_queryset(request)
- qs = qs.annotate(Count("transcription"))
+ qs = super().get_queryset(request).select_related("profile")
return qs
- @admin.display(ordering="transcription__count")
+ @admin.display(
+ description="Transcription Count", ordering="profile__transcribe_count"
+ )
def transcription_count(self, obj):
- return obj.transcription__count
+ return obj.profile.transcribe_count
+
+ @admin.display(description="Review Count", ordering="profile__review_count")
+ def review_count(self, obj):
+ return obj.profile.review_count
EXPORT_FIELDS = (
"username",
@@ -128,17 +134,31 @@ def transcription_count(self, obj):
"is_superuser",
"date_joined",
"last_login",
- "transcription__count",
+ "profile__transcribe_count",
+ "profile__review_count",
)
+ EXTRA_VERBOSE_NAMES = {
+ "profile__transcribe_count": "transcription count",
+ "profile__review_count": "review count",
+ }
+
def export_users_as_csv(self, request, queryset):
return export_to_csv_action(
- self, request, queryset, field_names=self.EXPORT_FIELDS
+ self,
+ request,
+ queryset,
+ field_names=self.EXPORT_FIELDS,
+ extra_verbose_names=self.EXTRA_VERBOSE_NAMES,
)
def export_users_as_excel(self, request, queryset):
return export_to_excel_action(
- self, request, queryset, field_names=self.EXPORT_FIELDS
+ self,
+ request,
+ queryset,
+ field_names=self.EXPORT_FIELDS,
+ extra_verbose_names=self.EXTRA_VERBOSE_NAMES,
)
actions = (anonymize_action, export_users_as_csv, export_users_as_excel)
@@ -358,8 +378,8 @@ class ResourceAdmin(admin.ModelAdmin, CustomListDisplayFieldsMixin):
list_filter = (
"resource_type",
ResourceCampaignStatusListFilter,
+ TopicListFilter,
ResourceCampaignListFilter,
- "title",
)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
@@ -396,7 +416,7 @@ def get_fields(self, request, obj=None):
@admin.register(Topic)
class TopicAdmin(admin.ModelAdmin):
- form = SanitizedDescriptionAdminForm
+ form = TopicAdminForm
list_display = (
"id",
@@ -523,6 +543,7 @@ def item_import_view(self, request, object_id):
@admin.register(Item)
class ItemAdmin(admin.ModelAdmin):
+ form = ItemAdminForm
list_display = ("title", "item_id", "campaign_title", "project", "published")
list_display_links = ("title", "item_id")
search_fields = [
diff --git a/concordia/admin/filters.py b/concordia/admin/filters.py
index 236c31337..4148a0d5b 100644
--- a/concordia/admin/filters.py
+++ b/concordia/admin/filters.py
@@ -1,7 +1,7 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
-from ..models import Campaign, Project
+from ..models import Campaign, Project, Topic
class NullableTimestampFilter(admin.SimpleListFilter):
@@ -92,6 +92,25 @@ def queryset(self, request, queryset):
return queryset
+class TopicListFilter(admin.SimpleListFilter):
+ """
+ Base class for admin topic filters
+ """
+
+ title = "Topic"
+ template = "admin/long_name_filter.html"
+ parameter_name = "topic__id__exact"
+
+ def lookups(self, request, model_admin):
+ queryset = Topic.objects.all()
+ return queryset.values_list("id", "title").order_by("title")
+
+ def queryset(self, request, queryset):
+ if self.value():
+ return queryset.filter(**{self.parameter_name: self.value()})
+ return queryset
+
+
class ProjectCampaignListFilter(CampaignListFilter):
parameter_name = "campaign__id__exact"
status_filter_parameter = "campaign__status"
diff --git a/concordia/admin/forms.py b/concordia/admin/forms.py
index ff3011a36..49df703b5 100644
--- a/concordia/admin/forms.py
+++ b/concordia/admin/forms.py
@@ -3,7 +3,7 @@
from django.core.cache import caches
from tinymce.widgets import TinyMCE
-from ..models import Campaign, Card, Guide, Project
+from ..models import Campaign, Card, Guide, Item, Project, Topic
FRAGMENT_ALLOWED_TAGS = {
"a",
@@ -90,6 +90,15 @@ def clean_short_description(self):
)
+class TopicAdminForm(SanitizedDescriptionAdminForm):
+ class Meta(SanitizedDescriptionAdminForm.Meta):
+ model = Topic
+ widgets = {
+ "description": TinyMCE(),
+ "short_description": TinyMCE(),
+ }
+
+
class CampaignAdminForm(SanitizedDescriptionAdminForm):
class Meta(SanitizedDescriptionAdminForm.Meta):
model = Campaign
@@ -108,6 +117,13 @@ class Meta(SanitizedDescriptionAdminForm.Meta):
}
+class ItemAdminForm(forms.ModelForm):
+ class Meta:
+ model = Item
+ widgets = {"description": TinyMCE()}
+ fields = "__all__"
+
+
class CardAdminForm(forms.ModelForm):
class Meta:
model = Card
diff --git a/concordia/documents.py b/concordia/documents.py
index 1a0620914..1060e3a6e 100644
--- a/concordia/documents.py
+++ b/concordia/documents.py
@@ -41,6 +41,7 @@ class Django:
fields = [
"created_on",
+ "report_name",
"assets_total",
"assets_published",
"assets_not_started",
@@ -54,12 +55,14 @@ class Django:
"projects_unpublished",
"anonymous_transcriptions",
"transcriptions_saved",
+ "daily_review_actions",
"distinct_tags",
"tag_uses",
"campaigns_published",
"campaigns_unpublished",
"users_registered",
"users_activated",
+ "registered_contributors",
"daily_active_users",
]
diff --git a/concordia/migrations/0097_alter_sitereport_options_userprofile_review_count_and_more.py b/concordia/migrations/0097_alter_sitereport_options_userprofile_review_count_and_more.py
new file mode 100644
index 000000000..73264a261
--- /dev/null
+++ b/concordia/migrations/0097_alter_sitereport_options_userprofile_review_count_and_more.py
@@ -0,0 +1,43 @@
+# Generated by Django 4.2.13 on 2024-07-29 17:30
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("concordia", "0096_transcription_source"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="sitereport",
+ options={"get_latest_by": "created_on", "ordering": ("-created_on",)},
+ ),
+ migrations.AddField(
+ model_name="userprofile",
+ name="review_count",
+ field=models.IntegerField(
+ default=0, verbose_name="transcription review count"
+ ),
+ ),
+ migrations.AddField(
+ model_name="userprofile",
+ name="transcribe_count",
+ field=models.IntegerField(
+ default=0, verbose_name="transcription save/submit count"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="user",
+ field=models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="profile",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/concordia/migrations/0098_userprofile_create_and_population.py b/concordia/migrations/0098_userprofile_create_and_population.py
new file mode 100644
index 000000000..36efd5bc7
--- /dev/null
+++ b/concordia/migrations/0098_userprofile_create_and_population.py
@@ -0,0 +1,40 @@
+# Generated by Django 4.2.13 on 2024-07-29 17:40
+
+from django.conf import settings
+from django.db import migrations
+
+
+def create_and_populate_profiles(apps, schema_editor):
+ User = apps.get_model("auth", "User")
+ UserProfile = apps.get_model("concordia", "UserProfile")
+ db_alias = schema_editor.connection.alias
+ for user in User.objects.using(db_alias).all().iterator(chunk_size=10000):
+ profile, created = UserProfile.objects.using(db_alias).get_or_create(user=user)
+ for activity in user.userprofileactivity_set.all():
+ profile.transcribe_count += activity.transcribe_count
+ profile.review_count += activity.review_count
+ profile.save()
+
+
+def revert_create_and_populate_profiles(apps, schema_editor):
+ # We can't actually revert the data to the state it was before,
+ # and there's no actual need to, but we need this function to be
+ # able to reverse this migration
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ (
+ "concordia",
+ "0097_alter_sitereport_options_userprofile_review_count_and_more",
+ ),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ create_and_populate_profiles, revert_create_and_populate_profiles
+ ),
+ ]
diff --git a/concordia/models.py b/concordia/models.py
index 495082209..e9c8b7939 100644
--- a/concordia/models.py
+++ b/concordia/models.py
@@ -11,7 +11,7 @@
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
-from django.db import connection, models
+from django.db import models
from django.db.models import Count, ExpressionWrapper, F, JSONField, Q
from django.db.models.functions import Round
from django.db.models.signals import post_save
@@ -27,8 +27,10 @@
User._meta.get_field("email").__dict__["_unique"] = True
+ONE_MINUTE = datetime.timedelta(minutes=1)
ONE_DAY = datetime.timedelta(days=1)
ONE_DAY_AGO = timezone.now() - ONE_DAY
+THRESHOLD = 3
def resource_file_upload_path(instance, filename):
@@ -72,9 +74,53 @@ def get_email_reconfirmation_key(self):
def validate_reconfirmation_email(self, email):
return email == self.get_email_for_reconfirmation()
+ def review_incidents(self, recent_accepts, recent_rejects, threshold=THRESHOLD):
+ accepts = recent_accepts.filter(reviewed_by=self).values_list(
+ "accepted", flat=True
+ )
+ rejects = recent_rejects.filter(reviewed_by=self).values_list(
+ "rejected", flat=True
+ )
+ timestamps = list(accepts) + list(rejects)
+ timestamps.sort()
+ incidents = 0
+ for i in range(len(timestamps)):
+ count = 1
+ for j in range(i + 1, len(timestamps)):
+ if (timestamps[j] - timestamps[i]).seconds <= 60:
+ count += 1
+ if count == threshold:
+ incidents += 1
+ break
+ else:
+ break
+ return incidents
+
+ def transcribe_incidents(self, transcriptions, threshold=THRESHOLD):
+ recent_transcriptions = transcriptions.filter(user=self).order_by("submitted")
+ timestamps = recent_transcriptions.values_list("submitted", flat=True)
+ incidents = 0
+ for i in range(len(timestamps)):
+ count = 1
+ for j in range(i + 1, len(timestamps)):
+ if (timestamps[j] - timestamps[i]).seconds <= 60:
+ count += 1
+ if count == threshold:
+ incidents += 1
+ break
+ else:
+ break
+ return incidents
+
class UserProfile(MetricsModelMixin("userprofile"), models.Model):
- user = models.ForeignKey(User, on_delete=models.CASCADE)
+ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
+ transcribe_count = models.IntegerField(
+ default=0, verbose_name="transcription save/submit count"
+ )
+ review_count = models.IntegerField(
+ default=0, verbose_name="transcription review count"
+ )
class OverlayPosition(object):
@@ -792,63 +838,49 @@ def recent_review_actions(self, days=1):
START = timezone.now() - datetime.timedelta(days=days)
return self.review_actions(START)
- def reviewing_too_quickly(self, start=ONE_DAY_AGO):
- with connection.cursor() as cursor:
- cursor.execute(
- f"""SELECT u.id, u.username, COUNT(*)
- FROM concordia_transcription t1
- JOIN concordia_transcription t2
- ON t1.id < t2.id
- JOIN concordia_transcription t3
- ON t2.id < t3.id
- AND t1.reviewed_by_id = t2.reviewed_by_id
- AND t2.reviewed_by_id = t3.reviewed_by_id
- AND t1.accepted >= '{start}'
- AND t2.accepted >= '{start}'
- AND t3.accepted >= '{start}'
- AND ABS(
- EXTRACT(EPOCH FROM (t1.updated_on - t2.updated_on))
- ) < 60
- AND ABS(
- EXTRACT(EPOCH FROM (t1.updated_on - t3.updated_on))
- ) < 60
- AND ABS(EXTRACT(
- EPOCH FROM (t2.updated_on - t3.updated_on))
- ) < 60
- JOIN auth_user u on t1.reviewed_by_id = u.id
- WHERE u.is_superuser = FALSE and u.is_staff = False
- GROUP BY u.id, u.username""" # nosec B608
- )
- return cursor.fetchall()
-
- def transcribing_too_quickly(self, start=ONE_DAY_AGO):
- with connection.cursor() as cursor:
- cursor.execute(
- f"""SELECT u.id, u.username, COUNT(*)
- FROM concordia_transcription t1
- JOIN concordia_transcription t2
- ON t1.id < t2.id
- JOIN concordia_transcription t3
- ON t2.id < t3.id
- AND t1.user_id = t2.user_id
- AND t2.user_id = t3.user_id
- AND t1.submitted >= '{start}'
- AND t2.submitted >= '{start}'
- AND t3.submitted >= '{start}'
- AND ABS(
- EXTRACT(EPOCH FROM (t1.created_on - t2.created_on))
- ) < 60
- AND ABS(
- EXTRACT(EPOCH FROM (t1.created_on - t3.created_on))
- ) < 60
- AND ABS(
- EXTRACT(EPOCH FROM (t2.created_on - t3.created_on))
- ) < 60
- JOIN auth_user u on t1.user_id = u.id
- WHERE u.is_superuser = FALSE and u.is_staff = False
- GROUP BY u.id, u.username""" # nosec B608
- )
- return cursor.fetchall()
+ def review_incidents(self):
+ user_incident_count = []
+ recent_accepts = self.filter(
+ accepted__gte=ONE_DAY_AGO,
+ reviewed_by__is_superuser=False,
+ reviewed_by__is_staff=False,
+ )
+ recent_rejects = self.filter(
+ rejected__gte=ONE_DAY_AGO,
+ reviewed_by__is_superuser=False,
+ reviewed_by__is_staff=False,
+ )
+ recent_actions = recent_accepts.union(recent_rejects)
+ user_ids = set(
+ recent_actions.order_by("reviewed_by").values_list("reviewed_by", flat=True)
+ )
+
+ for user_id in user_ids:
+ user = ConcordiaUser.objects.get(id=user_id)
+ incident_count = user.review_incidents(recent_accepts, recent_rejects)
+ if incident_count > 0:
+ user_incident_count.append((user.id, user.username, incident_count))
+
+ return user_incident_count
+
+ def transcribe_incidents(self):
+ user_incident_count = []
+ transcriptions = self.get_queryset().filter(
+ submitted__gte=ONE_DAY_AGO, user__is_superuser=False, user__is_staff=False
+ )
+ user_ids = (
+ transcriptions.order_by("user")
+ .distinct("user")
+ .values_list("user", flat=True)
+ )
+
+ for user_id in user_ids:
+ user = ConcordiaUser.objects.get(id=user_id)
+ incident_count = user.transcribe_incidents(transcriptions)
+ if incident_count > 0:
+ user_incident_count.append((user.id, user.username, incident_count))
+
+ return user_incident_count
class Transcription(MetricsModelMixin("transcription"), models.Model):
@@ -962,10 +994,13 @@ def on_transcription_save(sender, instance, **kwargs):
user=user,
campaign=instance.asset.item.project.campaign,
)
+ profile, created = UserProfile.objects.get_or_create(user=user)
if created:
setattr(user_profile_activity, attr_name, 1)
+ setattr(profile, attr_name, 1)
else:
setattr(user_profile_activity, attr_name, F(attr_name) + 1)
+ setattr(profile, attr_name, F(attr_name) + 1)
q = Q(transcription__user=user) | Q(transcription__reviewed_by=user)
user_profile_activity.asset_count = (
Asset.objects.filter(q)
@@ -974,6 +1009,7 @@ def on_transcription_save(sender, instance, **kwargs):
.count()
)
user_profile_activity.save()
+ profile.save()
post_save.connect(on_transcription_save, sender=Transcription)
diff --git a/concordia/signals/handlers.py b/concordia/signals/handlers.py
index 47451cfae..72f12620b 100644
--- a/concordia/signals/handlers.py
+++ b/concordia/signals/handlers.py
@@ -14,7 +14,7 @@
from django_registration.signals import user_activated, user_registered
from flags.state import flag_enabled
-from ..models import Asset, Transcription, TranscriptionStatus
+from ..models import Asset, Transcription, TranscriptionStatus, UserProfile
from ..tasks import calculate_difficulty_values
from .signals import reservation_obtained, reservation_released
@@ -191,3 +191,9 @@ def send_asset_reservation_message(
@receiver(post_delete, sender=Asset)
def remove_file_from_s3(sender, instance, using, **kwargs):
instance.storage_image.delete(save=False)
+
+
+@receiver(post_save, sender=settings.AUTH_USER_MODEL)
+def create_user_profile(sender, instance, *args, **kwargs):
+ if not hasattr(instance, "profile"):
+ UserProfile.objects.create(user=instance)
diff --git a/concordia/static/js/src/asset-reservation.js b/concordia/static/js/src/asset-reservation.js
index bb1ef4cfe..9e2299b59 100644
--- a/concordia/static/js/src/asset-reservation.js
+++ b/concordia/static/js/src/asset-reservation.js
@@ -1,5 +1,7 @@
/* global jQuery displayMessage displayHtmlMessage buildErrorMessage Sentry */
-/* exported attemptToReserveAsset */
+/* exported attemptToReserveAsset reserveAssetForEditing */
+
+const assetData = document.currentScript.dataset;
function attemptToReserveAsset(reservationURL, findANewPageURL, actionType) {
var $transcriptionEditor = jQuery('#transcription-editor');
@@ -95,3 +97,15 @@ function attemptToReserveAsset(reservationURL, findANewPageURL, actionType) {
}
});
}
+
+function reserveAssetForEditing() {
+ if (assetData.reserveAssetUrl) {
+ attemptToReserveAsset(assetData.reserveAssetUrl, '', 'transcribe');
+ }
+}
+
+jQuery(function () {
+ if (assetData.reserveForEditing) {
+ reserveAssetForEditing();
+ }
+});
diff --git a/concordia/static/js/src/banner.js b/concordia/static/js/src/banner.js
new file mode 100644
index 000000000..a46e3ea2a
--- /dev/null
+++ b/concordia/static/js/src/banner.js
@@ -0,0 +1,18 @@
+/* global $ */
+
+if (typeof Storage !== 'undefined') {
+ if (!(window.screen.width < 1024 || window.screen.height < 768)) {
+ for (var key in localStorage) {
+ if (key.startsWith('banner-')) {
+ if ($('#' + key).hasClass('alert')) {
+ $('#' + key).attr('hidden', true);
+ }
+ }
+ }
+ }
+}
+
+$('#no-interface-banner').click(function (event) {
+ localStorage.setItem(event.target.parentElement.id, true);
+ $('#' + event.target.parentElement.id).attr('hidden', true);
+});
diff --git a/concordia/static/js/src/contribute.js b/concordia/static/js/src/contribute.js
index 30f9c8942..f477ef02e 100644
--- a/concordia/static/js/src/contribute.js
+++ b/concordia/static/js/src/contribute.js
@@ -766,4 +766,27 @@ function setupPage() {
}
}
+let transcriptionForm = document.getElementById('transcription-editor');
+let ocrForm = document.getElementById('ocr-transcription-form');
+
+let formChanged = false;
+transcriptionForm.addEventListener('change', function () {
+ formChanged = true;
+});
+transcriptionForm.addEventListener('submit', function () {
+ formChanged = false;
+});
+if (ocrForm) {
+ ocrForm.addEventListener('submit', function () {
+ formChanged = false;
+ });
+}
+window.addEventListener('beforeunload', function (event) {
+ if (formChanged) {
+ // Some browsers ignore this value and always display a built-in message instead
+ return (event.returnValue =
+ "The transcription you've started has not been saved.");
+ }
+});
+
setupPage();
diff --git a/concordia/static/js/src/guide.js b/concordia/static/js/src/guide.js
new file mode 100644
index 000000000..2bfe28570
--- /dev/null
+++ b/concordia/static/js/src/guide.js
@@ -0,0 +1,86 @@
+/* global $ trackUIInteraction */
+/* exported openOffcanvas closeOffcanvas showPane hidePane */
+
+function openOffcanvas() {
+ var guide = document.getElementById('guide-sidebar');
+ guide.classList.remove('offscreen');
+ guide.style.borderWidth = '0 0 thick thick';
+ guide.style.borderStyle = 'solid';
+ guide.style.borderColor = '#0076ad';
+ document.getElementById('open-guide').style.display = 'none';
+ document.addEventListener('keydown', function (event) {
+ if (event.key == 'Escape') {
+ closeOffcanvas();
+ }
+ });
+}
+
+function closeOffcanvas() {
+ var guide = document.getElementById('guide-sidebar');
+ guide.classList.add('offscreen');
+
+ guide.style.border = 'none';
+ document.getElementById('open-guide').style.display = 'block';
+}
+
+function showPane(elementId) {
+ document.getElementById(elementId).classList.add('show', 'active');
+ document.getElementById('guide-nav').classList.remove('show', 'active');
+}
+
+function hidePane(elementId) {
+ document.getElementById(elementId).classList.remove('show', 'active');
+ document.getElementById('guide-nav').classList.add('show', 'active');
+}
+
+function trackHowToInteraction(element, label) {
+ trackUIInteraction(element, 'How To Guide', 'click', label);
+}
+
+$('#open-guide').on('click', function () {
+ trackHowToInteraction($(this), 'Open');
+});
+$('#close-guide').on('click', function () {
+ trackHowToInteraction($(this), 'Close');
+});
+$('#previous-guide').on('click', function () {
+ trackHowToInteraction($(this), 'Back');
+});
+$('#next-guide').on('click', function () {
+ trackHowToInteraction($(this), 'Next');
+});
+$('#guide-bars').on('click', function () {
+ trackHowToInteraction($(this), 'Hamburger Menu');
+});
+$('#guide-sidebar .nav-link').on('click', function () {
+ let label = $(this).text().trim();
+ trackHowToInteraction($(this), label);
+});
+
+$('#guide-carousel')
+ .carousel({
+ interval: false,
+ wrap: false,
+ })
+ .on('slide.bs.carousel', function (event) {
+ if (event.to == 0) {
+ $('#guide-bars').addClass('d-none');
+ } else {
+ $('#guide-bars').removeClass('d-none');
+ }
+ });
+
+$('#previous-card').hide();
+
+$('#card-carousel').on('slid.bs.carousel', function () {
+ if ($('#card-carousel .carousel-item:first').hasClass('active')) {
+ $('#previous-card').hide();
+ $('#next-card').show();
+ } else if ($('#card-carousel .carousel-item:last').hasClass('active')) {
+ $('#previous-card').show();
+ $('#next-card').hide();
+ } else {
+ $('#previous-card').show();
+ $('#next-card').show();
+ }
+});
diff --git a/concordia/static/js/src/ocr.js b/concordia/static/js/src/ocr.js
new file mode 100644
index 000000000..8c5cf0d07
--- /dev/null
+++ b/concordia/static/js/src/ocr.js
@@ -0,0 +1,7 @@
+/* global $ */
+/* exported selectLanguage */
+
+function selectLanguage() {
+ $('#ocr-transcription-modal').modal('hide');
+ $('#language-selection-modal').modal('show');
+}
diff --git a/concordia/static/js/src/quick-tips.js b/concordia/static/js/src/quick-tips.js
new file mode 100644
index 000000000..139eb3acf
--- /dev/null
+++ b/concordia/static/js/src/quick-tips.js
@@ -0,0 +1,38 @@
+/* global $ trackUIInteraction setTutorialHeight */
+
+function trackQuickTipsInteraction(element, label) {
+ trackUIInteraction(element, 'Quick Tips', 'click', label);
+}
+
+var mainContentHeight = $('#contribute-main-content').height();
+if (mainContentHeight < 710) {
+ $('.sidebar').height(mainContentHeight - 130);
+}
+
+$('#tutorial-popup').on('shown.bs.modal', function () {
+ setTutorialHeight();
+});
+
+$('#quick-tips').on('click', function () {
+ trackQuickTipsInteraction($(this), 'Open');
+});
+$('#previous-card').on('click', function () {
+ trackQuickTipsInteraction($(this), 'Back');
+});
+$('#next-card').on('click', function () {
+ trackQuickTipsInteraction($(this), 'Next');
+});
+$('.carousel-indicators li').on('click', function () {
+ let index = [...this.parentElement.children].indexOf(this);
+ trackQuickTipsInteraction($(this), `Carousel ${index}`);
+});
+$('#tutorial-popup').on('hidden.bs.modal', function () {
+ // We're tracking whenever the popup closes, so we don't separately track the close button being clicked
+ trackUIInteraction($(this), 'Quick Tips', 'click', 'Close');
+});
+$('#tutorial-popup').on('shown-on-load', function () {
+ // We set a timeout to make sure the analytics code is loaded before trying to track
+ setTimeout(function () {
+ trackUIInteraction($(this), 'Quick Tips', 'load', 'Open');
+ }, 1000);
+});
diff --git a/concordia/static/js/src/viewer-split.js b/concordia/static/js/src/viewer-split.js
new file mode 100644
index 000000000..2769b7b49
--- /dev/null
+++ b/concordia/static/js/src/viewer-split.js
@@ -0,0 +1,146 @@
+/* global Split seadragonViewer */
+
+let pageSplit;
+let contributeContainer = document.getElementById('contribute-container');
+let ocrSection = document.getElementById('ocr-section');
+let editorColumn = document.getElementById('editor-column');
+let viewerColumn = document.getElementById('viewer-column');
+let layoutColumns = ['#viewer-column', '#editor-column'];
+let verticalKey = 'transcription-split-sizes-vertical';
+let horizontalKey = 'transcription-split-sizes-horizontal';
+
+let sizesVertical = localStorage.getItem(verticalKey);
+
+if (sizesVertical) {
+ sizesVertical = JSON.parse(sizesVertical);
+} else {
+ sizesVertical = [50, 50];
+}
+
+let sizesHorizontal = localStorage.getItem(horizontalKey);
+
+if (sizesHorizontal) {
+ sizesHorizontal = JSON.parse(sizesHorizontal);
+} else {
+ sizesHorizontal = [50, 50];
+}
+
+let splitDirection = localStorage.getItem('transcription-split-direction');
+
+if (splitDirection) {
+ splitDirection = JSON.parse(splitDirection);
+} else {
+ splitDirection = 'h';
+}
+
+function saveSizes(sizes) {
+ let sizeKey;
+ if (splitDirection == 'h') {
+ sizeKey = horizontalKey;
+ sizesHorizontal = sizes;
+ } else {
+ sizeKey = verticalKey;
+ sizesVertical = sizes;
+ }
+ localStorage.setItem(sizeKey, JSON.stringify(sizes));
+}
+
+function saveDirection(direction) {
+ localStorage.setItem(
+ 'transcription-split-direction',
+ JSON.stringify(direction),
+ );
+}
+
+function verticalSplit() {
+ splitDirection = 'v';
+ saveDirection(splitDirection);
+ contributeContainer.classList.remove('flex-row');
+ contributeContainer.classList.add('flex-column');
+ viewerColumn.classList.remove('h-100');
+ if (ocrSection != undefined) {
+ editorColumn.prepend(ocrSection);
+ }
+
+ return Split(layoutColumns, {
+ sizes: sizesVertical,
+ minSize: 100,
+ gutterSize: 8,
+ direction: 'vertical',
+ elementStyle: function (dimension, size, gutterSize) {
+ return {
+ 'flex-basis': 'calc(' + size + '% - ' + gutterSize + 'px)',
+ };
+ },
+ gutterStyle: function (dimension, gutterSize) {
+ return {
+ 'flex-basis': gutterSize + 'px',
+ };
+ },
+ onDragEnd: saveSizes,
+ });
+}
+function horizontalSplit() {
+ splitDirection = 'h';
+ saveDirection(splitDirection);
+ contributeContainer.classList.remove('flex-column');
+ contributeContainer.classList.add('flex-row');
+ viewerColumn.classList.add('h-100');
+ if (ocrSection != undefined) {
+ viewerColumn.append(ocrSection);
+ }
+ return Split(layoutColumns, {
+ sizes: sizesHorizontal,
+ minSize: 100,
+ gutterSize: 8,
+ elementStyle: function (dimension, size, gutterSize) {
+ return {
+ 'flex-basis': 'calc(' + size + '% - ' + gutterSize + 'px)',
+ };
+ },
+ gutterStyle: function (dimension, gutterSize) {
+ return {
+ 'flex-basis': gutterSize + 'px',
+ };
+ },
+ onDragEnd: saveSizes,
+ });
+}
+
+document
+ .getElementById('viewer-layout-horizontal')
+ .addEventListener('click', function () {
+ if (splitDirection != 'h') {
+ if (pageSplit != undefined) {
+ pageSplit.destroy();
+ }
+ pageSplit = horizontalSplit();
+ setTimeout(function () {
+ // Some quirk in the viewer makes this
+ // sometimes not work depending on
+ // the rotation, unless it's delayed.
+ // Less than 10ms didn't reliable work.
+ seadragonViewer.viewport.zoomTo(1);
+ }, 10);
+ }
+ });
+
+document
+ .getElementById('viewer-layout-vertical')
+ .addEventListener('click', function () {
+ if (splitDirection != 'v') {
+ if (pageSplit != undefined) {
+ pageSplit.destroy();
+ }
+ pageSplit = verticalSplit();
+ setTimeout(function () {
+ seadragonViewer.viewport.zoomTo(1);
+ }, 10);
+ }
+ });
+
+if (splitDirection == 'v') {
+ pageSplit = verticalSplit();
+} else {
+ pageSplit = horizontalSplit();
+}
diff --git a/concordia/static/js/src/viewer.js b/concordia/static/js/src/viewer.js
new file mode 100644
index 000000000..9ad073409
--- /dev/null
+++ b/concordia/static/js/src/viewer.js
@@ -0,0 +1,200 @@
+/* global OpenSeadragon screenfull debounce */
+/* exported seadragonView stepUp stepDown resetImageFilterForms */
+
+const viewerData = document.currentScript.dataset;
+
+var seadragonViewer = OpenSeadragon({
+ id: 'asset-image',
+ prefixUrl: viewerData.prefixUrl,
+ tileSources: {
+ type: 'image',
+ url: viewerData.tileSourceUrl,
+ },
+ gestureSettingsTouch: {
+ pinchRotate: true,
+ },
+ showNavigator: true,
+ showRotationControl: true,
+ showFlipControl: true,
+ toolbar: 'viewer-controls',
+ zoomInButton: 'viewer-zoom-in',
+ zoomOutButton: 'viewer-zoom-out',
+ homeButton: 'viewer-home',
+ rotateLeftButton: 'viewer-rotate-left',
+ rotateRightButton: 'viewer-rotate-right',
+ flipButton: 'viewer-flip',
+ crossOriginPolicy: 'Anonymous',
+});
+
+// We need to define our own fullscreen function rather than using OpenSeadragon's
+// because the built-in fullscreen function overwrites the DOM with the viewer,
+// breaking our extra controls, such as the image filters.
+if (screenfull.isEnabled) {
+ let fullscreenButton = document.querySelector('#viewer-fullscreen');
+ fullscreenButton.addEventListener('click', function (event) {
+ event.preventDefault();
+ let targetElement = document.querySelector(
+ fullscreenButton.dataset.target,
+ );
+ if (screenfull.isFullscreen) {
+ screenfull.exit();
+ } else {
+ screenfull.request(targetElement);
+ }
+ });
+}
+
+// The buttons configured as controls for the viewer don't properly get focus
+// when clicked. This mostly isn't a problem, but causes odd-looking behavior
+// when one of the extra buttons in the control bar is clicked (and therefore
+// focused) first--clicking the control button leaves the focus on the extra
+// button.
+// TODO: Attempting to add focus to the clicked button here doesn't consistently
+// work for unknown reasons, so it just removes focus from the extra buttons
+// for now
+let viewerControlButtons = document.querySelectorAll('.viewer-control-button');
+for (const node of viewerControlButtons) {
+ node.addEventListener('click', function () {
+ let focusedButton = document.querySelector(
+ '.extra-control-button:focus',
+ );
+ if (focusedButton) {
+ focusedButton.blur();
+ }
+ });
+}
+
+/*
+ * Image filter handling
+ */
+
+let availableFilters = [
+ {
+ formId: 'gamma-form',
+ inputId: 'gamma',
+ getFilter: function () {
+ let value = document.getElementById(this.inputId).value;
+ if (
+ !Number.isNaN(value) &&
+ value != 1 &&
+ value >= 0 &&
+ value <= 5
+ ) {
+ return OpenSeadragon.Filters.GAMMA(value);
+ }
+ },
+ },
+ {
+ formId: 'invert-form',
+ inputId: 'invert',
+ getFilter: function () {
+ let value = document.getElementById(this.inputId).checked;
+ if (value) {
+ return OpenSeadragon.Filters.INVERT();
+ }
+ },
+ },
+ {
+ formId: 'threshold-form',
+ inputId: 'threshold',
+ getFilter: function () {
+ let value = document.getElementById(this.inputId).value;
+ if (!Number.isNaN(value) && value > 0 && value <= 255) {
+ return OpenSeadragon.Filters.THRESHOLDING(value);
+ }
+ },
+ },
+];
+
+function updateFilters() {
+ let filters = [];
+ for (const filterData of availableFilters) {
+ let filter = filterData.getFilter();
+ if (filter) {
+ filters.push(filter);
+ }
+ }
+
+ seadragonViewer.setFilterOptions({
+ filters: {
+ processors: filters,
+ },
+ });
+}
+
+for (const filterData of availableFilters) {
+ let form = document.getElementById(filterData.formId);
+ if (form) {
+ form.addEventListener('change', updateFilters);
+ form.addEventListener('reset', function () {
+ // We use setTimeout to push the updateFilters
+ // call to the next event cycle in order to
+ // call it after the form is reset, instead
+ // of before, which is when this listener
+ // triggers
+ setTimeout(updateFilters);
+ });
+ }
+
+ let input = document.getElementById(filterData.inputId);
+ if (input) {
+ // We use debounce here so that updateFilters is only called once,
+ // after the user stops typing or scrolling with their mousewheel
+ input.addEventListener(
+ 'keyup',
+ debounce(() => updateFilters()),
+ );
+ input.addEventListener(
+ 'wheel',
+ debounce(() => updateFilters()),
+ );
+ }
+}
+
+/*
+ * Image filter form handling
+ */
+function stepUp(id) {
+ let input = document.getElementById(id);
+ input.stepUp();
+ input.dispatchEvent(new Event('input', {bubbles: true}));
+ input.dispatchEvent(new Event('change', {bubbles: true}));
+ return false;
+}
+
+function stepDown(id) {
+ let input = document.getElementById(id);
+ input.stepDown();
+ input.dispatchEvent(new Event('input', {bubbles: true}));
+ input.dispatchEvent(new Event('change', {bubbles: true}));
+ return false;
+}
+
+function resetImageFilterForms() {
+ for (const filterData of availableFilters) {
+ let form = document.getElementById(filterData.formId);
+ form.reset();
+ }
+}
+
+let gammaNumber = document.getElementById('gamma');
+let gammaRange = document.getElementById('gamma-range');
+
+gammaNumber.addEventListener('input', function () {
+ gammaRange.value = gammaNumber.value;
+});
+
+gammaRange.addEventListener('input', function () {
+ gammaNumber.value = gammaRange.value;
+});
+
+let thresholdNumber = document.getElementById('threshold');
+let thresholdRange = document.getElementById('threshold-range');
+
+thresholdNumber.addEventListener('input', function () {
+ thresholdRange.value = thresholdNumber.value;
+});
+
+thresholdRange.addEventListener('input', function () {
+ thresholdNumber.value = thresholdRange.value;
+});
diff --git a/concordia/tasks.py b/concordia/tasks.py
index bb3054dad..8445b0670 100644
--- a/concordia/tasks.py
+++ b/concordia/tasks.py
@@ -1043,14 +1043,6 @@ def fix_storage_images(campaign_slug=None, asset_start_id=None):
logger.debug("%s / %s (%s%%)", count, full_count, str(count / full_count * 100))
-def transcribing_too_quickly():
- return Transcription.objects.transcribing_too_quickly()
-
-
-def reviewing_too_quickly():
- return Transcription.objects.reviewing_too_quickly()
-
-
@celery_app.task(ignore_result=True)
def clear_sessions():
# This clears expired Django sessions in the database
@@ -1067,8 +1059,8 @@ def unusual_activity():
"title": "Unusual User Activity Report for "
+ timezone.now().strftime("%b %d %Y, %I:%M %p"),
"domain": "https://" + site.domain,
- "transcriptions": transcribing_too_quickly(),
- "reviews": reviewing_too_quickly(),
+ "transcriptions": Transcription.objects.transcribe_incidents(),
+ "reviews": Transcription.objects.review_incidents(),
}
text_body_template = loader.get_template("emails/unusual_activity.txt")
diff --git a/concordia/templates/admin/concordia/item/change_form.html b/concordia/templates/admin/concordia/item/change_form.html
index 4e0dc3352..835d87b2e 100644
--- a/concordia/templates/admin/concordia/item/change_form.html
+++ b/concordia/templates/admin/concordia/item/change_form.html
@@ -29,18 +29,3 @@
{% endif %}
{{ block.super }}
{% endblock %}
-
-{% block extrahead %}
- {{ block.super }}
-
- {% include 'fragments/codemirror.html' %}
-{% endblock extrahead %}
-
-
-{% block content %}
- {{ block.super }}
-
-
-{% endblock content %}
diff --git a/concordia/templates/admin/concordia/topic/change_form.html b/concordia/templates/admin/concordia/topic/change_form.html
deleted file mode 100644
index e461b658c..000000000
--- a/concordia/templates/admin/concordia/topic/change_form.html
+++ /dev/null
@@ -1,19 +0,0 @@
-{% extends "admin/change_form.html" %}
-
-{% load i18n admin_urls static %}
-
-{% block extrahead %}
- {{ block.super }}
-
- {% include 'fragments/codemirror.html' %}
-{% endblock extrahead %}
-
-
-{% block content %}
- {{ block.super }}
-
-
-{% endblock content %}
diff --git a/concordia/templates/emails/unusual_activity.txt b/concordia/templates/emails/unusual_activity.txt
index eb2bf8549..daba6e152 100644
--- a/concordia/templates/emails/unusual_activity.txt
+++ b/concordia/templates/emails/unusual_activity.txt
@@ -6,7 +6,7 @@ Incidents of two or more transcriptions submitted within a single minute:
No transcriptions fell within the window.
{% endfor %}
Incidents of two or more transcriptions reviewed within a single minute:
-{% for row in reviews_by %}
+{% for row in reviews %}
{{ row.1 }} | {{ row.2 }}
{% empty %}
No reviews fell within the window.
diff --git a/concordia/templates/transcriptions/asset_detail.html b/concordia/templates/transcriptions/asset_detail.html
index 5ed967fbf..f67474313 100644
--- a/concordia/templates/transcriptions/asset_detail.html
+++ b/concordia/templates/transcriptions/asset_detail.html
@@ -18,6 +18,25 @@
+
+
+
+
+
+
+
+
+
+
+
{% endblock head_content %}
{% block breadcrumbs %}
@@ -35,330 +54,13 @@
-
+ {% include "transcriptions/asset_detail/navigation.html" %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Keyboard Shortcuts
-
-
-
-
Viewer Shortcuts
-
-
-
w, up arrow
-
Scroll the viewport up
-
-
-
s, down arrow
-
Scroll the viewport down
-
-
-
a, left arrow
-
Scroll the viewport left
-
-
-
d, right arrow
-
Scroll the viewport right
-
-
-
0
-
Fit the entire image to the viewport
-
-
-
-, _, Shift+W, Shift+Up arrow
-
Zoom the viewport out
-
-
-
=, +, Shift+S, Shift+Down arrow
-
Scroll the viewport in
-
-
-
r
-
Rotate the viewport clockwise
-
-
-
R
-
Rotate the viewport counterclockwise
-
-
-
f
-
Flip the viewport horizontally
-
-
-
-
-
-
-
-
-
-
-
-
About Transcribe with OCR
-
-
-
-
What is OCR?
-
OCR stands for Optical Character Recognition. OCR is a software tool that can extract print text from some documents.
-
When will OCR work well?
-
OCR does not work on handwriting. It only works for printed or typed text, meaning text created by a typewriter, printing press, or other mechanical means. OCR will do best on consistent and clear images of modern typefaces.
-
Do I still need to review pages started with OCR?
-
Yes! OCR is imperfect. It may not work well for some or all parts of a typed page, but it can be a great starting point. If you start a page with OCR, you should read the text closely before submitting. If you are reviewing a OCR-ed page, you also still need to review.
We always want to use volunteer time effectively. When the Library of Congress digitizes a large group of printed pages, it will usually OCR them. The materials in By the People campaigns are not good candidates for applying OCR at scale, either because they are handwritten, a mixed collection of handwritten and print materials, or printed on paper or in a typeface that does not produce accurate OCR results. However, OCR can still be a useful starting point for some typed pages. Use it if it if you like it or skip it if you don’t!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {% include "transcriptions/asset_detail/viewer.html" %}
+ {% include "transcriptions/asset_detail/ocr_help_modal.html" %}
+ {% include "transcriptions/asset_detail/viewer_filters.html" %}
{% if not disable_ocr %}
+ {% include "transcriptions/asset_detail/editor.html" %}
+ {% include "transcriptions/asset_detail/tags.html" %}
@@ -559,739 +97,26 @@
-
-
-
-
Someone else is already transcribing this page
-
-
-
-
You can help by transcribing a new page, adding tags to this page, or coming back later to review this page's transcription.
-
-
-
-
+ {% include "transcriptions/asset_detail/asset_reservation_failure_modal.html" %}
-
-
-
-
Nice Job!
-
-
-
-
- This page has been submitted for review.
-
-
- What do you want to do next?
-
-
-
-
-
+ {% include "transcriptions/asset_detail/successful_submission_modal.html" %}
-
-
-
-
Nice Job!
-
-
-
-
- Thanks for your help - we've saved your decision.
-
-
- What do you want to do next?
-
-
-
-
-
+ {% include "transcriptions/asset_detail/review_accepted_modal.html" %}
-
+ {% include "transcriptions/asset_detail/captcha_modal.html" %}
-
-
-
-
-
-
-
-
Are you sure?
-
Clicking "Transcribe with OCR" will remove all existing transcription text and replace it with automatically generated text. We recommend saving existing text in a separate document if you may want to revisit it.
-
-
-
-
-
+ {% include "transcriptions/asset_detail/ocr_transcription_modal.html" %}
-
-
-
-
-
-
-
-
+ {% include "transcriptions/asset_detail/language_selection_modal.html" %}
{% if cards %}
-
-
-
-
-
Quick Tips
-
-
-
-
-
- {% for card in cards %}
-
-
- {% if card.image %}
-
- {% endif %}
- {% if card.display_heading %}
-
You can help by transcribing a new page, adding tags to this page, or coming back later to review this page's transcription.
+
+
+
+
diff --git a/concordia/templates/transcriptions/asset_detail/captcha_modal.html b/concordia/templates/transcriptions/asset_detail/captcha_modal.html
new file mode 100644
index 000000000..2ca5c457d
--- /dev/null
+++ b/concordia/templates/transcriptions/asset_detail/captcha_modal.html
@@ -0,0 +1,38 @@
+
diff --git a/concordia/templates/transcriptions/asset_detail/editor.html b/concordia/templates/transcriptions/asset_detail/editor.html
new file mode 100644
index 000000000..92a09da5e
--- /dev/null
+++ b/concordia/templates/transcriptions/asset_detail/editor.html
@@ -0,0 +1,118 @@
+
+
+
+
+ Needs review
+
+
+
+ Completed
+
+
+
+ Not started
+
+
+
+ In progress
+
+
+
+ Another user is transcribing this page
+
+
+
+
+
diff --git a/concordia/templates/transcriptions/asset_detail/language_selection_modal.html b/concordia/templates/transcriptions/asset_detail/language_selection_modal.html
new file mode 100644
index 000000000..63ba19e31
--- /dev/null
+++ b/concordia/templates/transcriptions/asset_detail/language_selection_modal.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
diff --git a/concordia/templates/transcriptions/asset_detail/navigation.html b/concordia/templates/transcriptions/asset_detail/navigation.html
new file mode 100644
index 000000000..6f9c2259c
--- /dev/null
+++ b/concordia/templates/transcriptions/asset_detail/navigation.html
@@ -0,0 +1,49 @@
+
diff --git a/concordia/templates/transcriptions/asset_detail/ocr_help_modal.html b/concordia/templates/transcriptions/asset_detail/ocr_help_modal.html
new file mode 100644
index 000000000..b491590cf
--- /dev/null
+++ b/concordia/templates/transcriptions/asset_detail/ocr_help_modal.html
@@ -0,0 +1,27 @@
+
+
+
+
+
About Transcribe with OCR
+
+
+
+
What is OCR?
+
OCR stands for Optical Character Recognition. OCR is a software tool that can extract print text from some documents.
+
When will OCR work well?
+
OCR does not work on handwriting. It only works for printed or typed text, meaning text created by a typewriter, printing press, or other mechanical means. OCR will do best on consistent and clear images of modern typefaces.
+
Do I still need to review pages started with OCR?
+
Yes! OCR is imperfect. It may not work well for some or all parts of a typed page, but it can be a great starting point. If you start a page with OCR, you should read the text closely before submitting. If you are reviewing a OCR-ed page, you also still need to review.
We always want to use volunteer time effectively. When the Library of Congress digitizes a large group of printed pages, it will usually OCR them. The materials in By the People campaigns are not good candidates for applying OCR at scale, either because they are handwritten, a mixed collection of handwritten and print materials, or printed on paper or in a typeface that does not produce accurate OCR results. However, OCR can still be a useful starting point for some typed pages. Use it if it if you like it or skip it if you don’t!
+
+
+
+
+
diff --git a/concordia/templates/transcriptions/asset_detail/ocr_transcription_modal.html b/concordia/templates/transcriptions/asset_detail/ocr_transcription_modal.html
new file mode 100644
index 000000000..39ef4601b
--- /dev/null
+++ b/concordia/templates/transcriptions/asset_detail/ocr_transcription_modal.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
Are you sure?
+
Clicking "Transcribe with OCR" will remove all existing transcription text and replace it with automatically generated text. We recommend saving existing text in a separate document if you may want to revisit it.
+
+
+
+
+
diff --git a/concordia/templates/transcriptions/asset_detail/quick_tips_modal.html b/concordia/templates/transcriptions/asset_detail/quick_tips_modal.html
new file mode 100644
index 000000000..9d6337a28
--- /dev/null
+++ b/concordia/templates/transcriptions/asset_detail/quick_tips_modal.html
@@ -0,0 +1,42 @@
+
+
+
+
+
Campaign Tips
+
+
+
+
+
+ {% for card in cards %}
+
+
+ {% if card.image %}
+
+ {% endif %}
+ {% if card.display_heading %}
+