Compare commits
32 Commits
447e1bd3ff
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
5d5f0879c2
|
|||
|
046099b7f1
|
|||
|
d245f2f00a
|
|||
|
8ad7df30d7
|
|||
|
dcb1a59777
|
|||
|
e3a58556f7
|
|||
|
7b84b2d480
|
|||
|
ec94d82863
|
|||
|
089970a5cd
|
|||
|
38229c97f0
|
|||
|
09d1078dce
|
|||
|
1b5483602f
|
|||
|
2c8f8a218c
|
|||
|
b5c9188488
|
|||
|
433c88c921
|
|||
|
af8c3fe768
|
|||
|
a58e2b224e
|
|||
|
c9a2720d64
|
|||
| 0b0fba174e | |||
| 10763f0b5d | |||
| f536cfc591 | |||
| 80f7641e74 | |||
| 8b5caa2bea | |||
| 5ac3c71ff0 | |||
| a3d71b18da | |||
| 7f95d8e252 | |||
| e4c0558527 | |||
| 80b9cdbc33 | |||
| 154338057d | |||
| 337407c771 | |||
| cb9b108526 | |||
| 6d712d31be |
@@ -1,3 +1,4 @@
|
||||
.vscode
|
||||
.DS_Store
|
||||
*.swp
|
||||
scratch/
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DnsCmsConfig(AppConfig):
|
||||
name = "dnscms"
|
||||
|
||||
def ready(self):
|
||||
from dnscms import signals # noqa: F401
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,7 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Min, Q, UniqueConstraint
|
||||
from django.db.models import Min, Prefetch, Q, UniqueConstraint
|
||||
from django.utils import timezone
|
||||
from django.utils.html import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -35,7 +35,11 @@ from wagtail_headless_preview.models import HeadlessMixin
|
||||
from associations.widgets import AssociationChooserWidget
|
||||
from 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
|
||||
|
||||
|
||||
@@ -45,7 +49,23 @@ class EventIndex(HeadlessMixin, Page):
|
||||
subpage_types = ["events.EventPage"]
|
||||
|
||||
def future_events(self, info, **kwargs):
|
||||
return EventPage.objects.live().future().order_by("next_occurrence")
|
||||
return (
|
||||
EventPage.objects.live()
|
||||
.future()
|
||||
.order_by("next_occurrence")
|
||||
.select_related("featured_image")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"occurrences",
|
||||
queryset=EventOccurrence.objects.select_related("venue"),
|
||||
),
|
||||
"categories",
|
||||
Prefetch(
|
||||
"organizers",
|
||||
queryset=EventOrganizer.objects.select_related("association"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
graphql_fields = [
|
||||
GraphQLCollection(
|
||||
@@ -146,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,
|
||||
@@ -200,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")
|
||||
@@ -219,7 +246,11 @@ class EventPageQuerySet(PageQuerySet):
|
||||
)
|
||||
|
||||
|
||||
EventPageManager = PageManager.from_queryset(EventPageQuerySet)
|
||||
class EventPageManager(
|
||||
DeferWPFieldsManagerMixin,
|
||||
PageManager.from_queryset(EventPageQuerySet),
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
|
||||
@@ -418,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)
|
||||
@@ -572,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")),
|
||||
],
|
||||
),
|
||||
@@ -586,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(
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 "[...]"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,8 @@ from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.utils import timezone
|
||||
|
||||
from events.admin import EventDateColumn, OrganizersColumn
|
||||
@@ -12,6 +14,7 @@ from events.models import (
|
||||
EventOrganizerLink,
|
||||
EventPage,
|
||||
)
|
||||
from events.views import EventOrganizerCreationForm
|
||||
from tests.conftest import (
|
||||
AssociationPageFactory,
|
||||
CustomImageFactory,
|
||||
@@ -102,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())
|
||||
@@ -188,6 +242,80 @@ def test_graphql_event_index_future_events_query(event_index, graphql_post):
|
||||
assert "Upcoming gig" in titles
|
||||
|
||||
|
||||
def test_future_events_does_not_have_n_plus_one_queries(
|
||||
event_index, venue, association_index, graphql_post
|
||||
):
|
||||
"""Regression test: query count for futureEvents stays bounded as events grow."""
|
||||
konsert = EventCategory.objects.create(name="Konsert", slug="konsert")
|
||||
association = AssociationPageFactory(parent=association_index, title="DNS")
|
||||
org = EventOrganizer.objects.create(name="Forening", slug="forening", association=association)
|
||||
image = CustomImageFactory(title="Cover")
|
||||
now = timezone.now()
|
||||
|
||||
for i in range(5):
|
||||
event = EventPageFactory(
|
||||
parent=event_index,
|
||||
title=f"Event {i}",
|
||||
body=[("paragraph", "<p>x</p>")],
|
||||
featured_image=image,
|
||||
)
|
||||
event.categories.add(konsert)
|
||||
EventOrganizerLink.objects.create(event=event, organizer=org)
|
||||
EventOccurrence.objects.create(
|
||||
event=event,
|
||||
start=now + timedelta(days=i + 1),
|
||||
venue=venue,
|
||||
)
|
||||
|
||||
home_query = """
|
||||
query {
|
||||
eventIndex {
|
||||
futureEvents {
|
||||
id
|
||||
title
|
||||
subtitle
|
||||
body { blockType }
|
||||
featuredImage { url }
|
||||
occurrences { start end venueCustom venue { title } }
|
||||
categories { name slug }
|
||||
organizers { name slug association { title } }
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response, body = graphql_post(home_query)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "errors" not in body, body
|
||||
assert len(body["data"]["eventIndex"]["futureEvents"]) == 5
|
||||
|
||||
# Bump only alongside an intentional resolver change.
|
||||
max_queries = 6
|
||||
assert len(ctx) <= max_queries, (
|
||||
f"futureEvents took {len(ctx)} queries for 5 events — likely N+1. "
|
||||
f"Captured queries:\n"
|
||||
+ "\n".join(f" {i + 1}. {q['sql'][:120]}" for i, q in enumerate(ctx.captured_queries))
|
||||
)
|
||||
|
||||
|
||||
def test_future_events_does_not_load_wp_import_fields(event_index, graphql_post):
|
||||
"""wp_* columns must stay deferred and lazy-load on explicit access."""
|
||||
event = EventPageFactory(parent=event_index, wp_raw_content="marker")
|
||||
EventOccurrence.objects.create(
|
||||
event=event, start=timezone.now() + timedelta(days=1), venue_custom="X"
|
||||
)
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response, body = graphql_post("{ eventIndex { futureEvents { id } } }")
|
||||
|
||||
assert response.status_code == 200 and "errors" not in body, body
|
||||
sql = "\n".join(q["sql"] for q in ctx.captured_queries)
|
||||
assert "wp_raw_content" not in sql, f"wp_* must be deferred. SQL:\n{sql}"
|
||||
assert EventPage.objects.get(pk=event.pk).wp_raw_content == "marker"
|
||||
|
||||
|
||||
def test_graphql_event_index_future_events_ordered_by_next_occurrence(event_index, graphql_post):
|
||||
now = timezone.now()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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'},
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +16,7 @@ const nextConfig = {
|
||||
hostname: "**",
|
||||
},
|
||||
],
|
||||
formats: ["image/avif", "image/webp"],
|
||||
dangerouslyAllowLocalIP: process.env.NODE_ENV === "development",
|
||||
},
|
||||
turbopack: {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,51 @@
|
||||
grisemonster image variants
|
||||
===========================
|
||||
|
||||
Source: grisemonster-lossless-original.png (2048x1024 RGBA PNG, 2.0M)
|
||||
|
||||
Files in use
|
||||
------------
|
||||
grisemonster-2048.png 443K desktop (>1280px viewport)
|
||||
grisemonster-1280.png 227K tablet (601-1280px)
|
||||
grisemonster-800.png 97K mobile (<=600px)
|
||||
|
||||
The previous single grisemonster.png was 624K served to every device.
|
||||
Mobile now downloads 97K (-84%); desktop downloads 443K (-29%).
|
||||
|
||||
How they were generated
|
||||
-----------------------
|
||||
For each width W in {2048, 1280, 800}:
|
||||
|
||||
magick grisemonster-lossless-original.png -resize ${W}x base.png
|
||||
pngquant 64 --quality=50-80 --speed 1 --output grisemonster-${W}.png base.png
|
||||
oxipng -o max --strip safe grisemonster-${W}.png
|
||||
|
||||
pngquant reduces to a 64-colour palette (the image is a flat
|
||||
illustration with very few distinct colours, so this is visually
|
||||
lossless at the displayed scale). oxipng then does a lossless
|
||||
re-encode pass to squeeze the PNG further.
|
||||
|
||||
Wired up in src/components/layout/footer.module.scss via two
|
||||
min-width media queries on `.pigPattern`'s background-image.
|
||||
|
||||
Why PNG, not WebP/AVIF
|
||||
----------------------
|
||||
Tested at 2048 width:
|
||||
|
||||
PNG (pngquant 64 + oxipng) 443K <-- chosen
|
||||
PNG (pngquant 256 + oxipng) 522K
|
||||
AVIF q50 493K
|
||||
AVIF q60 598K
|
||||
AVIF q75 718K
|
||||
WebP q75 829K larger than current PNG
|
||||
WebP q85 903K larger than current PNG
|
||||
WebP lossless 1.0M larger than current PNG
|
||||
|
||||
For this kind of content -- a flat, limited-palette illustration with
|
||||
large transparent regions -- palette PNG is the smallest format.
|
||||
WebP and AVIF are tuned for photographic content and lose to a
|
||||
well-quantised PNG here. Visual quality of the three PNG/AVIF options
|
||||
above was indistinguishable at the rendered scale.
|
||||
|
||||
Conclusion: the win came from responsive sizing, not from changing
|
||||
format. Stayed on PNG to keep the CSS simple (no image-set() needed).
|
||||
|
Before Width: | Height: | Size: 610 KiB |
@@ -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() {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -34,7 +34,23 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="no">
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://use.typekit.net/spa5smt.css" />
|
||||
<link rel="preconnect" href="https://use.typekit.net" crossOrigin="anonymous" />
|
||||
<link rel="preconnect" href="https://p.typekit.net" crossOrigin="anonymous" />
|
||||
{/*
|
||||
Load Adobe Fonts without blocking render: the stylesheet is requested
|
||||
with media="print" (fetched but not applied), then the inline script
|
||||
below swaps it to media="all" once it has loaded.
|
||||
*/}
|
||||
<link
|
||||
id="typekit-css"
|
||||
rel="stylesheet"
|
||||
href="https://use.typekit.net/spa5smt.css"
|
||||
media="print"
|
||||
suppressHydrationWarning
|
||||
/>
|
||||
<script>
|
||||
{`(function(){var l=document.getElementById('typekit-css');if(!l)return;function s(){l.media='all'}if(l.sheet){s()}else{l.addEventListener('load',s)}})();`}
|
||||
</script>
|
||||
{process.env.UMAMI_SCRIPT_URL && process.env.UMAMI_WEBSITE_ID && (
|
||||
<script
|
||||
defer
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { SearchContainer } from "@/components/search/SearchContainer";
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
type SearchResult,
|
||||
SearchResults,
|
||||
} from "@/components/search/SearchResults";
|
||||
import { SearchShell } from "@/components/search/SearchShell";
|
||||
import { graphql } from "@/gql";
|
||||
|
||||
// TODO: seo metadata?
|
||||
|
||||
@@ -13,7 +16,9 @@ export default async function Page({
|
||||
}>;
|
||||
}) {
|
||||
const { q: query } = (await searchParams) ?? {};
|
||||
let results = [];
|
||||
let results: SearchResult[] = [];
|
||||
let totalCount = 0;
|
||||
const RESULT_LIMIT = 500;
|
||||
|
||||
if (query) {
|
||||
const searchQuery = graphql(`
|
||||
@@ -21,45 +26,62 @@ export default async function Page({
|
||||
results: search(query: $query) {
|
||||
__typename
|
||||
... on PageInterface {
|
||||
slug
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
... on NewsPage {
|
||||
id
|
||||
title
|
||||
excerpt
|
||||
featuredImage {
|
||||
...Image
|
||||
}
|
||||
firstPublishedAt
|
||||
}
|
||||
... on EventPage {
|
||||
id
|
||||
title
|
||||
subtitle
|
||||
featuredImage {
|
||||
...Image
|
||||
}
|
||||
occurrences {
|
||||
start
|
||||
}
|
||||
}
|
||||
... on GenericPage {
|
||||
id
|
||||
title
|
||||
lead
|
||||
}
|
||||
... on VenuePage {
|
||||
id
|
||||
title
|
||||
featuredImage {
|
||||
...Image
|
||||
}
|
||||
}
|
||||
... on AssociationPage {
|
||||
id
|
||||
title
|
||||
excerpt
|
||||
associationType
|
||||
logo {
|
||||
...Image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const { data, error } = await getClient().query(searchQuery, {
|
||||
query: query,
|
||||
});
|
||||
|
||||
results = (data?.results ?? []) as any;
|
||||
const { data } = await getClient().query(searchQuery, { query });
|
||||
const all = (data?.results ?? []) as SearchResult[];
|
||||
totalCount = all.length;
|
||||
results = all.slice(0, RESULT_LIMIT);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="site-main" id="main">
|
||||
<Suspense key={query}>
|
||||
<SearchContainer query={query ?? ""} results={results} />
|
||||
</Suspense>
|
||||
<SearchShell initialQuery={query ?? ""}>
|
||||
{query ? (
|
||||
<SearchResults
|
||||
results={results}
|
||||
totalCount={totalCount}
|
||||
query={query}
|
||||
/>
|
||||
) : null}
|
||||
</SearchShell>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,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";
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Image } from "@/components/general/Image";
|
||||
import {
|
||||
SingularEvent,
|
||||
EventFragment,
|
||||
EventListItemFragment,
|
||||
EventOverviewItemFragment,
|
||||
getFutureOccurrences,
|
||||
} from "@/lib/event";
|
||||
import {
|
||||
@@ -21,11 +23,19 @@ export const EventItem = ({
|
||||
mode,
|
||||
size,
|
||||
imageLoading,
|
||||
imageFetchPriority,
|
||||
}: {
|
||||
event: SingularEvent | EventFragment;
|
||||
event:
|
||||
| SingularEvent
|
||||
| SingularEvent<EventListItemFragment>
|
||||
| SingularEvent<EventOverviewItemFragment>
|
||||
| EventFragment
|
||||
| EventListItemFragment
|
||||
| EventOverviewItemFragment;
|
||||
mode: "list" | "calendar" | "singular-time-only";
|
||||
size?: "small" | "medium" | "large";
|
||||
imageLoading?: "eager" | "lazy";
|
||||
imageFetchPriority?: "high" | "low" | "auto";
|
||||
}) => {
|
||||
const futureOccurrences = getFutureOccurrences(event);
|
||||
const groupedOccurrences = groupConsecutiveDates(
|
||||
@@ -48,8 +58,9 @@ 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { EventFragment } from "@/gql/graphql";
|
||||
import { EventListItemFragment } from "@/gql/graphql";
|
||||
import { EventItem } from "./EventItem";
|
||||
import styles from "./featuredEvents.module.scss";
|
||||
import { SectionHeader } from "../general/SectionHeader";
|
||||
import { SectionFooter } from "../general/SectionFooter";
|
||||
|
||||
export const FeaturedEvents = ({ events }: { events: EventFragment[] }) => {
|
||||
export const FeaturedEvents = ({ events }: { events: EventListItemFragment[] }) => {
|
||||
return (
|
||||
<section className={styles.featuredEvents}>
|
||||
<SectionHeader heading="Arrangementer" link="/arrangementer" linkText="Se alle arrangementer" />
|
||||
<ul className={styles.eventList}>
|
||||
{events.slice(0, 3).map((event) => (
|
||||
<EventItem key={event.id} event={event} mode="list" imageLoading="eager" />
|
||||
<EventItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
mode="list"
|
||||
imageLoading="eager"
|
||||
imageFetchPriority="high"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<SectionFooter link="/arrangementer" linkText="Se alle arrangementer" />
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { EventFragment } from "@/gql/graphql";
|
||||
import { isTodayOrFuture, formatDate } from "@/lib/date";
|
||||
import { parse } from "date-fns";
|
||||
import { EventListItemFragment } from "@/gql/graphql";
|
||||
import { isTodayOrFuture } from "@/lib/date";
|
||||
import {
|
||||
getSingularEvents,
|
||||
organizeEventsByDate,
|
||||
sortSingularEvents,
|
||||
} from "@/lib/event";
|
||||
import Link from "next/link";
|
||||
import { EventItem } from "./EventItem";
|
||||
import styles from "./upcomingEvents.module.scss";
|
||||
import { SectionHeader } from "../general/SectionHeader";
|
||||
import { SectionFooter } from "../general/SectionFooter";
|
||||
|
||||
export const UpcomingEvents = ({ events }: { events: EventFragment[] }) => {
|
||||
export const UpcomingEvents = ({ events }: { events: EventListItemFragment[] }) => {
|
||||
const upcomingSingularEvents = sortSingularEvents(
|
||||
getSingularEvents(events).filter((event) =>
|
||||
isTodayOrFuture(event.occurrence.start)
|
||||
|
||||
@@ -2,8 +2,8 @@ import Link from "next/link";
|
||||
import { graphql } from "@/gql";
|
||||
import { HomeFragment } from "@/gql/graphql";
|
||||
import { getClient } from "@/app/client";
|
||||
import { EventFragment } from "@/lib/event";
|
||||
import { NewsFragment } from "@/lib/news";
|
||||
import { EventListItemFragment } from "@/lib/event";
|
||||
import { NewsListItemFragment } from "@/lib/news";
|
||||
import { FeaturedEvents } from "@/components/events/FeaturedEvents";
|
||||
import { UpcomingEvents } from "@/components/events/UpcomingEvents";
|
||||
import { Icon } from "@/components/general/Icon";
|
||||
@@ -28,7 +28,7 @@ const homeQuery = graphql(`
|
||||
... on EventIndex {
|
||||
futureEvents {
|
||||
... on EventPage {
|
||||
...Event
|
||||
...EventListItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ const homeQuery = graphql(`
|
||||
}
|
||||
news: pages(contentType: "news.newsPage", order: "-first_published_at", limit: 4) {
|
||||
... on NewsPage {
|
||||
...News
|
||||
...NewsListItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,8 @@ const homeQuery = graphql(`
|
||||
|
||||
export type HomePageViewProps = {
|
||||
home: HomeFragment;
|
||||
events: EventFragment[];
|
||||
news: NewsFragment[];
|
||||
events: EventListItemFragment[];
|
||||
news: NewsListItemFragment[];
|
||||
};
|
||||
|
||||
export async function loadHomePageProps(overrides?: {
|
||||
@@ -59,8 +59,8 @@ export async function loadHomePageProps(overrides?: {
|
||||
if (error) throw new Error(error.message);
|
||||
const home = overrides?.homeOverride ?? (data?.home as HomeFragment | undefined);
|
||||
if (!home) throw new Error("Failed to load /");
|
||||
const events = (data?.events?.futureEvents ?? []) as EventFragment[];
|
||||
const news = (data?.news ?? []) as NewsFragment[];
|
||||
const events = (data?.events?.futureEvents ?? []) as EventListItemFragment[];
|
||||
const news = (data?.news ?? []) as NewsListItemFragment[];
|
||||
return { home, events, news };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import styles from "./footer.module.scss";
|
||||
import { NeonChillPig } from "../general/pigs/fun/NeonChillPig";
|
||||
import { ToTop } from "./ToTop";
|
||||
import { Icon } from "../general/Icon";
|
||||
import {
|
||||
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,15 +141,11 @@ export const Footer = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.pig}>
|
||||
{/* <div className={styles.pig}>
|
||||
<NeonChillPig />
|
||||
</div>
|
||||
</div> */}
|
||||
</footer>
|
||||
<div className={styles.pigPattern}>
|
||||
<div className={styles.toTop}>
|
||||
<ToTop />
|
||||
</div>
|
||||
</div>
|
||||
<PigPattern />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import styles from "./footer.module.scss";
|
||||
import { ToTop } from "./ToTop";
|
||||
|
||||
export const PigPattern = () => {
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
rootMargin: "1000px",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${styles.pigPattern} ${inView ? styles.loaded : ""}`}
|
||||
>
|
||||
<div className={styles.toTop}>
|
||||
<ToTop />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -101,7 +101,6 @@
|
||||
|
||||
.pigPattern {
|
||||
background-color: var(--color-background);
|
||||
background-image: url("/assets/graphics/grisemonster.png");
|
||||
background-size: 100% auto;
|
||||
background-position: 0 100%;
|
||||
background-attachment: fixed;
|
||||
@@ -111,6 +110,18 @@
|
||||
position: relative;
|
||||
z-index: 700;
|
||||
|
||||
&.loaded {
|
||||
background-image: url("/assets/graphics/grisemonster-800.png");
|
||||
|
||||
@media (min-width: 601px) {
|
||||
background-image: url("/assets/graphics/grisemonster-1280.png");
|
||||
}
|
||||
|
||||
@media (min-width: 1281px) {
|
||||
background-image: url("/assets/graphics/grisemonster-2048.png");
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
background-size: 100% auto;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
transition: transform .6s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-animate=false] {
|
||||
.mainMenu {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
|
||||
@@ -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,10 +1,10 @@
|
||||
import styles from "./newsItem.module.scss";
|
||||
import { Image } from "@/components/general/Image";
|
||||
import { NewsFragment } from "@/lib/news";
|
||||
import { NewsFragment, NewsListItemFragment } from "@/lib/news";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import Link from "next/link";
|
||||
|
||||
export const NewsItem = ({ news }: { news: NewsFragment }) => {
|
||||
export const NewsItem = ({ news }: { news: NewsFragment | NewsListItemFragment }) => {
|
||||
const featuredImage: any = news.featuredImage;
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
import { SectionHeader } from "../general/SectionHeader";
|
||||
import { NewsItem } from "./NewsItem";
|
||||
import styles from "./newsList.module.scss";
|
||||
import { NewsFragment } from "@/lib/news";
|
||||
import { NewsFragment, NewsListItemFragment } from "@/lib/news";
|
||||
import { SectionFooter } from "../general/SectionFooter";
|
||||
|
||||
export const NewsList = ({
|
||||
@@ -11,7 +11,7 @@ export const NewsList = ({
|
||||
heading,
|
||||
featured
|
||||
}: {
|
||||
news: NewsFragment[];
|
||||
news: (NewsFragment | NewsListItemFragment)[];
|
||||
heading?: string;
|
||||
featured?: boolean;
|
||||
}) => {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
"use client";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { PageHeader } from "../general/PageHeader";
|
||||
import { useSearchParams, usePathname, useRouter } from "next/navigation";
|
||||
import { getSearchPath } from "@/lib/common";
|
||||
import styles from './searchContainer.module.scss';
|
||||
import { Icon } from "../general/Icon";
|
||||
import Link from "next/link";
|
||||
|
||||
export function SearchContainer({
|
||||
query,
|
||||
results,
|
||||
}: {
|
||||
query: string;
|
||||
results: any;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const { replace } = useRouter();
|
||||
|
||||
const onQueryChange = useDebouncedCallback((query) => {
|
||||
replace(getSearchPath(query));
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
<div className={styles.searchContainer}>
|
||||
<PageHeader heading="Søk" />
|
||||
<div className={styles.searchField}>
|
||||
<input
|
||||
name="query"
|
||||
type="text"
|
||||
autoFocus
|
||||
defaultValue={query ?? ""}
|
||||
onChange={(e) => {
|
||||
onQueryChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className={styles.searchIcon}>
|
||||
<Icon type="search" />
|
||||
</div>
|
||||
</div>
|
||||
{query && <SearchResults results={results} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function capitalizeFirstLetter(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function linkTo(page: any): string | null {
|
||||
if (page.__typename === "EventPage") {
|
||||
return `/arrangementer/${page.slug}`;
|
||||
}
|
||||
if (page.__typename === "NewsPage") {
|
||||
return `/aktuelt/${page.slug}`;
|
||||
}
|
||||
if (page.__typename === "AssociationPage") {
|
||||
return `/foreninger/${page.slug}`;
|
||||
}
|
||||
if (page.__typename === "GenericPage") {
|
||||
return `/{page.slug}`;
|
||||
}
|
||||
if (page.__typename === "VenuePage") {
|
||||
return `/lokaler/${page.slug}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const PAGE_TYPES: Record<string, string> = {
|
||||
NewsPage: "Nyhet",
|
||||
EventPage: "Arrangement",
|
||||
GenericPage: "Underside",
|
||||
VenuePage: "Lokale",
|
||||
AssociationPage: "Forening",
|
||||
};
|
||||
|
||||
function SearchResults({ results }: { results: any }) {
|
||||
if (!results.length) {
|
||||
return <div className={styles.noResults}>Ingen resultater</div>;
|
||||
}
|
||||
const supportedResults = results.filter(
|
||||
(result: any) =>
|
||||
!!result?.id && Object.keys(PAGE_TYPES).includes(result.__typename)
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<p className={styles.resultsCounter}>{results.length} resultater</p>
|
||||
{supportedResults.map((result: any) => {
|
||||
let resultType = PAGE_TYPES[result.__typename] ?? "";
|
||||
if (result.__typename === "AssociationPage") {
|
||||
resultType = capitalizeFirstLetter(result?.associationType);
|
||||
}
|
||||
const link = linkTo(result);
|
||||
const ResultItem = () => (
|
||||
<div className={styles.resultItem}>
|
||||
<span className={styles.suphead}>{resultType}</span>
|
||||
<h2 className={styles.title}>{result.title}</h2>
|
||||
</div>
|
||||
);
|
||||
if (link) {
|
||||
return (
|
||||
<Link key={result.id} href={link}>
|
||||
<ResultItem />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <ResultItem key={result.id} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 på «{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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
@@ -45,7 +45,7 @@ type Documents = {
|
||||
"\n fragment Generic on GenericPage {\n __typename\n id\n urlPath\n seoTitle\n searchDescription\n title\n lead\n pig\n body {\n ...Blocks\n }\n }\n": typeof types.GenericFragmentDoc,
|
||||
"\n query genericPageByUrl($urlPath: String!) {\n page: page(contentType: \"generic.GenericPage\", urlPath: $urlPath) {\n ... on GenericPage {\n ...Generic\n }\n }\n }\n": typeof types.GenericPageByUrlDocument,
|
||||
"\n fragment Home on HomePage {\n __typename\n featuredEvents {\n id\n }\n }\n": typeof types.HomeFragmentDoc,
|
||||
"\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n": typeof types.HomeDocument,
|
||||
"\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventListItem\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...NewsListItem\n }\n }\n }\n": typeof types.HomeDocument,
|
||||
"\n query newsBySlug($slug: String!) {\n news: page(contentType: \"news.NewsPage\", slug: $slug) {\n ... on NewsPage {\n ...News\n }\n }\n }\n": typeof types.NewsBySlugDocument,
|
||||
"\n fragment Sponsor on SponsorBlock {\n id\n name\n logo {\n ...Image\n }\n text\n website\n }\n": typeof types.SponsorFragmentDoc,
|
||||
"\n fragment SponsorsPage on SponsorsPage {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n sponsors {\n ... on SponsorBlock {\n ...Sponsor\n }\n }\n }\n": typeof types.SponsorsPageFragmentDoc,
|
||||
@@ -65,13 +65,17 @@ type Documents = {
|
||||
"\n fragment ContactEntity on ContactEntity {\n id\n name\n contactType\n title\n email\n phoneNumber\n image {\n ...Image\n }\n }\n": typeof types.ContactEntityFragmentDoc,
|
||||
"\n fragment 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,
|
||||
@@ -84,7 +88,7 @@ const documents: Documents = {
|
||||
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": types.AllAssociationSlugsDocument,
|
||||
"\n query 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,
|
||||
@@ -109,7 +113,7 @@ const documents: Documents = {
|
||||
"\n fragment Generic on GenericPage {\n __typename\n id\n urlPath\n seoTitle\n searchDescription\n title\n lead\n pig\n body {\n ...Blocks\n }\n }\n": types.GenericFragmentDoc,
|
||||
"\n query genericPageByUrl($urlPath: String!) {\n page: page(contentType: \"generic.GenericPage\", urlPath: $urlPath) {\n ... on GenericPage {\n ...Generic\n }\n }\n }\n": types.GenericPageByUrlDocument,
|
||||
"\n fragment Home on HomePage {\n __typename\n featuredEvents {\n id\n }\n }\n": types.HomeFragmentDoc,
|
||||
"\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n": types.HomeDocument,
|
||||
"\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventListItem\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...NewsListItem\n }\n }\n }\n": types.HomeDocument,
|
||||
"\n query newsBySlug($slug: String!) {\n news: page(contentType: \"news.NewsPage\", slug: $slug) {\n ... on NewsPage {\n ...News\n }\n }\n }\n": types.NewsBySlugDocument,
|
||||
"\n fragment Sponsor on SponsorBlock {\n id\n name\n logo {\n ...Image\n }\n text\n website\n }\n": types.SponsorFragmentDoc,
|
||||
"\n fragment SponsorsPage on SponsorsPage {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n sponsors {\n ... on SponsorBlock {\n ...Sponsor\n }\n }\n }\n": types.SponsorsPageFragmentDoc,
|
||||
@@ -129,13 +133,17 @@ const documents: Documents = {
|
||||
"\n fragment ContactEntity on ContactEntity {\n id\n name\n contactType\n title\n email\n phoneNumber\n image {\n ...Image\n }\n }\n": types.ContactEntityFragmentDoc,
|
||||
"\n fragment 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,
|
||||
@@ -183,7 +191,7 @@ export function graphql(source: "\n query previewPage($token: String!) {\n p
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -283,7 +291,7 @@ export function graphql(source: "\n fragment Home on HomePage {\n __typename
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n"): (typeof documents)["\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventListItem\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...NewsListItem\n }\n }\n }\n"): (typeof documents)["\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventListItem\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...NewsListItem\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -360,6 +368,14 @@ export function graphql(source: "\n fragment EventCategory on EventCategory {\n
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment EventOrganizer on EventOrganizer {\n __typename\n id\n name\n slug\n externalUrl\n association {\n ... on AssociationPage {\n url\n }\n }\n }\n"): (typeof documents)["\n fragment EventOrganizer on EventOrganizer {\n __typename\n id\n name\n slug\n externalUrl\n association {\n ... on AssociationPage {\n url\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment EventListItem on EventPage {\n __typename\n id\n slug\n title\n subtitle\n featuredImage {\n ...Image\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n }\n }\n }\n"): (typeof documents)["\n fragment EventListItem on EventPage {\n __typename\n id\n slug\n title\n subtitle\n featuredImage {\n ...Image\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment EventOverviewItem on EventPage {\n __typename\n id\n slug\n title\n subtitle\n featuredImage {\n ...Image\n }\n categories {\n ... on EventCategory {\n ...EventCategory\n }\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n venue {\n __typename\n id\n slug\n title\n preposition\n url\n }\n venueCustom\n }\n }\n organizers {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n }\n"): (typeof documents)["\n fragment EventOverviewItem on EventPage {\n __typename\n id\n slug\n title\n subtitle\n featuredImage {\n ...Image\n }\n categories {\n ... on EventCategory {\n ...EventCategory\n }\n }\n occurrences(limit: 5000) {\n ... on EventOccurrence {\n __typename\n id\n start\n end\n venue {\n __typename\n id\n slug\n title\n preposition\n url\n }\n venueCustom\n }\n }\n organizers {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -375,7 +391,11 @@ export function graphql(source: "\n query eventIndexMetadata {\n index: even
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query futureEvents {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n eventCategories: eventCategories(limit: 5000) {\n ... on EventCategory {\n ...EventCategory\n }\n }\n eventOrganizers: eventOrganizers(limit: 5000) {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n id\n title\n slug\n preposition\n }\n }\n }\n"): (typeof documents)["\n query futureEvents {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n eventCategories: eventCategories(limit: 5000) {\n ... on EventCategory {\n ...EventCategory\n }\n }\n eventOrganizers: eventOrganizers(limit: 5000) {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n id\n title\n slug\n preposition\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n query futureEvents {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventOverviewItem\n }\n }\n }\n }\n eventCategories: eventCategories(limit: 5000) {\n ... on EventCategory {\n ...EventCategory\n }\n }\n eventOrganizers: eventOrganizers(limit: 5000) {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n id\n title\n slug\n preposition\n }\n }\n }\n"): (typeof documents)["\n query futureEvents {\n index: eventIndex {\n ... on EventIndex {\n ...EventIndex\n }\n }\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...EventOverviewItem\n }\n }\n }\n }\n eventCategories: eventCategories(limit: 5000) {\n ... on EventCategory {\n ...EventCategory\n }\n }\n eventOrganizers: eventOrganizers(limit: 5000) {\n ... on EventOrganizer {\n ...EventOrganizer\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n id\n title\n slug\n preposition\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment NewsListItem on NewsPage {\n __typename\n id\n slug\n title\n firstPublishedAt\n excerpt\n featuredImage {\n ...Image\n }\n }\n"): (typeof documents)["\n fragment NewsListItem on NewsPage {\n __typename\n id\n slug\n title\n firstPublishedAt\n excerpt\n featuredImage {\n ...Image\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -387,7 +407,11 @@ export function graphql(source: "\n fragment NewsIndex on NewsIndex {\n __ty
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -12,17 +12,24 @@ import { graphql, unmaskFragment } from "@/gql";
|
||||
import {
|
||||
type EventCategoryFragment,
|
||||
type EventFragment,
|
||||
type EventListItemFragment,
|
||||
type EventOrganizerFragment,
|
||||
type EventOverviewItemFragment,
|
||||
} from "@/gql/graphql";
|
||||
import { PIG_NAMES, randomElement } from "@/lib/common";
|
||||
|
||||
export type EventOccurrence = EventFragment["occurrences"][number];
|
||||
export type EventListItemOccurrence = EventListItemFragment["occurrences"][number];
|
||||
export type EventCategory = EventCategoryFragment;
|
||||
export type EventOrganizer = EventOrganizerFragment;
|
||||
export type { EventFragment };
|
||||
export type { EventFragment, EventListItemFragment, EventOverviewItemFragment };
|
||||
|
||||
export type SingularEvent = EventFragment & {
|
||||
occurrence: EventOccurrence;
|
||||
type EventListable = {
|
||||
occurrences: ReadonlyArray<{ id: string | null; start: string; end: string | null }>;
|
||||
};
|
||||
|
||||
export type SingularEvent<E extends EventListable = EventFragment> = E & {
|
||||
occurrence: E["occurrences"][number];
|
||||
};
|
||||
|
||||
export const EventCategoryFragmentDefinition = graphql(`
|
||||
@@ -50,6 +57,67 @@ export const EventOrganizerFragmentDefinition = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
const EventListItemFragmentDefinition = graphql(`
|
||||
fragment EventListItem on EventPage {
|
||||
__typename
|
||||
id
|
||||
slug
|
||||
title
|
||||
subtitle
|
||||
featuredImage {
|
||||
...Image
|
||||
}
|
||||
occurrences(limit: 5000) {
|
||||
... on EventOccurrence {
|
||||
__typename
|
||||
id
|
||||
start
|
||||
end
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const EventOverviewItemFragmentDefinition = graphql(`
|
||||
fragment EventOverviewItem on EventPage {
|
||||
__typename
|
||||
id
|
||||
slug
|
||||
title
|
||||
subtitle
|
||||
featuredImage {
|
||||
...Image
|
||||
}
|
||||
categories {
|
||||
... on EventCategory {
|
||||
...EventCategory
|
||||
}
|
||||
}
|
||||
occurrences(limit: 5000) {
|
||||
... on EventOccurrence {
|
||||
__typename
|
||||
id
|
||||
start
|
||||
end
|
||||
venue {
|
||||
__typename
|
||||
id
|
||||
slug
|
||||
title
|
||||
preposition
|
||||
url
|
||||
}
|
||||
venueCustom
|
||||
}
|
||||
}
|
||||
organizers {
|
||||
... on EventOrganizer {
|
||||
...EventOrganizer
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const EventFragmentDefinition = graphql(`
|
||||
fragment Event on EventPage {
|
||||
__typename
|
||||
@@ -135,7 +203,7 @@ export const eventsOverviewQuery = graphql(`
|
||||
... on EventIndex {
|
||||
futureEvents {
|
||||
... on EventPage {
|
||||
...Event
|
||||
...EventOverviewItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,37 +229,39 @@ export const eventsOverviewQuery = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
export function getSingularEvents(events: EventFragment[]): SingularEvent[] {
|
||||
return events
|
||||
.map((event) => {
|
||||
return event.occurrences.map((occurrence) => {
|
||||
const eventOccurrence: any = structuredClone(event);
|
||||
export function getSingularEvents<E extends EventListable>(
|
||||
events: E[]
|
||||
): SingularEvent<E>[] {
|
||||
return events.flatMap((event) =>
|
||||
event.occurrences.map((occurrence) => {
|
||||
const eventOccurrence = structuredClone(event) as SingularEvent<E>;
|
||||
eventOccurrence.occurrence = occurrence;
|
||||
return eventOccurrence;
|
||||
});
|
||||
})
|
||||
.flat();
|
||||
);
|
||||
}
|
||||
|
||||
export function sortSingularEvents(events: SingularEvent[]) {
|
||||
export function sortSingularEvents<E extends EventListable>(
|
||||
events: SingularEvent<E>[]
|
||||
) {
|
||||
return events.sort((a, b) =>
|
||||
compareDates(a.occurrence.start, b.occurrence.start)
|
||||
);
|
||||
}
|
||||
interface EventCalendar {
|
||||
interface EventCalendar<E extends EventListable = EventFragment> {
|
||||
[yearMonth: string]: {
|
||||
[week: string]: {
|
||||
[day: string]: SingularEvent[];
|
||||
[day: string]: SingularEvent<E>[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function organizeEventsInCalendar(
|
||||
events: SingularEvent[]
|
||||
): EventCalendar {
|
||||
export function organizeEventsInCalendar<E extends EventListable>(
|
||||
events: SingularEvent<E>[]
|
||||
): EventCalendar<E> {
|
||||
const sortedEvents = sortSingularEvents(events);
|
||||
|
||||
const calendar: EventCalendar = {};
|
||||
const calendar: EventCalendar<E> = {};
|
||||
|
||||
const minDate = new Date(sortedEvents[0]?.occurrence.start);
|
||||
const maxDate = new Date(
|
||||
@@ -243,13 +313,15 @@ export function organizeEventsInCalendar(
|
||||
return calendar;
|
||||
}
|
||||
|
||||
interface EventsByDate {
|
||||
[day: string]: SingularEvent[];
|
||||
interface EventsByDate<E extends EventListable = EventFragment> {
|
||||
[day: string]: SingularEvent<E>[];
|
||||
}
|
||||
|
||||
export function organizeEventsByDate(events: SingularEvent[]): EventsByDate {
|
||||
export function organizeEventsByDate<E extends EventListable>(
|
||||
events: SingularEvent<E>[]
|
||||
): EventsByDate<E> {
|
||||
const sortedEvents = sortSingularEvents(events);
|
||||
const eventsByDate: EventsByDate = {};
|
||||
const eventsByDate: EventsByDate<E> = {};
|
||||
|
||||
sortedEvents.forEach((event) => {
|
||||
const start = toLocalTime(event.occurrence.start);
|
||||
@@ -263,7 +335,9 @@ export function organizeEventsByDate(events: SingularEvent[]): EventsByDate {
|
||||
return eventsByDate;
|
||||
}
|
||||
|
||||
export function getFutureOccurrences(event: EventFragment): EventOccurrence[] {
|
||||
export function getFutureOccurrences<E extends EventListable>(
|
||||
event: E
|
||||
): E["occurrences"][number][] {
|
||||
const today = startOfToday();
|
||||
const occurrences = event?.occurrences ?? [];
|
||||
const futureOccurrences = occurrences.filter((occurrence) =>
|
||||
@@ -272,7 +346,7 @@ export function getFutureOccurrences(event: EventFragment): EventOccurrence[] {
|
||||
futureOccurrences.sort(
|
||||
(a, b) => parseISO(a.start).getTime() - parseISO(b.start).getTime()
|
||||
);
|
||||
return futureOccurrences as EventOccurrence[];
|
||||
return futureOccurrences as E["occurrences"][number][];
|
||||
}
|
||||
|
||||
export function getEventPig(event: EventFragment): string | null {
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { graphql } from "@/gql";
|
||||
import { NewsFragment } from "@/gql/graphql";
|
||||
|
||||
export type { NewsFragment, NewsIndexFragment } from "@/gql/graphql";
|
||||
export type { NewsFragment, NewsIndexFragment, NewsListItemFragment } from "@/gql/graphql";
|
||||
|
||||
const NewsListItemFragmentDefinition = graphql(`
|
||||
fragment NewsListItem on NewsPage {
|
||||
__typename
|
||||
id
|
||||
slug
|
||||
title
|
||||
firstPublishedAt
|
||||
excerpt
|
||||
featuredImage {
|
||||
...Image
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const NewsFragmentDefinition = graphql(`
|
||||
fragment News on NewsPage {
|
||||
@@ -35,6 +49,16 @@ const NewsIndexFragmentDefinition = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
export const newsIndexMetadataQuery = graphql(`
|
||||
query newsIndexMetadata {
|
||||
index: newsIndex {
|
||||
... on NewsIndex {
|
||||
...NewsIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const newsQuery = graphql(`
|
||||
query news {
|
||||
index: newsIndex {
|
||||
@@ -44,7 +68,7 @@ export const newsQuery = graphql(`
|
||||
}
|
||||
news: pages(contentType: "news.NewsPage", order: "-first_published_at", limit: 1000) {
|
||||
... on NewsPage {
|
||||
...News
|
||||
...NewsListItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||