16 Commits

38 changed files with 1049 additions and 313 deletions
+21 -10
View File
@@ -1,7 +1,7 @@
from django.utils.translation import gettext_lazy as _
from wagtail.admin.ui.tables import Column, DateColumn
from wagtail.admin.ui.tables.pages import PageStatusColumn, PageTitleColumn
from wagtail.admin.viewsets.pages import PageListingViewSet
from wagtail.admin.viewsets.pages import PageListingViewSet, PageViewSet
from associations.models import AssociationPage
from dnscms.admin import ListingRedirectChooseParentView
@@ -16,15 +16,11 @@ class AssociationChooseParentView(ListingRedirectChooseParentView):
listing_url_name = "associations:index"
class AssociationPageListingViewSet(PageListingViewSet):
model = AssociationPage
choose_parent_view_class = AssociationChooseParentView
icon = "group"
menu_label = _("Associations")
menu_order = 2
add_to_admin_menu = True
ordering = "title"
class AssociationListingMixin:
"""Shared model + columns for the standalone listing and the page explorer."""
model = AssociationPage
icon = "group"
columns = [
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
AssociationTypeColumn(
@@ -43,4 +39,19 @@ class AssociationPageListingViewSet(PageListingViewSet):
]
association_page_listing_viewset = AssociationPageListingViewSet("associations")
class AssociationSidebarViewSet(AssociationListingMixin, PageListingViewSet):
"""Standalone 'Associations' sidebar entry, reached independently of the page tree."""
choose_parent_view_class = AssociationChooseParentView
menu_label = _("Associations")
menu_order = 2
add_to_admin_menu = True
ordering = "title"
class AssociationExplorerViewSet(AssociationListingMixin, PageViewSet):
"""Applies the same columns when navigating into AssociationIndex via the page explorer."""
association_sidebar_viewset = AssociationSidebarViewSet("associations")
association_explorer_viewset = AssociationExplorerViewSet()
+8 -3
View File
@@ -1,6 +1,6 @@
from wagtail import hooks
from .admin import association_page_listing_viewset
from .admin import association_sidebar_viewset, association_explorer_viewset
from .views import association_chooser_viewset
@@ -10,5 +10,10 @@ def register_viewset():
@hooks.register("register_admin_viewset")
def register_association_page_listing_viewset():
return association_page_listing_viewset
def register_association_sidebar_viewset():
return association_sidebar_viewset
@hooks.register("register_admin_viewset")
def register_association_explorer_viewset():
return association_explorer_viewset
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class DnsCmsConfig(AppConfig):
name = "dnscms"
def ready(self):
from dnscms import signals # noqa: F401
+4 -1
View File
@@ -173,7 +173,10 @@ MEDIA_URL = "/media/"
# Wagtail settings
WAGTAIL_SITE_NAME = "dnscms"
WAGTAIL_ALLOW_UNICODE_SLUGS = False
WAGTAIL_ALLOW_UNICODE_SLUGS = True
# Headless: the Next.js frontend uses trailing-slash-free URLs, so strip
# trailing slashes from links generated by Wagtail (e.g. the GraphQL `url` field).
WAGTAIL_APPEND_SLASH = False
WAGTAILIMAGES_IMAGE_MODEL = "images.CustomImage"
WAGTAILIMAGES_EXTENSIONS = ["avif", "gif", "jpg", "jpeg", "png", "webp", "svg"]
+15
View File
@@ -0,0 +1,15 @@
from django.db.models.signals import pre_save
from django.dispatch import receiver
from wagtail.models import Page
from dnscms.utils import slugify
SLUGGED_SNIPPETS = {"events.EventOrganizer", "events.EventCategory"}
@receiver(pre_save)
def normalize_slug(sender, instance, **kwargs):
label = f"{sender._meta.app_label}.{sender.__name__}"
if isinstance(instance, Page) or label in SLUGGED_SNIPPETS:
if getattr(instance, "slug", None):
instance.slug = slugify(instance.slug)
+7
View File
@@ -0,0 +1,7 @@
from django.utils.text import slugify as django_slugify
NORWEGIAN_TRANSLITERATIONS = str.maketrans({"æ": "ae", "ø": "o", "å": "a"})
def slugify(value: str) -> str:
return django_slugify(value.lower().translate(NORWEGIAN_TRANSLITERATIONS))
+37
View File
@@ -1,6 +1,9 @@
from django.templatetags.static import static
from django.utils.html import format_html
from grapple.registry import registry as grapple_registry
from wagtail import hooks
from wagtail.models import Page
from wagtail.search.backends import get_search_backend
@hooks.register("register_rich_text_features")
@@ -8,6 +11,40 @@ def enable_additional_rich_text_features(features):
features.default_features.extend(["h5", "h6", "blockquote"])
@hooks.register("register_schema_query")
def override_search_resolver(query_mixins):
"""
Override Grapple's `search` resolver. Two fixes vs. the upstream version:
1. Restrict pages to live + public so drafts and access-restricted pages
don't leak via the public API.
2. Run a single search across all `Page` subclasses (instead of iterating
per-model) so results are ranked by relevance across types rather than
grouped by content type. Specific instances are fetched in a second
bulk query and reordered to match the search ranking.
Documents and images are intentionally not searched. The upstream resolver
includes them, but the frontend search page only renders Page types and
discards everything else, so iterating those indexes is wasted work.
"""
if not grapple_registry.class_models:
return
class SearchOverrideMixin:
def resolve_search(self, info, **kwargs):
query = kwargs.get("query")
if not query:
return None
s = get_search_backend()
ranked = list(s.search(query, Page.objects.live().public()))
if not ranked:
return []
ids = [p.id for p in ranked]
specific_map = {p.id: p for p in Page.objects.filter(id__in=ids).specific()}
return [specific_map[i] for i in ids if i in specific_map]
query_mixins.insert(0, SearchOverrideMixin)
@hooks.register("construct_page_action_menu")
def make_publish_default_action(menu_items, request, context):
for index, item in enumerate(menu_items):
+36 -13
View File
@@ -3,8 +3,8 @@ from django.utils.translation import gettext, gettext_lazy as _
from django.utils.translation import ngettext
from wagtail.admin.ui.tables import Column, DateColumn
from wagtail.admin.ui.tables.pages import PageStatusColumn, PageTitleColumn
from wagtail.admin.views.pages.listing import IndexView
from wagtail.admin.viewsets.pages import PageListingViewSet
from wagtail.admin.views.pages.listing import ExplorableIndexView, IndexView
from wagtail.admin.viewsets.pages import PageListingViewSet, PageViewSet
from dnscms.admin import ListingRedirectChooseParentView
from events.models import EventPage
@@ -32,7 +32,9 @@ class OrganizersColumn(Column):
return f"{names[0]} (+{len(names) - 1})"
class EventPageIndexView(IndexView):
class EventPagePrefetchMixin:
"""Prefetch the relations the event columns read, so the listing avoids N+1."""
def annotate_queryset(self, pages):
pages = super().annotate_queryset(pages)
return pages.prefetch_related(
@@ -41,20 +43,23 @@ class EventPageIndexView(IndexView):
)
class EventPageIndexView(EventPagePrefetchMixin, IndexView):
pass
class EventPageExplorableIndexView(EventPagePrefetchMixin, ExplorableIndexView):
pass
class EventChooseParentView(ListingRedirectChooseParentView):
listing_url_name = "events:index"
class EventPageListingViewSet(PageListingViewSet):
model = EventPage
index_view_class = EventPageIndexView
choose_parent_view_class = EventChooseParentView
icon = "date"
menu_label = _("Events")
menu_order = 1
add_to_admin_menu = True
ordering = "-latest_revision_created_at"
class EventListingMixin:
"""Shared model + columns for the standalone listing and the page explorer."""
model = EventPage
icon = "date"
columns = [
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
EventDateColumn("event_date", label=_("Date"), width="13%"),
@@ -69,4 +74,22 @@ class EventPageListingViewSet(PageListingViewSet):
]
event_page_listing_viewset = EventPageListingViewSet("events")
class EventSidebarViewSet(EventListingMixin, PageListingViewSet):
"""Standalone 'Events' sidebar entry, reached independently of the page tree."""
index_view_class = EventPageIndexView
choose_parent_view_class = EventChooseParentView
menu_label = _("Events")
menu_order = 1
add_to_admin_menu = True
ordering = "-latest_revision_created_at"
class EventExplorerViewSet(EventListingMixin, PageViewSet):
"""Applies the same columns when navigating into EventIndex via the page explorer."""
index_view_class = EventPageExplorableIndexView
event_sidebar_viewset = EventSidebarViewSet("events")
event_explorer_viewset = EventExplorerViewSet()
+16 -2
View File
@@ -166,7 +166,7 @@ class EventOrganizerLink(Orderable):
@register_snippet
@register_query_field("eventOrganizer", "eventOrganizers")
class EventOrganizer(ClusterableModel):
class EventOrganizer(index.Indexed, ClusterableModel):
objects = WPAwareManager()
name = models.CharField(
@@ -222,6 +222,11 @@ class EventOrganizer(ClusterableModel):
GraphQLString("external_url"),
]
search_fields = [
index.SearchField("name"),
index.AutocompleteField("name"),
]
class Meta:
verbose_name = _("event organizer")
verbose_name_plural = _("event organizers")
@@ -478,7 +483,7 @@ class EventOccurrence(Orderable):
),
FieldRowPanel(
children=[
FieldPanel("venue", heading=_("Venue")),
FieldPanel("venue", heading=_("Venue"), widget=forms.Select),
FieldPanel("venue_custom", heading=_("Venue as free text")),
],
),
@@ -492,6 +497,15 @@ class EventOccurrence(Orderable):
]
def clean(self):
if self.venue_custom:
trimmed = self.venue_custom.strip()
self.venue_custom = trimmed
if trimmed:
match = VenuePage.objects.filter(title=trimmed).first()
if match:
self.venue = match
self.venue_custom = ""
if self.venue and self.venue_custom:
raise ValidationError(
{
+19 -1
View File
@@ -1,6 +1,24 @@
from django.utils.translation import gettext_lazy as _
from wagtail.admin.forms import WagtailAdminModelForm
from wagtail.admin.viewsets.chooser import ChooserViewSet
from dnscms.utils import slugify
from events.models import EventOrganizer
class EventOrganizerCreationForm(WagtailAdminModelForm):
class Meta:
model = EventOrganizer
fields = ["name", "association", "external_url"]
def save(self, commit=True):
instance = super().save(commit=False)
if not instance.slug:
instance.slug = slugify(instance.name)
if commit:
instance.save()
return instance
class EventOrganizerChooserViewSet(ChooserViewSet):
model = "events.EventOrganizer"
@@ -10,7 +28,7 @@ class EventOrganizerChooserViewSet(ChooserViewSet):
choose_one_text = _("Choose an organizer")
choose_another_text = _("Choose another organizer")
edit_item_text = _("Edit this organizer")
form_fields = ["name", "association", "external_url"]
creation_form_class = EventOrganizerCreationForm
event_organizer_chooser_viewset = EventOrganizerChooserViewSet("event_organizer_chooser")
+8 -3
View File
@@ -1,6 +1,6 @@
from wagtail import hooks
from .admin import event_page_listing_viewset
from .admin import event_sidebar_viewset, event_explorer_viewset
from .views import event_organizer_chooser_viewset
@@ -10,5 +10,10 @@ def register_viewset():
@hooks.register("register_admin_viewset")
def register_event_page_listing_viewset():
return event_page_listing_viewset
def register_event_sidebar_viewset():
return event_sidebar_viewset
@hooks.register("register_admin_viewset")
def register_event_explorer_viewset():
return event_explorer_viewset
Binary file not shown.
+45 -99
View File
@@ -7,187 +7,143 @@ msgid ""
msgstr ""
"Project-Id-Version: dnscms\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-19 21:55+0200\n"
"POT-Creation-Date: 2026-05-26 01:39+0200\n"
"Language: nb\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: associations/admin.py:23
msgid "Associations"
msgstr "Foreninger"
#: associations/admin.py:29 events/admin.py:59 news/admin.py:24
msgid "Title"
msgstr "Tittel"
#: associations/admin.py:32 associations/models.py:79
msgid "Type"
msgstr "Type"
#: associations/admin.py:38 events/admin.py:64 news/admin.py:27
msgid "Updated"
msgstr "Oppdatert"
#: associations/admin.py:42 events/admin.py:68 news/admin.py:31
msgid "Status"
msgstr "Status"
#: associations/models.py:30 associations/models.py:76 events/models.py:327
#: news/models.py:23 news/models.py:69
msgid "Associations"
msgstr "Foreninger"
msgid "Lead"
msgstr "Ingress"
#: associations/models.py:31 associations/models.py:77
msgid "Content"
msgstr "Innhold"
#: associations/models.py:42
msgid "association index"
msgstr "foreningsoversikt"
#: associations/models.py:43
msgid "association indexes"
msgstr "foreningsoversikter"
#: associations/models.py:52
msgid "Association"
msgstr "Forening"
#: associations/models.py:53
msgid "Committee"
msgstr "Utvalg"
#: associations/models.py:73 news/models.py:60
msgid "Excerpt"
msgstr "Utdrag"
#: associations/models.py:74
msgid "A very short summary of the content below. Used in listing views."
msgstr ""
"En veldig kort oppsummering av innholdet nedenfor. Brukes i listevisninger."
#: associations/models.py:80 events/models.py:189
msgid "Website"
msgstr "Nettsted"
#: associations/models.py:98
msgid "association"
msgstr "forening"
#: associations/models.py:99
msgid "associations"
msgstr "foreninger"
#: associations/views.py:8
msgid "Choose an association"
msgstr "Velg en forening"
#: associations/views.py:9
msgid "Choose another association"
msgstr "Velg en annen forening"
#: associations/views.py:10
msgid "Edit this association"
msgstr "Rediger denne foreningen"
#: events/admin.py:20
msgid "%Y-%m-%d at %H:%M"
msgstr "%Y-%m-%d kl %H:%M"
#: events/admin.py:22
#, python-format
msgid "%(count)d occurrence"
msgid_plural "%(count)d occurrences"
msgstr[0] "%(count)d forekomst"
msgstr[1] "%(count)d forekomster"
#: events/admin.py:53
msgid "Events"
msgstr "Arrangementer"
#: events/admin.py:60
msgid "Date"
msgstr "Dato"
#: events/admin.py:61 events/models.py:331
msgid "Organizers"
msgstr "Arrangører"
#: events/models.py:73 events/models.py:156
msgid "Events"
msgstr "Arrangementer"
msgid "slug"
msgstr "permalenke"
#: events/models.py:75
msgid "The name of the category as it will appear in URLs."
msgstr "Navnet på kategorien slik det vil vises i URL-er."
#: events/models.py:79
msgid "Should this category be available as a filter in the event programme?"
msgstr "Skal denne kategorien være mulig å filtrere på i programmet?"
#: events/models.py:83 events/models.py:266
msgid "None"
msgstr "Ingen"
#: events/models.py:91
msgid "Default pig for events of this kind."
msgstr "Standardgris for arrangementer av denne typen."
#: events/models.py:98 events/models.py:341
msgid "Pig"
msgstr "Gris"
#: events/models.py:109
msgid "event category"
msgstr "arrangementskategori"
#: events/models.py:110
msgid "event categories"
msgstr "arrangementskategorier"
#: events/models.py:138
msgid "organizer"
msgstr "arrangør"
#: events/models.py:139
msgid "organizers"
msgstr "arrangører"
#: events/models.py:158
msgid "The name of the organizer as it will appear in URLs."
msgstr "Navnet på arrangøren slik det vil vises i URL-er."
#: events/models.py:167
msgid "If a DNS association or committee is behind it, choose it here."
msgstr "Om en samfundsforening eller -utvalg står bak, velg det her."
#: events/models.py:173
msgid "Link to the external organizer's website"
msgstr "Lenke til nettstedet til ekstern arrangør"
#: events/models.py:182
msgid "Internal organizer"
msgstr "Intern arrangør"
#: events/models.py:185
msgid "External organizer"
msgstr "Ekstern arrangør"
#: events/models.py:190
msgid "Leave this empty if the organizer exists in the list above."
msgstr "La denne stå tom om arrangøren finnes i lista over."
#: events/models.py:204
msgid "event organizer"
msgstr "arrangør"
#: events/models.py:205
msgid "event organizers"
msgstr "arrangører"
#: events/models.py:239
msgid ""
"Choose an image for use in the programme and other surfaces. Should be a "
"photo or an illustration without too much text don't reuse a Facebook "
@@ -197,7 +153,6 @@ msgstr ""
"bilde eller en illustrasjon uten for mye tekst ikke gjenbruk et Facebook-"
"cover ukritisk!"
#: events/models.py:249
msgid ""
"A short text that appears right below the title. Feel free to leave it empty "
"if you fit most of it in the main title."
@@ -205,11 +160,9 @@ msgstr ""
"En kort tekst som kommer rett under tittelen. La denne gjerne stå tom om du "
"fikk plass til det meste i hovedtittelen."
#: events/models.py:267
msgid "Automatic"
msgstr "Automatisk"
#: events/models.py:276
msgid ""
"The pig that hangs out on the event page. Automatic causes one to be chosen "
"based on the event's category."
@@ -217,36 +170,28 @@ msgstr ""
"Grisen som henger på arrangementssiden. Automatisk fører til at en velges "
"basert på arrangementets kategori."
#: events/models.py:284
msgid "Direct link to ticket purchase, e.g. TicketCo, Billetto or Ticketmaster"
msgstr ""
"Lenke direkte til billettkjøp, f.eks. TicketCo, Billetto eller Ticketmaster"
#: events/models.py:289
msgid "Direct link to the event on Facebook"
msgstr "Lenke direkte til arrangementet på Facebook"
#: events/models.py:298
msgid "Free"
msgstr "Gratis"
#: events/models.py:298
msgid "Is this event free for everyone?"
msgstr "Er dette arrangementet gratis for alle?"
#: events/models.py:303
msgid "Regular price"
msgstr "Ordinær pris"
#: events/models.py:304
msgid "Price for students"
msgstr "Pris for studenter"
#: events/models.py:305
msgid "Price for DNS members"
msgstr "Pris for medlemmer av DNS"
#: events/models.py:312
msgid ""
"Write <strong>0</strong> for free. An empty field hides the price category. "
"If possible, write digits only."
@@ -254,57 +199,44 @@ msgstr ""
"Skriv <strong>0</strong> om gratis. Tomt felt skjuler priskategorien. Om "
"mulig, skriv kun tall."
#: events/models.py:321
msgid "Ticket purchase link"
msgstr "Billettkjøpslenke"
#: events/models.py:325
msgid "Subtitle"
msgstr "Undertittel"
#: events/models.py:334
msgid "Who is behind the event?"
msgstr "Hvem står bak arrangementet?"
#: events/models.py:337
msgid "Organizer"
msgstr "Arrangør"
#: events/models.py:344
msgid "Facebook link"
msgstr "Facebook-lenke"
#: events/models.py:345
msgid "Direct link to the event on Facebook."
msgstr "Lenke direkte til arrangementet på Facebook."
#: events/models.py:347
msgid "Pricing and tickets"
msgstr "Priser og billettkjøp"
#: events/models.py:349
msgid "Date, time and venue"
msgstr "Dato, tid og lokale"
#: events/models.py:353
msgid "If the event spans several days, add each day as a separate occurrence."
msgstr ""
"Om arrangementet går over flere dager, legg inn hver dag som en egen "
"forekomst."
#: events/models.py:356
msgid "Occurrence"
msgstr "Forekomst"
#: events/models.py:399
msgid "event"
msgstr "arrangement"
#: events/models.py:400
msgid "events"
msgstr "arrangementer"
#: events/models.py:560
msgid ""
"Use this <em>if none of the venues that can be selected on the left</em> "
"fit. E.g. <em>Frederikkeplassen</em> or <em>Sirkusteltet</em>."
@@ -312,75 +244,60 @@ msgstr ""
"Bruk denne <em>om ingen av lokalene som kan velges til venstre</em> passer. "
"F.eks. <em>Frederikkeplassen</em> eller <em>Sirkusteltet</em>."
#: events/models.py:569
msgid "Start"
msgstr "Start"
#: events/models.py:570
msgid "End"
msgstr "Slutt"
#: events/models.py:575
msgid "Venue"
msgstr "Lokale"
#: events/models.py:576
msgid "Venue as free text"
msgstr "Lokale som fritekst"
#: events/models.py:593
msgid "You can't both pick a venue and write something in this field."
msgstr "Du kan ikke både velge et lokale og skrive noe i dette feltet."
#: events/models.py:598
msgid "Venue is required."
msgstr "Lokale er påkrevd."
#: events/models.py:604
msgid "occurrence"
msgstr "forekomst"
#: events/models.py:605
msgid "occurrences"
msgstr "forekomster"
#: events/views.py:9
msgid "Choose organizers"
msgstr "Velg arrangører"
#: events/views.py:10
msgid "Choose an organizer"
msgstr "Velg en arrangør"
#: events/views.py:11
msgid "Choose another organizer"
msgstr "Velg en annen arrangør"
#: events/views.py:12
msgid "Edit this organizer"
msgstr "Rediger denne arrangøren"
#: images/models.py:40
msgid "image"
msgstr "bilde"
#: images/models.py:41
msgid "images"
msgstr "bilder"
#: news/admin.py:18
msgid "First published"
msgstr "Først publisert"
msgid "News"
msgstr "Nyheter"
#: news/models.py:33
msgid "news index"
msgstr "nyhetsoversikt"
#: news/models.py:34
msgid "news indexes"
msgstr "nyhetsoversikter"
#: news/models.py:52
msgid ""
"Choose an image for use on the front page and other surfaces. Should be a "
"photo or an illustration without too much text."
@@ -388,15 +305,13 @@ msgstr ""
"Velg et bilde til bruk på forsiden og andre visningsflater. Bør være et "
"bilde eller en illustrasjon uten for mye tekst."
#: news/models.py:62
msgid ""
"A very short summary of the article's content. Used on the front page and in "
"the article listing."
msgstr ""
"En veldig kort oppsummering av innholdet i artikkelen. Brukes på forsiden "
"og i artikkeloversikten."
"En veldig kort oppsummering av innholdet i artikkelen. Brukes på forsiden og "
"i artikkeloversikten."
#: news/models.py:71
msgid ""
"A brief, introductory paragraph that summarizes the main content of the "
"article."
@@ -404,10 +319,41 @@ msgstr ""
"Et kortfattet, innledende avsnitt som oppsummerer hovedinnholdet i "
"artikkelen."
#: news/models.py:92
msgid "news article"
msgstr "nyhetsartikkel"
#: news/models.py:93
msgid "news articles"
msgstr "nyhetsartikler"
msgid "Rentals page"
msgstr "Utleieside"
msgid "Venue overview"
msgstr "Lokaleoversikt"
msgid "Venues"
msgstr "Lokaler"
msgid "venue index"
msgstr "lokaleoversikt"
msgid "venue indexes"
msgstr "lokaleoversikter"
msgid "rentals page"
msgstr "utleieside"
msgid "rentals pages"
msgstr "utleiesider"
msgid "venue"
msgstr "lokale"
msgid "venues"
msgstr "lokaler"
#~ msgid "Bookable"
#~ msgstr "Til utleie"
#~ msgid "Listed"
#~ msgstr "I oversikt"
+27 -10
View File
@@ -1,7 +1,7 @@
from django.utils.translation import gettext_lazy as _
from wagtail.admin.ui.tables import DateColumn
from wagtail.admin.ui.tables.pages import PageStatusColumn, PageTitleColumn
from wagtail.admin.viewsets.pages import PageListingViewSet
from wagtail.admin.viewsets.pages import PageListingViewSet, PageViewSet
from dnscms.admin import ListingRedirectChooseParentView
from news.models import NewsPage
@@ -11,17 +11,19 @@ class NewsChooseParentView(ListingRedirectChooseParentView):
listing_url_name = "news:index"
class NewsPageListingViewSet(PageListingViewSet):
model = NewsPage
choose_parent_view_class = NewsChooseParentView
icon = "info-circle"
menu_label = _("News")
menu_order = 3
add_to_admin_menu = True
ordering = "-latest_revision_created_at"
class NewsListingMixin:
"""Shared model + columns for the standalone listing and the page explorer."""
model = NewsPage
icon = "info-circle"
columns = [
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
DateColumn(
"first_published_at",
label=_("First published"),
sort_key="first_published_at",
width="10%",
),
DateColumn(
"latest_revision_created_at",
label=_("Updated"),
@@ -32,4 +34,19 @@ class NewsPageListingViewSet(PageListingViewSet):
]
news_page_listing_viewset = NewsPageListingViewSet("news")
class NewsSidebarViewSet(NewsListingMixin, PageListingViewSet):
"""Standalone 'News' sidebar entry, reached independently of the page tree."""
choose_parent_view_class = NewsChooseParentView
menu_label = _("News")
menu_order = 3
add_to_admin_menu = True
ordering = "-latest_revision_created_at"
class NewsExplorerViewSet(NewsListingMixin, PageViewSet):
"""Applies the same columns when navigating into NewsIndex via the page explorer."""
news_sidebar_viewset = NewsSidebarViewSet("news")
news_explorer_viewset = NewsExplorerViewSet()
+8 -3
View File
@@ -1,8 +1,13 @@
from wagtail import hooks
from .admin import news_page_listing_viewset
from .admin import news_sidebar_viewset, news_explorer_viewset
@hooks.register("register_admin_viewset")
def register_news_page_listing_viewset():
return news_page_listing_viewset
def register_news_sidebar_viewset():
return news_sidebar_viewset
@hooks.register("register_admin_viewset")
def register_news_explorer_viewset():
return news_explorer_viewset
+52
View File
@@ -14,6 +14,7 @@ from events.models import (
EventOrganizerLink,
EventPage,
)
from events.views import EventOrganizerCreationForm
from tests.conftest import (
AssociationPageFactory,
CustomImageFactory,
@@ -104,6 +105,57 @@ def test_eventoccurrence_clean_rejects_both_venue_and_venue_custom(event_index,
assert "venue_custom" in exc.value.message_dict
def test_eventoccurrence_clean_promotes_matching_custom_text_to_venue(event_index, venue):
event = EventPageFactory(parent=event_index)
occurrence = EventOccurrence(
event=event,
start=timezone.now(),
venue_custom=f" {venue.title} ",
)
occurrence.clean()
assert occurrence.venue_id == venue.pk
assert occurrence.venue_custom == ""
def test_event_organizer_creation_form_auto_slugifies_name(db):
form = EventOrganizerCreationForm(data={"name": "Forening for ÆØÅ", "external_url": ""})
assert form.is_valid(), form.errors
organizer = form.save()
assert organizer.pk is not None
assert organizer.name == "Forening for ÆØÅ"
assert organizer.slug == "forening-for-aeoa"
def test_event_organizer_creation_form_keeps_explicit_slug(db):
organizer = EventOrganizer(name="Forening", slug="custom-slug")
form = EventOrganizerCreationForm(
data={"name": "Forening", "external_url": ""}, instance=organizer
)
assert form.is_valid(), form.errors
organizer = form.save()
assert organizer.slug == "custom-slug"
def test_eventoccurrence_clean_keeps_custom_text_when_no_venue_matches(event_index):
event = EventPageFactory(parent=event_index)
occurrence = EventOccurrence(
event=event,
start=timezone.now(),
venue_custom=" Frederikkeplassen ",
)
occurrence.clean()
assert occurrence.venue is None
assert occurrence.venue_custom == "Frederikkeplassen"
def test_eventoccurrence_clean_requires_venue_or_venue_custom(event_index):
event = EventPageFactory(parent=event_index)
occurrence = EventOccurrence(event=event, start=timezone.now())
+4 -4
View File
@@ -1,4 +1,4 @@
from news.admin import NewsPageListingViewSet
from news.admin import NewsSidebarViewSet
from news.models import NewsPage
from tests.conftest import NewsPageFactory
@@ -11,9 +11,9 @@ def test_news_page_persists_via_factory(news_index):
assert reloaded.excerpt == "Short summary"
def test_news_listing_viewset_wired_to_newspage():
assert NewsPageListingViewSet.model is NewsPage
assert NewsPageListingViewSet.add_to_admin_menu is True
def test_news_sidebar_viewset_wired_to_newspage():
assert NewsSidebarViewSet.model is NewsPage
assert NewsSidebarViewSet.add_to_admin_menu is True
def test_graphql_news_index_query(news_index, graphql_post):
+131
View File
@@ -0,0 +1,131 @@
from wagtail.search.backends import get_search_backend
from tests.conftest import EventPageFactory, GenericPageFactory
SEARCH_QUERY = """
query Search($query: String) {
results: search(query: $query) {
__typename
... on PageInterface {
title
}
}
}
"""
def _index(page):
# Wagtail's post_save signal enqueues indexing via django-tasks, which isn't
# drained synchronously in tests. Call the backend directly so the page is
# findable through the live search code path.
get_search_backend().add(page)
def _titles_for(body, typename):
return [r["title"] for r in body["data"]["results"] if r["__typename"] == typename]
def test_search_returns_live_generic_page(home_page, graphql_post):
page = GenericPageFactory(
parent=home_page,
title="PublishedGenericSearchToken",
slug="published-generic-search",
)
_index(page)
response, body = graphql_post(SEARCH_QUERY, {"query": "PublishedGenericSearchToken"})
assert response.status_code == 200
assert "errors" not in body, body
assert "PublishedGenericSearchToken" in _titles_for(body, "GenericPage")
def test_search_excludes_draft_generic_page(home_page, graphql_post):
page = GenericPageFactory(
parent=home_page,
title="DraftGenericSearchToken",
slug="draft-generic-search",
live=False,
)
_index(page)
response, body = graphql_post(SEARCH_QUERY, {"query": "DraftGenericSearchToken"})
assert response.status_code == 200
assert "errors" not in body, body
assert "DraftGenericSearchToken" not in _titles_for(body, "GenericPage")
def test_search_returns_live_event_page(home_page, event_index, graphql_post):
page = EventPageFactory(
parent=event_index,
title="PublishedEventSearchToken",
slug="published-event-search",
)
_index(page)
response, body = graphql_post(SEARCH_QUERY, {"query": "PublishedEventSearchToken"})
assert response.status_code == 200
assert "errors" not in body, body
assert "PublishedEventSearchToken" in _titles_for(body, "EventPage")
def test_search_excludes_draft_event_page(home_page, event_index, graphql_post):
page = EventPageFactory(
parent=event_index,
title="DraftEventSearchToken",
slug="draft-event-search",
live=False,
)
_index(page)
response, body = graphql_post(SEARCH_QUERY, {"query": "DraftEventSearchToken"})
assert response.status_code == 200
assert "errors" not in body, body
assert "DraftEventSearchToken" not in _titles_for(body, "EventPage")
def test_search_results_not_grouped_by_type(home_page, event_index, graphql_post):
# Two pages of different types matching the query equally, plus a third
# page of one of those types that should rank highest. Under the
# per-model-iteration resolver, all Generic results come before all Event
# results (or vice versa) — type-grouped — so the highest-relevance Event
# ends up after a less-relevant Generic. Cross-type relevance ordering
# should put the strongest match first regardless of type.
weak_generic = GenericPageFactory(
parent=home_page,
title="Klatremus klatremus klatremus",
slug="weak-generic",
)
weak_event = EventPageFactory(
parent=event_index,
title="Klatremus klatremus klatremus",
slug="weak-event",
)
strong_event = EventPageFactory(
parent=event_index,
title="Klatremus klatremus klatremus klatremus klatremus klatremus",
slug="strong-event",
)
_index(weak_generic)
_index(weak_event)
_index(strong_event)
response, body = graphql_post(SEARCH_QUERY, {"query": "klatremus"})
assert response.status_code == 200
assert "errors" not in body, body
order = [
(r["__typename"], r["title"])
for r in body["data"]["results"]
if r["__typename"] in ("GenericPage", "EventPage")
]
assert len(order) == 3, order
# Per-type grouping would put all results of one type consecutively
# before the other type. Cross-type relevance ordering should interleave.
types_seen = [t for t, _ in order]
assert types_seen != ["GenericPage", "EventPage", "EventPage"], order
assert types_seen != ["EventPage", "EventPage", "GenericPage"], order
+41
View File
@@ -0,0 +1,41 @@
import pytest
from dnscms.utils import slugify
from events.models import EventCategory, EventOrganizer
from tests.conftest import GenericPageFactory
def test_slugify_transliterates_norwegian_letters():
assert slugify("Bjørn") == "bjorn"
assert slugify("Møterom") == "moterom"
assert slugify("Forening for ÆØÅ") == "forening-for-aeoa"
def test_slugify_is_idempotent_on_ascii():
assert slugify("already-clean-slug") == "already-clean-slug"
def test_page_save_transliterates_unicode_in_slug(home_page):
page = GenericPageFactory(parent=home_page, title="Møterom", slug="møterom")
assert page.slug == "moterom"
def test_page_save_leaves_clean_slug_untouched(home_page):
page = GenericPageFactory(parent=home_page, title="Om oss", slug="om-oss")
assert page.slug == "om-oss"
@pytest.mark.django_db
def test_event_organizer_save_transliterates_unicode_in_slug():
organizer = EventOrganizer.objects.create(name="Bjørn", slug="bjørn")
assert organizer.slug == "bjorn"
@pytest.mark.django_db
def test_event_category_save_transliterates_unicode_in_slug():
category = EventCategory.objects.create(name="Mørkerom", slug="mørkerom")
assert category.slug == "morkerom"
+58
View File
@@ -0,0 +1,58 @@
from django.utils.translation import gettext_lazy as _
from wagtail.admin.ui.tables import BooleanColumn, DateColumn
from wagtail.admin.ui.tables.pages import PageStatusColumn, PageTitleColumn
from wagtail.admin.viewsets.pages import PageListingViewSet, PageViewSet
from dnscms.admin import ListingRedirectChooseParentView
from venues.models import VenuePage
class VenueChooseParentView(ListingRedirectChooseParentView):
listing_url_name = "venues:index"
class VenueListingMixin:
"""Shared model + columns for the standalone listing and the page explorer."""
model = VenuePage
icon = "home"
columns = [
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
BooleanColumn(
"show_as_bookable",
label=_("Rentals page"),
sort_key="show_as_bookable",
width="10%",
),
BooleanColumn(
"show_in_overview",
label=_("Venue overview"),
sort_key="show_in_overview",
width="10%",
),
DateColumn(
"latest_revision_created_at",
label=_("Updated"),
sort_key="latest_revision_created_at",
width="10%",
),
PageStatusColumn("status", label=_("Status"), sort_key="live", width="10%"),
]
class VenueSidebarViewSet(VenueListingMixin, PageListingViewSet):
"""Standalone 'Venues' sidebar entry, reached independently of the page tree."""
choose_parent_view_class = VenueChooseParentView
menu_label = _("Venues")
menu_order = 4
add_to_admin_menu = True
ordering = "title"
class VenueExplorerViewSet(VenueListingMixin, PageViewSet):
"""Applies the same columns when navigating into VenueIndex via the page explorer."""
venue_sidebar_viewset = VenueSidebarViewSet("venues")
venue_explorer_viewset = VenueExplorerViewSet()
@@ -0,0 +1,25 @@
# Generated by Django 6.0.5 on 2026-05-25 23:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('venues', '0024_venuepage_show_in_overview_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='venueindex',
options={'verbose_name': 'venue index', 'verbose_name_plural': 'venue indexes'},
),
migrations.AlterModelOptions(
name='venuepage',
options={'verbose_name': 'venue', 'verbose_name_plural': 'venues'},
),
migrations.AlterModelOptions(
name='venuerentalindex',
options={'verbose_name': 'rentals page', 'verbose_name_plural': 'rentals pages'},
),
]
+13
View File
@@ -1,4 +1,5 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from grapple.helpers import register_singular_query_field
from grapple.models import (
GraphQLBoolean,
@@ -38,6 +39,10 @@ class VenueIndex(HeadlessMixin, Page):
graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")]
class Meta:
verbose_name = _("venue index")
verbose_name_plural = _("venue indexes")
@register_singular_query_field("venueRentalIndex")
class VenueRentalIndex(HeadlessMixin, Page):
@@ -55,6 +60,10 @@ class VenueRentalIndex(HeadlessMixin, Page):
graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")]
class Meta:
verbose_name = _("rentals page")
verbose_name_plural = _("rentals pages")
class VenuePage(HeadlessMixin, WPImportedPageMixin, Page):
# no children
@@ -184,3 +193,7 @@ class VenuePage(HeadlessMixin, WPImportedPageMixin, Page):
search_fields = Page.search_fields + [
index.SearchField("body"),
]
class Meta:
verbose_name = _("venue")
verbose_name_plural = _("venues")
+13
View File
@@ -0,0 +1,13 @@
from wagtail import hooks
from .admin import venue_explorer_viewset, venue_sidebar_viewset
@hooks.register("register_admin_viewset")
def register_venue_sidebar_viewset():
return venue_sidebar_viewset
@hooks.register("register_admin_viewset")
def register_venue_explorer_viewset():
return venue_explorer_viewset
+5 -1
View File
@@ -48,5 +48,9 @@
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
}
},
"browserslist": [
"> 0.5% in NO",
"not dead"
]
}
+16 -1
View File
@@ -35,7 +35,22 @@ export default function RootLayout({
<html lang="no">
<head>
<link rel="preconnect" href="https://use.typekit.net" crossOrigin="anonymous" />
<link rel="stylesheet" href="https://use.typekit.net/spa5smt.css" />
<link rel="preconnect" href="https://p.typekit.net" crossOrigin="anonymous" />
{/*
Load Adobe Fonts without blocking render: the stylesheet is requested
with media="print" (fetched but not applied), then the inline script
below swaps it to media="all" once it has loaded.
*/}
<link
id="typekit-css"
rel="stylesheet"
href="https://use.typekit.net/spa5smt.css"
media="print"
suppressHydrationWarning
/>
<script>
{`(function(){var l=document.getElementById('typekit-css');if(!l)return;function s(){l.media='all'}if(l.sheet){s()}else{l.addEventListener('load',s)}})();`}
</script>
{process.env.UMAMI_SCRIPT_URL && process.env.UMAMI_WEBSITE_ID && (
<script
defer
+8 -1
View File
@@ -10,6 +10,8 @@ import { getSeoMetadata } from "@/lib/seo";
type Params = Promise<{ slug: string }>;
const EXCLUDED_SLUGS = ["hele-huset"];
export async function generateMetadata(
{ params }: { params: Params },
parent: ResolvingMetadata
@@ -40,13 +42,18 @@ export async function generateStaticParams() {
);
}
return data.pages.map((page: any) => ({
return data.pages
.filter((page) => !EXCLUDED_SLUGS.includes(page.slug))
.map((page) => ({
slug: page.slug,
}));
}
export default async function Page({ params }: { params: Params }) {
const { slug } = await params;
if (EXCLUDED_SLUGS.includes(slug)) {
return notFound();
}
const props = await loadVenuePageProps({ slug });
if (!props) return notFound();
return <VenuePageView {...props} />;
+45 -23
View File
@@ -1,7 +1,10 @@
import { graphql } from "@/gql";
import { getClient } from "@/app/client";
import { SearchContainer } from "@/components/search/SearchContainer";
import { Suspense } from "react";
import {
type SearchResult,
SearchResults,
} from "@/components/search/SearchResults";
import { SearchShell } from "@/components/search/SearchShell";
import { graphql } from "@/gql";
// TODO: seo metadata?
@@ -13,7 +16,9 @@ export default async function Page({
}>;
}) {
const { q: query } = (await searchParams) ?? {};
let results = [];
let results: SearchResult[] = [];
let totalCount = 0;
const RESULT_LIMIT = 500;
if (query) {
const searchQuery = graphql(`
@@ -21,45 +26,62 @@ export default async function Page({
results: search(query: $query) {
__typename
... on PageInterface {
slug
id
title
url
}
... on NewsPage {
id
title
excerpt
featuredImage {
...Image
}
firstPublishedAt
}
... on EventPage {
id
title
subtitle
featuredImage {
...Image
}
occurrences {
start
}
}
... on GenericPage {
id
title
lead
}
... on VenuePage {
id
title
featuredImage {
...Image
}
}
... on AssociationPage {
id
title
excerpt
associationType
logo {
...Image
}
}
}
}
`);
const { data, error } = await getClient().query(searchQuery, {
query: query,
});
results = (data?.results ?? []) as any;
const { data } = await getClient().query(searchQuery, { query });
const all = (data?.results ?? []) as SearchResult[];
totalCount = all.length;
results = all.slice(0, RESULT_LIMIT);
}
return (
<main className="site-main" id="main">
<Suspense key={query}>
<SearchContainer query={query ?? ""} results={results} />
</Suspense>
<SearchShell initialQuery={query ?? ""}>
{query ? (
<SearchResults
results={results}
totalCount={totalCount}
query={query}
/>
) : null}
</SearchShell>
</main>
);
}
+1 -1
View File
@@ -58,7 +58,7 @@ export const EventItem = ({
alt={featuredImage.alt}
width={0}
height={0}
sizes="(max-width: 900px) 100vw, 25vw"
sizes="(max-width: 900px) calc(100vw - 2rem), 30vw"
loading={imageLoading}
fetchPriority={imageFetchPriority}
/>
+2 -2
View File
@@ -9,13 +9,13 @@ export const FeaturedEvents = ({ events }: { events: EventListItemFragment[] })
<section className={styles.featuredEvents}>
<SectionHeader heading="Arrangementer" link="/arrangementer" linkText="Se alle arrangementer" />
<ul className={styles.eventList}>
{events.slice(0, 3).map((event, index) => (
{events.slice(0, 3).map((event) => (
<EventItem
key={event.id}
event={event}
mode="list"
imageLoading="eager"
imageFetchPriority={index < 2 ? "high" : undefined}
imageFetchPriority="high"
/>
))}
</ul>
+5
View File
@@ -25,8 +25,12 @@ export const Header = () => {
const { replace } = useRouter();
const pathname = usePathname();
const [showMenu, setShowMenu] = useState(false);
// Only enable the menu's slide/fade animations once the user has interacted,
// so they don't play on first page load
const [hasInteracted, setHasInteracted] = useState(false);
function toggleMenu() {
setHasInteracted(true);
setShowMenu(!showMenu);
}
@@ -61,6 +65,7 @@ export const Header = () => {
<header
className={styles.header}
data-show={showMenu}
data-animate={hasInteracted}
data-small={!isInView}
>
<Link href="/" aria-label="Hjem">
@@ -54,6 +54,12 @@
transition: transform .6s ease;
}
}
&[data-animate=false] {
.mainMenu {
animation: none;
}
}
}
.overlay {
@@ -1,112 +0,0 @@
"use client";
import { useDebouncedCallback } from "use-debounce";
import { PageHeader } from "../general/PageHeader";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { getSearchPath } from "@/lib/common";
import styles from './searchContainer.module.scss';
import { Icon } from "../general/Icon";
import Link from "next/link";
export function SearchContainer({
query,
results,
}: {
query: string;
results: any;
}) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const onQueryChange = useDebouncedCallback((query) => {
replace(getSearchPath(query));
}, 500);
return (
<div className={styles.searchContainer}>
<PageHeader heading="Søk" />
<div className={styles.searchField}>
<input
name="query"
type="text"
autoFocus
defaultValue={query ?? ""}
onChange={(e) => {
onQueryChange(e.target.value);
}}
/>
<div className={styles.searchIcon}>
<Icon type="search" />
</div>
</div>
{query && <SearchResults results={results} />}
</div>
);
}
function capitalizeFirstLetter(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function linkTo(page: any): string | null {
if (page.__typename === "EventPage") {
return `/arrangementer/${page.slug}`;
}
if (page.__typename === "NewsPage") {
return `/aktuelt/${page.slug}`;
}
if (page.__typename === "AssociationPage") {
return `/foreninger/${page.slug}`;
}
if (page.__typename === "GenericPage") {
return `/{page.slug}`;
}
if (page.__typename === "VenuePage") {
return `/lokaler/${page.slug}`;
}
return null;
}
const PAGE_TYPES: Record<string, string> = {
NewsPage: "Nyhet",
EventPage: "Arrangement",
GenericPage: "Underside",
VenuePage: "Lokale",
AssociationPage: "Forening",
};
function SearchResults({ results }: { results: any }) {
if (!results.length) {
return <div className={styles.noResults}>Ingen resultater</div>;
}
const supportedResults = results.filter(
(result: any) =>
!!result?.id && Object.keys(PAGE_TYPES).includes(result.__typename)
);
return (
<div>
<p className={styles.resultsCounter}>{results.length} resultater</p>
{supportedResults.map((result: any) => {
let resultType = PAGE_TYPES[result.__typename] ?? "";
if (result.__typename === "AssociationPage") {
resultType = capitalizeFirstLetter(result?.associationType);
}
const link = linkTo(result);
const ResultItem = () => (
<div className={styles.resultItem}>
<span className={styles.suphead}>{resultType}</span>
<h2 className={styles.title}>{result.title}</h2>
</div>
);
if (link) {
return (
<Link key={result.id} href={link}>
<ResultItem />
</Link>
);
}
return <ResultItem key={result.id} />;
})}
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { unmaskFragment } from "@/gql";
import type { ImageFragment, SearchQuery } from "@/gql/graphql";
import { ImageFragmentDefinition, stripHtml } from "@/lib/common";
import { formatDate, formatOccurrenceMonths } from "@/lib/date";
import Link from "next/link";
import { Image } from "../general/Image";
import styles from "./searchContainer.module.scss";
export type SearchResult = SearchQuery["results"][number];
const PAGE_TYPES = {
NewsPage: "Nyhet",
EventPage: "Arrangement",
GenericPage: "Underside",
VenuePage: "Lokale",
AssociationPage: "Forening",
} as const;
type SupportedTypename = keyof typeof PAGE_TYPES;
type SupportedResult = Extract<SearchResult, { __typename: SupportedTypename }>;
function capitalizeFirstLetter(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function isSupported(result: SearchResult): result is SupportedResult {
return result.__typename in PAGE_TYPES && "id" in result && !!result.id;
}
function getResultType(result: SupportedResult): string {
if (result.__typename === "AssociationPage" && result.associationType) {
return capitalizeFirstLetter(result.associationType);
}
return PAGE_TYPES[result.__typename];
}
function getResultImage(result: SupportedResult): ImageFragment | null {
switch (result.__typename) {
case "NewsPage":
case "EventPage":
case "VenuePage":
return unmaskFragment(ImageFragmentDefinition, result.featuredImage);
case "AssociationPage":
return unmaskFragment(ImageFragmentDefinition, result.logo);
default:
return null;
}
}
function getResultDate(result: SupportedResult): string | null {
if (result.__typename === "EventPage") {
const starts = result.occurrences
.map((o) => o.start)
.filter((s): s is string => !!s);
if (starts.length === 0) return null;
if (starts.length === 1) return formatDate(starts[0], "d. MMMM yyyy");
return formatOccurrenceMonths(starts);
}
if (result.__typename === "NewsPage" && result.firstPublishedAt) {
return formatDate(result.firstPublishedAt, "d. MMMM yyyy");
}
return null;
}
function getResultSnippet(result: SupportedResult): string | null {
switch (result.__typename) {
case "NewsPage":
case "AssociationPage":
return result.excerpt ?? null;
case "EventPage":
return result.subtitle ?? null;
case "GenericPage":
return result.lead ? stripHtml(result.lead).trim() : null;
default:
return null;
}
}
function highlight(text: string, query: string): React.ReactNode {
if (query.length < 2) return text;
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(escaped, "gi");
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(pattern)) {
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
}
nodes.push(<mark key={`m-${match.index}`}>{match[0]}</mark>);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
export function SearchResults({
results,
totalCount,
query,
}: {
results: SearchResult[];
totalCount: number;
query: string;
}) {
if (!results.length) {
return (
<div className={styles.noResults} aria-live="polite">
<p className={styles.noResultsHeading}>Ingen treff «{query}»</p>
</div>
);
}
const supportedResults = results.filter(isSupported);
const truncated = totalCount > results.length;
return (
<div>
<p className={styles.resultsCounter} aria-live="polite">
{truncated
? `Viser de første ${results.length} av ${totalCount} treff — prøv et mer spesifikt søk.`
: `${results.length} resultater`}
</p>
{supportedResults.map((result) => (
<ResultRow key={result.id} result={result} query={query} />
))}
</div>
);
}
function ResultRow({
result,
query,
}: {
result: SupportedResult;
query: string;
}) {
const image = getResultImage(result);
const snippet = getResultSnippet(result);
const date = getResultDate(result);
const resultType = getResultType(result);
const link = result.url;
const body = (
<div className={styles.resultItem}>
<div className={styles.resultBody}>
<span className={styles.suphead}>{resultType}</span>
<h2 className={styles.title}>{highlight(result.title, query)}</h2>
{date && <p className={styles.date}>{date}</p>}
{snippet && (
<p className={styles.snippet}>{highlight(snippet, query)}</p>
)}
</div>
{image?.url && (
<div className={styles.thumb}>
<Image
src={image.url}
alt={image.alt ?? ""}
width={image.width}
height={image.height}
sizes="100px"
/>
</div>
)}
</div>
);
if (link) {
return <Link href={link}>{body}</Link>;
}
return body;
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { getSearchPath } from "@/lib/common";
import { useRouter } from "next/navigation";
import {
type ReactNode,
useEffect,
useRef,
useState,
useTransition,
} from "react";
import { useDebouncedCallback } from "use-debounce";
import { Icon } from "../general/Icon";
import { PageHeader } from "../general/PageHeader";
import styles from "./searchContainer.module.scss";
export function SearchShell({
initialQuery,
children,
}: {
initialQuery: string;
children: ReactNode;
}) {
const { replace } = useRouter();
const [inputValue, setInputValue] = useState(initialQuery);
const [isPending, startTransition] = useTransition();
const lastPushedRef = useRef(initialQuery);
const fetching = isPending || inputValue !== lastPushedRef.current;
const pushQuery = useDebouncedCallback((next: string) => {
lastPushedRef.current = next;
startTransition(() => {
replace(getSearchPath(next));
});
}, 300);
useEffect(() => {
if (initialQuery !== lastPushedRef.current) {
lastPushedRef.current = initialQuery;
setInputValue(initialQuery);
}
}, [initialQuery]);
return (
<div className={styles.searchContainer}>
<PageHeader heading={initialQuery ? `Søk: «${initialQuery}»` : "Søk"} />
<form
action="/sok"
method="get"
onSubmit={(e) => {
e.preventDefault();
pushQuery.cancel();
lastPushedRef.current = inputValue;
startTransition(() => {
replace(getSearchPath(inputValue));
});
}}
>
<div className={styles.searchField}>
<label htmlFor="search-query" className="sr-only">
Søk
</label>
<input
id="search-query"
name="q"
type="text"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
pushQuery(e.target.value);
}}
/>
<div className={styles.searchIcon} aria-hidden="true">
<Icon type="search" />
</div>
</div>
</form>
<div
className={fetching ? styles.fetching : undefined}
aria-busy={fetching}
>
{children}
</div>
</div>
);
}
@@ -5,6 +5,13 @@
a {
text-decoration: none;
}
mark {
background: var(--color-goldenBeige);
color: inherit;
padding: 0 .05em;
border-radius: 2px;
}
}
.searchField {
@@ -29,11 +36,52 @@
}
.resultItem {
display: flex;
align-items: flex-start;
gap: var(--spacing-m);
border-top: var(--border);
margin-top: var(--spacing-m);
padding-top: var(--spacing-s);
}
.resultBody {
flex: 1;
min-width: 0;
}
.date {
margin-top: 0.25em;
font-family: var(--font-serif);
font-size: var(--font-size-caption);
color: var(--color-chateauBlue-05);
}
.snippet {
margin-top: 0.25em;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fetching {
opacity: 0.5;
transition: opacity .15s ease;
}
.thumb {
flex: 0 0 5rem;
width: 5rem;
height: 5rem;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.suphead {
display: block;
font-size: var(--font-size-caption);
+3 -3
View File
@@ -20,7 +20,7 @@ type Documents = {
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": typeof types.AllAssociationSlugsDocument,
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": typeof types.AllVenueSlugsDocument,
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": typeof types.PreviewPageDocument,
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n ": typeof types.SearchDocument,
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n ": typeof types.SearchDocument,
"\n fragment AssociationIndex on AssociationIndex {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n }\n": typeof types.AssociationIndexFragmentDoc,
"\n fragment Association on AssociationPage {\n __typename\n id\n slug\n title\n seoTitle\n searchDescription\n excerpt\n lead\n body {\n ...Blocks\n }\n logo {\n url\n width\n height\n }\n associationType\n websiteUrl\n }\n": typeof types.AssociationFragmentDoc,
"\n query allAssociations {\n index: associationIndex {\n ... on AssociationIndex {\n ...AssociationIndex\n }\n }\n associations: pages(\n contentType: \"associations.AssociationPage\"\n limit: 1000\n ) {\n ... on AssociationPage {\n ...Association\n }\n }\n }\n": typeof types.AllAssociationsDocument,
@@ -88,7 +88,7 @@ const documents: Documents = {
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": types.AllAssociationSlugsDocument,
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": types.AllVenueSlugsDocument,
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": types.PreviewPageDocument,
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n ": types.SearchDocument,
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n ": types.SearchDocument,
"\n fragment AssociationIndex on AssociationIndex {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n }\n": types.AssociationIndexFragmentDoc,
"\n fragment Association on AssociationPage {\n __typename\n id\n slug\n title\n seoTitle\n searchDescription\n excerpt\n lead\n body {\n ...Blocks\n }\n logo {\n url\n width\n height\n }\n associationType\n websiteUrl\n }\n": types.AssociationFragmentDoc,
"\n query allAssociations {\n index: associationIndex {\n ... on AssociationIndex {\n ...AssociationIndex\n }\n }\n associations: pages(\n contentType: \"associations.AssociationPage\"\n limit: 1000\n ) {\n ... on AssociationPage {\n ...Association\n }\n }\n }\n": types.AllAssociationsDocument,
@@ -191,7 +191,7 @@ export function graphql(source: "\n query previewPage($token: String!) {\n p
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n "): (typeof documents)["\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n "];
export function graphql(source: "\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n "): (typeof documents)["\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n "];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
+16 -16
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -95,6 +95,44 @@ export function groupConsecutiveDates(dates: string[]): string[][] {
return groupedDates;
}
export function formatOccurrenceMonths(starts: string[]): string {
if (starts.length === 0) return "";
const months = unique(
starts.map((s) => format(toLocalTime(s), "yyyy-MM"))
).sort() as string[];
const monthIndex = (ym: string) => {
const [y, m] = ym.split("-").map(Number);
return y * 12 + (m - 1);
};
const groups: string[][] = [];
for (const ym of months) {
const last = groups[groups.length - 1];
if (last && monthIndex(ym) === monthIndex(last[last.length - 1]) + 1) {
last.push(ym);
} else {
groups.push([ym]);
}
}
return groups
.map((g) => {
const first = parse(g[0], "yyyy-MM", new Date());
if (g.length === 1) {
return formatDate(first, "MMMM yyyy");
}
const last = parse(g[g.length - 1], "yyyy-MM", new Date());
const firstFmt =
first.getFullYear() === last.getFullYear()
? formatDate(first, "MMMM")
: formatDate(first, "MMMM yyyy");
return `${firstFmt} ${formatDate(last, "MMMM yyyy")}`;
})
.join(", ");
}
export function formatDateRange(dates: string[]): string {
if (dates.length === 1) {
return formatDate(dates[0], "d. MMMM");