40 Commits

Author SHA1 Message Date
ponas 5d5f0879c2 set browserslist 2026-06-01 04:38:21 +02:00
ponas 046099b7f1 adjust image sizes somewhat 2026-06-01 04:30:14 +02:00
ponas d245f2f00a prioritize all three featured event images 2026-06-01 04:17:54 +02:00
ponas 8ad7df30d7 don't let typekit css block rendering 2026-06-01 04:09:41 +02:00
ponas dcb1a59777 don't animate menu unless interacted with 2026-06-01 03:40:43 +02:00
ponas e3a58556f7 don't generate a page for the 'hele-huset' venue 2026-06-01 03:21:58 +02:00
ponas 7b84b2d480 dnscms: better organizer chooser, fixes slugs for organizers, better slugs 2026-05-26 02:24:42 +02:00
ponas ec94d82863 dnscms: improve venue selection for event occurrences 2026-05-26 01:57:04 +02:00
ponas 089970a5cd dnscms: use wagtail pageviewsets everywhere 2026-05-26 01:41:39 +02:00
ponas 38229c97f0 dnscms: exclude non-live pages from search results, rank results across page types 2026-05-26 00:41:19 +02:00
ponas 09d1078dce dnscms: hide drafts from search results 2026-05-26 00:01:37 +02:00
ponas 1b5483602f web: highlight word matches, limit search results 2026-05-25 23:39:11 +02:00
ponas 2c8f8a218c web: more a11y for search 2026-05-25 23:25:51 +02:00
ponas b5c9188488 web: fix search debounce and client/server boundary 2026-05-25 23:16:52 +02:00
ponas 433c88c921 web: more details in search results 2026-05-25 22:56:24 +02:00
ponas af8c3fe768 web: simplify link generation for search 2026-05-25 22:37:19 +02:00
ponas a58e2b224e web: optimize event data detching on /arrangementer 2026-05-25 20:11:44 +02:00
ponas c9a2720d64 web: optimize news fetching 2026-05-25 19:57:00 +02:00
ponas 0b0fba174e dnscms: remove some wordpress import functionality, keep fields around but defer 2026-05-23 01:31:44 +02:00
ponas 10763f0b5d dnscms: add missing migration for help text changes 2026-05-23 01:28:22 +02:00
ponas f536cfc591 web: add lightspeed tools 2026-05-20 01:02:02 +02:00
ponas 80f7641e74 web: fix typo in media query for venue items 2026-05-20 01:01:35 +02:00
ponas 8b5caa2bea web: dynamically load swiper 2026-05-20 00:43:50 +02:00
ponas 5ac3c71ff0 web: disable hidden NeonChillPig 2026-05-20 00:39:54 +02:00
ponas a3d71b18da web: enable avif image format 2026-05-20 00:06:04 +02:00
ponas 7f95d8e252 web: set high fetch priority for top two featured events (above the fold on mobile) 2026-05-20 00:05:09 +02:00
ponas e4c0558527 web: fetch less data when just listing events and news articles on home page 2026-05-19 23:47:30 +02:00
ponas 80b9cdbc33 dnscms: prefetch and select related when quering future events 2026-05-19 23:46:13 +02:00
ponas 154338057d web: preconnect to typekit 2026-05-19 23:02:50 +02:00
ponas 337407c771 web: optimize favicon.ico and icon.png 2026-05-19 22:59:08 +02:00
ponas cb9b108526 web: don't start loading pig pattern until user starts to approach it 2026-05-19 22:50:33 +02:00
ponas 6d712d31be web: optimize delivery of pig pattern in footer 2026-05-19 22:09:25 +02:00
ponas 447e1bd3ff dnscms: improve news app 2026-05-19 22:00:11 +02:00
ponas 29c61ffc76 dnscms: improve associations app 2026-05-19 21:49:38 +02:00
ponas 4a264c589d web: add eager loading to featured event images on home page 2026-05-19 21:32:56 +02:00
ponas 9ca9f5db11 dnscms: translate events app 2026-05-19 21:25:28 +02:00
ponas 696e6b8f11 dnscms: move sample legacy events to separate file 2026-05-19 21:00:21 +02:00
ponas 5f354972d9 dnscms: start adding locales, set and translate verbose names for events 2026-05-19 20:55:53 +02:00
ponas 6a9fff8917 dnscms: improve events listing view 2026-05-19 20:35:46 +02:00
ponas 1073adacbb dnscms: bump wagtail 2026-05-19 19:47:46 +02:00
83 changed files with 5271 additions and 993 deletions
+1
View File
@@ -1,3 +1,4 @@
.vscode .vscode
.DS_Store .DS_Store
*.swp *.swp
scratch/
+57
View File
@@ -0,0 +1,57 @@
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, PageViewSet
from associations.models import AssociationPage
from dnscms.admin import ListingRedirectChooseParentView
class AssociationTypeColumn(Column):
def get_value(self, instance):
return instance.get_association_type_display()
class AssociationChooseParentView(ListingRedirectChooseParentView):
listing_url_name = "associations:index"
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(
"association_type",
label=_("Type"),
sort_key="association_type",
width="15%",
),
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 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()
@@ -0,0 +1,26 @@
# Generated by Django 6.0.5 on 2026-05-19 19:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('associations', '0025_associationpage_lead'),
]
operations = [
migrations.AlterModelOptions(
name='associationindex',
options={'verbose_name': 'association index', 'verbose_name_plural': 'association indexes'},
),
migrations.AlterModelOptions(
name='associationpage',
options={'verbose_name': 'association', 'verbose_name_plural': 'associations'},
),
migrations.AlterField(
model_name='associationpage',
name='association_type',
field=models.CharField(choices=[('forening', 'Association'), ('utvalg', 'Committee')], default='forening', max_length=64),
),
]
+26 -41
View File
@@ -1,4 +1,5 @@
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _
from grapple.helpers import register_singular_query_field from grapple.helpers import register_singular_query_field
from grapple.models import ( from grapple.models import (
GraphQLImage, GraphQLImage,
@@ -8,12 +9,16 @@ from grapple.models import (
) )
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField from wagtail.fields import RichTextField
from wagtail.models import Page from wagtail.models import Page, PageManager
from wagtail.search import index from wagtail.search import index
from wagtail_headless_preview.models import HeadlessMixin from wagtail_headless_preview.models import HeadlessMixin
from dnscms.fields import CommonStreamField from dnscms.fields import CommonStreamField
from dnscms.wordpress.models import WPImportedPageMixin from dnscms.wordpress.models import DeferWPFieldsManagerMixin, WPImportedPageMixin
class AssociationPageManager(DeferWPFieldsManagerMixin, PageManager):
pass
@register_singular_query_field("associationIndex") @register_singular_query_field("associationIndex")
@@ -26,8 +31,8 @@ class AssociationIndex(HeadlessMixin, Page):
body = CommonStreamField body = CommonStreamField
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel("lead", heading="Ingress"), FieldPanel("lead", heading=_("Lead")),
FieldPanel("body", heading="Innhold"), FieldPanel("body", heading=_("Content")),
] ]
graphql_fields = [ graphql_fields = [
@@ -37,15 +42,21 @@ class AssociationIndex(HeadlessMixin, Page):
search_fields = Page.search_fields search_fields = Page.search_fields
class Meta:
verbose_name = _("association index")
verbose_name_plural = _("association indexes")
class AssociationPage(HeadlessMixin, WPImportedPageMixin, Page): class AssociationPage(HeadlessMixin, WPImportedPageMixin, Page):
subpage_types = [] subpage_types = []
parent_page_types = ["associations.AssociationIndex"] parent_page_types = ["associations.AssociationIndex"]
show_in_menus = False show_in_menus = False
objects = AssociationPageManager()
class AssociationType(models.TextChoices): class AssociationType(models.TextChoices):
FORENING = "forening", "Forening" FORENING = "forening", _("Association")
UTVALG = "utvalg", "Utvalg" UTVALG = "utvalg", _("Committee")
excerpt = models.TextField(max_length=512, blank=False) excerpt = models.TextField(max_length=512, blank=False)
lead = RichTextField(features=["italic", "link"], blank=True) lead = RichTextField(features=["italic", "link"], blank=True)
@@ -65,14 +76,14 @@ class AssociationPage(HeadlessMixin, WPImportedPageMixin, Page):
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel( FieldPanel(
"excerpt", "excerpt",
heading="Utdrag", heading=_("Excerpt"),
help_text="En veldig kort oppsummering av innholdet nedenfor. Brukes i listevisninger.", help_text=_("A very short summary of the content below. Used in listing views."),
), ),
FieldPanel("lead", heading="Ingress"), FieldPanel("lead", heading=_("Lead")),
FieldPanel("body", heading="Innhold"), FieldPanel("body", heading=_("Content")),
FieldPanel("logo"), FieldPanel("logo"),
FieldPanel("association_type", heading="Type"), FieldPanel("association_type", heading=_("Type")),
FieldPanel("website_url", heading="Nettside"), FieldPanel("website_url", heading=_("Website")),
] ]
graphql_fields = [ graphql_fields = [
@@ -89,32 +100,6 @@ class AssociationPage(HeadlessMixin, WPImportedPageMixin, Page):
index.SearchField("body"), index.SearchField("body"),
] ]
def import_wordpress_data(self, data): class Meta:
import html verbose_name = _("association")
verbose_name_plural = _("associations")
# Wagtail page model fields
self.title = html.unescape(data["title"])
self.slug = data["slug"]
self.first_published_at = data["first_published_at"]
self.last_published_at = data["last_published_at"]
self.latest_revision_created_at = data["latest_revision_created_at"]
self.search_description = data["search_description"]
# debug fields
self.wp_post_id = data["wp_post_id"]
self.wp_post_type = data["wp_post_type"]
self.wp_link = data["wp_link"]
self.wp_raw_content = data["wp_raw_content"]
self.wp_block_json = data["wp_block_json"]
self.wp_processed_content = data["wp_processed_content"]
self.wp_normalized_styles = data["wp_normalized_styles"]
self.wp_post_meta = data["wp_post_meta"]
# own model fields
self.body = data["body"] or ""
meta = data["wp_post_meta"]
self.association_type = meta.get("neuf_associations_type").lower()
self.website_url = meta.get("neuf_associations_homepage") or ""
self.excerpt = meta.get("excerpt_encoded") or "TODO"
+4 -3
View File
@@ -1,12 +1,13 @@
from django.utils.translation import gettext_lazy as _
from wagtail.admin.viewsets.chooser import ChooserViewSet from wagtail.admin.viewsets.chooser import ChooserViewSet
class AssociationChooserViewSet(ChooserViewSet): class AssociationChooserViewSet(ChooserViewSet):
model = "associations.AssociationPage" model = "associations.AssociationPage"
icon = "group" icon = "group"
choose_one_text = "Choose an association" choose_one_text = _("Choose an association")
choose_another_text = "Choose another association" choose_another_text = _("Choose another association")
edit_item_text = "Edit this association" edit_item_text = _("Edit this association")
# form_fields = ["name"] # form_fields = ["name"]
+11
View File
@@ -1,8 +1,19 @@
from wagtail import hooks from wagtail import hooks
from .admin import association_sidebar_viewset, association_explorer_viewset
from .views import association_chooser_viewset from .views import association_chooser_viewset
@hooks.register("register_admin_viewset") @hooks.register("register_admin_viewset")
def register_viewset(): def register_viewset():
return association_chooser_viewset return association_chooser_viewset
@hooks.register("register_admin_viewset")
def register_association_sidebar_viewset():
return association_sidebar_viewset
@hooks.register("register_admin_viewset")
def register_association_explorer_viewset():
return association_explorer_viewset
+27
View File
@@ -0,0 +1,27 @@
from urllib.parse import urlencode
from django.urls import reverse
from wagtail.admin.views.pages.choose_parent import ChooseParentView
class ListingRedirectChooseParentView(ChooseParentView):
"""ChooseParentView that redirects new pages back to a listing viewset.
Subclasses set ``listing_url_name`` (e.g. ``"events:index"``).
"""
listing_url_name: str
def _with_next(self, response):
if response.status_code != 302:
return response
url = response["Location"]
sep = "&" if "?" in url else "?"
response["Location"] = f"{url}{sep}{urlencode({'next': reverse(self.listing_url_name)})}"
return response
def get(self, request, *args, **kwargs):
return self._with_next(super().get(request, *args, **kwargs))
def form_valid(self, form):
return self._with_next(super().form_valid(form))
+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
+6 -73
View File
@@ -12,7 +12,6 @@ https://docs.djangoproject.com/en/4.2/ref/settings/
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os import os
import re
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE_DIR = os.path.dirname(PROJECT_DIR) BASE_DIR = os.path.dirname(PROJECT_DIR)
@@ -144,6 +143,8 @@ USE_L10N = True
USE_TZ = True USE_TZ = True
LOCALE_PATHS = [os.path.join(BASE_DIR, "locale")]
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/ # https://docs.djangoproject.com/en/4.2/howto/static-files/
@@ -172,7 +173,10 @@ MEDIA_URL = "/media/"
# Wagtail settings # Wagtail settings
WAGTAIL_SITE_NAME = "dnscms" 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_IMAGE_MODEL = "images.CustomImage"
WAGTAILIMAGES_EXTENSIONS = ["avif", "gif", "jpg", "jpeg", "png", "webp", "svg"] WAGTAILIMAGES_EXTENSIONS = ["avif", "gif", "jpg", "jpeg", "png", "webp", "svg"]
@@ -226,74 +230,3 @@ GRAPPLE = {
"PAGE_SIZE": 100, "PAGE_SIZE": 100,
"MAX_PAGE_SIZE": 5000, "MAX_PAGE_SIZE": 5000,
} }
# Wgtail WordPress import
WAGTAIL_WORDPRESS_IMPORTER_SOURCE_DOMAIN = "https://studentersamfundet.no/"
WAGTAIL_WORDPRESS_IMPORTER_CONVERT_HTML_TAGS_TO_BLOCKS = {
# "h1": "wagtail_wordpress_import.block_builder_defaults.build_heading_block",
"table": "wagtail_wordpress_import.block_builder_defaults.build_table_block",
# "iframe": "wagtail_wordpress_import.block_builder_defaults.build_iframe_block",
# "form": "wagtail_wordpress_import.block_builder_defaults.build_form_block",
# "img": "wagtail_wordpress_import.block_builder_defaults.build_image_block",
# "blockquote": "wagtail_wordpress_import.block_builder_defaults.build_block_quote_block",
}
WAGTAIL_WORDPRESS_IMPORTER_FALLBACK_BLOCK = (
"dnscms.wordpress.block_builder.build_richtext_block_content"
)
WORDPRESS_IMPORT_HOOKS_ITEMS_TO_CACHE = {
"attachment": {
"DATA_TAG": "thumbnail_id",
"FUNCTION": "dnscms.wordpress.import_hooks.header_image_processor",
}
}
# WORDPRESS_IMPORT_HOOKS_TAGS_TO_CACHE = {
# "wp:term": {
# "DATA_TAG": "category",
# "FUNCTION": "dnscms.wordpress.import_hooks.categories_processor",
# }
# }
WAGTAIL_WORDPRESS_IMPORTER_PROMOTE_CHILD_TAGS = {
"TAGS_TO_PROMOTE": [],
"PARENTS_TO_REMOVE": ["p", "div", "span"],
}
WAGTAIL_WORDPRESS_IMPORT_PREFILTERS = [
{
"FUNCTION": "wagtail_wordpress_import.prefilters.linebreaks_wp",
},
{
"FUNCTION": "wagtail_wordpress_import.prefilters.transform_shortcodes",
},
{
"FUNCTION": "wagtail_wordpress_import.prefilters.transform_inline_styles",
"OPTIONS": {
"TRANSFORM_STYLES_MAPPING": [
(
re.compile(r"font-style:italic;font-weight:bold;", re.IGNORECASE),
"wagtail_wordpress_import.prefilters.transform_styles_defaults.transform_style_bold_italic",
),
(
re.compile(r"font-weight:bold;", re.IGNORECASE),
"wagtail_wordpress_import.prefilters.transform_styles_defaults.transform_style_bold",
),
(
re.compile(r"font-style:italic;", re.IGNORECASE),
"wagtail_wordpress_import.prefilters.transform_styles_defaults.transform_style_italic",
),
# (
# re.compile(
# r"text-align:center;",
# re.IGNORECASE,
# ),
# transform_style_center,
# ),
# (re.compile(r"text-align:left;", re.IGNORECASE), transform_style_left),
# (re.compile(r"text-align:right;", re.IGNORECASE), transform_style_right),
# (re.compile(r"float:left;", re.IGNORECASE), transform_float_left),
# (re.compile(r"float:right;", re.IGNORECASE), transform_float_right),
],
},
},
{
"FUNCTION": "wagtail_wordpress_import.prefilters.bleach_clean",
},
]
+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))
+32 -29
View File
@@ -1,13 +1,9 @@
from django.contrib.admin.utils import quote
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import format_html from django.utils.html import format_html
from grapple.registry import registry as grapple_registry
from wagtail import hooks from wagtail import hooks
from wagtail.admin.menu import MenuItem from wagtail.models import Page
from wagtail.search.backends import get_search_backend
from associations.models import AssociationIndex
from events.models import EventIndex
from news.models import NewsIndex
@hooks.register("register_rich_text_features") @hooks.register("register_rich_text_features")
@@ -15,31 +11,38 @@ def enable_additional_rich_text_features(features):
features.default_features.extend(["h5", "h6", "blockquote"]) features.default_features.extend(["h5", "h6", "blockquote"])
@hooks.register("register_admin_menu_item") @hooks.register("register_schema_query")
def register_events_menu_item(): def override_search_resolver(query_mixins):
page = EventIndex.objects.first() """
events_url = "#" Override Grapple's `search` resolver. Two fixes vs. the upstream version:
if page: 1. Restrict pages to live + public so drafts and access-restricted pages
events_url = reverse("wagtailadmin_explore", args=(quote(page.pk),)) don't leak via the public API.
return MenuItem("Arrangementer", events_url, icon_name="date", order=1) 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
@hooks.register("register_admin_menu_item") class SearchOverrideMixin:
def register_associations_menu_item(): def resolve_search(self, info, **kwargs):
page = AssociationIndex.objects.first() query = kwargs.get("query")
associations_url = "#" if not query:
if page: return None
associations_url = reverse("wagtailadmin_explore", args=(quote(page.pk),)) s = get_search_backend()
return MenuItem("Foreninger", associations_url, icon_name="group", order=2) 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("register_admin_menu_item")
def register_associations_menu_item():
page = NewsIndex.objects.first()
news_url = "#"
if page:
news_url = reverse("wagtailadmin_explore", args=(quote(page.pk),))
return MenuItem("Nyheter", news_url, icon_name="info-circle", order=3)
@hooks.register("construct_page_action_menu") @hooks.register("construct_page_action_menu")
-24
View File
@@ -1,24 +0,0 @@
from django.conf import settings
from wagtail_wordpress_import.block_builder_defaults import (
document_linker,
image_linker,
import_string,
)
def build_richtext_block_content(html, blocks):
"""
image_linker is called to link up and retrive the remote image
document_linker is called to link up and retrive the remote documents
filters are called to replace inline shortcodes
"""
html = image_linker(html)
html = document_linker(html)
for inline_shortcode_handler in getattr(
settings, "WAGTAIL_WORDPRESS_IMPORTER_INLINE_SHORTCODE_HANDLERS", []
):
function = import_string(inline_shortcode_handler).construct_html_tag
html = function(html)
blocks.append({"type": "paragraph", "value": html})
html = ""
return html
-51
View File
@@ -1,51 +0,0 @@
from wagtail_wordpress_import.block_builder_defaults import get_or_save_image
def header_image_processor(imported_pages, data_tag, items_cache):
"""
imported_pages:
Is a specific() page model queryset of all imported pages.
data_tag:
Is the value of the `DATA_TAG` key from the configuration above.
items_cache:
Is a list of dictionaries, one for each item in the XML file.
"""
# See note above about leading _ and : characters in the XML value
lookup = f"wp_post_meta__{data_tag}"
for attachment in items_cache:
# The id of the cached item used in the filter
thumbnail_id = attachment.get("wp:post_id")
# Filter the imported_pages for only pages that include the
# matching thumbnail_id in the wp_post_meta field
pages = imported_pages.filter(**{lookup: thumbnail_id})
if pages.exists():
# guid is the url of the image to fetch, the get_or_save_image()
# function will fetch the image if it doesn't exist
image_url = attachment.get("guid")
# fix cases where the /wp prefix is missing from the image url
if image_url.startswith("https://studentersamfundet.no/wp-content/uploads/"):
image_url = image_url.replace(
"https://studentersamfundet.no/wp-content/uploads/",
"https://studentersamfundet.no/wp/wp-content/uploads/",
)
try:
image = get_or_save_image(image_url)
except Exception as e:
print("Error with image", image_url, "associated with pages:", pages)
print(e)
continue
print("Attaching header images to pages:", pages)
try:
pages.update(featured_image=image)
except Exception:
pass
try:
pages.update(logo=image)
except Exception:
pass
+73 -6
View File
@@ -1,17 +1,84 @@
from django.core.exceptions import FieldDoesNotExist
from django.db import models from django.db import models
from wagtail.models import Page from wagtail.models import Page
# Field names declared by WPImportedPageMixin. Concrete models that mix it in
# get a manager that .defer()s these so they're never loaded by default — the
# columns stay in the database (no migration), and any code path that
# explicitly reads them still works via Django's lazy-load.
WP_IMPORT_FIELDS = (
"wp_post_id",
"wp_post_type",
"wp_link",
"wp_raw_content",
"wp_processed_content",
"wp_block_json",
"wp_normalized_styles",
"wp_post_meta",
)
# https://github.com/wagtail/wagtail-wordpress-import/blob/main/wagtail_wordpress_import/models.py # https://github.com/wagtail/wagtail-wordpress-import/blob/main/wagtail_wordpress_import/models.py
# DJ001 (null=True on string fields) is suppressed: the schema mirrors the
# upstream mixin and changing nullability would force a migration we avoid.
class WPImportedPageMixin(Page): class WPImportedPageMixin(Page):
wp_post_id = models.IntegerField(blank=True, null=True) wp_post_id = models.IntegerField(blank=True, null=True)
wp_post_type = models.CharField(max_length=255, blank=True, null=True) wp_post_type = models.CharField(max_length=255, blank=True, null=True) # noqa: DJ001
wp_link = models.TextField(blank=True, null=True) wp_link = models.TextField(blank=True, null=True) # noqa: DJ001
wp_raw_content = models.TextField(blank=True, null=True) wp_raw_content = models.TextField(blank=True, null=True) # noqa: DJ001
wp_processed_content = models.TextField(blank=True, null=True) wp_processed_content = models.TextField(blank=True, null=True) # noqa: DJ001
wp_block_json = models.TextField(blank=True, null=True) wp_block_json = models.TextField(blank=True, null=True) # noqa: DJ001
wp_normalized_styles = models.TextField(blank=True, null=True) wp_normalized_styles = models.TextField(blank=True, null=True) # noqa: DJ001
wp_post_meta = models.JSONField(blank=True, null=True) wp_post_meta = models.JSONField(blank=True, null=True)
class Meta: class Meta:
abstract = True abstract = True
class DeferWPFieldsManagerMixin:
"""
Manager mixin that always .defer()s the wp_* import columns, so they are
never SELECTed by default queries. The columns remain in the database;
accessing one on an instance still works (Django lazy-loads it).
"""
def get_queryset(self):
return super().get_queryset().defer(*WP_IMPORT_FIELDS)
def _resolve_related_model(model, lookup_path):
"""Walk a ``foo__bar`` lookup path and return the final related model, or None."""
current = model
for part in lookup_path.split("__"):
try:
field = current._meta.get_field(part)
except FieldDoesNotExist:
return None
current = getattr(field, "related_model", None)
if current is None:
return None
return current
class WPAwareQuerySet(models.QuerySet):
"""
QuerySet whose ``select_related()`` auto-defers wp_* columns when a join
targets a WPImportedPageMixin model. Without this, ``select_related``
builds a JOIN that ignores the related model's manager and SELECTs every
column including the wp_* blobs. Apply via ``objects = WPAwareManager()``
on any model that has a ForeignKey into a WPImported page.
"""
def select_related(self, *fields):
qs = super().select_related(*fields)
defers = [
f"{path}__{name}"
for path in fields
if (related := _resolve_related_model(qs.model, path)) is not None
and issubclass(related, WPImportedPageMixin)
for name in WP_IMPORT_FIELDS
]
return qs.defer(*defers) if defers else qs
WPAwareManager = models.Manager.from_queryset(WPAwareQuerySet)
+95
View File
@@ -0,0 +1,95 @@
from django.utils import timezone
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 ExplorableIndexView, IndexView
from wagtail.admin.viewsets.pages import PageListingViewSet, PageViewSet
from dnscms.admin import ListingRedirectChooseParentView
from events.models import EventPage
class EventDateColumn(Column):
def get_value(self, instance):
occurrences = list(instance.occurrences.order_by("start"))
if not occurrences:
return ""
if len(occurrences) == 1:
local = timezone.localtime(occurrences[0].start)
return local.strftime(gettext("%Y-%m-%d at %H:%M"))
count = len(occurrences)
return ngettext("%(count)d occurrence", "%(count)d occurrences", count) % {"count": count}
class OrganizersColumn(Column):
def get_value(self, instance):
names = list(instance.organizers.values_list("name", flat=True))
if not names:
return ""
if len(names) == 1:
return names[0]
return f"{names[0]} (+{len(names) - 1})"
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(
"occurrences",
"organizer_links__organizer",
)
class EventPageIndexView(EventPagePrefetchMixin, IndexView):
pass
class EventPageExplorableIndexView(EventPagePrefetchMixin, ExplorableIndexView):
pass
class EventChooseParentView(ListingRedirectChooseParentView):
listing_url_name = "events:index"
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%"),
OrganizersColumn("organizers", label=_("Organizers"), width="12%"),
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 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()
@@ -0,0 +1,17 @@
# Generated by Django 6.0.5 on 2026-05-19 18:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('events', '0053_eventpage_lead'),
]
operations = [
migrations.AlterModelOptions(
name='eventpage',
options={'verbose_name': 'event', 'verbose_name_plural': 'events'},
),
]
@@ -0,0 +1,82 @@
# Generated by Django 6.0.5 on 2026-05-22 23:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('associations', '0026_alter_association_options'),
('events', '0054_alter_eventpage_options'),
('images', '0005_customimage_description'),
]
operations = [
migrations.AlterModelOptions(
name='eventcategory',
options={'ordering': ['name'], 'verbose_name': 'event category', 'verbose_name_plural': 'event categories'},
),
migrations.AlterModelOptions(
name='eventoccurrence',
options={'verbose_name': 'occurrence', 'verbose_name_plural': 'occurrences'},
),
migrations.AlterModelOptions(
name='eventorganizer',
options={'ordering': ['name'], 'verbose_name': 'event organizer', 'verbose_name_plural': 'event organizers'},
),
migrations.AlterModelOptions(
name='eventorganizerlink',
options={'verbose_name': 'organizer', 'verbose_name_plural': 'organizers'},
),
migrations.AlterField(
model_name='eventcategory',
name='pig',
field=models.CharField(blank=True, choices=[('', 'None'), ('logo', 'Logogrisen'), ('music', 'Musikergrisen'), ('drink', 'Drikkegrisen'), ('dance', 'Dansegrisen'), ('point', 'Pekegrisen'), ('student', 'Studentgrisen'), ('listen', 'Lyttegrisen'), ('guard', 'Vaktgrisen'), ('key', 'Nøkkelgrisen'), ('chill', 'Liggegrisen'), ('peek', 'Tittegrisen')], default='', help_text='Default pig for events of this kind.', max_length=32),
),
migrations.AlterField(
model_name='eventcategory',
name='show_in_filters',
field=models.BooleanField(default=False, help_text='Should this category be available as a filter in the event programme?'),
),
migrations.AlterField(
model_name='eventoccurrence',
name='venue_custom',
field=models.CharField(blank=True, help_text='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>.', max_length=128),
),
migrations.AlterField(
model_name='eventorganizer',
name='association',
field=models.ForeignKey(blank=True, help_text='If a DNS association or committee is behind it, choose it here.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='organizers', to='associations.associationpage'),
),
migrations.AlterField(
model_name='eventorganizer',
name='external_url',
field=models.URLField(blank=True, help_text="Link to the external organizer's website", max_length=512),
),
migrations.AlterField(
model_name='eventpage',
name='facebook_url',
field=models.URLField(blank=True, help_text='Direct link to the event on Facebook', max_length=1024),
),
migrations.AlterField(
model_name='eventpage',
name='featured_image',
field=models.ForeignKey(blank=True, help_text="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 cover uncritically!", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.customimage'),
),
migrations.AlterField(
model_name='eventpage',
name='pig',
field=models.CharField(blank=True, choices=[('', 'None'), ('automatic', 'Automatic'), ('logo', 'Logogrisen'), ('music', 'Musikergrisen'), ('drink', 'Drikkegrisen'), ('dance', 'Dansegrisen'), ('point', 'Pekegrisen'), ('student', 'Studentgrisen'), ('listen', 'Lyttegrisen'), ('guard', 'Vaktgrisen'), ('key', 'Nøkkelgrisen'), ('chill', 'Liggegrisen'), ('peek', 'Tittegrisen')], default='automatic', help_text="The pig that hangs out on the event page. Automatic causes one to be chosen based on the event's category.", max_length=32),
),
migrations.AlterField(
model_name='eventpage',
name='subtitle',
field=models.CharField(blank=True, help_text='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.', max_length=128),
),
migrations.AlterField(
model_name='eventpage',
name='ticket_url',
field=models.URLField(blank=True, help_text='Direct link to ticket purchase, e.g. TicketCo, Billetto or Ticketmaster', max_length=1024),
),
]
+121 -311
View File
@@ -1,7 +1,7 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Min, Q, UniqueConstraint from django.db.models import Min, Prefetch, Q, UniqueConstraint
from django.utils import timezone from django.utils import timezone
from django.utils.html import mark_safe from django.utils.html import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -35,7 +35,11 @@ from wagtail_headless_preview.models import HeadlessMixin
from associations.widgets import AssociationChooserWidget from associations.widgets import AssociationChooserWidget
from dnscms.fields import CommonStreamField from dnscms.fields import CommonStreamField
from dnscms.options import ALL_PIGS from dnscms.options import ALL_PIGS
from dnscms.wordpress.models import WPImportedPageMixin from dnscms.wordpress.models import (
DeferWPFieldsManagerMixin,
WPAwareManager,
WPImportedPageMixin,
)
from venues.models import VenuePage from venues.models import VenuePage
@@ -45,7 +49,23 @@ class EventIndex(HeadlessMixin, Page):
subpage_types = ["events.EventPage"] subpage_types = ["events.EventPage"]
def future_events(self, info, **kwargs): def future_events(self, info, **kwargs):
return EventPage.objects.live().future().order_by("next_occurrence") return (
EventPage.objects.live()
.future()
.order_by("next_occurrence")
.select_related("featured_image")
.prefetch_related(
Prefetch(
"occurrences",
queryset=EventOccurrence.objects.select_related("venue"),
),
"categories",
Prefetch(
"organizers",
queryset=EventOrganizer.objects.select_related("association"),
),
)
)
graphql_fields = [ graphql_fields = [
GraphQLCollection( GraphQLCollection(
@@ -75,11 +95,12 @@ class EventCategory(models.Model):
help_text=_("The name of the category as it will appear in URLs."), help_text=_("The name of the category as it will appear in URLs."),
) )
show_in_filters = models.BooleanField( show_in_filters = models.BooleanField(
default=False, help_text="Skal denne kategorien være mulig å filtrere på i programmet?" default=False,
help_text=_("Should this category be available as a filter in the event programme?"),
) )
PIG_CHOICES = [ PIG_CHOICES = [
("", "Ingen"), ("", _("None")),
] + ALL_PIGS ] + ALL_PIGS
pig = models.CharField( pig = models.CharField(
@@ -87,14 +108,14 @@ class EventCategory(models.Model):
choices=PIG_CHOICES, choices=PIG_CHOICES,
default="", default="",
blank=True, blank=True,
help_text="Standardgris for arrangementer av denne typen.", help_text=_("Default pig for events of this kind."),
) )
panels = [ panels = [
TitleFieldPanel("name"), TitleFieldPanel("name"),
FieldPanel("slug"), FieldPanel("slug"),
FieldPanel("show_in_filters"), FieldPanel("show_in_filters"),
FieldPanel("pig", heading="Gris"), FieldPanel("pig", heading=_("Pig")),
] ]
graphql_fields = [ graphql_fields = [
@@ -105,8 +126,8 @@ class EventCategory(models.Model):
] ]
class Meta: class Meta:
verbose_name = "Event category" verbose_name = _("event category")
verbose_name_plural = "Event categories" verbose_name_plural = _("event categories")
ordering = ["name"] ordering = ["name"]
def __str__(self): def __str__(self):
@@ -134,8 +155,8 @@ class EventOrganizerLink(Orderable):
return f"{self.organizer.name}" return f"{self.organizer.name}"
class Meta: class Meta:
verbose_name = "Arrangør" verbose_name = _("organizer")
verbose_name_plural = "Arrangører" verbose_name_plural = _("organizers")
constraints = [ constraints = [
UniqueConstraint( UniqueConstraint(
"event", "organizer", name="event_organizer_link_event_organizer_unique" "event", "organizer", name="event_organizer_link_event_organizer_unique"
@@ -145,7 +166,9 @@ class EventOrganizerLink(Orderable):
@register_snippet @register_snippet
@register_query_field("eventOrganizer", "eventOrganizers") @register_query_field("eventOrganizer", "eventOrganizers")
class EventOrganizer(ClusterableModel): class EventOrganizer(index.Indexed, ClusterableModel):
objects = WPAwareManager()
name = models.CharField( name = models.CharField(
max_length=100, max_length=100,
null=False, null=False,
@@ -163,13 +186,13 @@ class EventOrganizer(ClusterableModel):
blank=True, blank=True,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="organizers", related_name="organizers",
help_text="Om en samfundsforening eller et utvalg står bak, velg det her.", help_text=_("If a DNS association or committee is behind it, choose it here."),
) )
external_url = models.URLField( external_url = models.URLField(
blank=True, blank=True,
max_length=512, max_length=512,
help_text="Lenke til nettstedet til ekstern arrangør", help_text=_("Link to the external organizer's website"),
) )
panels = [ panels = [
@@ -178,15 +201,15 @@ class EventOrganizer(ClusterableModel):
FieldPanel( FieldPanel(
"association", "association",
widget=AssociationChooserWidget(linked_fields={"association": "#id_association"}), widget=AssociationChooserWidget(linked_fields={"association": "#id_association"}),
heading="Intern arrangør", heading=_("Internal organizer"),
), ),
MultiFieldPanel( MultiFieldPanel(
heading="Ekstern arrangør", heading=_("External organizer"),
children=[ children=[
FieldPanel( FieldPanel(
"external_url", "external_url",
heading="Nettsted", heading=_("Website"),
help_text="La denne stå tom om arrangøren finnes i lista over.", help_text=_("Leave this empty if the organizer exists in the list above."),
), ),
], ],
), ),
@@ -199,9 +222,14 @@ class EventOrganizer(ClusterableModel):
GraphQLString("external_url"), GraphQLString("external_url"),
] ]
search_fields = [
index.SearchField("name"),
index.AutocompleteField("name"),
]
class Meta: class Meta:
verbose_name = "Event organizer" verbose_name = _("event organizer")
verbose_name_plural = "Event organizers" verbose_name_plural = _("event organizers")
ordering = ["name"] ordering = ["name"]
def __str__(self): def __str__(self):
@@ -218,7 +246,11 @@ class EventPageQuerySet(PageQuerySet):
) )
EventPageManager = PageManager.from_queryset(EventPageQuerySet) class EventPageManager(
DeferWPFieldsManagerMixin,
PageManager.from_queryset(EventPageQuerySet),
):
pass
class EventPage(HeadlessMixin, WPImportedPageMixin, Page): class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
@@ -234,19 +266,19 @@ class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="+", related_name="+",
help_text=( help_text=_(
"Velg et bilde til bruk i programmet og andre visningsflater. " "Choose an image for use in the programme and other surfaces. "
"Bør være et bilde eller en illustrasjon uten tekst " "Should be a photo or an illustration without too much text "
" ikke gjenbruk et Facebook-cover ukritisk!" " don't reuse a Facebook cover uncritically!"
), ),
) )
subtitle = models.CharField( subtitle = models.CharField(
blank=True, blank=True,
max_length=128, max_length=128,
help_text=( help_text=_(
"En kort tekst som kommer rett under under tittelen. " "A short text that appears right below the title. "
"La denne gjerne stå tom om du fikk plass til det meste i tittelen." "Feel free to leave it empty if you fit most of it in the main title."
), ),
) )
lead = RichTextField(features=["italic", "link"], blank=True) lead = RichTextField(features=["italic", "link"], blank=True)
@@ -262,8 +294,8 @@ class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
) )
PIG_CHOICES = [ PIG_CHOICES = [
("", "Ingen"), ("", _("None")),
("automatic", "Automatisk"), ("automatic", _("Automatic")),
] + ALL_PIGS ] + ALL_PIGS
pig = models.CharField( pig = models.CharField(
@@ -271,21 +303,21 @@ class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
choices=PIG_CHOICES, choices=PIG_CHOICES,
default="automatic", default="automatic",
blank=True, blank=True,
help_text=( help_text=_(
"Grisen som henger på arrangementssiden. " "The pig that hangs out on the event page. "
"Automatisk fører til at en velges basert på arrangementets kategori." "Automatic causes one to be chosen based on the event's category."
), ),
) )
ticket_url = models.URLField( ticket_url = models.URLField(
blank=True, blank=True,
max_length=1024, max_length=1024,
help_text="Lenke direkte til billettkjøp, f.eks. TicketCo eller Ticketmaster", help_text=_("Direct link to ticket purchase, e.g. TicketCo, Billetto or Ticketmaster"),
) )
facebook_url = models.URLField( facebook_url = models.URLField(
blank=True, blank=True,
max_length=1024, max_length=1024,
help_text="Lenke direkte til arrangementet på Facebook", help_text=_("Direct link to the event on Facebook"),
) )
free = models.BooleanField(null=False, default=False) free = models.BooleanField(null=False, default=False)
@@ -294,62 +326,65 @@ class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
price_member = models.CharField(max_length=32, blank=True) price_member = models.CharField(max_length=32, blank=True)
ticket_panels = [ ticket_panels = [
FieldPanel("free", heading="Gratis", help_text="Er dette arrangementet gratis for alle?"), FieldPanel("free", heading=_("Free"), help_text=_("Is this event free for everyone?")),
MultiFieldPanel( MultiFieldPanel(
children=[ children=[
FieldRowPanel( FieldRowPanel(
children=[ children=[
FieldPanel("price_regular", heading="Ordinær pris"), FieldPanel("price_regular", heading=_("Regular price")),
FieldPanel("price_student", heading="Pris for studenter"), FieldPanel("price_student", heading=_("Price for students")),
FieldPanel("price_member", heading="Pris for medlemmer av DNS"), FieldPanel("price_member", heading=_("Price for DNS members")),
], ],
help_text="", help_text="",
), ),
HelpPanel( HelpPanel(
content=mark_safe( content=mark_safe(
"Skriv <strong>0</strong> om gratis. Tomt felt skjuler priskategorien. Om mulig, skriv kun tall." _(
"Write <strong>0</strong> for free. "
"An empty field hides the price category. "
"If possible, write digits only."
)
) )
), ),
], ],
attrs={"id": "specific_pricing_panel"}, attrs={"id": "specific_pricing_panel"},
), ),
FieldPanel("ticket_url", heading="Billettkjøpslenke"), FieldPanel("ticket_url", heading=_("Ticket purchase link")),
] ]
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel("subtitle", heading="Undertittel"), FieldPanel("subtitle", heading=_("Subtitle")),
FieldPanel("featured_image"), FieldPanel("featured_image"),
FieldPanel("lead", heading="Ingress"), FieldPanel("lead", heading=_("Lead")),
FieldPanel("body"), FieldPanel("body"),
FieldPanel("categories", widget=forms.CheckboxSelectMultiple), FieldPanel("categories", widget=forms.CheckboxSelectMultiple),
MultiFieldPanel( MultiFieldPanel(
heading="Arrangører", heading=_("Organizers"),
children=[ children=[
HelpPanel( HelpPanel(
content=("Hvem står bak arrangementet?"), content=_("Who is behind the event?"),
), ),
MultipleChooserPanel( MultipleChooserPanel(
"organizer_links", chooser_field_name="organizer", label="Arrangør" "organizer_links", chooser_field_name="organizer", label=_("Organizer")
), ),
], ],
), ),
FieldPanel("pig", heading="Gris"), FieldPanel("pig", heading=_("Pig")),
FieldPanel( FieldPanel(
"facebook_url", "facebook_url",
heading="Facebook-lenke", heading=_("Facebook link"),
help_text="Lenke direkte til arrangementet på Facebook.", help_text=_("Direct link to the event on Facebook."),
), ),
MultiFieldPanel(heading="Priser og billettkjøp", children=ticket_panels), MultiFieldPanel(heading=_("Pricing and tickets"), children=ticket_panels),
MultiFieldPanel( MultiFieldPanel(
heading="Dato, tid og lokale", heading=_("Date, time and venue"),
children=[ children=[
HelpPanel( HelpPanel(
content=( content=_(
"Om arrangementet går over flere dager, " "If the event spans several days, add each day as a separate occurrence."
"legg inn hver dag som en egen forekomst."
), ),
), ),
InlinePanel("occurrences", min_num=1, label="Forekomst"), InlinePanel("occurrences", min_num=1, label=_("Occurrence")),
], ],
), ),
] ]
@@ -391,6 +426,10 @@ class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
search_fields = Page.search_fields + [index.SearchField("body")] search_fields = Page.search_fields + [index.SearchField("body")]
class Meta:
verbose_name = _("event")
verbose_name_plural = _("events")
def clean(self): def clean(self):
super().clean() super().clean()
@@ -410,130 +449,10 @@ class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
self.price_student = "" self.price_student = ""
self.price_member = "" self.price_member = ""
def import_wordpress_data(self, data):
import datetime
import html
from zoneinfo import ZoneInfo
from django.core.validators import URLValidator
validate_url = URLValidator(schemes=["http", "https"])
def fix_url(url):
if not url:
return None
url = url.strip()
try:
validate_url(url)
except Exception:
print(f"Bogus URL for {self.wp_post_id}: {url}")
return None
return url
# Wagtail page model fields
self.title = html.unescape(data["title"])
self.slug = data["slug"]
self.first_published_at = data["first_published_at"]
self.last_published_at = data["last_published_at"]
self.latest_revision_created_at = data["latest_revision_created_at"]
self.search_description = data["search_description"]
# debug fields
self.wp_post_id = data["wp_post_id"]
self.wp_post_type = data["wp_post_type"]
self.wp_link = data["wp_link"]
self.wp_raw_content = data["wp_raw_content"]
self.wp_block_json = data["wp_block_json"]
self.wp_processed_content = data["wp_processed_content"]
self.wp_normalized_styles = data["wp_normalized_styles"]
self.wp_post_meta = data["wp_post_meta"]
# own model fields
self.body = data["body"] or ""
# categories (organizers and event types)
wp_categories = data["wp_categories"]
# organizers
organizer_cats = [x for x in wp_categories if x["domain"] == "event_organizer"]
organizers = []
for x in organizer_cats:
try:
organizer = EventOrganizer.objects.get(slug=x["nicename"])
except EventOrganizer.DoesNotExist:
organizer = EventOrganizer.objects.create(name=x["name"], slug=x["nicename"])
organizers.append(organizer)
self.organizer_links.set(
[EventOrganizerLink(event=self, organizer=organizer) for organizer in organizers]
)
## event types
# type_cats = [x for x in wp_categories if x["domain"] == "event_type"]
# event_categories = []
# for x in type_cats:
# try:
# event_category = EventCategory.objects.get(slug=x["nicename"])
# except EventCategory.DoesNotExist:
# event_category = EventCategory.objects.create(
# name=x["name"], slug=x["nicename"], show_in_filters=False
# )
# event_categories.append(event_category)
# self.categories.set(event_categories)
meta = data["wp_post_meta"]
start_ts = meta.get("neuf_events_starttime") or 1337
end_ts = meta.get("neuf_events_endtime")
tz = ZoneInfo("Europe/Oslo")
start = start_ts and datetime.datetime.fromtimestamp(start_ts, datetime.UTC).replace(
tzinfo=tz
)
end = end_ts and datetime.datetime.fromtimestamp(end_ts, datetime.UTC).replace(tzinfo=tz)
venue_id = meta.get("neuf_events_venue_id")
venue_custom = meta.get("neuf_events_venue")
venue = None
if venue_id:
venue = VenuePage.objects.get(wp_post_id=venue_id)
venue_custom = ""
else:
venue_custom = venue_custom or ""
occurrence = EventOccurrence(
event=self, start=start, end=end, venue=venue, venue_custom=venue_custom
)
self.occurrences.set([occurrence])
self.ticket_url = fix_url(meta.get("neuf_events_bs_url")) or ""
self.facebook_url = fix_url(meta.get("neuf_events_fb_url")) or ""
def parse_price(price):
if price is None:
return ""
if type(price) is int:
return price
p = price.strip()
if p == "":
return ""
try:
return int(p)
except ValueError:
pass
free = ["gratis", "free", "gratis/free", "free/gratis"]
if p.lower() in free:
return 0
return price
price_regular = parse_price(meta.get("neuf_events_price_regular"))
price_member = parse_price(meta.get("neuf_events_price_member"))
if not price_regular and not price_member:
self.free = True
else:
self.price_regular = parse_price(meta.get("neuf_events_price_regular"))
self.price_member = parse_price(meta.get("neuf_events_price_member"))
class EventOccurrence(Orderable): class EventOccurrence(Orderable):
objects = WPAwareManager()
event = ParentalKey(EventPage, on_delete=models.CASCADE, related_name="occurrences") event = ParentalKey(EventPage, on_delete=models.CASCADE, related_name="occurrences")
start = models.DateTimeField() start = models.DateTimeField()
end = models.DateTimeField(null=True, blank=True) end = models.DateTimeField(null=True, blank=True)
@@ -548,22 +467,24 @@ class EventOccurrence(Orderable):
blank=True, blank=True,
max_length=128, max_length=128,
help_text=mark_safe( help_text=mark_safe(
"Bruk denne <em>om ingen av lokalene som kan velges til venstre</em> passer. " _(
"F.eks. <em>Frederikkeplassen</em> eller <em>Sirkusteltet</em>." "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>."
)
), ),
) )
panels = [ panels = [
FieldRowPanel( FieldRowPanel(
children=[ children=[
FieldPanel("start", heading="Start"), FieldPanel("start", heading=_("Start")),
FieldPanel("end", heading="Slutt"), FieldPanel("end", heading=_("End")),
], ],
), ),
FieldRowPanel( FieldRowPanel(
children=[ children=[
FieldPanel("venue", heading="Lokale"), FieldPanel("venue", heading=_("Venue"), widget=forms.Select),
FieldPanel("venue_custom", heading="Lokale som fritekst"), FieldPanel("venue_custom", heading=_("Venue as free text")),
], ],
), ),
] ]
@@ -576,140 +497,29 @@ class EventOccurrence(Orderable):
] ]
def clean(self): 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: if self.venue and self.venue_custom:
raise ValidationError( raise ValidationError(
{"venue_custom": "Du kan ikke både velge et lokale og skrive noe i dette feltet."} {
"venue_custom": _(
"You can't both pick a venue and write something in this field."
)
}
) )
if not self.venue and not self.venue_custom: if not self.venue and not self.venue_custom:
raise ValidationError({"venue": "Lokale er påkrevd."}) raise ValidationError({"venue": _("Venue is required.")})
def __str__(self): def __str__(self):
return f"{self.start}--{self.end}" return f"{self.start}--{self.end}"
class Meta: class Meta:
verbose_name = "Forekomst" verbose_name = _("occurrence")
verbose_name_plural = "Forekomster" verbose_name_plural = _("occurrences")
sample_legacy_event_json = """
{
"id": 64573,
"date": "2023-12-27T11:28:34",
"date_gmt": "2023-12-27T10:28:34",
"guid": {
"rendered": "https://studentersamfundet.no/?post_type=event&#038;p=64573"
},
"modified": "2023-12-27T11:44:11",
"modified_gmt": "2023-12-27T10:44:11",
"slug": "quiz-147-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2",
"status": "publish",
"type": "event",
"link": "https://studentersamfundet.no/arrangement/quiz-147-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2/",
"title": {
"rendered": "QUIZ",
"decoded": "QUIZ"
},
"content": {
"rendered": "\n<p>Det Norske Studentersamfund inviterer til quiz hver tirsdag kl. 19:00.</p>\n\n\n\n<p>Vi serverer 50 spørsmål som kan spenne seg fra one hit wonders fra 80-tallet, universets uendelighet, dyrelivets merkverdigheter og mye, mye mer!</p>\n\n\n\n<p>Quiz på Chateau Neuf er åpent for alle. Vinnere og &#8220;lucky losers&#8221; vil bli utnevnt hver kveld. Lag som er over seks personer er tillatt, men da trekkes dere for ett poeng per deltaker per runde.</p>\n\n\n\n<p>For de som ønsker å være med på sammenlagtkonkurransen for høsten vil den regnes ut for de tolv beste prestasjonene laget leverer. Så det vil fremdeles være god sjanse for å vinne sammenlagt selv dere må droppe en quiz eller to for eksamener eller andre forpliktelser.</p>\n\n\n\n<p>Velkommen quizglade mennesker!</p>\n\n\n\n<p>Gratis inngang!</p>\n",
"protected": false
},
"excerpt": {
"rendered": "<p>Det Norske Studentersamfund inviterer til quiz hver tirsdag kl. 19:00. Vi serverer 50 spørsmål som kan spenne seg fra one hit wonders fra 80-tallet, universets uendelighet, dyrelivets merkverdigheter og mye, mye mer! Quiz på Chateau Neuf er åpent for alle. Vinnere og &#8220;lucky losers&#8221; vil bli utnevnt hver kveld. Lag som er over seks personer [&hellip;]</p>\n",
"protected": false
},
"author": 2150,
"featured_media": 64585,
"template": "",
"meta": [],
"event_types": [13],
"event_organizers": [390, 322],
"facebook_url": "https://fb.me/e/2RDR5pZdr",
"ticket_url": "",
"price_regular": "",
"price_member": "",
"start_time": "2024-05-07T17:00:00+00:00",
"end_time": "2024-05-07T20:00:00+00:00",
"venue": "Glassbaren",
"venue_id": "55063",
"thumbnail": {
"thumbnail": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-150x150.png",
"medium": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-300x169.png",
"medium_large": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-768x433.png",
"large": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1280x720.png",
"1536x1536": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1536x865.png",
"2048x2048": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1.png",
"four-column": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-393x342.png",
"six-column": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-608x342.png",
"extra-large": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1600x901.png",
"newsletter-half": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-320x190.png",
"newsletter-third": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-213x126.png",
"featured": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1200x480.png"
},
"_links": {
"self": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/events/64573"
}
],
"collection": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/events"
}
],
"about": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/types/event"
}
],
"author": [
{
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/users/2150"
}
],
"version-history": [
{
"count": 1,
"href": "https://studentersamfundet.no/wp-json/wp/v2/events/64573/revisions"
}
],
"predecessor-version": [
{
"id": 64574,
"href": "https://studentersamfundet.no/wp-json/wp/v2/events/64573/revisions/64574"
}
],
"wp:featuredmedia": [
{
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/media/64585"
}
],
"wp:attachment": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/media?parent=64573"
}
],
"wp:term": [
{
"taxonomy": "event_type",
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/event_types?post=64573"
},
{
"taxonomy": "event_organizer",
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/event_organizers?post=64573"
}
],
"curies": [
{
"name": "wp",
"href": "https://api.w.org/{rel}",
"templated": true
}
]
}
}
"""
+119
View File
@@ -0,0 +1,119 @@
{
"id": 64573,
"date": "2023-12-27T11:28:34",
"date_gmt": "2023-12-27T10:28:34",
"guid": {
"rendered": "https://studentersamfundet.no/?post_type=event&#038;p=64573"
},
"modified": "2023-12-27T11:44:11",
"modified_gmt": "2023-12-27T10:44:11",
"slug": "quiz-147-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2",
"status": "publish",
"type": "event",
"link": "https://studentersamfundet.no/arrangement/quiz-147-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2/",
"title": {
"rendered": "QUIZ",
"decoded": "QUIZ"
},
"content": {
"rendered": "\n<p>Det Norske Studentersamfund inviterer til quiz hver tirsdag kl. 19:00.</p>\n\n\n\n<p>Vi serverer 50 spørsmål som kan spenne seg fra one hit wonders fra 80-tallet, universets uendelighet, dyrelivets merkverdigheter og mye, mye mer!</p>\n\n\n\n<p>Quiz på Chateau Neuf er åpent for alle. Vinnere og &#8220;lucky losers&#8221; vil bli utnevnt hver kveld. Lag som er over seks personer er tillatt, men da trekkes dere for ett poeng per deltaker per runde.</p>\n\n\n\n<p>For de som ønsker å være med på sammenlagtkonkurransen for høsten vil den regnes ut for de tolv beste prestasjonene laget leverer. Så det vil fremdeles være god sjanse for å vinne sammenlagt selv dere må droppe en quiz eller to for eksamener eller andre forpliktelser.</p>\n\n\n\n<p>Velkommen quizglade mennesker!</p>\n\n\n\n<p>Gratis inngang!</p>\n",
"protected": false
},
"excerpt": {
"rendered": "<p>Det Norske Studentersamfund inviterer til quiz hver tirsdag kl. 19:00. Vi serverer 50 spørsmål som kan spenne seg fra one hit wonders fra 80-tallet, universets uendelighet, dyrelivets merkverdigheter og mye, mye mer! Quiz på Chateau Neuf er åpent for alle. Vinnere og &#8220;lucky losers&#8221; vil bli utnevnt hver kveld. Lag som er over seks personer [&hellip;]</p>\n",
"protected": false
},
"author": 2150,
"featured_media": 64585,
"template": "",
"meta": [],
"event_types": [13],
"event_organizers": [390, 322],
"facebook_url": "https://fb.me/e/2RDR5pZdr",
"ticket_url": "",
"price_regular": "",
"price_member": "",
"start_time": "2024-05-07T17:00:00+00:00",
"end_time": "2024-05-07T20:00:00+00:00",
"venue": "Glassbaren",
"venue_id": "55063",
"thumbnail": {
"thumbnail": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-150x150.png",
"medium": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-300x169.png",
"medium_large": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-768x433.png",
"large": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1280x720.png",
"1536x1536": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1536x865.png",
"2048x2048": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1.png",
"four-column": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-393x342.png",
"six-column": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-608x342.png",
"extra-large": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1600x901.png",
"newsletter-half": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-320x190.png",
"newsletter-third": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-213x126.png",
"featured": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1200x480.png"
},
"_links": {
"self": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/events/64573"
}
],
"collection": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/events"
}
],
"about": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/types/event"
}
],
"author": [
{
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/users/2150"
}
],
"version-history": [
{
"count": 1,
"href": "https://studentersamfundet.no/wp-json/wp/v2/events/64573/revisions"
}
],
"predecessor-version": [
{
"id": 64574,
"href": "https://studentersamfundet.no/wp-json/wp/v2/events/64573/revisions/64574"
}
],
"wp:featuredmedia": [
{
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/media/64585"
}
],
"wp:attachment": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/media?parent=64573"
}
],
"wp:term": [
{
"taxonomy": "event_type",
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/event_types?post=64573"
},
{
"taxonomy": "event_organizer",
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/event_organizers?post=64573"
}
],
"curies": [
{
"name": "wp",
"href": "https://api.w.org/{rel}",
"templated": true
}
]
}
}
+24 -5
View File
@@ -1,15 +1,34 @@
from django.utils.translation import gettext_lazy as _
from wagtail.admin.forms import WagtailAdminModelForm
from wagtail.admin.viewsets.chooser import ChooserViewSet 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): class EventOrganizerChooserViewSet(ChooserViewSet):
model = "events.EventOrganizer" model = "events.EventOrganizer"
icon = "group" icon = "group"
per_page = 30 per_page = 30
page_title = "Choose organizers" page_title = _("Choose organizers")
choose_one_text = "Choose an organizer" choose_one_text = _("Choose an organizer")
choose_another_text = "Choose another organizer" choose_another_text = _("Choose another organizer")
edit_item_text = "Edit this 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") event_organizer_chooser_viewset = EventOrganizerChooserViewSet("event_organizer_chooser")
+11
View File
@@ -1,8 +1,19 @@
from wagtail import hooks from wagtail import hooks
from .admin import event_sidebar_viewset, event_explorer_viewset
from .views import event_organizer_chooser_viewset from .views import event_organizer_chooser_viewset
@hooks.register("register_admin_viewset") @hooks.register("register_admin_viewset")
def register_viewset(): def register_viewset():
return event_organizer_chooser_viewset return event_organizer_chooser_viewset
@hooks.register("register_admin_viewset")
def register_event_sidebar_viewset():
return event_sidebar_viewset
@hooks.register("register_admin_viewset")
def register_event_explorer_viewset():
return event_explorer_viewset
-21
View File
@@ -1,21 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block body_class %}template-homepage{% endblock %}
{% block extra_css %}
{% comment %}
Delete the line below if you're just getting started and want to remove the welcome screen!
{% endcomment %}
<link rel="stylesheet" href="{% static 'css/welcome_page.css' %}">
{% endblock extra_css %}
{% block content %}
{% comment %}
Delete the line below if you're just getting started and want to remove the welcome screen!
{% endcomment %}
{% include 'home/welcome_page.html' %}
{% endblock content %}
@@ -1,52 +0,0 @@
{% load i18n wagtailcore_tags %}
<header class="header">
<div class="logo">
<a href="https://wagtail.org/">
<svg class="figure-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 342.5 126.2"><title>{% trans "Visit the Wagtail website" %}</title><path fill="#FFF" d="M84 1.9v5.7s-10.2-3.8-16.8 3.1c-4.8 5-5.2 10.6-3 18.1 21.6 0 25 12.1 25 12.1L87 27l6.8-8.3c0-9.8-8.1-16.3-9.8-16.8z"/><circle cx="85.9" cy="15.9" r="2.6"/><path d="M89.2 40.9s-3.3-16.6-24.9-12.1c-2.2-7.5-1.8-13 3-18.1C73.8 3.8 84 7.6 84 7.6V1.9C80.4.3 77 0 73.2 0 59.3 0 51.6 10.4 48.3 17.4L9.2 89.3l11-2.1-20.2 39 14.1-2.5L24.9 93c30.6 0 69.8-11 64.3-52.1z"/><path d="M102.4 27l-8.6-8.3L87 27z"/><path fill="#FFF" d="M30 84.1s1-.2 2.8-.6c1.8-.4 4.3-1 7.3-1.8 1.5-.4 3.1-.9 4.8-1.5 1.7-.6 3.5-1.2 5.2-2 1.8-.7 3.6-1.6 5.4-2.6 1.8-1 3.5-2.1 5.1-3.4.4-.3.8-.6 1.2-1l1.2-1c.7-.7 1.5-1.4 2.2-2.2.7-.7 1.3-1.5 1.9-2.3l.9-1.2.4-.6.4-.6c.2-.4.5-.8.7-1.2.2-.4.4-.8.7-1.2l.3-.6.3-.6c.2-.4.4-.8.5-1.2l.9-2.4c.2-.8.5-1.6.7-2.3.2-.7.3-1.5.5-2.1.1-.7.2-1.3.3-2 .1-.6.2-1.2.2-1.7.1-.5.1-1 .2-1.5.1-1.8.1-2.8.1-2.8l1.6.1s-.1 1.1-.2 2.9c-.1.5-.1 1-.2 1.5-.1.6-.1 1.2-.3 1.8-.1.6-.3 1.3-.4 2-.2.7-.4 1.4-.6 2.2-.2.8-.5 1.5-.8 2.4-.3.8-.6 1.6-1 2.5l-.6 1.2-.3.6-.3.6c-.2.4-.5.8-.7 1.3-.3.4-.5.8-.8 1.2-.1.2-.3.4-.4.6l-.4.6-.9 1.2c-.7.8-1.3 1.6-2.1 2.3-.7.8-1.5 1.4-2.3 2.2l-1.2 1c-.4.3-.8.6-1.3.9-1.7 1.2-3.5 2.3-5.3 3.3-1.8.9-3.7 1.8-5.5 2.5-1.8.7-3.6 1.3-5.3 1.8-1.7.5-3.3 1-4.9 1.3-3 .7-5.6 1.3-7.4 1.6-1.6.6-2.6.8-2.6.8z"/><g fill="#231F20"><path d="M127 83.9h-8.8l-12.6-36.4h7.9l9 27.5 9-27.5h7.9l9 27.5 9-27.5h7.9L153 83.9h-8.8L135.6 59 127 83.9zM200.1 83.9h-7V79c-3 3.6-7 5.4-12.1 5.4-3.8 0-6.9-1.1-9.4-3.2s-3.7-5-3.7-8.6c0-3.6 1.3-6.3 4-8 2.6-1.8 6.2-2.7 10.7-2.7h9.9v-1.4c0-4.8-2.7-7.3-8.1-7.3-3.4 0-6.9 1.2-10.5 3.7l-3.4-4.8c4.4-3.5 9.4-5.3 15.1-5.3 4.3 0 7.8 1.1 10.5 3.2 2.7 2.2 4.1 5.6 4.1 10.2v23.7zm-7.7-13.6v-3.1h-8.6c-5.5 0-8.3 1.7-8.3 5.2 0 1.8.7 3.1 2.1 4.1 1.4.9 3.3 1.4 5.7 1.4 2.4 0 4.6-.7 6.4-2.1 1.8-1.3 2.7-3.1 2.7-5.5zM241.7 47.5v31.7c0 6.4-1.7 11.3-5.2 14.5-3.5 3.2-8 4.8-13.4 4.8-5.5 0-10.4-1.7-14.8-5.1l3.6-5.8c3.6 2.7 7.1 4 10.8 4 3.6 0 6.5-.9 8.6-2.8 2.1-1.9 3.2-4.9 3.2-9v-4.7c-1.1 2.1-2.8 3.9-4.9 5.1-2.1 1.3-4.5 1.9-7.1 1.9-4.8 0-8.8-1.7-11.9-5.1-3.1-3.4-4.7-7.6-4.7-12.6s1.6-9.2 4.7-12.6c3.1-3.4 7.1-5.1 11.9-5.1 4.8 0 8.7 2 11.7 6v-5.4h7.5zm-28.4 16.8c0 3 .9 5.6 2.8 7.7 1.8 2.2 4.3 3.2 7.5 3.2 3.1 0 5.7-1 7.6-3.1 1.9-2.1 2.9-4.7 2.9-7.8 0-3.1-1-5.8-2.9-7.9-2-2.2-4.5-3.2-7.6-3.2-3.1 0-5.6 1.1-7.4 3.4-2 2.1-2.9 4.7-2.9 7.7zM260.9 53.6v18.5c0 1.7.5 3.1 1.4 4.1.9 1 2.2 1.5 3.8 1.5 1.6 0 3.2-.8 4.7-2.4l3.1 5.4c-2.7 2.4-5.7 3.6-8.9 3.6-3.3 0-6-1.1-8.3-3.4-2.3-2.3-3.5-5.3-3.5-9.1V53.6h-4.6v-6.2h4.6V36.1h7.7v11.4h9.6v6.2h-9.6zM309.5 83.9h-7V79c-3 3.6-7 5.4-12.1 5.4-3.8 0-6.9-1.1-9.4-3.2s-3.7-5-3.7-8.6c0-3.6 1.3-6.3 4-8 2.6-1.8 6.2-2.7 10.7-2.7h9.9v-1.4c0-4.8-2.7-7.3-8.1-7.3-3.4 0-6.9 1.2-10.5 3.7l-3.4-4.8c4.4-3.5 9.4-5.3 15.1-5.3 4.3 0 7.8 1.1 10.5 3.2 2.7 2.2 4.1 5.6 4.1 10.2v23.7zm-7.7-13.6v-3.1h-8.6c-5.5 0-8.3 1.7-8.3 5.2 0 1.8.7 3.1 2.1 4.1 1.4.9 3.3 1.4 5.7 1.4 2.4 0 4.6-.7 6.4-2.1 1.8-1.3 2.7-3.1 2.7-5.5zM319.3 40.2c-1-1-1.4-2.1-1.4-3.4 0-1.3.5-2.5 1.4-3.4 1-1 2.1-1.4 3.4-1.4 1.3 0 2.5.5 3.4 1.4 1 1 1.4 2.1 1.4 3.4 0 1.3-.5 2.5-1.4 3.4s-2.1 1.4-3.4 1.4c-1.3.1-2.4-.4-3.4-1.4zm7.2 43.7h-7.7V47.5h7.7v36.4zM342.5 83.9h-7.7V33.1h7.7v50.8z"/></g></svg>
</a>
</div>
<div class="header-link">
{% comment %}
This works for all cases but prerelease versions:
{% endcomment %}
<a href="{% wagtail_documentation_path %}/releases/{% wagtail_release_notes_path %}">
{% trans "View the release notes" %}
</a>
</div>
</header>
<main class="main">
<div class="figure">
<svg class="figure-space" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" aria-hidden="true">
<path class="egg" fill="currentColor" d="M150 250c-42.741 0-75-32.693-75-90s42.913-110 75-110c32.088 0 75 52.693 75 110s-32.258 90-75 90z"/>
<ellipse fill="#ddd" cx="150" cy="270" rx="40" ry="7"/>
</svg>
</div>
<div class="main-text">
<h1>{% trans "Welcome to your new Wagtail site!" %}</h1>
<p>{% trans 'Please feel free to <a href="https://github.com/wagtail/wagtail/wiki/Slack">join our community on Slack</a>, or get started with one of the links below.' %}</p>
</div>
</main>
<footer class="footer" role="contentinfo">
<a class="option option-one" href="{% wagtail_documentation_path %}/">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 21c0 .5.4 1 1 1h4c.6 0 1-.5 1-1v-1H9v1zm3-19C8.1 2 5 5.1 5 9c0 2.4 1.2 4.5 3 5.7V17c0 .5.4 1 1 1h6c.6 0 1-.5 1-1v-2.3c1.8-1.3 3-3.4 3-5.7 0-3.9-3.1-7-7-7zm2.9 11.1l-.9.6V16h-4v-2.3l-.9-.6C7.8 12.2 7 10.6 7 9c0-2.8 2.2-5 5-5s5 2.2 5 5c0 1.6-.8 3.2-2.1 4.1z"/></svg>
<div>
<h2>{% trans "Wagtail Documentation" %}</h2>
<p>{% trans "Topics, references, & how-tos" %}</p>
</div>
</a>
<a class="option option-two" href="{% wagtail_documentation_path %}/getting_started/tutorial.html">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>
<div>
<h2>{% trans "Tutorial" %}</h2>
<p>{% trans "Build your first Wagtail site" %}</p>
</div>
</a>
<a class="option option-three" href="{% url 'wagtailadmin_home' %}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.5 13c-1.2 0-3.07.34-4.5 1-1.43-.67-3.3-1-4.5-1C5.33 13 1 14.08 1 16.25V19h22v-2.75c0-2.17-4.33-3.25-6.5-3.25zm-4 4.5h-10v-1.25c0-.54 2.56-1.75 5-1.75s5 1.21 5 1.75v1.25zm9 0H14v-1.25c0-.46-.2-.86-.52-1.22.88-.3 1.96-.53 3.02-.53 2.44 0 5 1.21 5 1.75v1.25zM7.5 12c1.93 0 3.5-1.57 3.5-3.5S9.43 5 7.5 5 4 6.57 4 8.5 5.57 12 7.5 12zm0-5.5c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm9 5.5c1.93 0 3.5-1.57 3.5-3.5S18.43 5 16.5 5 13 6.57 13 8.5s1.57 3.5 3.5 3.5zm0-5.5c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2z"/></svg>
<div>
<h2>{% trans "Admin Interface" %}</h2>
<p>{% trans "Create your superuser first!" %}</p>
</div>
</a>
</footer>
Binary file not shown.
+359
View File
@@ -0,0 +1,359 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: dnscms\n"
"Report-Msgid-Bugs-To: \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"
msgid "Title"
msgstr "Tittel"
msgid "Type"
msgstr "Type"
msgid "Updated"
msgstr "Oppdatert"
msgid "Status"
msgstr "Status"
msgid "Associations"
msgstr "Foreninger"
msgid "Lead"
msgstr "Ingress"
msgid "Content"
msgstr "Innhold"
msgid "association index"
msgstr "foreningsoversikt"
msgid "association indexes"
msgstr "foreningsoversikter"
msgid "Association"
msgstr "Forening"
msgid "Committee"
msgstr "Utvalg"
msgid "Excerpt"
msgstr "Utdrag"
msgid "A very short summary of the content below. Used in listing views."
msgstr ""
"En veldig kort oppsummering av innholdet nedenfor. Brukes i listevisninger."
msgid "Website"
msgstr "Nettsted"
msgid "association"
msgstr "forening"
msgid "associations"
msgstr "foreninger"
msgid "Choose an association"
msgstr "Velg en forening"
msgid "Choose another association"
msgstr "Velg en annen forening"
msgid "Edit this association"
msgstr "Rediger denne foreningen"
msgid "%Y-%m-%d at %H:%M"
msgstr "%Y-%m-%d kl %H:%M"
#, python-format
msgid "%(count)d occurrence"
msgid_plural "%(count)d occurrences"
msgstr[0] "%(count)d forekomst"
msgstr[1] "%(count)d forekomster"
msgid "Date"
msgstr "Dato"
msgid "Organizers"
msgstr "Arrangører"
msgid "Events"
msgstr "Arrangementer"
msgid "slug"
msgstr "permalenke"
msgid "The name of the category as it will appear in URLs."
msgstr "Navnet på kategorien slik det vil vises i URL-er."
msgid "Should this category be available as a filter in the event programme?"
msgstr "Skal denne kategorien være mulig å filtrere på i programmet?"
msgid "None"
msgstr "Ingen"
msgid "Default pig for events of this kind."
msgstr "Standardgris for arrangementer av denne typen."
msgid "Pig"
msgstr "Gris"
msgid "event category"
msgstr "arrangementskategori"
msgid "event categories"
msgstr "arrangementskategorier"
msgid "organizer"
msgstr "arrangør"
msgid "organizers"
msgstr "arrangører"
msgid "The name of the organizer as it will appear in URLs."
msgstr "Navnet på arrangøren slik det vil vises i URL-er."
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."
msgid "Link to the external organizer's website"
msgstr "Lenke til nettstedet til ekstern arrangør"
msgid "Internal organizer"
msgstr "Intern arrangør"
msgid "External organizer"
msgstr "Ekstern arrangør"
msgid "Leave this empty if the organizer exists in the list above."
msgstr "La denne stå tom om arrangøren finnes i lista over."
msgid "event organizer"
msgstr "arrangør"
msgid "event organizers"
msgstr "arrangører"
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 "
"cover uncritically!"
msgstr ""
"Velg et bilde til bruk i programmet og andre visningsflater. Bør være et "
"bilde eller en illustrasjon uten for mye tekst ikke gjenbruk et Facebook-"
"cover ukritisk!"
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."
msgstr ""
"En kort tekst som kommer rett under tittelen. La denne gjerne stå tom om du "
"fikk plass til det meste i hovedtittelen."
msgid "Automatic"
msgstr "Automatisk"
msgid ""
"The pig that hangs out on the event page. Automatic causes one to be chosen "
"based on the event's category."
msgstr ""
"Grisen som henger på arrangementssiden. Automatisk fører til at en velges "
"basert på arrangementets kategori."
msgid "Direct link to ticket purchase, e.g. TicketCo, Billetto or Ticketmaster"
msgstr ""
"Lenke direkte til billettkjøp, f.eks. TicketCo, Billetto eller Ticketmaster"
msgid "Direct link to the event on Facebook"
msgstr "Lenke direkte til arrangementet på Facebook"
msgid "Free"
msgstr "Gratis"
msgid "Is this event free for everyone?"
msgstr "Er dette arrangementet gratis for alle?"
msgid "Regular price"
msgstr "Ordinær pris"
msgid "Price for students"
msgstr "Pris for studenter"
msgid "Price for DNS members"
msgstr "Pris for medlemmer av DNS"
msgid ""
"Write <strong>0</strong> for free. An empty field hides the price category. "
"If possible, write digits only."
msgstr ""
"Skriv <strong>0</strong> om gratis. Tomt felt skjuler priskategorien. Om "
"mulig, skriv kun tall."
msgid "Ticket purchase link"
msgstr "Billettkjøpslenke"
msgid "Subtitle"
msgstr "Undertittel"
msgid "Who is behind the event?"
msgstr "Hvem står bak arrangementet?"
msgid "Organizer"
msgstr "Arrangør"
msgid "Facebook link"
msgstr "Facebook-lenke"
msgid "Direct link to the event on Facebook."
msgstr "Lenke direkte til arrangementet på Facebook."
msgid "Pricing and tickets"
msgstr "Priser og billettkjøp"
msgid "Date, time and venue"
msgstr "Dato, tid og lokale"
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."
msgid "Occurrence"
msgstr "Forekomst"
msgid "event"
msgstr "arrangement"
msgid "events"
msgstr "arrangementer"
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>."
msgstr ""
"Bruk denne <em>om ingen av lokalene som kan velges til venstre</em> passer. "
"F.eks. <em>Frederikkeplassen</em> eller <em>Sirkusteltet</em>."
msgid "Start"
msgstr "Start"
msgid "End"
msgstr "Slutt"
msgid "Venue"
msgstr "Lokale"
msgid "Venue as free text"
msgstr "Lokale som fritekst"
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."
msgid "Venue is required."
msgstr "Lokale er påkrevd."
msgid "occurrence"
msgstr "forekomst"
msgid "occurrences"
msgstr "forekomster"
msgid "Choose organizers"
msgstr "Velg arrangører"
msgid "Choose an organizer"
msgstr "Velg en arrangør"
msgid "Choose another organizer"
msgstr "Velg en annen arrangør"
msgid "Edit this organizer"
msgstr "Rediger denne arrangøren"
msgid "image"
msgstr "bilde"
msgid "images"
msgstr "bilder"
msgid "First published"
msgstr "Først publisert"
msgid "News"
msgstr "Nyheter"
msgid "news index"
msgstr "nyhetsoversikt"
msgid "news indexes"
msgstr "nyhetsoversikter"
msgid ""
"Choose an image for use on the front page and other surfaces. Should be a "
"photo or an illustration without too much text."
msgstr ""
"Velg et bilde til bruk på forsiden og andre visningsflater. Bør være et "
"bilde eller en illustrasjon uten for mye tekst."
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."
msgid ""
"A brief, introductory paragraph that summarizes the main content of the "
"article."
msgstr ""
"Et kortfattet, innledende avsnitt som oppsummerer hovedinnholdet i "
"artikkelen."
msgid "news article"
msgstr "nyhetsartikkel"
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"
+8
View File
@@ -0,0 +1,8 @@
# Translation tasks
[tasks.compilemessages]
description = "Compile translation files for Norwegian Bokmal"
run = "uv run manage.py compilemessages -l nb"
[tasks.makemessages]
description = "Extract translation strings for Norwegian Bokmal"
run = "uv run manage.py makemessages -l nb"
+52
View File
@@ -0,0 +1,52 @@
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, PageViewSet
from dnscms.admin import ListingRedirectChooseParentView
from news.models import NewsPage
class NewsChooseParentView(ListingRedirectChooseParentView):
listing_url_name = "news:index"
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"),
sort_key="latest_revision_created_at",
width="10%",
),
PageStatusColumn("status", label=_("Status"), sort_key="live", width="10%"),
]
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()
@@ -0,0 +1,28 @@
# Generated by Django 6.0.5 on 2026-05-19 19:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('images', '0005_customimage_description'),
('news', '0018_newspage_wp_block_json_newspage_wp_link_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='newsindex',
options={'verbose_name': 'news index', 'verbose_name_plural': 'news indexes'},
),
migrations.AlterModelOptions(
name='newspage',
options={'verbose_name': 'news article', 'verbose_name_plural': 'news articles'},
),
migrations.AlterField(
model_name='newspage',
name='featured_image',
field=models.ForeignKey(blank=True, help_text='Choose an image for use on the front page and other surfaces. Should be a photo or an illustration without too much text.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.customimage'),
),
]
+29 -58
View File
@@ -1,14 +1,19 @@
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _
from grapple.helpers import register_singular_query_field from grapple.helpers import register_singular_query_field
from grapple.models import GraphQLImage, GraphQLRichText, GraphQLStreamfield, GraphQLString from grapple.models import GraphQLImage, GraphQLRichText, GraphQLStreamfield, GraphQLString
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField from wagtail.fields import RichTextField
from wagtail.models import Page from wagtail.models import Page, PageManager
from wagtail.search import index from wagtail.search import index
from wagtail_headless_preview.models import HeadlessMixin from wagtail_headless_preview.models import HeadlessMixin
from dnscms.fields import CommonStreamField from dnscms.fields import CommonStreamField
from dnscms.wordpress.models import WPImportedPageMixin from dnscms.wordpress.models import DeferWPFieldsManagerMixin, WPImportedPageMixin
class NewsPageManager(DeferWPFieldsManagerMixin, PageManager):
pass
@register_singular_query_field("newsIndex") @register_singular_query_field("newsIndex")
@@ -19,7 +24,7 @@ class NewsIndex(HeadlessMixin, Page):
lead = RichTextField(features=["italic", "link"], blank=True) lead = RichTextField(features=["italic", "link"], blank=True)
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel("lead", heading="Ingress"), FieldPanel("lead", heading=_("Lead")),
] ]
graphql_fields = [ graphql_fields = [
@@ -28,12 +33,18 @@ class NewsIndex(HeadlessMixin, Page):
search_fields = [] search_fields = []
class Meta:
verbose_name = _("news index")
verbose_name_plural = _("news indexes")
class NewsPage(HeadlessMixin, WPImportedPageMixin, Page): class NewsPage(HeadlessMixin, WPImportedPageMixin, Page):
subpage_types = [] subpage_types = []
parent_page_types = ["news.NewsIndex"] parent_page_types = ["news.NewsIndex"]
show_in_menus = False show_in_menus = False
objects = NewsPageManager()
excerpt = models.TextField(max_length=512, blank=False) excerpt = models.TextField(max_length=512, blank=False)
lead = RichTextField(features=["italic", "link"], blank=True) lead = RichTextField(features=["italic", "link"], blank=True)
body = CommonStreamField body = CommonStreamField
@@ -43,23 +54,28 @@ class NewsPage(HeadlessMixin, WPImportedPageMixin, Page):
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="+", related_name="+",
help_text=( help_text=_(
"Velg et bilde til bruk i på forsiden og andre visningsflater. " "Choose an image for use on the front page and other surfaces. "
"Bør være et bilde eller en illustrasjon uten tekst." "Should be a photo or an illustration without too much text."
), ),
) )
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel( FieldPanel(
"excerpt", "excerpt",
heading="Utdrag", heading=_("Excerpt"),
help_text="En veldig kort oppsummering av innholdet i artikkelen. Brukes på forsiden og i artikkeloversikten.", help_text=_(
"A very short summary of the article's content. "
"Used on the front page and in the article listing."
),
), ),
FieldPanel("featured_image"), FieldPanel("featured_image"),
FieldPanel( FieldPanel(
"lead", "lead",
heading="Ingress", heading=_("Lead"),
help_text="Et kortfattet, innledende avsnitt som oppsummerer hovedinnholdet i artikkelen.", help_text=_(
"A brief, introductory paragraph that summarizes the main content of the article."
),
), ),
FieldPanel("body"), FieldPanel("body"),
] ]
@@ -77,51 +93,6 @@ class NewsPage(HeadlessMixin, WPImportedPageMixin, Page):
index.SearchField("body"), index.SearchField("body"),
] ]
def import_wordpress_data(self, data): class Meta:
import html verbose_name = _("news article")
verbose_name_plural = _("news articles")
from bs4 import BeautifulSoup
def generate_excerpt(html_content):
soup = BeautifulSoup(html_content, features="lxml")
VALID_TAGS = ["div", "p"]
for tag in soup.findAll("p"):
if tag.name not in VALID_TAGS:
tag.remove()
text = soup.get_text().strip()
words = text.split(" ")
if len(words) < 26:
return text
return " ".join(words[:25]) + " [...]"
# Wagtail page model fields
self.title = html.unescape(data["title"])
self.slug = data["slug"]
self.first_published_at = data["first_published_at"]
self.last_published_at = data["last_published_at"]
self.latest_revision_created_at = data["latest_revision_created_at"]
self.search_description = data["search_description"]
# debug fields
self.wp_post_id = data["wp_post_id"]
self.wp_post_type = data["wp_post_type"]
self.wp_link = data["wp_link"]
self.wp_raw_content = data["wp_raw_content"]
self.wp_block_json = data["wp_block_json"]
self.wp_processed_content = data["wp_processed_content"]
self.wp_normalized_styles = data["wp_normalized_styles"]
self.wp_post_meta = data["wp_post_meta"]
# own model fields
self.body = data["body"] or ""
meta = data["wp_post_meta"]
written_excerpt = meta.get("excerpt_encoded")
generated_excerpt = ""
if not written_excerpt:
generated_excerpt = generate_excerpt(self.wp_processed_content)
self.excerpt = written_excerpt or generated_excerpt or "[...]"
+13
View File
@@ -0,0 +1,13 @@
from wagtail import hooks
from .admin import news_sidebar_viewset, news_explorer_viewset
@hooks.register("register_admin_viewset")
def register_news_sidebar_viewset():
return news_sidebar_viewset
@hooks.register("register_admin_viewset")
def register_news_explorer_viewset():
return news_explorer_viewset
+1 -1
View File
@@ -5,7 +5,7 @@ description = ""
authors = [{ name = "EDB", email = "edb@neuf.no" }] authors = [{ name = "EDB", email = "edb@neuf.no" }]
requires-python = ">=3.14, <3.15" requires-python = ">=3.14, <3.15"
dependencies = [ dependencies = [
"wagtail>=7.4,<8", "wagtail>=7.4.1,<8",
"wagtail-grapple>=0.31.0,<0.32", "wagtail-grapple>=0.31.0,<0.32",
"wagtail-headless-preview>=0.8,<0.9", "wagtail-headless-preview>=0.8,<0.9",
"django>=6.0.5,<7", "django>=6.0.5,<7",
+53
View File
@@ -0,0 +1,53 @@
from associations.admin import AssociationTypeColumn
from associations.models import AssociationPage
from tests.conftest import AssociationPageFactory
def test_associationpage_persists_via_factory(association_index):
page = AssociationPageFactory(
parent=association_index,
title="EDB-gjengen",
excerpt="WOW FLINKE",
association_type=AssociationPage.AssociationType.UTVALG,
)
reloaded = AssociationPage.objects.get(pk=page.pk)
assert reloaded.title == "EDB-gjengen"
assert reloaded.excerpt == "WOW FLINKE"
assert reloaded.association_type == "utvalg"
def test_association_type_column_renders_forening_display(association_index):
page = AssociationPageFactory(
parent=association_index,
association_type=AssociationPage.AssociationType.FORENING,
)
column = AssociationTypeColumn("association_type")
assert column.get_value(page) == "Forening"
def test_association_type_column_renders_utvalg_display(association_index):
page = AssociationPageFactory(
parent=association_index,
association_type=AssociationPage.AssociationType.UTVALG,
)
column = AssociationTypeColumn("association_type")
assert column.get_value(page) == "Utvalg"
def test_graphql_association_index_query(association_index, graphql_post):
response, body = graphql_post(
"""
query {
associationIndex {
title
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
assert body["data"]["associationIndex"]["title"] == association_index.title
+185
View File
@@ -2,8 +2,11 @@ from datetime import datetime, timedelta
import pytest import pytest
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.utils import timezone from django.utils import timezone
from events.admin import EventDateColumn, OrganizersColumn
from events.models import ( from events.models import (
EventCategory, EventCategory,
EventOccurrence, EventOccurrence,
@@ -11,6 +14,7 @@ from events.models import (
EventOrganizerLink, EventOrganizerLink,
EventPage, EventPage,
) )
from events.views import EventOrganizerCreationForm
from tests.conftest import ( from tests.conftest import (
AssociationPageFactory, AssociationPageFactory,
CustomImageFactory, CustomImageFactory,
@@ -101,6 +105,57 @@ def test_eventoccurrence_clean_rejects_both_venue_and_venue_custom(event_index,
assert "venue_custom" in exc.value.message_dict 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): def test_eventoccurrence_clean_requires_venue_or_venue_custom(event_index):
event = EventPageFactory(parent=event_index) event = EventPageFactory(parent=event_index)
occurrence = EventOccurrence(event=event, start=timezone.now()) occurrence = EventOccurrence(event=event, start=timezone.now())
@@ -187,6 +242,80 @@ def test_graphql_event_index_future_events_query(event_index, graphql_post):
assert "Upcoming gig" in titles assert "Upcoming gig" in titles
def test_future_events_does_not_have_n_plus_one_queries(
event_index, venue, association_index, graphql_post
):
"""Regression test: query count for futureEvents stays bounded as events grow."""
konsert = EventCategory.objects.create(name="Konsert", slug="konsert")
association = AssociationPageFactory(parent=association_index, title="DNS")
org = EventOrganizer.objects.create(name="Forening", slug="forening", association=association)
image = CustomImageFactory(title="Cover")
now = timezone.now()
for i in range(5):
event = EventPageFactory(
parent=event_index,
title=f"Event {i}",
body=[("paragraph", "<p>x</p>")],
featured_image=image,
)
event.categories.add(konsert)
EventOrganizerLink.objects.create(event=event, organizer=org)
EventOccurrence.objects.create(
event=event,
start=now + timedelta(days=i + 1),
venue=venue,
)
home_query = """
query {
eventIndex {
futureEvents {
id
title
subtitle
body { blockType }
featuredImage { url }
occurrences { start end venueCustom venue { title } }
categories { name slug }
organizers { name slug association { title } }
}
}
}
"""
with CaptureQueriesContext(connection) as ctx:
response, body = graphql_post(home_query)
assert response.status_code == 200
assert "errors" not in body, body
assert len(body["data"]["eventIndex"]["futureEvents"]) == 5
# Bump only alongside an intentional resolver change.
max_queries = 6
assert len(ctx) <= max_queries, (
f"futureEvents took {len(ctx)} queries for 5 events — likely N+1. "
f"Captured queries:\n"
+ "\n".join(f" {i + 1}. {q['sql'][:120]}" for i, q in enumerate(ctx.captured_queries))
)
def test_future_events_does_not_load_wp_import_fields(event_index, graphql_post):
"""wp_* columns must stay deferred and lazy-load on explicit access."""
event = EventPageFactory(parent=event_index, wp_raw_content="marker")
EventOccurrence.objects.create(
event=event, start=timezone.now() + timedelta(days=1), venue_custom="X"
)
with CaptureQueriesContext(connection) as ctx:
response, body = graphql_post("{ eventIndex { futureEvents { id } } }")
assert response.status_code == 200 and "errors" not in body, body
sql = "\n".join(q["sql"] for q in ctx.captured_queries)
assert "wp_raw_content" not in sql, f"wp_* must be deferred. SQL:\n{sql}"
assert EventPage.objects.get(pk=event.pk).wp_raw_content == "marker"
def test_graphql_event_index_future_events_ordered_by_next_occurrence(event_index, graphql_post): def test_graphql_event_index_future_events_ordered_by_next_occurrence(event_index, graphql_post):
now = timezone.now() now = timezone.now()
@@ -212,6 +341,62 @@ def test_graphql_event_index_future_events_ordered_by_next_occurrence(event_inde
assert titles.index("Sooner gig") < titles.index("Later gig") assert titles.index("Sooner gig") < titles.index("Later gig")
def test_event_date_column_no_occurrences(event_index):
event = EventPageFactory(parent=event_index)
column = EventDateColumn("event_date")
assert column.get_value(event) == ""
def test_event_date_column_single_occurrence(event_index):
event = EventPageFactory(parent=event_index)
start = timezone.make_aware(datetime(2025, 7, 22, 19, 30))
EventOccurrence.objects.create(event=event, start=start, venue_custom="X")
column = EventDateColumn("event_date")
assert column.get_value(event) == "2025-07-22 kl 19:30"
def test_event_date_column_multiple_occurrences_shows_count(event_index):
event = EventPageFactory(parent=event_index)
now = timezone.now()
EventOccurrence.objects.create(event=event, start=now, venue_custom="X")
EventOccurrence.objects.create(event=event, start=now + timedelta(days=1), venue_custom="X")
EventOccurrence.objects.create(event=event, start=now + timedelta(days=2), venue_custom="X")
column = EventDateColumn("event_date")
assert column.get_value(event) == "3 forekomster"
def test_organizers_column_no_organizers(event_index):
event = EventPageFactory(parent=event_index)
column = OrganizersColumn("organizers")
assert column.get_value(event) == ""
def test_organizers_column_single_organizer_shows_name(event_index):
org = EventOrganizer.objects.create(name="Forening A", slug="forening-a")
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=org)
column = OrganizersColumn("organizers")
assert column.get_value(event) == "Forening A"
def test_organizers_column_multiple_organizers_truncates_with_count(event_index):
org_a = EventOrganizer.objects.create(name="Forening A", slug="forening-a")
org_b = EventOrganizer.objects.create(name="Forening B", slug="forening-b")
org_c = EventOrganizer.objects.create(name="Forening C", slug="forening-c")
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=org_a, sort_order=0)
EventOrganizerLink.objects.create(event=event, organizer=org_b, sort_order=1)
EventOrganizerLink.objects.create(event=event, organizer=org_c, sort_order=2)
column = OrganizersColumn("organizers")
assert column.get_value(event) == "Forening A (+2)"
@pytest.fixture @pytest.fixture
def comprehensive_event(event_index, venue, association_index): def comprehensive_event(event_index, venue, association_index):
"""A fully-populated paid EventPage exercising every field exposed via GraphQL.""" """A fully-populated paid EventPage exercising every field exposed via GraphQL."""
+6
View File
@@ -1,3 +1,4 @@
from news.admin import NewsSidebarViewSet
from news.models import NewsPage from news.models import NewsPage
from tests.conftest import NewsPageFactory from tests.conftest import NewsPageFactory
@@ -10,6 +11,11 @@ def test_news_page_persists_via_factory(news_index):
assert reloaded.excerpt == "Short summary" assert reloaded.excerpt == "Short summary"
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): def test_graphql_news_index_query(news_index, graphql_post):
response, body = graphql_post( response, body = 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"
+4 -4
View File
@@ -274,7 +274,7 @@ requires-dist = [
{ name = "django-extensions", specifier = ">=4.1,<5" }, { name = "django-extensions", specifier = ">=4.1,<5" },
{ name = "gunicorn", specifier = ">=26.0.0,<27" }, { name = "gunicorn", specifier = ">=26.0.0,<27" },
{ name = "psycopg2-binary", specifier = ">=2.9.12,<3" }, { name = "psycopg2-binary", specifier = ">=2.9.12,<3" },
{ name = "wagtail", specifier = ">=7.4,<8" }, { name = "wagtail", specifier = ">=7.4.1,<8" },
{ name = "wagtail-grapple", specifier = ">=0.31.0,<0.32" }, { name = "wagtail-grapple", specifier = ">=0.31.0,<0.32" },
{ name = "wagtail-headless-preview", specifier = ">=0.8,<0.9" }, { name = "wagtail-headless-preview", specifier = ">=0.8,<0.9" },
{ name = "whitenoise", specifier = ">=6.12.0,<7" }, { name = "whitenoise", specifier = ">=6.12.0,<7" },
@@ -731,7 +731,7 @@ wheels = [
[[package]] [[package]]
name = "wagtail" name = "wagtail"
version = "7.4" version = "7.4.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyascii" }, { name = "anyascii" },
@@ -753,9 +753,9 @@ dependencies = [
{ name = "telepath" }, { name = "telepath" },
{ name = "willow", extra = ["heif"] }, { name = "willow", extra = ["heif"] },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a6/10/ad6e496beed01a58656ddc70c16f8a8c269698a568d9054d3d5a57b56655/wagtail-7.4.tar.gz", hash = "sha256:9570c51d61ee524cc5558a84df40e2ccc03f7c50c6dad343644a45d037096d13", size = 6939468, upload-time = "2026-05-05T16:51:18.534Z" } sdist = { url = "https://files.pythonhosted.org/packages/81/da/c060bd9ec00335336e04a610cd1d6762e459ade9c4983f7ecddae15af36d/wagtail-7.4.1.tar.gz", hash = "sha256:c7232cbe16afa5842c236e069b2baead3b3e87e9dc90a7493330b54b69e18b83", size = 6944979, upload-time = "2026-05-19T17:26:18.762Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/c1/d8effc43aaf3ba84754f247c2fb97e119534b579486b278d991068629af1/wagtail-7.4-py3-none-any.whl", hash = "sha256:24a4d55e6bed22ccce4aae1b3f7e22a46605c4bc4dc9894d5a415cde418840c6", size = 9587583, upload-time = "2026-05-05T16:51:12.575Z" }, { url = "https://files.pythonhosted.org/packages/53/c8/162fff3c5ef9e158127c9f4aa1f0d38edd8d1fa242629ae1d8af3b6306c7/wagtail-7.4.1-py3-none-any.whl", hash = "sha256:a42dc18b0fb818649c0d704ea50a2dea06d1b75614abdf837e90766263645c76", size = 9597601, upload-time = "2026-05-19T17:26:12.53Z" },
] ]
[[package]] [[package]]
+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'},
),
]
+20 -40
View File
@@ -1,4 +1,5 @@
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _
from grapple.helpers import register_singular_query_field from grapple.helpers import register_singular_query_field
from grapple.models import ( from grapple.models import (
GraphQLBoolean, GraphQLBoolean,
@@ -9,13 +10,17 @@ from grapple.models import (
) )
from wagtail.admin.panels import FieldPanel, FieldRowPanel, MultiFieldPanel from wagtail.admin.panels import FieldPanel, FieldRowPanel, MultiFieldPanel
from wagtail.fields import RichTextField, StreamField from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page from wagtail.models import Page, PageManager
from wagtail.search import index from wagtail.search import index
from wagtail_headless_preview.models import HeadlessMixin from wagtail_headless_preview.models import HeadlessMixin
from dnscms.blocks import ImageSliderBlock from dnscms.blocks import ImageSliderBlock
from dnscms.fields import CommonStreamField from dnscms.fields import CommonStreamField
from dnscms.wordpress.models import WPImportedPageMixin from dnscms.wordpress.models import DeferWPFieldsManagerMixin, WPImportedPageMixin
class VenuePageManager(DeferWPFieldsManagerMixin, PageManager):
pass
@register_singular_query_field("venueIndex") @register_singular_query_field("venueIndex")
@@ -34,6 +39,10 @@ class VenueIndex(HeadlessMixin, Page):
graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")] graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")]
class Meta:
verbose_name = _("venue index")
verbose_name_plural = _("venue indexes")
@register_singular_query_field("venueRentalIndex") @register_singular_query_field("venueRentalIndex")
class VenueRentalIndex(HeadlessMixin, Page): class VenueRentalIndex(HeadlessMixin, Page):
@@ -51,6 +60,10 @@ class VenueRentalIndex(HeadlessMixin, Page):
graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")] graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")]
class Meta:
verbose_name = _("rentals page")
verbose_name_plural = _("rentals pages")
class VenuePage(HeadlessMixin, WPImportedPageMixin, Page): class VenuePage(HeadlessMixin, WPImportedPageMixin, Page):
# no children # no children
@@ -59,6 +72,8 @@ class VenuePage(HeadlessMixin, WPImportedPageMixin, Page):
# should not be able to be shown in menus # should not be able to be shown in menus
show_in_menus = False show_in_menus = False
objects = VenuePageManager()
featured_image = models.ForeignKey( featured_image = models.ForeignKey(
"images.CustomImage", "images.CustomImage",
null=True, null=True,
@@ -179,41 +194,6 @@ class VenuePage(HeadlessMixin, WPImportedPageMixin, Page):
index.SearchField("body"), index.SearchField("body"),
] ]
def import_wordpress_data(self, data): class Meta:
import html verbose_name = _("venue")
verbose_name_plural = _("venues")
# Wagtail page model fields
self.title = html.unescape(data["title"])
self.slug = data["slug"]
self.first_published_at = data["first_published_at"]
self.last_published_at = data["last_published_at"]
self.latest_revision_created_at = data["latest_revision_created_at"]
self.search_description = data["search_description"]
# debug fields
self.wp_post_id = data["wp_post_id"]
self.wp_post_type = data["wp_post_type"]
self.wp_link = data["wp_link"]
self.wp_raw_content = data["wp_raw_content"]
self.wp_block_json = data["wp_block_json"]
self.wp_processed_content = data["wp_processed_content"]
self.wp_normalized_styles = data["wp_normalized_styles"]
self.wp_post_meta = data["wp_post_meta"]
# own model fields
self.body = data["body"] or ""
meta = data["wp_post_meta"]
self.show_as_bookable = meta.get("neuf_venues_show_on_booking_page", False)
self.preposition = meta.get("neuf_venues_preposition") or ""
self.floor = meta.get("neuf_venues_floor") or ""
self.used_for = meta.get("neuf_venues_used_for") or ""
self.capability_bar = meta.get("neuf_venues_bar") or ""
self.capability_audio = meta.get("neuf_venues_audio") or ""
self.capability_lighting = meta.get("neuf_venues_lighting") or ""
self.capability_audio_video = meta.get("neuf_venues_audio_video") or ""
self.capacity_legal = meta.get("neuf_venues_capacity_legal") or ""
self.capacity_standing = meta.get("neuf_venues_capacity_standing") or ""
self.capacity_sitting = meta.get("neuf_venues_capacity_sitting") or ""
+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
+1
View File
@@ -16,6 +16,7 @@ const nextConfig = {
hostname: "**", hostname: "**",
}, },
], ],
formats: ["image/avif", "image/webp"],
dangerouslyAllowLocalIP: process.env.NODE_ENV === "development", dangerouslyAllowLocalIP: process.env.NODE_ENV === "development",
}, },
turbopack: { turbopack: {
+2668 -7
View File
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -7,7 +7,12 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"codegen": "graphql-codegen" "codegen": "graphql-codegen",
"perf:build": "next build",
"perf:serve": "next start -p 3100",
"perf:lh:desktop": "lighthouse http://localhost:3100/ --preset=desktop --output=html --output=json --output-path=../scratch/lighthouse/home-desktop --only-categories=performance --chrome-flags=\"--headless=new\" --quiet",
"perf:lh:mobile": "lighthouse http://localhost:3100/ --output=html --output=json --output-path=../scratch/lighthouse/home-mobile --only-categories=performance --chrome-flags=\"--headless=new\" --quiet",
"perf:lh": "wait-on http://localhost:3100/ && npm run perf:lh:desktop && npm run perf:lh:mobile"
}, },
"dependencies": { "dependencies": {
"@graphql-codegen/cli": "^7.0.0", "@graphql-codegen/cli": "^7.0.0",
@@ -36,10 +41,16 @@
"baseline-browser-mapping": "^2.10.29", "baseline-browser-mapping": "^2.10.29",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.6", "eslint-config-next": "16.2.6",
"typescript": "^6" "lighthouse": "^13.3.0",
"typescript": "^6",
"wait-on": "^9.0.10"
}, },
"overrides": { "overrides": {
"@types/react": "19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3" "@types/react-dom": "19.2.3"
} },
"browserslist": [
"> 0.5% in NO",
"not dead"
]
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

@@ -0,0 +1,51 @@
grisemonster image variants
===========================
Source: grisemonster-lossless-original.png (2048x1024 RGBA PNG, 2.0M)
Files in use
------------
grisemonster-2048.png 443K desktop (>1280px viewport)
grisemonster-1280.png 227K tablet (601-1280px)
grisemonster-800.png 97K mobile (<=600px)
The previous single grisemonster.png was 624K served to every device.
Mobile now downloads 97K (-84%); desktop downloads 443K (-29%).
How they were generated
-----------------------
For each width W in {2048, 1280, 800}:
magick grisemonster-lossless-original.png -resize ${W}x base.png
pngquant 64 --quality=50-80 --speed 1 --output grisemonster-${W}.png base.png
oxipng -o max --strip safe grisemonster-${W}.png
pngquant reduces to a 64-colour palette (the image is a flat
illustration with very few distinct colours, so this is visually
lossless at the displayed scale). oxipng then does a lossless
re-encode pass to squeeze the PNG further.
Wired up in src/components/layout/footer.module.scss via two
min-width media queries on `.pigPattern`'s background-image.
Why PNG, not WebP/AVIF
----------------------
Tested at 2048 width:
PNG (pngquant 64 + oxipng) 443K <-- chosen
PNG (pngquant 256 + oxipng) 522K
AVIF q50 493K
AVIF q60 598K
AVIF q75 718K
WebP q75 829K larger than current PNG
WebP q85 903K larger than current PNG
WebP lossless 1.0M larger than current PNG
For this kind of content -- a flat, limited-palette illustration with
large transparent regions -- palette PNG is the smallest format.
WebP and AVIF are tuned for photographic content and lose to a
well-quantised PNG here. Visual quality of the three PNG/AVIF options
above was indistinguishable at the rendered scale.
Conclusion: the win came from responsive sizing, not from changing
format. Stayed on PNG to keep the CSS simple (no image-set() needed).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

+7 -2
View File
@@ -1,16 +1,21 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { getClient } from "@/app/client";
import { import {
NewsIndexView, NewsIndexView,
loadNewsIndexProps, loadNewsIndexProps,
} from "@/components/news/NewsIndexView"; } from "@/components/news/NewsIndexView";
import { NewsIndexFragment } from "@/gql/graphql";
import { newsIndexMetadataQuery } from "@/lib/news";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
export async function generateMetadata( export async function generateMetadata(
_: unknown, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { index } = await loadNewsIndexProps(); const { data, error } = await getClient().query(newsIndexMetadataQuery, {});
return getSeoMetadata(index, parent); if (error) throw new Error(error.message);
if (!data?.index) return null;
return getSeoMetadata(data.index as NewsIndexFragment, parent);
} }
export default async function Page() { export default async function Page() {
+2 -2
View File
@@ -4,7 +4,7 @@ import {
eventsOverviewQuery, eventsOverviewQuery,
getSingularEvents, getSingularEvents,
getFutureOccurrences, getFutureOccurrences,
EventFragment, EventOverviewItemFragment,
EventCategory, EventCategory,
EventOrganizer, EventOrganizer,
} from "@/lib/event"; } from "@/lib/event";
@@ -59,7 +59,7 @@ export async function GET(req: NextRequest) {
throw new Error("Failed to fetch events"); throw new Error("Failed to fetch events");
} }
const futureEvents = (data?.events?.futureEvents ?? []) as EventFragment[]; const futureEvents = (data?.events?.futureEvents ?? []) as EventOverviewItemFragment[];
const eventCategories = (data?.eventCategories ?? []) as EventCategory[]; const eventCategories = (data?.eventCategories ?? []) as EventCategory[];
const eventOrganizers = (data?.eventOrganizers ?? []) as EventOrganizer[]; const eventOrganizers = (data?.eventOrganizers ?? []) as EventOrganizer[];
const venues = (data?.venues ?? []) as VenueFragment[]; const venues = (data?.venues ?? []) as VenueFragment[];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+17 -1
View File
@@ -34,7 +34,23 @@ export default function RootLayout({
return ( return (
<html lang="no"> <html lang="no">
<head> <head>
<link rel="stylesheet" href="https://use.typekit.net/spa5smt.css" /> <link rel="preconnect" href="https://use.typekit.net" crossOrigin="anonymous" />
<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 && ( {process.env.UMAMI_SCRIPT_URL && process.env.UMAMI_WEBSITE_ID && (
<script <script
defer defer
+8 -1
View File
@@ -10,6 +10,8 @@ import { getSeoMetadata } from "@/lib/seo";
type Params = Promise<{ slug: string }>; type Params = Promise<{ slug: string }>;
const EXCLUDED_SLUGS = ["hele-huset"];
export async function generateMetadata( export async function generateMetadata(
{ params }: { params: Params }, { params }: { params: Params },
parent: ResolvingMetadata 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, slug: page.slug,
})); }));
} }
export default async function Page({ params }: { params: Params }) { export default async function Page({ params }: { params: Params }) {
const { slug } = await params; const { slug } = await params;
if (EXCLUDED_SLUGS.includes(slug)) {
return notFound();
}
const props = await loadVenuePageProps({ slug }); const props = await loadVenuePageProps({ slug });
if (!props) return notFound(); if (!props) return notFound();
return <VenuePageView {...props} />; return <VenuePageView {...props} />;
+45 -23
View File
@@ -1,7 +1,10 @@
import { graphql } from "@/gql";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { SearchContainer } from "@/components/search/SearchContainer"; import {
import { Suspense } from "react"; type SearchResult,
SearchResults,
} from "@/components/search/SearchResults";
import { SearchShell } from "@/components/search/SearchShell";
import { graphql } from "@/gql";
// TODO: seo metadata? // TODO: seo metadata?
@@ -13,7 +16,9 @@ export default async function Page({
}>; }>;
}) { }) {
const { q: query } = (await searchParams) ?? {}; const { q: query } = (await searchParams) ?? {};
let results = []; let results: SearchResult[] = [];
let totalCount = 0;
const RESULT_LIMIT = 500;
if (query) { if (query) {
const searchQuery = graphql(` const searchQuery = graphql(`
@@ -21,45 +26,62 @@ export default async function Page({
results: search(query: $query) { results: search(query: $query) {
__typename __typename
... on PageInterface { ... on PageInterface {
slug id
title
url
} }
... on NewsPage { ... on NewsPage {
id excerpt
title featuredImage {
...Image
}
firstPublishedAt
} }
... on EventPage { ... on EventPage {
id subtitle
title featuredImage {
...Image
}
occurrences {
start
}
} }
... on GenericPage { ... on GenericPage {
id lead
title
} }
... on VenuePage { ... on VenuePage {
id featuredImage {
title ...Image
}
} }
... on AssociationPage { ... on AssociationPage {
id excerpt
title
associationType associationType
logo {
...Image
}
} }
} }
} }
`); `);
const { data, error } = await getClient().query(searchQuery, { const { data } = await getClient().query(searchQuery, { query });
query: query, const all = (data?.results ?? []) as SearchResult[];
}); totalCount = all.length;
results = all.slice(0, RESULT_LIMIT);
results = (data?.results ?? []) as any;
} }
return ( return (
<main className="site-main" id="main"> <main className="site-main" id="main">
<Suspense key={query}> <SearchShell initialQuery={query ?? ""}>
<SearchContainer query={query ?? ""} results={results} /> {query ? (
</Suspense> <SearchResults
results={results}
totalCount={totalCount}
query={query}
/>
) : null}
</SearchShell>
</main> </main>
); );
} }
+5 -1
View File
@@ -1,7 +1,11 @@
import dynamic from "next/dynamic";
import { RichTextBlock } from "./RichTextBlock"; import { RichTextBlock } from "./RichTextBlock";
import { ImageWithTextBlock } from "./ImageWithTextBlock"; import { ImageWithTextBlock } from "./ImageWithTextBlock";
import { ImageSliderBlock } from "./ImageSliderBlock";
import { HorizontalRuleBlock } from "./HorizontalRuleBlock"; import { HorizontalRuleBlock } from "./HorizontalRuleBlock";
const ImageSliderBlock = dynamic(() =>
import("./ImageSliderBlock").then((m) => m.ImageSliderBlock)
);
import { FeaturedBlock } from "./FeaturedBlock"; import { FeaturedBlock } from "./FeaturedBlock";
import { AccordionBlock } from "./AccordionBlock"; import { AccordionBlock } from "./AccordionBlock";
import { EmbedBlock } from "./EmbedBlock"; import { EmbedBlock } from "./EmbedBlock";
+10 -6
View File
@@ -13,7 +13,7 @@ import { unmaskFragment } from "@/gql";
import { import {
EventCategoryFragmentDefinition, EventCategoryFragmentDefinition,
EventOrganizerFragmentDefinition, EventOrganizerFragmentDefinition,
EventFragment, EventOverviewItemFragment,
EventCategory, EventCategory,
SingularEvent, SingularEvent,
getSingularEvents, getSingularEvents,
@@ -44,7 +44,7 @@ export const EventContainer = ({
eventOrganizers, eventOrganizers,
venues, venues,
}: { }: {
events: EventFragment[]; events: EventOverviewItemFragment[];
eventCategories: EventCategory[]; eventCategories: EventCategory[];
eventOrganizers: EventOrganizer[]; eventOrganizers: EventOrganizer[];
venues: VenueFragment[]; venues: VenueFragment[];
@@ -233,7 +233,7 @@ export const EventContainer = ({
); );
}; };
const EventList = ({ events }: { events: EventFragment[] }) => { const EventList = ({ events }: { events: EventOverviewItemFragment[] }) => {
if (events.length === 0) { if (events.length === 0) {
return <div className={styles.noEvents}>Ingen kommende arrangementer.</div>; return <div className={styles.noEvents}>Ingen kommende arrangementer.</div>;
} }
@@ -255,7 +255,7 @@ const CalendarDay = ({
events, events,
}: { }: {
day: string; day: string;
events: SingularEvent[]; events: SingularEvent<EventOverviewItemFragment>[];
}) => ( }) => (
<div <div
className={`${styles.calendarDay} ${events.length === 0 && styles.empty}`} className={`${styles.calendarDay} ${events.length === 0 && styles.empty}`}
@@ -274,7 +274,11 @@ const CalendarDay = ({
</div> </div>
); );
const CalendarWeek = ({ days }: { days: Record<string, SingularEvent[]> }) => { const CalendarWeek = ({
days,
}: {
days: Record<string, SingularEvent<EventOverviewItemFragment>[]>;
}) => {
const daysInWeek = Object.keys(days); const daysInWeek = Object.keys(days);
const firstDay = daysInWeek[0]; const firstDay = daysInWeek[0];
const lastDay = daysInWeek[daysInWeek.length - 1]; const lastDay = daysInWeek[daysInWeek.length - 1];
@@ -310,7 +314,7 @@ function maybeYear(yearMonthString: string) {
return ` ${yearMonth.getFullYear()}`; return ` ${yearMonth.getFullYear()}`;
} }
const EventCalendar = ({ events }: { events: EventFragment[] }) => { const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
const futureSingularEvents = getSingularEvents(events).filter( const futureSingularEvents = getSingularEvents(events).filter(
(x) => x.occurrence?.start && isTodayOrFuture(x.occurrence.start), (x) => x.occurrence?.start && isTodayOrFuture(x.occurrence.start),
); );
+3 -3
View File
@@ -5,13 +5,13 @@ import { EventContainer } from "@/components/events/EventContainer";
import { PageHeader } from "@/components/general/PageHeader"; import { PageHeader } from "@/components/general/PageHeader";
import { import {
EventCategory, EventCategory,
EventFragment, EventOverviewItemFragment,
EventOrganizer, EventOrganizer,
eventsOverviewQuery, eventsOverviewQuery,
} from "@/lib/event"; } from "@/lib/event";
export type EventIndexViewProps = { export type EventIndexViewProps = {
events: EventFragment[]; events: EventOverviewItemFragment[];
eventCategories: EventCategory[]; eventCategories: EventCategory[];
eventOrganizers: EventOrganizer[]; eventOrganizers: EventOrganizer[];
venues: VenueFragment[]; venues: VenueFragment[];
@@ -30,7 +30,7 @@ export async function loadEventIndexProps(): Promise<EventIndexViewProps> {
throw new Error("Failed to load /arrangementer"); throw new Error("Failed to load /arrangementer");
} }
return { return {
events: data.events.futureEvents as EventFragment[], events: data.events.futureEvents as EventOverviewItemFragment[],
eventCategories: data.eventCategories as EventCategory[], eventCategories: data.eventCategories as EventCategory[],
eventOrganizers: data.eventOrganizers as EventOrganizer[], eventOrganizers: data.eventOrganizers as EventOrganizer[],
venues: data.venues as VenueFragment[], venues: data.venues as VenueFragment[],
+16 -2
View File
@@ -6,6 +6,8 @@ import { Image } from "@/components/general/Image";
import { import {
SingularEvent, SingularEvent,
EventFragment, EventFragment,
EventListItemFragment,
EventOverviewItemFragment,
getFutureOccurrences, getFutureOccurrences,
} from "@/lib/event"; } from "@/lib/event";
import { import {
@@ -20,10 +22,20 @@ export const EventItem = ({
event, event,
mode, mode,
size, size,
imageLoading,
imageFetchPriority,
}: { }: {
event: SingularEvent | EventFragment; event:
| SingularEvent
| SingularEvent<EventListItemFragment>
| SingularEvent<EventOverviewItemFragment>
| EventFragment
| EventListItemFragment
| EventOverviewItemFragment;
mode: "list" | "calendar" | "singular-time-only"; mode: "list" | "calendar" | "singular-time-only";
size?: "small" | "medium" | "large"; size?: "small" | "medium" | "large";
imageLoading?: "eager" | "lazy";
imageFetchPriority?: "high" | "low" | "auto";
}) => { }) => {
const futureOccurrences = getFutureOccurrences(event); const futureOccurrences = getFutureOccurrences(event);
const groupedOccurrences = groupConsecutiveDates( const groupedOccurrences = groupConsecutiveDates(
@@ -46,7 +58,9 @@ export const EventItem = ({
alt={featuredImage.alt} alt={featuredImage.alt}
width={0} width={0}
height={0} height={0}
sizes="(max-width: 900px) 100vw, 25vw" sizes="(max-width: 900px) calc(100vw - 2rem), 30vw"
loading={imageLoading}
fetchPriority={imageFetchPriority}
/> />
)} )}
</div> </div>
+9 -3
View File
@@ -1,16 +1,22 @@
import { EventFragment } from "@/gql/graphql"; import { EventListItemFragment } from "@/gql/graphql";
import { EventItem } from "./EventItem"; import { EventItem } from "./EventItem";
import styles from "./featuredEvents.module.scss"; import styles from "./featuredEvents.module.scss";
import { SectionHeader } from "../general/SectionHeader"; import { SectionHeader } from "../general/SectionHeader";
import { SectionFooter } from "../general/SectionFooter"; import { SectionFooter } from "../general/SectionFooter";
export const FeaturedEvents = ({ events }: { events: EventFragment[] }) => { export const FeaturedEvents = ({ events }: { events: EventListItemFragment[] }) => {
return ( return (
<section className={styles.featuredEvents}> <section className={styles.featuredEvents}>
<SectionHeader heading="Arrangementer" link="/arrangementer" linkText="Se alle arrangementer" /> <SectionHeader heading="Arrangementer" link="/arrangementer" linkText="Se alle arrangementer" />
<ul className={styles.eventList}> <ul className={styles.eventList}>
{events.slice(0, 3).map((event) => ( {events.slice(0, 3).map((event) => (
<EventItem key={event.id} event={event} mode="list" /> <EventItem
key={event.id}
event={event}
mode="list"
imageLoading="eager"
imageFetchPriority="high"
/>
))} ))}
</ul> </ul>
<SectionFooter link="/arrangementer" linkText="Se alle arrangementer" /> <SectionFooter link="/arrangementer" linkText="Se alle arrangementer" />
+3 -5
View File
@@ -1,18 +1,16 @@
import { EventFragment } from "@/gql/graphql"; import { EventListItemFragment } from "@/gql/graphql";
import { isTodayOrFuture, formatDate } from "@/lib/date"; import { isTodayOrFuture } from "@/lib/date";
import { parse } from "date-fns";
import { import {
getSingularEvents, getSingularEvents,
organizeEventsByDate, organizeEventsByDate,
sortSingularEvents, sortSingularEvents,
} from "@/lib/event"; } from "@/lib/event";
import Link from "next/link";
import { EventItem } from "./EventItem"; import { EventItem } from "./EventItem";
import styles from "./upcomingEvents.module.scss"; import styles from "./upcomingEvents.module.scss";
import { SectionHeader } from "../general/SectionHeader"; import { SectionHeader } from "../general/SectionHeader";
import { SectionFooter } from "../general/SectionFooter"; import { SectionFooter } from "../general/SectionFooter";
export const UpcomingEvents = ({ events }: { events: EventFragment[] }) => { export const UpcomingEvents = ({ events }: { events: EventListItemFragment[] }) => {
const upcomingSingularEvents = sortSingularEvents( const upcomingSingularEvents = sortSingularEvents(
getSingularEvents(events).filter((event) => getSingularEvents(events).filter((event) =>
isTodayOrFuture(event.occurrence.start) isTodayOrFuture(event.occurrence.start)
+8 -8
View File
@@ -2,8 +2,8 @@ import Link from "next/link";
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { HomeFragment } from "@/gql/graphql"; import { HomeFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { EventFragment } from "@/lib/event"; import { EventListItemFragment } from "@/lib/event";
import { NewsFragment } from "@/lib/news"; import { NewsListItemFragment } from "@/lib/news";
import { FeaturedEvents } from "@/components/events/FeaturedEvents"; import { FeaturedEvents } from "@/components/events/FeaturedEvents";
import { UpcomingEvents } from "@/components/events/UpcomingEvents"; import { UpcomingEvents } from "@/components/events/UpcomingEvents";
import { Icon } from "@/components/general/Icon"; import { Icon } from "@/components/general/Icon";
@@ -28,7 +28,7 @@ const homeQuery = graphql(`
... on EventIndex { ... on EventIndex {
futureEvents { futureEvents {
... on EventPage { ... on EventPage {
...Event ...EventListItem
} }
} }
} }
@@ -40,7 +40,7 @@ const homeQuery = graphql(`
} }
news: pages(contentType: "news.newsPage", order: "-first_published_at", limit: 4) { news: pages(contentType: "news.newsPage", order: "-first_published_at", limit: 4) {
... on NewsPage { ... on NewsPage {
...News ...NewsListItem
} }
} }
} }
@@ -48,8 +48,8 @@ const homeQuery = graphql(`
export type HomePageViewProps = { export type HomePageViewProps = {
home: HomeFragment; home: HomeFragment;
events: EventFragment[]; events: EventListItemFragment[];
news: NewsFragment[]; news: NewsListItemFragment[];
}; };
export async function loadHomePageProps(overrides?: { export async function loadHomePageProps(overrides?: {
@@ -59,8 +59,8 @@ export async function loadHomePageProps(overrides?: {
if (error) throw new Error(error.message); if (error) throw new Error(error.message);
const home = overrides?.homeOverride ?? (data?.home as HomeFragment | undefined); const home = overrides?.homeOverride ?? (data?.home as HomeFragment | undefined);
if (!home) throw new Error("Failed to load /"); if (!home) throw new Error("Failed to load /");
const events = (data?.events?.futureEvents ?? []) as EventFragment[]; const events = (data?.events?.futureEvents ?? []) as EventListItemFragment[];
const news = (data?.news ?? []) as NewsFragment[]; const news = (data?.news ?? []) as NewsListItemFragment[];
return { home, events, news }; return { home, events, news };
} }
+8 -12
View File
@@ -1,12 +1,12 @@
import Link from "next/link";
import styles from "./footer.module.scss";
import { NeonChillPig } from "../general/pigs/fun/NeonChillPig";
import { ToTop } from "./ToTop";
import { Icon } from "../general/Icon";
import { import {
getOpeningHours, getOpeningHours,
getTodaysOpeningHoursForFunction, getTodaysOpeningHoursForFunction,
} from "@/lib/openinghours"; } from "@/lib/openinghours";
import Link from "next/link";
import { Icon } from "../general/Icon";
// import { NeonChillPig } from "../general/pigs/fun/NeonChillPig";
import styles from "./footer.module.scss";
import { PigPattern } from "./PigPattern";
async function OpeningHoursTable() { async function OpeningHoursTable() {
const allOpeningHours = await getOpeningHours(); const allOpeningHours = await getOpeningHours();
@@ -141,15 +141,11 @@ export const Footer = () => {
</div> </div>
</div> </div>
</div> </div>
<div className={styles.pig}> {/* <div className={styles.pig}>
<NeonChillPig /> <NeonChillPig />
</div> </div> */}
</footer> </footer>
<div className={styles.pigPattern}> <PigPattern />
<div className={styles.toTop}>
<ToTop />
</div>
</div>
</> </>
); );
}; };
+5
View File
@@ -25,8 +25,12 @@ export const Header = () => {
const { replace } = useRouter(); const { replace } = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [showMenu, setShowMenu] = useState(false); 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() { function toggleMenu() {
setHasInteracted(true);
setShowMenu(!showMenu); setShowMenu(!showMenu);
} }
@@ -61,6 +65,7 @@ export const Header = () => {
<header <header
className={styles.header} className={styles.header}
data-show={showMenu} data-show={showMenu}
data-animate={hasInteracted}
data-small={!isInView} data-small={!isInView}
> >
<Link href="/" aria-label="Hjem"> <Link href="/" aria-label="Hjem">
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { useInView } from "react-intersection-observer";
import styles from "./footer.module.scss";
import { ToTop } from "./ToTop";
export const PigPattern = () => {
const { ref, inView } = useInView({
triggerOnce: true,
rootMargin: "1000px",
});
return (
<div
ref={ref}
className={`${styles.pigPattern} ${inView ? styles.loaded : ""}`}
>
<div className={styles.toTop}>
<ToTop />
</div>
</div>
);
};
+12 -1
View File
@@ -101,7 +101,6 @@
.pigPattern { .pigPattern {
background-color: var(--color-background); background-color: var(--color-background);
background-image: url("/assets/graphics/grisemonster.png");
background-size: 100% auto; background-size: 100% auto;
background-position: 0 100%; background-position: 0 100%;
background-attachment: fixed; background-attachment: fixed;
@@ -111,6 +110,18 @@
position: relative; position: relative;
z-index: 700; z-index: 700;
&.loaded {
background-image: url("/assets/graphics/grisemonster-800.png");
@media (min-width: 601px) {
background-image: url("/assets/graphics/grisemonster-1280.png");
}
@media (min-width: 1281px) {
background-image: url("/assets/graphics/grisemonster-2048.png");
}
}
@media (orientation: landscape) { @media (orientation: landscape) {
background-size: 100% auto; background-size: 100% auto;
} }
@@ -54,6 +54,12 @@
transition: transform .6s ease; transition: transform .6s ease;
} }
} }
&[data-animate=false] {
.mainMenu {
animation: none;
}
}
} }
.overlay { .overlay {
+3 -3
View File
@@ -1,11 +1,11 @@
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { PageHeader } from "@/components/general/PageHeader"; import { PageHeader } from "@/components/general/PageHeader";
import { NewsList } from "@/components/news/NewsList"; import { NewsList } from "@/components/news/NewsList";
import { NewsFragment, NewsIndexFragment, newsQuery } from "@/lib/news"; import { NewsIndexFragment, NewsListItemFragment, newsQuery } from "@/lib/news";
export type NewsIndexViewProps = { export type NewsIndexViewProps = {
index: NewsIndexFragment; index: NewsIndexFragment;
news: NewsFragment[]; news: NewsListItemFragment[];
}; };
export async function loadNewsIndexProps(overrides?: { export async function loadNewsIndexProps(overrides?: {
@@ -15,7 +15,7 @@ export async function loadNewsIndexProps(overrides?: {
if (error) throw new Error(error.message); if (error) throw new Error(error.message);
const index = overrides?.indexOverride ?? (data?.index as NewsIndexFragment | undefined); const index = overrides?.indexOverride ?? (data?.index as NewsIndexFragment | undefined);
if (!index) throw new Error("Failed to load /aktuelt"); if (!index) throw new Error("Failed to load /aktuelt");
const news = (data?.news ?? []) as NewsFragment[]; const news = (data?.news ?? []) as NewsListItemFragment[];
return { index, news }; return { index, news };
} }
+2 -2
View File
@@ -1,10 +1,10 @@
import styles from "./newsItem.module.scss"; import styles from "./newsItem.module.scss";
import { Image } from "@/components/general/Image"; import { Image } from "@/components/general/Image";
import { NewsFragment } from "@/lib/news"; import { NewsFragment, NewsListItemFragment } from "@/lib/news";
import { formatDate } from "@/lib/date"; import { formatDate } from "@/lib/date";
import Link from "next/link"; import Link from "next/link";
export const NewsItem = ({ news }: { news: NewsFragment }) => { export const NewsItem = ({ news }: { news: NewsFragment | NewsListItemFragment }) => {
const featuredImage: any = news.featuredImage; const featuredImage: any = news.featuredImage;
return ( return (
+2 -2
View File
@@ -3,7 +3,7 @@ import { useState } from "react";
import { SectionHeader } from "../general/SectionHeader"; import { SectionHeader } from "../general/SectionHeader";
import { NewsItem } from "./NewsItem"; import { NewsItem } from "./NewsItem";
import styles from "./newsList.module.scss"; import styles from "./newsList.module.scss";
import { NewsFragment } from "@/lib/news"; import { NewsFragment, NewsListItemFragment } from "@/lib/news";
import { SectionFooter } from "../general/SectionFooter"; import { SectionFooter } from "../general/SectionFooter";
export const NewsList = ({ export const NewsList = ({
@@ -11,7 +11,7 @@ export const NewsList = ({
heading, heading,
featured featured
}: { }: {
news: NewsFragment[]; news: (NewsFragment | NewsListItemFragment)[];
heading?: string; heading?: string;
featured?: boolean; featured?: boolean;
}) => { }) => {
@@ -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 { a {
text-decoration: none; text-decoration: none;
} }
mark {
background: var(--color-goldenBeige);
color: inherit;
padding: 0 .05em;
border-radius: 2px;
}
} }
.searchField { .searchField {
@@ -29,11 +36,52 @@
} }
.resultItem { .resultItem {
display: flex;
align-items: flex-start;
gap: var(--spacing-m);
border-top: var(--border); border-top: var(--border);
margin-top: var(--spacing-m); margin-top: var(--spacing-m);
padding-top: var(--spacing-s); 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 { .suphead {
display: block; display: block;
font-size: var(--font-size-caption); font-size: var(--font-size-caption);
+1 -1
View File
@@ -26,7 +26,7 @@ export const VenueItem = ({ venue }: { venue: VenueFragment }) => {
alt={featuredImage.alt} alt={featuredImage.alt}
width={0} width={0}
height={0} height={0}
sizes="(max-width: 600px) 100vw, (max-width: 900xpx) 50vw, 35vw" sizes="(max-width: 600px) 100vw, (max-width: 900px) 50vw, 35vw"
/> />
)} )}
</div> </div>
+6 -4
View File
@@ -1,15 +1,17 @@
import dynamic from "next/dynamic";
import { VenueFragment } from "@/gql/graphql"; import { VenueFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { import { ImageSliderBlockFragmentDefinition } from "@/components/blocks/ImageSliderBlock";
ImageSliderBlock,
ImageSliderBlockFragmentDefinition,
} from "@/components/blocks/ImageSliderBlock";
import { Breadcrumb } from "@/components/general/Breadcrumb"; import { Breadcrumb } from "@/components/general/Breadcrumb";
import { PageContent } from "@/components/general/PageContent"; import { PageContent } from "@/components/general/PageContent";
import { NeufMap } from "@/components/venues/NeufMap"; import { NeufMap } from "@/components/venues/NeufMap";
import { VenueInfo } from "@/components/venues/VenueInfo"; import { VenueInfo } from "@/components/venues/VenueInfo";
import { graphql, unmaskFragment } from "@/gql"; import { graphql, unmaskFragment } from "@/gql";
const ImageSliderBlock = dynamic(() =>
import("@/components/blocks/ImageSliderBlock").then((m) => m.ImageSliderBlock)
);
const venueBySlugQuery = graphql(` const venueBySlugQuery = graphql(`
query venueBySlug($slug: String!) { query venueBySlug($slug: String!) {
venue: page(contentType: "venues.VenuePage", slug: $slug) { venue: page(contentType: "venues.VenuePage", slug: $slug) {
+36 -12
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 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 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 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 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 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, "\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,
@@ -45,7 +45,7 @@ type Documents = {
"\n fragment Generic on GenericPage {\n __typename\n id\n urlPath\n seoTitle\n searchDescription\n title\n lead\n pig\n body {\n ...Blocks\n }\n }\n": typeof types.GenericFragmentDoc, "\n fragment Generic on GenericPage {\n __typename\n id\n urlPath\n seoTitle\n searchDescription\n title\n lead\n pig\n body {\n ...Blocks\n }\n }\n": typeof types.GenericFragmentDoc,
"\n query genericPageByUrl($urlPath: String!) {\n page: page(contentType: \"generic.GenericPage\", urlPath: $urlPath) {\n ... on GenericPage {\n ...Generic\n }\n }\n }\n": typeof types.GenericPageByUrlDocument, "\n query genericPageByUrl($urlPath: String!) {\n page: page(contentType: \"generic.GenericPage\", urlPath: $urlPath) {\n ... on GenericPage {\n ...Generic\n }\n }\n }\n": typeof types.GenericPageByUrlDocument,
"\n fragment Home on HomePage {\n __typename\n featuredEvents {\n id\n }\n }\n": typeof types.HomeFragmentDoc, "\n fragment Home on HomePage {\n __typename\n featuredEvents {\n id\n }\n }\n": typeof types.HomeFragmentDoc,
"\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n": typeof types.HomeDocument, "\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventListItem\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...NewsListItem\n }\n }\n }\n": typeof types.HomeDocument,
"\n query newsBySlug($slug: String!) {\n news: page(contentType: \"news.NewsPage\", slug: $slug) {\n ... on NewsPage {\n ...News\n }\n }\n }\n": typeof types.NewsBySlugDocument, "\n query newsBySlug($slug: String!) {\n news: page(contentType: \"news.NewsPage\", slug: $slug) {\n ... on NewsPage {\n ...News\n }\n }\n }\n": typeof types.NewsBySlugDocument,
"\n fragment Sponsor on SponsorBlock {\n id\n name\n logo {\n ...Image\n }\n text\n website\n }\n": typeof types.SponsorFragmentDoc, "\n fragment Sponsor on SponsorBlock {\n id\n name\n logo {\n ...Image\n }\n text\n website\n }\n": typeof types.SponsorFragmentDoc,
"\n fragment SponsorsPage on SponsorsPage {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n sponsors {\n ... on SponsorBlock {\n ...Sponsor\n }\n }\n }\n": typeof types.SponsorsPageFragmentDoc, "\n fragment SponsorsPage on SponsorsPage {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n sponsors {\n ... on SponsorBlock {\n ...Sponsor\n }\n }\n }\n": typeof types.SponsorsPageFragmentDoc,
@@ -65,13 +65,17 @@ type Documents = {
"\n fragment ContactEntity on ContactEntity {\n id\n name\n contactType\n title\n email\n phoneNumber\n image {\n ...Image\n }\n }\n": typeof types.ContactEntityFragmentDoc, "\n fragment ContactEntity on ContactEntity {\n id\n name\n contactType\n title\n email\n phoneNumber\n image {\n ...Image\n }\n }\n": typeof types.ContactEntityFragmentDoc,
"\n fragment EventCategory on EventCategory {\n __typename\n name\n slug\n pig\n showInFilters\n }\n": typeof types.EventCategoryFragmentDoc, "\n fragment EventCategory on EventCategory {\n __typename\n name\n slug\n pig\n showInFilters\n }\n": typeof types.EventCategoryFragmentDoc,
"\n fragment EventOrganizer on EventOrganizer {\n __typename\n id\n name\n slug\n externalUrl\n association {\n ... on AssociationPage {\n url\n }\n }\n }\n": typeof types.EventOrganizerFragmentDoc, "\n fragment EventOrganizer on EventOrganizer {\n __typename\n id\n name\n slug\n externalUrl\n association {\n ... on AssociationPage {\n url\n }\n }\n }\n": typeof types.EventOrganizerFragmentDoc,
"\n fragment EventListItem on EventPage {\n __typename\n id\n slug\n title\n subtitle\n featuredImage {\n ...Image\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n }\n }\n }\n": typeof types.EventListItemFragmentDoc,
"\n fragment EventOverviewItem on EventPage {\n __typename\n id\n slug\n title\n subtitle\n featuredImage {\n ...Image\n }\n categories {\n ... on EventCategory {\n ...EventCategory\n }\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n venue {\n __typename\n id\n slug\n title\n preposition\n url\n }\n venueCustom\n }\n }\n organizers {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n }\n": typeof types.EventOverviewItemFragmentDoc,
"\n fragment Event on EventPage {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n subtitle\n lead\n body {\n ...OneLevelOfBlocks\n }\n featuredImage {\n ...Image\n }\n pig\n facebookUrl\n ticketUrl\n free\n priceRegular\n priceMember\n priceStudent\n categories {\n ... on EventCategory {\n ...EventCategory\n }\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n venue {\n __typename\n id\n slug\n title\n preposition\n url\n }\n venueCustom\n }\n }\n organizers {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n }\n": typeof types.EventFragmentDoc, "\n fragment Event on EventPage {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n subtitle\n lead\n body {\n ...OneLevelOfBlocks\n }\n featuredImage {\n ...Image\n }\n pig\n facebookUrl\n ticketUrl\n free\n priceRegular\n priceMember\n priceStudent\n categories {\n ... on EventCategory {\n ...EventCategory\n }\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n venue {\n __typename\n id\n slug\n title\n preposition\n url\n }\n venueCustom\n }\n }\n organizers {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n }\n": typeof types.EventFragmentDoc,
"\n fragment EventIndex on EventIndex {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n }\n": typeof types.EventIndexFragmentDoc, "\n fragment EventIndex on EventIndex {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n }\n": typeof types.EventIndexFragmentDoc,
"\n query eventIndexMetadata {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n }\n": typeof types.EventIndexMetadataDocument, "\n query eventIndexMetadata {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n }\n": typeof types.EventIndexMetadataDocument,
"\n query futureEvents {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n eventCategories: eventCategories(limit: 5000) {\n ... on EventCategory {\n ...EventCategory\n }\n }\n eventOrganizers: eventOrganizers(limit: 5000) {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n id\n title\n slug\n preposition\n }\n }\n }\n": typeof types.FutureEventsDocument, "\n query futureEvents {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventOverviewItem\n }\n }\n }\n }\n eventCategories: eventCategories(limit: 5000) {\n ... on EventCategory {\n ...EventCategory\n }\n }\n eventOrganizers: eventOrganizers(limit: 5000) {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n id\n title\n slug\n preposition\n }\n }\n }\n": typeof types.FutureEventsDocument,
"\n fragment NewsListItem on NewsPage {\n __typename\n id\n slug\n title\n firstPublishedAt\n excerpt\n featuredImage {\n ...Image\n }\n }\n": typeof types.NewsListItemFragmentDoc,
"\n fragment News on NewsPage {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n firstPublishedAt\n excerpt\n lead\n featuredImage {\n ...Image\n }\n body {\n ...Blocks\n }\n }\n": typeof types.NewsFragmentDoc, "\n fragment News on NewsPage {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n firstPublishedAt\n excerpt\n lead\n featuredImage {\n ...Image\n }\n body {\n ...Blocks\n }\n }\n": typeof types.NewsFragmentDoc,
"\n fragment NewsIndex on NewsIndex {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n lead\n }\n": typeof types.NewsIndexFragmentDoc, "\n fragment NewsIndex on NewsIndex {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n lead\n }\n": typeof types.NewsIndexFragmentDoc,
"\n query news {\n index: newsIndex {\n ... on NewsIndex {\n ...NewsIndex\n }\n }\n news: pages(contentType: \"news.NewsPage\", order: \"-first_published_at\", limit: 1000) {\n ... on NewsPage {\n ...News\n }\n }\n }\n": typeof types.NewsDocument, "\n query newsIndexMetadata {\n index: newsIndex {\n ... on NewsIndex {\n ...NewsIndex\n }\n }\n }\n": typeof types.NewsIndexMetadataDocument,
"\n query news {\n index: newsIndex {\n ... on NewsIndex {\n ...NewsIndex\n }\n }\n news: pages(contentType: \"news.NewsPage\", order: \"-first_published_at\", limit: 1000) {\n ... on NewsPage {\n ...NewsListItem\n }\n }\n }\n": typeof types.NewsDocument,
"\n query openingHoursSets {\n openingHoursSets {\n ...OpeningHoursSet\n }\n }\n": typeof types.OpeningHoursSetsDocument, "\n query openingHoursSets {\n openingHoursSets {\n ...OpeningHoursSet\n }\n }\n": typeof types.OpeningHoursSetsDocument,
"\n fragment OpeningHoursSet on OpeningHoursSet {\n name\n effectiveFrom\n effectiveTo\n announcement\n items {\n id\n function\n week {\n __typename\n ... on OpeningHoursWeekBlock {\n ...OpeningHoursWeekBlock\n }\n }\n }\n }\n": typeof types.OpeningHoursSetFragmentDoc, "\n fragment OpeningHoursSet on OpeningHoursSet {\n name\n effectiveFrom\n effectiveTo\n announcement\n items {\n id\n function\n week {\n __typename\n ... on OpeningHoursWeekBlock {\n ...OpeningHoursWeekBlock\n }\n }\n }\n }\n": typeof types.OpeningHoursSetFragmentDoc,
"\n fragment OpeningHoursRangeBlock on OpeningHoursRangeBlock {\n timeFrom\n timeTo\n custom\n }\n": typeof types.OpeningHoursRangeBlockFragmentDoc, "\n fragment OpeningHoursRangeBlock on OpeningHoursRangeBlock {\n timeFrom\n timeTo\n custom\n }\n": typeof types.OpeningHoursRangeBlockFragmentDoc,
@@ -84,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 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 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 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 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 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, "\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,
@@ -109,7 +113,7 @@ const documents: Documents = {
"\n fragment Generic on GenericPage {\n __typename\n id\n urlPath\n seoTitle\n searchDescription\n title\n lead\n pig\n body {\n ...Blocks\n }\n }\n": types.GenericFragmentDoc, "\n fragment Generic on GenericPage {\n __typename\n id\n urlPath\n seoTitle\n searchDescription\n title\n lead\n pig\n body {\n ...Blocks\n }\n }\n": types.GenericFragmentDoc,
"\n query genericPageByUrl($urlPath: String!) {\n page: page(contentType: \"generic.GenericPage\", urlPath: $urlPath) {\n ... on GenericPage {\n ...Generic\n }\n }\n }\n": types.GenericPageByUrlDocument, "\n query genericPageByUrl($urlPath: String!) {\n page: page(contentType: \"generic.GenericPage\", urlPath: $urlPath) {\n ... on GenericPage {\n ...Generic\n }\n }\n }\n": types.GenericPageByUrlDocument,
"\n fragment Home on HomePage {\n __typename\n featuredEvents {\n id\n }\n }\n": types.HomeFragmentDoc, "\n fragment Home on HomePage {\n __typename\n featuredEvents {\n id\n }\n }\n": types.HomeFragmentDoc,
"\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n": types.HomeDocument, "\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventListItem\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...NewsListItem\n }\n }\n }\n": types.HomeDocument,
"\n query newsBySlug($slug: String!) {\n news: page(contentType: \"news.NewsPage\", slug: $slug) {\n ... on NewsPage {\n ...News\n }\n }\n }\n": types.NewsBySlugDocument, "\n query newsBySlug($slug: String!) {\n news: page(contentType: \"news.NewsPage\", slug: $slug) {\n ... on NewsPage {\n ...News\n }\n }\n }\n": types.NewsBySlugDocument,
"\n fragment Sponsor on SponsorBlock {\n id\n name\n logo {\n ...Image\n }\n text\n website\n }\n": types.SponsorFragmentDoc, "\n fragment Sponsor on SponsorBlock {\n id\n name\n logo {\n ...Image\n }\n text\n website\n }\n": types.SponsorFragmentDoc,
"\n fragment SponsorsPage on SponsorsPage {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n sponsors {\n ... on SponsorBlock {\n ...Sponsor\n }\n }\n }\n": types.SponsorsPageFragmentDoc, "\n fragment SponsorsPage on SponsorsPage {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n sponsors {\n ... on SponsorBlock {\n ...Sponsor\n }\n }\n }\n": types.SponsorsPageFragmentDoc,
@@ -129,13 +133,17 @@ const documents: Documents = {
"\n fragment ContactEntity on ContactEntity {\n id\n name\n contactType\n title\n email\n phoneNumber\n image {\n ...Image\n }\n }\n": types.ContactEntityFragmentDoc, "\n fragment ContactEntity on ContactEntity {\n id\n name\n contactType\n title\n email\n phoneNumber\n image {\n ...Image\n }\n }\n": types.ContactEntityFragmentDoc,
"\n fragment EventCategory on EventCategory {\n __typename\n name\n slug\n pig\n showInFilters\n }\n": types.EventCategoryFragmentDoc, "\n fragment EventCategory on EventCategory {\n __typename\n name\n slug\n pig\n showInFilters\n }\n": types.EventCategoryFragmentDoc,
"\n fragment EventOrganizer on EventOrganizer {\n __typename\n id\n name\n slug\n externalUrl\n association {\n ... on AssociationPage {\n url\n }\n }\n }\n": types.EventOrganizerFragmentDoc, "\n fragment EventOrganizer on EventOrganizer {\n __typename\n id\n name\n slug\n externalUrl\n association {\n ... on AssociationPage {\n url\n }\n }\n }\n": types.EventOrganizerFragmentDoc,
"\n fragment EventListItem on EventPage {\n __typename\n id\n slug\n title\n subtitle\n featuredImage {\n ...Image\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n }\n }\n }\n": types.EventListItemFragmentDoc,
"\n fragment EventOverviewItem on EventPage {\n __typename\n id\n slug\n title\n subtitle\n featuredImage {\n ...Image\n }\n categories {\n ... on EventCategory {\n ...EventCategory\n }\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n venue {\n __typename\n id\n slug\n title\n preposition\n url\n }\n venueCustom\n }\n }\n organizers {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n }\n": types.EventOverviewItemFragmentDoc,
"\n fragment Event on EventPage {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n subtitle\n lead\n body {\n ...OneLevelOfBlocks\n }\n featuredImage {\n ...Image\n }\n pig\n facebookUrl\n ticketUrl\n free\n priceRegular\n priceMember\n priceStudent\n categories {\n ... on EventCategory {\n ...EventCategory\n }\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n venue {\n __typename\n id\n slug\n title\n preposition\n url\n }\n venueCustom\n }\n }\n organizers {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n }\n": types.EventFragmentDoc, "\n fragment Event on EventPage {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n subtitle\n lead\n body {\n ...OneLevelOfBlocks\n }\n featuredImage {\n ...Image\n }\n pig\n facebookUrl\n ticketUrl\n free\n priceRegular\n priceMember\n priceStudent\n categories {\n ... on EventCategory {\n ...EventCategory\n }\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n venue {\n __typename\n id\n slug\n title\n preposition\n url\n }\n venueCustom\n }\n }\n organizers {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n }\n": types.EventFragmentDoc,
"\n fragment EventIndex on EventIndex {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n }\n": types.EventIndexFragmentDoc, "\n fragment EventIndex on EventIndex {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n }\n": types.EventIndexFragmentDoc,
"\n query eventIndexMetadata {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n }\n": types.EventIndexMetadataDocument, "\n query eventIndexMetadata {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n }\n": types.EventIndexMetadataDocument,
"\n query futureEvents {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n eventCategories: eventCategories(limit: 5000) {\n ... on EventCategory {\n ...EventCategory\n }\n }\n eventOrganizers: eventOrganizers(limit: 5000) {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n id\n title\n slug\n preposition\n }\n }\n }\n": types.FutureEventsDocument, "\n query futureEvents {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventOverviewItem\n }\n }\n }\n }\n eventCategories: eventCategories(limit: 5000) {\n ... on EventCategory {\n ...EventCategory\n }\n }\n eventOrganizers: eventOrganizers(limit: 5000) {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n id\n title\n slug\n preposition\n }\n }\n }\n": types.FutureEventsDocument,
"\n fragment NewsListItem on NewsPage {\n __typename\n id\n slug\n title\n firstPublishedAt\n excerpt\n featuredImage {\n ...Image\n }\n }\n": types.NewsListItemFragmentDoc,
"\n fragment News on NewsPage {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n firstPublishedAt\n excerpt\n lead\n featuredImage {\n ...Image\n }\n body {\n ...Blocks\n }\n }\n": types.NewsFragmentDoc, "\n fragment News on NewsPage {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n firstPublishedAt\n excerpt\n lead\n featuredImage {\n ...Image\n }\n body {\n ...Blocks\n }\n }\n": types.NewsFragmentDoc,
"\n fragment NewsIndex on NewsIndex {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n lead\n }\n": types.NewsIndexFragmentDoc, "\n fragment NewsIndex on NewsIndex {\n __typename\n id\n slug\n seoTitle\n searchDescription\n title\n lead\n }\n": types.NewsIndexFragmentDoc,
"\n query news {\n index: newsIndex {\n ... on NewsIndex {\n ...NewsIndex\n }\n }\n news: pages(contentType: \"news.NewsPage\", order: \"-first_published_at\", limit: 1000) {\n ... on NewsPage {\n ...News\n }\n }\n }\n": types.NewsDocument, "\n query newsIndexMetadata {\n index: newsIndex {\n ... on NewsIndex {\n ...NewsIndex\n }\n }\n }\n": types.NewsIndexMetadataDocument,
"\n query news {\n index: newsIndex {\n ... on NewsIndex {\n ...NewsIndex\n }\n }\n news: pages(contentType: \"news.NewsPage\", order: \"-first_published_at\", limit: 1000) {\n ... on NewsPage {\n ...NewsListItem\n }\n }\n }\n": types.NewsDocument,
"\n query openingHoursSets {\n openingHoursSets {\n ...OpeningHoursSet\n }\n }\n": types.OpeningHoursSetsDocument, "\n query openingHoursSets {\n openingHoursSets {\n ...OpeningHoursSet\n }\n }\n": types.OpeningHoursSetsDocument,
"\n fragment OpeningHoursSet on OpeningHoursSet {\n name\n effectiveFrom\n effectiveTo\n announcement\n items {\n id\n function\n week {\n __typename\n ... on OpeningHoursWeekBlock {\n ...OpeningHoursWeekBlock\n }\n }\n }\n }\n": types.OpeningHoursSetFragmentDoc, "\n fragment OpeningHoursSet on OpeningHoursSet {\n name\n effectiveFrom\n effectiveTo\n announcement\n items {\n id\n function\n week {\n __typename\n ... on OpeningHoursWeekBlock {\n ...OpeningHoursWeekBlock\n }\n }\n }\n }\n": types.OpeningHoursSetFragmentDoc,
"\n fragment OpeningHoursRangeBlock on OpeningHoursRangeBlock {\n timeFrom\n timeTo\n custom\n }\n": types.OpeningHoursRangeBlockFragmentDoc, "\n fragment OpeningHoursRangeBlock on OpeningHoursRangeBlock {\n timeFrom\n timeTo\n custom\n }\n": types.OpeningHoursRangeBlockFragmentDoc,
@@ -183,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. * 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. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
@@ -283,7 +291,7 @@ export function graphql(source: "\n fragment Home on HomePage {\n __typename
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n"): (typeof documents)["\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n"]; export function graphql(source: "\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventListItem\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...NewsListItem\n }\n }\n }\n"): (typeof documents)["\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventListItem\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...NewsListItem\n }\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
@@ -360,6 +368,14 @@ export function graphql(source: "\n fragment EventCategory on EventCategory {\n
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function graphql(source: "\n fragment EventOrganizer on EventOrganizer {\n __typename\n id\n name\n slug\n externalUrl\n association {\n ... on AssociationPage {\n url\n }\n }\n }\n"): (typeof documents)["\n fragment EventOrganizer on EventOrganizer {\n __typename\n id\n name\n slug\n externalUrl\n association {\n ... on AssociationPage {\n url\n }\n }\n }\n"]; export function graphql(source: "\n fragment EventOrganizer on EventOrganizer {\n __typename\n id\n name\n slug\n externalUrl\n association {\n ... on AssociationPage {\n url\n }\n }\n }\n"): (typeof documents)["\n fragment EventOrganizer on EventOrganizer {\n __typename\n id\n name\n slug\n externalUrl\n association {\n ... on AssociationPage {\n url\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment EventListItem on EventPage {\n __typename\n id\n slug\n title\n subtitle\n featuredImage {\n ...Image\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n }\n }\n }\n"): (typeof documents)["\n fragment EventListItem on EventPage {\n __typename\n id\n slug\n title\n subtitle\n featuredImage {\n ...Image\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment EventOverviewItem on EventPage {\n __typename\n id\n slug\n title\n subtitle\n featuredImage {\n ...Image\n }\n categories {\n ... on EventCategory {\n ...EventCategory\n }\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n venue {\n __typename\n id\n slug\n title\n preposition\n url\n }\n venueCustom\n }\n }\n organizers {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n }\n"): (typeof documents)["\n fragment EventOverviewItem on EventPage {\n __typename\n id\n slug\n title\n subtitle\n featuredImage {\n ...Image\n }\n categories {\n ... on EventCategory {\n ...EventCategory\n }\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n venue {\n __typename\n id\n slug\n title\n preposition\n url\n }\n venueCustom\n }\n }\n organizers {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
@@ -375,7 +391,11 @@ export function graphql(source: "\n query eventIndexMetadata {\n index: even
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 futureEvents {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n eventCategories: eventCategories(limit: 5000) {\n ... on EventCategory {\n ...EventCategory\n }\n }\n eventOrganizers: eventOrganizers(limit: 5000) {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n id\n title\n slug\n preposition\n }\n }\n }\n"): (typeof documents)["\n query futureEvents {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n eventCategories: eventCategories(limit: 5000) {\n ... on EventCategory {\n ...EventCategory\n }\n }\n eventOrganizers: eventOrganizers(limit: 5000) {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n id\n title\n slug\n preposition\n }\n }\n }\n"]; export function graphql(source: "\n query futureEvents {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventOverviewItem\n }\n }\n }\n }\n eventCategories: eventCategories(limit: 5000) {\n ... on EventCategory {\n ...EventCategory\n }\n }\n eventOrganizers: eventOrganizers(limit: 5000) {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n id\n title\n slug\n preposition\n }\n }\n }\n"): (typeof documents)["\n query futureEvents {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventOverviewItem\n }\n }\n }\n }\n eventCategories: eventCategories(limit: 5000) {\n ... on EventCategory {\n ...EventCategory\n }\n }\n eventOrganizers: eventOrganizers(limit: 5000) {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n id\n title\n slug\n preposition\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment NewsListItem on NewsPage {\n __typename\n id\n slug\n title\n firstPublishedAt\n excerpt\n featuredImage {\n ...Image\n }\n }\n"): (typeof documents)["\n fragment NewsListItem on NewsPage {\n __typename\n id\n slug\n title\n firstPublishedAt\n excerpt\n featuredImage {\n ...Image\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
@@ -387,7 +407,11 @@ export function graphql(source: "\n fragment NewsIndex on NewsIndex {\n __ty
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 news {\n index: newsIndex {\n ... on NewsIndex {\n ...NewsIndex\n }\n }\n news: pages(contentType: \"news.NewsPage\", order: \"-first_published_at\", limit: 1000) {\n ... on NewsPage {\n ...News\n }\n }\n }\n"): (typeof documents)["\n query news {\n index: newsIndex {\n ... on NewsIndex {\n ...NewsIndex\n }\n }\n news: pages(contentType: \"news.NewsPage\", order: \"-first_published_at\", limit: 1000) {\n ... on NewsPage {\n ...News\n }\n }\n }\n"]; export function graphql(source: "\n query newsIndexMetadata {\n index: newsIndex {\n ... on NewsIndex {\n ...NewsIndex\n }\n }\n }\n"): (typeof documents)["\n query newsIndexMetadata {\n index: newsIndex {\n ... on NewsIndex {\n ...NewsIndex\n }\n }\n }\n"];
/**
* 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 news {\n index: newsIndex {\n ... on NewsIndex {\n ...NewsIndex\n }\n }\n news: pages(contentType: \"news.NewsPage\", order: \"-first_published_at\", limit: 1000) {\n ... on NewsPage {\n ...NewsListItem\n }\n }\n }\n"): (typeof documents)["\n query news {\n index: newsIndex {\n ... on NewsIndex {\n ...NewsIndex\n }\n }\n news: pages(contentType: \"news.NewsPage\", order: \"-first_published_at\", limit: 1000) {\n ... on NewsPage {\n ...NewsListItem\n }\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
+38 -23
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; 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 { export function formatDateRange(dates: string[]): string {
if (dates.length === 1) { if (dates.length === 1) {
return formatDate(dates[0], "d. MMMM"); return formatDate(dates[0], "d. MMMM");
+98 -24
View File
@@ -12,17 +12,24 @@ import { graphql, unmaskFragment } from "@/gql";
import { import {
type EventCategoryFragment, type EventCategoryFragment,
type EventFragment, type EventFragment,
type EventListItemFragment,
type EventOrganizerFragment, type EventOrganizerFragment,
type EventOverviewItemFragment,
} from "@/gql/graphql"; } from "@/gql/graphql";
import { PIG_NAMES, randomElement } from "@/lib/common"; import { PIG_NAMES, randomElement } from "@/lib/common";
export type EventOccurrence = EventFragment["occurrences"][number]; export type EventOccurrence = EventFragment["occurrences"][number];
export type EventListItemOccurrence = EventListItemFragment["occurrences"][number];
export type EventCategory = EventCategoryFragment; export type EventCategory = EventCategoryFragment;
export type EventOrganizer = EventOrganizerFragment; export type EventOrganizer = EventOrganizerFragment;
export type { EventFragment }; export type { EventFragment, EventListItemFragment, EventOverviewItemFragment };
export type SingularEvent = EventFragment & { type EventListable = {
occurrence: EventOccurrence; occurrences: ReadonlyArray<{ id: string | null; start: string; end: string | null }>;
};
export type SingularEvent<E extends EventListable = EventFragment> = E & {
occurrence: E["occurrences"][number];
}; };
export const EventCategoryFragmentDefinition = graphql(` export const EventCategoryFragmentDefinition = graphql(`
@@ -50,6 +57,67 @@ export const EventOrganizerFragmentDefinition = graphql(`
} }
`); `);
const EventListItemFragmentDefinition = graphql(`
fragment EventListItem on EventPage {
__typename
id
slug
title
subtitle
featuredImage {
...Image
}
occurrences(limit: 5000) {
... on EventOccurrence {
__typename
id
start
end
}
}
}
`);
const EventOverviewItemFragmentDefinition = graphql(`
fragment EventOverviewItem on EventPage {
__typename
id
slug
title
subtitle
featuredImage {
...Image
}
categories {
... on EventCategory {
...EventCategory
}
}
occurrences(limit: 5000) {
... on EventOccurrence {
__typename
id
start
end
venue {
__typename
id
slug
title
preposition
url
}
venueCustom
}
}
organizers {
... on EventOrganizer {
...EventOrganizer
}
}
}
`);
const EventFragmentDefinition = graphql(` const EventFragmentDefinition = graphql(`
fragment Event on EventPage { fragment Event on EventPage {
__typename __typename
@@ -135,7 +203,7 @@ export const eventsOverviewQuery = graphql(`
... on EventIndex { ... on EventIndex {
futureEvents { futureEvents {
... on EventPage { ... on EventPage {
...Event ...EventOverviewItem
} }
} }
} }
@@ -161,37 +229,39 @@ export const eventsOverviewQuery = graphql(`
} }
`); `);
export function getSingularEvents(events: EventFragment[]): SingularEvent[] { export function getSingularEvents<E extends EventListable>(
return events events: E[]
.map((event) => { ): SingularEvent<E>[] {
return event.occurrences.map((occurrence) => { return events.flatMap((event) =>
const eventOccurrence: any = structuredClone(event); event.occurrences.map((occurrence) => {
const eventOccurrence = structuredClone(event) as SingularEvent<E>;
eventOccurrence.occurrence = occurrence; eventOccurrence.occurrence = occurrence;
return eventOccurrence; return eventOccurrence;
});
}) })
.flat(); );
} }
export function sortSingularEvents(events: SingularEvent[]) { export function sortSingularEvents<E extends EventListable>(
events: SingularEvent<E>[]
) {
return events.sort((a, b) => return events.sort((a, b) =>
compareDates(a.occurrence.start, b.occurrence.start) compareDates(a.occurrence.start, b.occurrence.start)
); );
} }
interface EventCalendar { interface EventCalendar<E extends EventListable = EventFragment> {
[yearMonth: string]: { [yearMonth: string]: {
[week: string]: { [week: string]: {
[day: string]: SingularEvent[]; [day: string]: SingularEvent<E>[];
}; };
}; };
} }
export function organizeEventsInCalendar( export function organizeEventsInCalendar<E extends EventListable>(
events: SingularEvent[] events: SingularEvent<E>[]
): EventCalendar { ): EventCalendar<E> {
const sortedEvents = sortSingularEvents(events); const sortedEvents = sortSingularEvents(events);
const calendar: EventCalendar = {}; const calendar: EventCalendar<E> = {};
const minDate = new Date(sortedEvents[0]?.occurrence.start); const minDate = new Date(sortedEvents[0]?.occurrence.start);
const maxDate = new Date( const maxDate = new Date(
@@ -243,13 +313,15 @@ export function organizeEventsInCalendar(
return calendar; return calendar;
} }
interface EventsByDate { interface EventsByDate<E extends EventListable = EventFragment> {
[day: string]: SingularEvent[]; [day: string]: SingularEvent<E>[];
} }
export function organizeEventsByDate(events: SingularEvent[]): EventsByDate { export function organizeEventsByDate<E extends EventListable>(
events: SingularEvent<E>[]
): EventsByDate<E> {
const sortedEvents = sortSingularEvents(events); const sortedEvents = sortSingularEvents(events);
const eventsByDate: EventsByDate = {}; const eventsByDate: EventsByDate<E> = {};
sortedEvents.forEach((event) => { sortedEvents.forEach((event) => {
const start = toLocalTime(event.occurrence.start); const start = toLocalTime(event.occurrence.start);
@@ -263,7 +335,9 @@ export function organizeEventsByDate(events: SingularEvent[]): EventsByDate {
return eventsByDate; return eventsByDate;
} }
export function getFutureOccurrences(event: EventFragment): EventOccurrence[] { export function getFutureOccurrences<E extends EventListable>(
event: E
): E["occurrences"][number][] {
const today = startOfToday(); const today = startOfToday();
const occurrences = event?.occurrences ?? []; const occurrences = event?.occurrences ?? [];
const futureOccurrences = occurrences.filter((occurrence) => const futureOccurrences = occurrences.filter((occurrence) =>
@@ -272,7 +346,7 @@ export function getFutureOccurrences(event: EventFragment): EventOccurrence[] {
futureOccurrences.sort( futureOccurrences.sort(
(a, b) => parseISO(a.start).getTime() - parseISO(b.start).getTime() (a, b) => parseISO(a.start).getTime() - parseISO(b.start).getTime()
); );
return futureOccurrences as EventOccurrence[]; return futureOccurrences as E["occurrences"][number][];
} }
export function getEventPig(event: EventFragment): string | null { export function getEventPig(event: EventFragment): string | null {
+26 -2
View File
@@ -1,7 +1,21 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { NewsFragment } from "@/gql/graphql"; import { NewsFragment } from "@/gql/graphql";
export type { NewsFragment, NewsIndexFragment } from "@/gql/graphql"; export type { NewsFragment, NewsIndexFragment, NewsListItemFragment } from "@/gql/graphql";
const NewsListItemFragmentDefinition = graphql(`
fragment NewsListItem on NewsPage {
__typename
id
slug
title
firstPublishedAt
excerpt
featuredImage {
...Image
}
}
`);
const NewsFragmentDefinition = graphql(` const NewsFragmentDefinition = graphql(`
fragment News on NewsPage { fragment News on NewsPage {
@@ -35,6 +49,16 @@ const NewsIndexFragmentDefinition = graphql(`
} }
`); `);
export const newsIndexMetadataQuery = graphql(`
query newsIndexMetadata {
index: newsIndex {
... on NewsIndex {
...NewsIndex
}
}
}
`);
export const newsQuery = graphql(` export const newsQuery = graphql(`
query news { query news {
index: newsIndex { index: newsIndex {
@@ -44,7 +68,7 @@ export const newsQuery = graphql(`
} }
news: pages(contentType: "news.NewsPage", order: "-first_published_at", limit: 1000) { news: pages(contentType: "news.NewsPage", order: "-first_published_at", limit: 1000) {
... on NewsPage { ... on NewsPage {
...News ...NewsListItem
} }
} }
} }