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