Compare commits
16 Commits
a58e2b224e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
5d5f0879c2
|
|||
|
046099b7f1
|
|||
|
d245f2f00a
|
|||
|
8ad7df30d7
|
|||
|
dcb1a59777
|
|||
|
e3a58556f7
|
|||
|
7b84b2d480
|
|||
|
ec94d82863
|
|||
|
089970a5cd
|
|||
|
38229c97f0
|
|||
|
09d1078dce
|
|||
|
1b5483602f
|
|||
|
2c8f8a218c
|
|||
|
b5c9188488
|
|||
|
433c88c921
|
|||
|
af8c3fe768
|
@@ -1,7 +1,7 @@
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from wagtail.admin.ui.tables import Column, DateColumn
|
from wagtail.admin.ui.tables import Column, DateColumn
|
||||||
from wagtail.admin.ui.tables.pages import PageStatusColumn, PageTitleColumn
|
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 associations.models import AssociationPage
|
||||||
from dnscms.admin import ListingRedirectChooseParentView
|
from dnscms.admin import ListingRedirectChooseParentView
|
||||||
@@ -16,15 +16,11 @@ class AssociationChooseParentView(ListingRedirectChooseParentView):
|
|||||||
listing_url_name = "associations:index"
|
listing_url_name = "associations:index"
|
||||||
|
|
||||||
|
|
||||||
class AssociationPageListingViewSet(PageListingViewSet):
|
class AssociationListingMixin:
|
||||||
model = AssociationPage
|
"""Shared model + columns for the standalone listing and the page explorer."""
|
||||||
choose_parent_view_class = AssociationChooseParentView
|
|
||||||
icon = "group"
|
|
||||||
menu_label = _("Associations")
|
|
||||||
menu_order = 2
|
|
||||||
add_to_admin_menu = True
|
|
||||||
ordering = "title"
|
|
||||||
|
|
||||||
|
model = AssociationPage
|
||||||
|
icon = "group"
|
||||||
columns = [
|
columns = [
|
||||||
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
|
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
|
||||||
AssociationTypeColumn(
|
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()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from wagtail import hooks
|
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
|
from .views import association_chooser_viewset
|
||||||
|
|
||||||
|
|
||||||
@@ -10,5 +10,10 @@ def register_viewset():
|
|||||||
|
|
||||||
|
|
||||||
@hooks.register("register_admin_viewset")
|
@hooks.register("register_admin_viewset")
|
||||||
def register_association_page_listing_viewset():
|
def register_association_sidebar_viewset():
|
||||||
return association_page_listing_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
|
||||||
@@ -173,7 +173,10 @@ MEDIA_URL = "/media/"
|
|||||||
# Wagtail settings
|
# Wagtail settings
|
||||||
|
|
||||||
WAGTAIL_SITE_NAME = "dnscms"
|
WAGTAIL_SITE_NAME = "dnscms"
|
||||||
WAGTAIL_ALLOW_UNICODE_SLUGS = False
|
WAGTAIL_ALLOW_UNICODE_SLUGS = True
|
||||||
|
# Headless: the Next.js frontend uses trailing-slash-free URLs, so strip
|
||||||
|
# trailing slashes from links generated by Wagtail (e.g. the GraphQL `url` field).
|
||||||
|
WAGTAIL_APPEND_SLASH = False
|
||||||
|
|
||||||
WAGTAILIMAGES_IMAGE_MODEL = "images.CustomImage"
|
WAGTAILIMAGES_IMAGE_MODEL = "images.CustomImage"
|
||||||
WAGTAILIMAGES_EXTENSIONS = ["avif", "gif", "jpg", "jpeg", "png", "webp", "svg"]
|
WAGTAILIMAGES_EXTENSIONS = ["avif", "gif", "jpg", "jpeg", "png", "webp", "svg"]
|
||||||
|
|||||||
@@ -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.templatetags.static import static
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
from grapple.registry import registry as grapple_registry
|
||||||
from wagtail import hooks
|
from wagtail import hooks
|
||||||
|
from wagtail.models import Page
|
||||||
|
from wagtail.search.backends import get_search_backend
|
||||||
|
|
||||||
|
|
||||||
@hooks.register("register_rich_text_features")
|
@hooks.register("register_rich_text_features")
|
||||||
@@ -8,6 +11,40 @@ def enable_additional_rich_text_features(features):
|
|||||||
features.default_features.extend(["h5", "h6", "blockquote"])
|
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")
|
@hooks.register("construct_page_action_menu")
|
||||||
def make_publish_default_action(menu_items, request, context):
|
def make_publish_default_action(menu_items, request, context):
|
||||||
for index, item in enumerate(menu_items):
|
for index, item in enumerate(menu_items):
|
||||||
|
|||||||
+36
-13
@@ -3,8 +3,8 @@ from django.utils.translation import gettext, gettext_lazy as _
|
|||||||
from django.utils.translation import ngettext
|
from django.utils.translation import ngettext
|
||||||
from wagtail.admin.ui.tables import Column, DateColumn
|
from wagtail.admin.ui.tables import Column, DateColumn
|
||||||
from wagtail.admin.ui.tables.pages import PageStatusColumn, PageTitleColumn
|
from wagtail.admin.ui.tables.pages import PageStatusColumn, PageTitleColumn
|
||||||
from wagtail.admin.views.pages.listing import IndexView
|
from wagtail.admin.views.pages.listing import ExplorableIndexView, IndexView
|
||||||
from wagtail.admin.viewsets.pages import PageListingViewSet
|
from wagtail.admin.viewsets.pages import PageListingViewSet, PageViewSet
|
||||||
|
|
||||||
from dnscms.admin import ListingRedirectChooseParentView
|
from dnscms.admin import ListingRedirectChooseParentView
|
||||||
from events.models import EventPage
|
from events.models import EventPage
|
||||||
@@ -32,7 +32,9 @@ class OrganizersColumn(Column):
|
|||||||
return f"{names[0]} (+{len(names) - 1})"
|
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):
|
def annotate_queryset(self, pages):
|
||||||
pages = super().annotate_queryset(pages)
|
pages = super().annotate_queryset(pages)
|
||||||
return pages.prefetch_related(
|
return pages.prefetch_related(
|
||||||
@@ -41,20 +43,23 @@ class EventPageIndexView(IndexView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EventPageIndexView(EventPagePrefetchMixin, IndexView):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EventPageExplorableIndexView(EventPagePrefetchMixin, ExplorableIndexView):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class EventChooseParentView(ListingRedirectChooseParentView):
|
class EventChooseParentView(ListingRedirectChooseParentView):
|
||||||
listing_url_name = "events:index"
|
listing_url_name = "events:index"
|
||||||
|
|
||||||
|
|
||||||
class EventPageListingViewSet(PageListingViewSet):
|
class EventListingMixin:
|
||||||
model = EventPage
|
"""Shared model + columns for the standalone listing and the page explorer."""
|
||||||
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"
|
|
||||||
|
|
||||||
|
model = EventPage
|
||||||
|
icon = "date"
|
||||||
columns = [
|
columns = [
|
||||||
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
|
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
|
||||||
EventDateColumn("event_date", label=_("Date"), width="13%"),
|
EventDateColumn("event_date", label=_("Date"), width="13%"),
|
||||||
@@ -69,4 +74,22 @@ class EventPageListingViewSet(PageListingViewSet):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
event_page_listing_viewset = EventPageListingViewSet("events")
|
class EventSidebarViewSet(EventListingMixin, PageListingViewSet):
|
||||||
|
"""Standalone 'Events' sidebar entry, reached independently of the page tree."""
|
||||||
|
|
||||||
|
index_view_class = EventPageIndexView
|
||||||
|
choose_parent_view_class = EventChooseParentView
|
||||||
|
menu_label = _("Events")
|
||||||
|
menu_order = 1
|
||||||
|
add_to_admin_menu = True
|
||||||
|
ordering = "-latest_revision_created_at"
|
||||||
|
|
||||||
|
|
||||||
|
class EventExplorerViewSet(EventListingMixin, PageViewSet):
|
||||||
|
"""Applies the same columns when navigating into EventIndex via the page explorer."""
|
||||||
|
|
||||||
|
index_view_class = EventPageExplorableIndexView
|
||||||
|
|
||||||
|
|
||||||
|
event_sidebar_viewset = EventSidebarViewSet("events")
|
||||||
|
event_explorer_viewset = EventExplorerViewSet()
|
||||||
|
|||||||
+16
-2
@@ -166,7 +166,7 @@ class EventOrganizerLink(Orderable):
|
|||||||
|
|
||||||
@register_snippet
|
@register_snippet
|
||||||
@register_query_field("eventOrganizer", "eventOrganizers")
|
@register_query_field("eventOrganizer", "eventOrganizers")
|
||||||
class EventOrganizer(ClusterableModel):
|
class EventOrganizer(index.Indexed, ClusterableModel):
|
||||||
objects = WPAwareManager()
|
objects = WPAwareManager()
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
@@ -222,6 +222,11 @@ class EventOrganizer(ClusterableModel):
|
|||||||
GraphQLString("external_url"),
|
GraphQLString("external_url"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
search_fields = [
|
||||||
|
index.SearchField("name"),
|
||||||
|
index.AutocompleteField("name"),
|
||||||
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("event organizer")
|
verbose_name = _("event organizer")
|
||||||
verbose_name_plural = _("event organizers")
|
verbose_name_plural = _("event organizers")
|
||||||
@@ -478,7 +483,7 @@ class EventOccurrence(Orderable):
|
|||||||
),
|
),
|
||||||
FieldRowPanel(
|
FieldRowPanel(
|
||||||
children=[
|
children=[
|
||||||
FieldPanel("venue", heading=_("Venue")),
|
FieldPanel("venue", heading=_("Venue"), widget=forms.Select),
|
||||||
FieldPanel("venue_custom", heading=_("Venue as free text")),
|
FieldPanel("venue_custom", heading=_("Venue as free text")),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -492,6 +497,15 @@ class EventOccurrence(Orderable):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
if self.venue_custom:
|
||||||
|
trimmed = self.venue_custom.strip()
|
||||||
|
self.venue_custom = trimmed
|
||||||
|
if trimmed:
|
||||||
|
match = VenuePage.objects.filter(title=trimmed).first()
|
||||||
|
if match:
|
||||||
|
self.venue = match
|
||||||
|
self.venue_custom = ""
|
||||||
|
|
||||||
if self.venue and self.venue_custom:
|
if self.venue and self.venue_custom:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
{
|
{
|
||||||
|
|||||||
+19
-1
@@ -1,6 +1,24 @@
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from wagtail.admin.forms import WagtailAdminModelForm
|
||||||
from wagtail.admin.viewsets.chooser import ChooserViewSet
|
from wagtail.admin.viewsets.chooser import ChooserViewSet
|
||||||
|
|
||||||
|
from dnscms.utils import slugify
|
||||||
|
from events.models import EventOrganizer
|
||||||
|
|
||||||
|
|
||||||
|
class EventOrganizerCreationForm(WagtailAdminModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = EventOrganizer
|
||||||
|
fields = ["name", "association", "external_url"]
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
if not instance.slug:
|
||||||
|
instance.slug = slugify(instance.name)
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
class EventOrganizerChooserViewSet(ChooserViewSet):
|
class EventOrganizerChooserViewSet(ChooserViewSet):
|
||||||
model = "events.EventOrganizer"
|
model = "events.EventOrganizer"
|
||||||
@@ -10,7 +28,7 @@ class EventOrganizerChooserViewSet(ChooserViewSet):
|
|||||||
choose_one_text = _("Choose an organizer")
|
choose_one_text = _("Choose an organizer")
|
||||||
choose_another_text = _("Choose another organizer")
|
choose_another_text = _("Choose another organizer")
|
||||||
edit_item_text = _("Edit this organizer")
|
edit_item_text = _("Edit this organizer")
|
||||||
form_fields = ["name", "association", "external_url"]
|
creation_form_class = EventOrganizerCreationForm
|
||||||
|
|
||||||
|
|
||||||
event_organizer_chooser_viewset = EventOrganizerChooserViewSet("event_organizer_chooser")
|
event_organizer_chooser_viewset = EventOrganizerChooserViewSet("event_organizer_chooser")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from wagtail import hooks
|
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
|
from .views import event_organizer_chooser_viewset
|
||||||
|
|
||||||
|
|
||||||
@@ -10,5 +10,10 @@ def register_viewset():
|
|||||||
|
|
||||||
|
|
||||||
@hooks.register("register_admin_viewset")
|
@hooks.register("register_admin_viewset")
|
||||||
def register_event_page_listing_viewset():
|
def register_event_sidebar_viewset():
|
||||||
return event_page_listing_viewset
|
return event_sidebar_viewset
|
||||||
|
|
||||||
|
|
||||||
|
@hooks.register("register_admin_viewset")
|
||||||
|
def register_event_explorer_viewset():
|
||||||
|
return event_explorer_viewset
|
||||||
|
|||||||
Binary file not shown.
@@ -7,187 +7,143 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dnscms\n"
|
"Project-Id-Version: dnscms\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"Language: nb\n"
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\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"
|
msgid "Title"
|
||||||
msgstr "Tittel"
|
msgstr "Tittel"
|
||||||
|
|
||||||
#: associations/admin.py:32 associations/models.py:79
|
|
||||||
msgid "Type"
|
msgid "Type"
|
||||||
msgstr "Type"
|
msgstr "Type"
|
||||||
|
|
||||||
#: associations/admin.py:38 events/admin.py:64 news/admin.py:27
|
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "Oppdatert"
|
msgstr "Oppdatert"
|
||||||
|
|
||||||
#: associations/admin.py:42 events/admin.py:68 news/admin.py:31
|
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr "Status"
|
msgstr "Status"
|
||||||
|
|
||||||
#: associations/models.py:30 associations/models.py:76 events/models.py:327
|
msgid "Associations"
|
||||||
#: news/models.py:23 news/models.py:69
|
msgstr "Foreninger"
|
||||||
|
|
||||||
msgid "Lead"
|
msgid "Lead"
|
||||||
msgstr "Ingress"
|
msgstr "Ingress"
|
||||||
|
|
||||||
#: associations/models.py:31 associations/models.py:77
|
|
||||||
msgid "Content"
|
msgid "Content"
|
||||||
msgstr "Innhold"
|
msgstr "Innhold"
|
||||||
|
|
||||||
#: associations/models.py:42
|
|
||||||
msgid "association index"
|
msgid "association index"
|
||||||
msgstr "foreningsoversikt"
|
msgstr "foreningsoversikt"
|
||||||
|
|
||||||
#: associations/models.py:43
|
|
||||||
msgid "association indexes"
|
msgid "association indexes"
|
||||||
msgstr "foreningsoversikter"
|
msgstr "foreningsoversikter"
|
||||||
|
|
||||||
#: associations/models.py:52
|
|
||||||
msgid "Association"
|
msgid "Association"
|
||||||
msgstr "Forening"
|
msgstr "Forening"
|
||||||
|
|
||||||
#: associations/models.py:53
|
|
||||||
msgid "Committee"
|
msgid "Committee"
|
||||||
msgstr "Utvalg"
|
msgstr "Utvalg"
|
||||||
|
|
||||||
#: associations/models.py:73 news/models.py:60
|
|
||||||
msgid "Excerpt"
|
msgid "Excerpt"
|
||||||
msgstr "Utdrag"
|
msgstr "Utdrag"
|
||||||
|
|
||||||
#: associations/models.py:74
|
|
||||||
msgid "A very short summary of the content below. Used in listing views."
|
msgid "A very short summary of the content below. Used in listing views."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"En veldig kort oppsummering av innholdet nedenfor. Brukes i listevisninger."
|
"En veldig kort oppsummering av innholdet nedenfor. Brukes i listevisninger."
|
||||||
|
|
||||||
#: associations/models.py:80 events/models.py:189
|
|
||||||
msgid "Website"
|
msgid "Website"
|
||||||
msgstr "Nettsted"
|
msgstr "Nettsted"
|
||||||
|
|
||||||
#: associations/models.py:98
|
|
||||||
msgid "association"
|
msgid "association"
|
||||||
msgstr "forening"
|
msgstr "forening"
|
||||||
|
|
||||||
#: associations/models.py:99
|
|
||||||
msgid "associations"
|
msgid "associations"
|
||||||
msgstr "foreninger"
|
msgstr "foreninger"
|
||||||
|
|
||||||
#: associations/views.py:8
|
|
||||||
msgid "Choose an association"
|
msgid "Choose an association"
|
||||||
msgstr "Velg en forening"
|
msgstr "Velg en forening"
|
||||||
|
|
||||||
#: associations/views.py:9
|
|
||||||
msgid "Choose another association"
|
msgid "Choose another association"
|
||||||
msgstr "Velg en annen forening"
|
msgstr "Velg en annen forening"
|
||||||
|
|
||||||
#: associations/views.py:10
|
|
||||||
msgid "Edit this association"
|
msgid "Edit this association"
|
||||||
msgstr "Rediger denne foreningen"
|
msgstr "Rediger denne foreningen"
|
||||||
|
|
||||||
#: events/admin.py:20
|
|
||||||
msgid "%Y-%m-%d at %H:%M"
|
msgid "%Y-%m-%d at %H:%M"
|
||||||
msgstr "%Y-%m-%d kl %H:%M"
|
msgstr "%Y-%m-%d kl %H:%M"
|
||||||
|
|
||||||
#: events/admin.py:22
|
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)d occurrence"
|
msgid "%(count)d occurrence"
|
||||||
msgid_plural "%(count)d occurrences"
|
msgid_plural "%(count)d occurrences"
|
||||||
msgstr[0] "%(count)d forekomst"
|
msgstr[0] "%(count)d forekomst"
|
||||||
msgstr[1] "%(count)d forekomster"
|
msgstr[1] "%(count)d forekomster"
|
||||||
|
|
||||||
#: events/admin.py:53
|
|
||||||
msgid "Events"
|
|
||||||
msgstr "Arrangementer"
|
|
||||||
|
|
||||||
#: events/admin.py:60
|
|
||||||
msgid "Date"
|
msgid "Date"
|
||||||
msgstr "Dato"
|
msgstr "Dato"
|
||||||
|
|
||||||
#: events/admin.py:61 events/models.py:331
|
|
||||||
msgid "Organizers"
|
msgid "Organizers"
|
||||||
msgstr "Arrangører"
|
msgstr "Arrangører"
|
||||||
|
|
||||||
#: events/models.py:73 events/models.py:156
|
msgid "Events"
|
||||||
|
msgstr "Arrangementer"
|
||||||
|
|
||||||
msgid "slug"
|
msgid "slug"
|
||||||
msgstr "permalenke"
|
msgstr "permalenke"
|
||||||
|
|
||||||
#: events/models.py:75
|
|
||||||
msgid "The name of the category as it will appear in URLs."
|
msgid "The name of the category as it will appear in URLs."
|
||||||
msgstr "Navnet på kategorien slik det vil vises i URL-er."
|
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?"
|
msgid "Should this category be available as a filter in the event programme?"
|
||||||
msgstr "Skal denne kategorien være mulig å filtrere på i programmet?"
|
msgstr "Skal denne kategorien være mulig å filtrere på i programmet?"
|
||||||
|
|
||||||
#: events/models.py:83 events/models.py:266
|
|
||||||
msgid "None"
|
msgid "None"
|
||||||
msgstr "Ingen"
|
msgstr "Ingen"
|
||||||
|
|
||||||
#: events/models.py:91
|
|
||||||
msgid "Default pig for events of this kind."
|
msgid "Default pig for events of this kind."
|
||||||
msgstr "Standardgris for arrangementer av denne typen."
|
msgstr "Standardgris for arrangementer av denne typen."
|
||||||
|
|
||||||
#: events/models.py:98 events/models.py:341
|
|
||||||
msgid "Pig"
|
msgid "Pig"
|
||||||
msgstr "Gris"
|
msgstr "Gris"
|
||||||
|
|
||||||
#: events/models.py:109
|
|
||||||
msgid "event category"
|
msgid "event category"
|
||||||
msgstr "arrangementskategori"
|
msgstr "arrangementskategori"
|
||||||
|
|
||||||
#: events/models.py:110
|
|
||||||
msgid "event categories"
|
msgid "event categories"
|
||||||
msgstr "arrangementskategorier"
|
msgstr "arrangementskategorier"
|
||||||
|
|
||||||
#: events/models.py:138
|
|
||||||
msgid "organizer"
|
msgid "organizer"
|
||||||
msgstr "arrangør"
|
msgstr "arrangør"
|
||||||
|
|
||||||
#: events/models.py:139
|
|
||||||
msgid "organizers"
|
msgid "organizers"
|
||||||
msgstr "arrangører"
|
msgstr "arrangører"
|
||||||
|
|
||||||
#: events/models.py:158
|
|
||||||
msgid "The name of the organizer as it will appear in URLs."
|
msgid "The name of the organizer as it will appear in URLs."
|
||||||
msgstr "Navnet på arrangøren slik det vil vises i URL-er."
|
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."
|
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."
|
msgstr "Om en samfundsforening eller -utvalg står bak, velg det her."
|
||||||
|
|
||||||
#: events/models.py:173
|
|
||||||
msgid "Link to the external organizer's website"
|
msgid "Link to the external organizer's website"
|
||||||
msgstr "Lenke til nettstedet til ekstern arrangør"
|
msgstr "Lenke til nettstedet til ekstern arrangør"
|
||||||
|
|
||||||
#: events/models.py:182
|
|
||||||
msgid "Internal organizer"
|
msgid "Internal organizer"
|
||||||
msgstr "Intern arrangør"
|
msgstr "Intern arrangør"
|
||||||
|
|
||||||
#: events/models.py:185
|
|
||||||
msgid "External organizer"
|
msgid "External organizer"
|
||||||
msgstr "Ekstern arrangør"
|
msgstr "Ekstern arrangør"
|
||||||
|
|
||||||
#: events/models.py:190
|
|
||||||
msgid "Leave this empty if the organizer exists in the list above."
|
msgid "Leave this empty if the organizer exists in the list above."
|
||||||
msgstr "La denne stå tom om arrangøren finnes i lista over."
|
msgstr "La denne stå tom om arrangøren finnes i lista over."
|
||||||
|
|
||||||
#: events/models.py:204
|
|
||||||
msgid "event organizer"
|
msgid "event organizer"
|
||||||
msgstr "arrangør"
|
msgstr "arrangør"
|
||||||
|
|
||||||
#: events/models.py:205
|
|
||||||
msgid "event organizers"
|
msgid "event organizers"
|
||||||
msgstr "arrangører"
|
msgstr "arrangører"
|
||||||
|
|
||||||
#: events/models.py:239
|
|
||||||
msgid ""
|
msgid ""
|
||||||
"Choose an image for use in the programme and other surfaces. Should be a "
|
"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 "
|
"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-"
|
"bilde eller en illustrasjon uten for mye tekst – ikke gjenbruk et Facebook-"
|
||||||
"cover ukritisk!"
|
"cover ukritisk!"
|
||||||
|
|
||||||
#: events/models.py:249
|
|
||||||
msgid ""
|
msgid ""
|
||||||
"A short text that appears right below the title. Feel free to leave it empty "
|
"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."
|
"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 "
|
"En kort tekst som kommer rett under tittelen. La denne gjerne stå tom om du "
|
||||||
"fikk plass til det meste i hovedtittelen."
|
"fikk plass til det meste i hovedtittelen."
|
||||||
|
|
||||||
#: events/models.py:267
|
|
||||||
msgid "Automatic"
|
msgid "Automatic"
|
||||||
msgstr "Automatisk"
|
msgstr "Automatisk"
|
||||||
|
|
||||||
#: events/models.py:276
|
|
||||||
msgid ""
|
msgid ""
|
||||||
"The pig that hangs out on the event page. Automatic causes one to be chosen "
|
"The pig that hangs out on the event page. Automatic causes one to be chosen "
|
||||||
"based on the event's category."
|
"based on the event's category."
|
||||||
@@ -217,36 +170,28 @@ msgstr ""
|
|||||||
"Grisen som henger på arrangementssiden. Automatisk fører til at en velges "
|
"Grisen som henger på arrangementssiden. Automatisk fører til at en velges "
|
||||||
"basert på arrangementets kategori."
|
"basert på arrangementets kategori."
|
||||||
|
|
||||||
#: events/models.py:284
|
|
||||||
msgid "Direct link to ticket purchase, e.g. TicketCo, Billetto or Ticketmaster"
|
msgid "Direct link to ticket purchase, e.g. TicketCo, Billetto or Ticketmaster"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Lenke direkte til billettkjøp, f.eks. TicketCo, Billetto eller Ticketmaster"
|
"Lenke direkte til billettkjøp, f.eks. TicketCo, Billetto eller Ticketmaster"
|
||||||
|
|
||||||
#: events/models.py:289
|
|
||||||
msgid "Direct link to the event on Facebook"
|
msgid "Direct link to the event on Facebook"
|
||||||
msgstr "Lenke direkte til arrangementet på Facebook"
|
msgstr "Lenke direkte til arrangementet på Facebook"
|
||||||
|
|
||||||
#: events/models.py:298
|
|
||||||
msgid "Free"
|
msgid "Free"
|
||||||
msgstr "Gratis"
|
msgstr "Gratis"
|
||||||
|
|
||||||
#: events/models.py:298
|
|
||||||
msgid "Is this event free for everyone?"
|
msgid "Is this event free for everyone?"
|
||||||
msgstr "Er dette arrangementet gratis for alle?"
|
msgstr "Er dette arrangementet gratis for alle?"
|
||||||
|
|
||||||
#: events/models.py:303
|
|
||||||
msgid "Regular price"
|
msgid "Regular price"
|
||||||
msgstr "Ordinær pris"
|
msgstr "Ordinær pris"
|
||||||
|
|
||||||
#: events/models.py:304
|
|
||||||
msgid "Price for students"
|
msgid "Price for students"
|
||||||
msgstr "Pris for studenter"
|
msgstr "Pris for studenter"
|
||||||
|
|
||||||
#: events/models.py:305
|
|
||||||
msgid "Price for DNS members"
|
msgid "Price for DNS members"
|
||||||
msgstr "Pris for medlemmer av DNS"
|
msgstr "Pris for medlemmer av DNS"
|
||||||
|
|
||||||
#: events/models.py:312
|
|
||||||
msgid ""
|
msgid ""
|
||||||
"Write <strong>0</strong> for free. An empty field hides the price category. "
|
"Write <strong>0</strong> for free. An empty field hides the price category. "
|
||||||
"If possible, write digits only."
|
"If possible, write digits only."
|
||||||
@@ -254,57 +199,44 @@ msgstr ""
|
|||||||
"Skriv <strong>0</strong> om gratis. Tomt felt skjuler priskategorien. Om "
|
"Skriv <strong>0</strong> om gratis. Tomt felt skjuler priskategorien. Om "
|
||||||
"mulig, skriv kun tall."
|
"mulig, skriv kun tall."
|
||||||
|
|
||||||
#: events/models.py:321
|
|
||||||
msgid "Ticket purchase link"
|
msgid "Ticket purchase link"
|
||||||
msgstr "Billettkjøpslenke"
|
msgstr "Billettkjøpslenke"
|
||||||
|
|
||||||
#: events/models.py:325
|
|
||||||
msgid "Subtitle"
|
msgid "Subtitle"
|
||||||
msgstr "Undertittel"
|
msgstr "Undertittel"
|
||||||
|
|
||||||
#: events/models.py:334
|
|
||||||
msgid "Who is behind the event?"
|
msgid "Who is behind the event?"
|
||||||
msgstr "Hvem står bak arrangementet?"
|
msgstr "Hvem står bak arrangementet?"
|
||||||
|
|
||||||
#: events/models.py:337
|
|
||||||
msgid "Organizer"
|
msgid "Organizer"
|
||||||
msgstr "Arrangør"
|
msgstr "Arrangør"
|
||||||
|
|
||||||
#: events/models.py:344
|
|
||||||
msgid "Facebook link"
|
msgid "Facebook link"
|
||||||
msgstr "Facebook-lenke"
|
msgstr "Facebook-lenke"
|
||||||
|
|
||||||
#: events/models.py:345
|
|
||||||
msgid "Direct link to the event on Facebook."
|
msgid "Direct link to the event on Facebook."
|
||||||
msgstr "Lenke direkte til arrangementet på Facebook."
|
msgstr "Lenke direkte til arrangementet på Facebook."
|
||||||
|
|
||||||
#: events/models.py:347
|
|
||||||
msgid "Pricing and tickets"
|
msgid "Pricing and tickets"
|
||||||
msgstr "Priser og billettkjøp"
|
msgstr "Priser og billettkjøp"
|
||||||
|
|
||||||
#: events/models.py:349
|
|
||||||
msgid "Date, time and venue"
|
msgid "Date, time and venue"
|
||||||
msgstr "Dato, tid og lokale"
|
msgstr "Dato, tid og lokale"
|
||||||
|
|
||||||
#: events/models.py:353
|
|
||||||
msgid "If the event spans several days, add each day as a separate occurrence."
|
msgid "If the event spans several days, add each day as a separate occurrence."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Om arrangementet går over flere dager, legg inn hver dag som en egen "
|
"Om arrangementet går over flere dager, legg inn hver dag som en egen "
|
||||||
"forekomst."
|
"forekomst."
|
||||||
|
|
||||||
#: events/models.py:356
|
|
||||||
msgid "Occurrence"
|
msgid "Occurrence"
|
||||||
msgstr "Forekomst"
|
msgstr "Forekomst"
|
||||||
|
|
||||||
#: events/models.py:399
|
|
||||||
msgid "event"
|
msgid "event"
|
||||||
msgstr "arrangement"
|
msgstr "arrangement"
|
||||||
|
|
||||||
#: events/models.py:400
|
|
||||||
msgid "events"
|
msgid "events"
|
||||||
msgstr "arrangementer"
|
msgstr "arrangementer"
|
||||||
|
|
||||||
#: events/models.py:560
|
|
||||||
msgid ""
|
msgid ""
|
||||||
"Use this <em>if none of the venues that can be selected on the left</em> "
|
"Use this <em>if none of the venues that can be selected on the left</em> "
|
||||||
"fit. E.g. <em>Frederikkeplassen</em> or <em>Sirkusteltet</em>."
|
"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. "
|
"Bruk denne <em>om ingen av lokalene som kan velges til venstre</em> passer. "
|
||||||
"F.eks. <em>Frederikkeplassen</em> eller <em>Sirkusteltet</em>."
|
"F.eks. <em>Frederikkeplassen</em> eller <em>Sirkusteltet</em>."
|
||||||
|
|
||||||
#: events/models.py:569
|
|
||||||
msgid "Start"
|
msgid "Start"
|
||||||
msgstr "Start"
|
msgstr "Start"
|
||||||
|
|
||||||
#: events/models.py:570
|
|
||||||
msgid "End"
|
msgid "End"
|
||||||
msgstr "Slutt"
|
msgstr "Slutt"
|
||||||
|
|
||||||
#: events/models.py:575
|
|
||||||
msgid "Venue"
|
msgid "Venue"
|
||||||
msgstr "Lokale"
|
msgstr "Lokale"
|
||||||
|
|
||||||
#: events/models.py:576
|
|
||||||
msgid "Venue as free text"
|
msgid "Venue as free text"
|
||||||
msgstr "Lokale som fritekst"
|
msgstr "Lokale som fritekst"
|
||||||
|
|
||||||
#: events/models.py:593
|
|
||||||
msgid "You can't both pick a venue and write something in this field."
|
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."
|
msgstr "Du kan ikke både velge et lokale og skrive noe i dette feltet."
|
||||||
|
|
||||||
#: events/models.py:598
|
|
||||||
msgid "Venue is required."
|
msgid "Venue is required."
|
||||||
msgstr "Lokale er påkrevd."
|
msgstr "Lokale er påkrevd."
|
||||||
|
|
||||||
#: events/models.py:604
|
|
||||||
msgid "occurrence"
|
msgid "occurrence"
|
||||||
msgstr "forekomst"
|
msgstr "forekomst"
|
||||||
|
|
||||||
#: events/models.py:605
|
|
||||||
msgid "occurrences"
|
msgid "occurrences"
|
||||||
msgstr "forekomster"
|
msgstr "forekomster"
|
||||||
|
|
||||||
#: events/views.py:9
|
|
||||||
msgid "Choose organizers"
|
msgid "Choose organizers"
|
||||||
msgstr "Velg arrangører"
|
msgstr "Velg arrangører"
|
||||||
|
|
||||||
#: events/views.py:10
|
|
||||||
msgid "Choose an organizer"
|
msgid "Choose an organizer"
|
||||||
msgstr "Velg en arrangør"
|
msgstr "Velg en arrangør"
|
||||||
|
|
||||||
#: events/views.py:11
|
|
||||||
msgid "Choose another organizer"
|
msgid "Choose another organizer"
|
||||||
msgstr "Velg en annen arrangør"
|
msgstr "Velg en annen arrangør"
|
||||||
|
|
||||||
#: events/views.py:12
|
|
||||||
msgid "Edit this organizer"
|
msgid "Edit this organizer"
|
||||||
msgstr "Rediger denne arrangøren"
|
msgstr "Rediger denne arrangøren"
|
||||||
|
|
||||||
#: images/models.py:40
|
|
||||||
msgid "image"
|
msgid "image"
|
||||||
msgstr "bilde"
|
msgstr "bilde"
|
||||||
|
|
||||||
#: images/models.py:41
|
|
||||||
msgid "images"
|
msgid "images"
|
||||||
msgstr "bilder"
|
msgstr "bilder"
|
||||||
|
|
||||||
#: news/admin.py:18
|
msgid "First published"
|
||||||
|
msgstr "Først publisert"
|
||||||
|
|
||||||
msgid "News"
|
msgid "News"
|
||||||
msgstr "Nyheter"
|
msgstr "Nyheter"
|
||||||
|
|
||||||
#: news/models.py:33
|
|
||||||
msgid "news index"
|
msgid "news index"
|
||||||
msgstr "nyhetsoversikt"
|
msgstr "nyhetsoversikt"
|
||||||
|
|
||||||
#: news/models.py:34
|
|
||||||
msgid "news indexes"
|
msgid "news indexes"
|
||||||
msgstr "nyhetsoversikter"
|
msgstr "nyhetsoversikter"
|
||||||
|
|
||||||
#: news/models.py:52
|
|
||||||
msgid ""
|
msgid ""
|
||||||
"Choose an image for use on the front page and other surfaces. Should be a "
|
"Choose an image for use on the front page and other surfaces. Should be a "
|
||||||
"photo or an illustration without too much text."
|
"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 "
|
"Velg et bilde til bruk på forsiden og andre visningsflater. Bør være et "
|
||||||
"bilde eller en illustrasjon uten for mye tekst."
|
"bilde eller en illustrasjon uten for mye tekst."
|
||||||
|
|
||||||
#: news/models.py:62
|
|
||||||
msgid ""
|
msgid ""
|
||||||
"A very short summary of the article's content. Used on the front page and in "
|
"A very short summary of the article's content. Used on the front page and in "
|
||||||
"the article listing."
|
"the article listing."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"En veldig kort oppsummering av innholdet i artikkelen. Brukes på forsiden "
|
"En veldig kort oppsummering av innholdet i artikkelen. Brukes på forsiden og "
|
||||||
"og i artikkeloversikten."
|
"i artikkeloversikten."
|
||||||
|
|
||||||
#: news/models.py:71
|
|
||||||
msgid ""
|
msgid ""
|
||||||
"A brief, introductory paragraph that summarizes the main content of the "
|
"A brief, introductory paragraph that summarizes the main content of the "
|
||||||
"article."
|
"article."
|
||||||
@@ -404,10 +319,41 @@ msgstr ""
|
|||||||
"Et kortfattet, innledende avsnitt som oppsummerer hovedinnholdet i "
|
"Et kortfattet, innledende avsnitt som oppsummerer hovedinnholdet i "
|
||||||
"artikkelen."
|
"artikkelen."
|
||||||
|
|
||||||
#: news/models.py:92
|
|
||||||
msgid "news article"
|
msgid "news article"
|
||||||
msgstr "nyhetsartikkel"
|
msgstr "nyhetsartikkel"
|
||||||
|
|
||||||
#: news/models.py:93
|
|
||||||
msgid "news articles"
|
msgid "news articles"
|
||||||
msgstr "nyhetsartikler"
|
msgstr "nyhetsartikler"
|
||||||
|
|
||||||
|
msgid "Rentals page"
|
||||||
|
msgstr "Utleieside"
|
||||||
|
|
||||||
|
msgid "Venue overview"
|
||||||
|
msgstr "Lokaleoversikt"
|
||||||
|
|
||||||
|
msgid "Venues"
|
||||||
|
msgstr "Lokaler"
|
||||||
|
|
||||||
|
msgid "venue index"
|
||||||
|
msgstr "lokaleoversikt"
|
||||||
|
|
||||||
|
msgid "venue indexes"
|
||||||
|
msgstr "lokaleoversikter"
|
||||||
|
|
||||||
|
msgid "rentals page"
|
||||||
|
msgstr "utleieside"
|
||||||
|
|
||||||
|
msgid "rentals pages"
|
||||||
|
msgstr "utleiesider"
|
||||||
|
|
||||||
|
msgid "venue"
|
||||||
|
msgstr "lokale"
|
||||||
|
|
||||||
|
msgid "venues"
|
||||||
|
msgstr "lokaler"
|
||||||
|
|
||||||
|
#~ msgid "Bookable"
|
||||||
|
#~ msgstr "Til utleie"
|
||||||
|
|
||||||
|
#~ msgid "Listed"
|
||||||
|
#~ msgstr "I oversikt"
|
||||||
|
|||||||
+27
-10
@@ -1,7 +1,7 @@
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from wagtail.admin.ui.tables import DateColumn
|
from wagtail.admin.ui.tables import DateColumn
|
||||||
from wagtail.admin.ui.tables.pages import PageStatusColumn, PageTitleColumn
|
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 dnscms.admin import ListingRedirectChooseParentView
|
||||||
from news.models import NewsPage
|
from news.models import NewsPage
|
||||||
@@ -11,17 +11,19 @@ class NewsChooseParentView(ListingRedirectChooseParentView):
|
|||||||
listing_url_name = "news:index"
|
listing_url_name = "news:index"
|
||||||
|
|
||||||
|
|
||||||
class NewsPageListingViewSet(PageListingViewSet):
|
class NewsListingMixin:
|
||||||
model = NewsPage
|
"""Shared model + columns for the standalone listing and the page explorer."""
|
||||||
choose_parent_view_class = NewsChooseParentView
|
|
||||||
icon = "info-circle"
|
|
||||||
menu_label = _("News")
|
|
||||||
menu_order = 3
|
|
||||||
add_to_admin_menu = True
|
|
||||||
ordering = "-latest_revision_created_at"
|
|
||||||
|
|
||||||
|
model = NewsPage
|
||||||
|
icon = "info-circle"
|
||||||
columns = [
|
columns = [
|
||||||
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
|
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
|
||||||
|
DateColumn(
|
||||||
|
"first_published_at",
|
||||||
|
label=_("First published"),
|
||||||
|
sort_key="first_published_at",
|
||||||
|
width="10%",
|
||||||
|
),
|
||||||
DateColumn(
|
DateColumn(
|
||||||
"latest_revision_created_at",
|
"latest_revision_created_at",
|
||||||
label=_("Updated"),
|
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()
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
from wagtail import hooks
|
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")
|
@hooks.register("register_admin_viewset")
|
||||||
def register_news_page_listing_viewset():
|
def register_news_sidebar_viewset():
|
||||||
return news_page_listing_viewset
|
return news_sidebar_viewset
|
||||||
|
|
||||||
|
|
||||||
|
@hooks.register("register_admin_viewset")
|
||||||
|
def register_news_explorer_viewset():
|
||||||
|
return news_explorer_viewset
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from events.models import (
|
|||||||
EventOrganizerLink,
|
EventOrganizerLink,
|
||||||
EventPage,
|
EventPage,
|
||||||
)
|
)
|
||||||
|
from events.views import EventOrganizerCreationForm
|
||||||
from tests.conftest import (
|
from tests.conftest import (
|
||||||
AssociationPageFactory,
|
AssociationPageFactory,
|
||||||
CustomImageFactory,
|
CustomImageFactory,
|
||||||
@@ -104,6 +105,57 @@ def test_eventoccurrence_clean_rejects_both_venue_and_venue_custom(event_index,
|
|||||||
assert "venue_custom" in exc.value.message_dict
|
assert "venue_custom" in exc.value.message_dict
|
||||||
|
|
||||||
|
|
||||||
|
def test_eventoccurrence_clean_promotes_matching_custom_text_to_venue(event_index, venue):
|
||||||
|
event = EventPageFactory(parent=event_index)
|
||||||
|
occurrence = EventOccurrence(
|
||||||
|
event=event,
|
||||||
|
start=timezone.now(),
|
||||||
|
venue_custom=f" {venue.title} ",
|
||||||
|
)
|
||||||
|
|
||||||
|
occurrence.clean()
|
||||||
|
|
||||||
|
assert occurrence.venue_id == venue.pk
|
||||||
|
assert occurrence.venue_custom == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_organizer_creation_form_auto_slugifies_name(db):
|
||||||
|
form = EventOrganizerCreationForm(data={"name": "Forening for ÆØÅ", "external_url": ""})
|
||||||
|
|
||||||
|
assert form.is_valid(), form.errors
|
||||||
|
organizer = form.save()
|
||||||
|
|
||||||
|
assert organizer.pk is not None
|
||||||
|
assert organizer.name == "Forening for ÆØÅ"
|
||||||
|
assert organizer.slug == "forening-for-aeoa"
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_organizer_creation_form_keeps_explicit_slug(db):
|
||||||
|
organizer = EventOrganizer(name="Forening", slug="custom-slug")
|
||||||
|
form = EventOrganizerCreationForm(
|
||||||
|
data={"name": "Forening", "external_url": ""}, instance=organizer
|
||||||
|
)
|
||||||
|
|
||||||
|
assert form.is_valid(), form.errors
|
||||||
|
organizer = form.save()
|
||||||
|
|
||||||
|
assert organizer.slug == "custom-slug"
|
||||||
|
|
||||||
|
|
||||||
|
def test_eventoccurrence_clean_keeps_custom_text_when_no_venue_matches(event_index):
|
||||||
|
event = EventPageFactory(parent=event_index)
|
||||||
|
occurrence = EventOccurrence(
|
||||||
|
event=event,
|
||||||
|
start=timezone.now(),
|
||||||
|
venue_custom=" Frederikkeplassen ",
|
||||||
|
)
|
||||||
|
|
||||||
|
occurrence.clean()
|
||||||
|
|
||||||
|
assert occurrence.venue is None
|
||||||
|
assert occurrence.venue_custom == "Frederikkeplassen"
|
||||||
|
|
||||||
|
|
||||||
def test_eventoccurrence_clean_requires_venue_or_venue_custom(event_index):
|
def test_eventoccurrence_clean_requires_venue_or_venue_custom(event_index):
|
||||||
event = EventPageFactory(parent=event_index)
|
event = EventPageFactory(parent=event_index)
|
||||||
occurrence = EventOccurrence(event=event, start=timezone.now())
|
occurrence = EventOccurrence(event=event, start=timezone.now())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from news.admin import NewsPageListingViewSet
|
from news.admin import NewsSidebarViewSet
|
||||||
from news.models import NewsPage
|
from news.models import NewsPage
|
||||||
from tests.conftest import NewsPageFactory
|
from tests.conftest import NewsPageFactory
|
||||||
|
|
||||||
@@ -11,9 +11,9 @@ def test_news_page_persists_via_factory(news_index):
|
|||||||
assert reloaded.excerpt == "Short summary"
|
assert reloaded.excerpt == "Short summary"
|
||||||
|
|
||||||
|
|
||||||
def test_news_listing_viewset_wired_to_newspage():
|
def test_news_sidebar_viewset_wired_to_newspage():
|
||||||
assert NewsPageListingViewSet.model is NewsPage
|
assert NewsSidebarViewSet.model is NewsPage
|
||||||
assert NewsPageListingViewSet.add_to_admin_menu is True
|
assert NewsSidebarViewSet.add_to_admin_menu is True
|
||||||
|
|
||||||
|
|
||||||
def test_graphql_news_index_query(news_index, graphql_post):
|
def test_graphql_news_index_query(news_index, graphql_post):
|
||||||
|
|||||||
@@ -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.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from grapple.helpers import register_singular_query_field
|
from grapple.helpers import register_singular_query_field
|
||||||
from grapple.models import (
|
from grapple.models import (
|
||||||
GraphQLBoolean,
|
GraphQLBoolean,
|
||||||
@@ -38,6 +39,10 @@ class VenueIndex(HeadlessMixin, Page):
|
|||||||
|
|
||||||
graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")]
|
graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("venue index")
|
||||||
|
verbose_name_plural = _("venue indexes")
|
||||||
|
|
||||||
|
|
||||||
@register_singular_query_field("venueRentalIndex")
|
@register_singular_query_field("venueRentalIndex")
|
||||||
class VenueRentalIndex(HeadlessMixin, Page):
|
class VenueRentalIndex(HeadlessMixin, Page):
|
||||||
@@ -55,6 +60,10 @@ class VenueRentalIndex(HeadlessMixin, Page):
|
|||||||
|
|
||||||
graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")]
|
graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("rentals page")
|
||||||
|
verbose_name_plural = _("rentals pages")
|
||||||
|
|
||||||
|
|
||||||
class VenuePage(HeadlessMixin, WPImportedPageMixin, Page):
|
class VenuePage(HeadlessMixin, WPImportedPageMixin, Page):
|
||||||
# no children
|
# no children
|
||||||
@@ -184,3 +193,7 @@ class VenuePage(HeadlessMixin, WPImportedPageMixin, Page):
|
|||||||
search_fields = Page.search_fields + [
|
search_fields = Page.search_fields + [
|
||||||
index.SearchField("body"),
|
index.SearchField("body"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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
|
||||||
+5
-1
@@ -48,5 +48,9 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3"
|
"@types/react-dom": "19.2.3"
|
||||||
}
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"> 0.5% in NO",
|
||||||
|
"not dead"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -35,7 +35,22 @@ export default function RootLayout({
|
|||||||
<html lang="no">
|
<html lang="no">
|
||||||
<head>
|
<head>
|
||||||
<link rel="preconnect" href="https://use.typekit.net" crossOrigin="anonymous" />
|
<link rel="preconnect" href="https://use.typekit.net" crossOrigin="anonymous" />
|
||||||
<link rel="stylesheet" href="https://use.typekit.net/spa5smt.css" />
|
<link rel="preconnect" href="https://p.typekit.net" crossOrigin="anonymous" />
|
||||||
|
{/*
|
||||||
|
Load Adobe Fonts without blocking render: the stylesheet is requested
|
||||||
|
with media="print" (fetched but not applied), then the inline script
|
||||||
|
below swaps it to media="all" once it has loaded.
|
||||||
|
*/}
|
||||||
|
<link
|
||||||
|
id="typekit-css"
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://use.typekit.net/spa5smt.css"
|
||||||
|
media="print"
|
||||||
|
suppressHydrationWarning
|
||||||
|
/>
|
||||||
|
<script>
|
||||||
|
{`(function(){var l=document.getElementById('typekit-css');if(!l)return;function s(){l.media='all'}if(l.sheet){s()}else{l.addEventListener('load',s)}})();`}
|
||||||
|
</script>
|
||||||
{process.env.UMAMI_SCRIPT_URL && process.env.UMAMI_WEBSITE_ID && (
|
{process.env.UMAMI_SCRIPT_URL && process.env.UMAMI_WEBSITE_ID && (
|
||||||
<script
|
<script
|
||||||
defer
|
defer
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { getSeoMetadata } from "@/lib/seo";
|
|||||||
|
|
||||||
type Params = Promise<{ slug: string }>;
|
type Params = Promise<{ slug: string }>;
|
||||||
|
|
||||||
|
const EXCLUDED_SLUGS = ["hele-huset"];
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata(
|
||||||
{ params }: { params: Params },
|
{ params }: { params: Params },
|
||||||
parent: ResolvingMetadata
|
parent: ResolvingMetadata
|
||||||
@@ -40,13 +42,18 @@ export async function generateStaticParams() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.pages.map((page: any) => ({
|
return data.pages
|
||||||
slug: page.slug,
|
.filter((page) => !EXCLUDED_SLUGS.includes(page.slug))
|
||||||
}));
|
.map((page) => ({
|
||||||
|
slug: page.slug,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Params }) {
|
export default async function Page({ params }: { params: Params }) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
if (EXCLUDED_SLUGS.includes(slug)) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
const props = await loadVenuePageProps({ slug });
|
const props = await loadVenuePageProps({ slug });
|
||||||
if (!props) return notFound();
|
if (!props) return notFound();
|
||||||
return <VenuePageView {...props} />;
|
return <VenuePageView {...props} />;
|
||||||
|
|||||||
+45
-23
@@ -1,7 +1,10 @@
|
|||||||
import { graphql } from "@/gql";
|
|
||||||
import { getClient } from "@/app/client";
|
import { getClient } from "@/app/client";
|
||||||
import { SearchContainer } from "@/components/search/SearchContainer";
|
import {
|
||||||
import { Suspense } from "react";
|
type SearchResult,
|
||||||
|
SearchResults,
|
||||||
|
} from "@/components/search/SearchResults";
|
||||||
|
import { SearchShell } from "@/components/search/SearchShell";
|
||||||
|
import { graphql } from "@/gql";
|
||||||
|
|
||||||
// TODO: seo metadata?
|
// TODO: seo metadata?
|
||||||
|
|
||||||
@@ -13,7 +16,9 @@ export default async function Page({
|
|||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const { q: query } = (await searchParams) ?? {};
|
const { q: query } = (await searchParams) ?? {};
|
||||||
let results = [];
|
let results: SearchResult[] = [];
|
||||||
|
let totalCount = 0;
|
||||||
|
const RESULT_LIMIT = 500;
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const searchQuery = graphql(`
|
const searchQuery = graphql(`
|
||||||
@@ -21,45 +26,62 @@ export default async function Page({
|
|||||||
results: search(query: $query) {
|
results: search(query: $query) {
|
||||||
__typename
|
__typename
|
||||||
... on PageInterface {
|
... on PageInterface {
|
||||||
slug
|
id
|
||||||
|
title
|
||||||
|
url
|
||||||
}
|
}
|
||||||
... on NewsPage {
|
... on NewsPage {
|
||||||
id
|
excerpt
|
||||||
title
|
featuredImage {
|
||||||
|
...Image
|
||||||
|
}
|
||||||
|
firstPublishedAt
|
||||||
}
|
}
|
||||||
... on EventPage {
|
... on EventPage {
|
||||||
id
|
subtitle
|
||||||
title
|
featuredImage {
|
||||||
|
...Image
|
||||||
|
}
|
||||||
|
occurrences {
|
||||||
|
start
|
||||||
|
}
|
||||||
}
|
}
|
||||||
... on GenericPage {
|
... on GenericPage {
|
||||||
id
|
lead
|
||||||
title
|
|
||||||
}
|
}
|
||||||
... on VenuePage {
|
... on VenuePage {
|
||||||
id
|
featuredImage {
|
||||||
title
|
...Image
|
||||||
|
}
|
||||||
}
|
}
|
||||||
... on AssociationPage {
|
... on AssociationPage {
|
||||||
id
|
excerpt
|
||||||
title
|
|
||||||
associationType
|
associationType
|
||||||
|
logo {
|
||||||
|
...Image
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const { data, error } = await getClient().query(searchQuery, {
|
const { data } = await getClient().query(searchQuery, { query });
|
||||||
query: query,
|
const all = (data?.results ?? []) as SearchResult[];
|
||||||
});
|
totalCount = all.length;
|
||||||
|
results = all.slice(0, RESULT_LIMIT);
|
||||||
results = (data?.results ?? []) as any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="site-main" id="main">
|
<main className="site-main" id="main">
|
||||||
<Suspense key={query}>
|
<SearchShell initialQuery={query ?? ""}>
|
||||||
<SearchContainer query={query ?? ""} results={results} />
|
{query ? (
|
||||||
</Suspense>
|
<SearchResults
|
||||||
|
results={results}
|
||||||
|
totalCount={totalCount}
|
||||||
|
query={query}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SearchShell>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const EventItem = ({
|
|||||||
alt={featuredImage.alt}
|
alt={featuredImage.alt}
|
||||||
width={0}
|
width={0}
|
||||||
height={0}
|
height={0}
|
||||||
sizes="(max-width: 900px) 100vw, 25vw"
|
sizes="(max-width: 900px) calc(100vw - 2rem), 30vw"
|
||||||
loading={imageLoading}
|
loading={imageLoading}
|
||||||
fetchPriority={imageFetchPriority}
|
fetchPriority={imageFetchPriority}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ export const FeaturedEvents = ({ events }: { events: EventListItemFragment[] })
|
|||||||
<section className={styles.featuredEvents}>
|
<section className={styles.featuredEvents}>
|
||||||
<SectionHeader heading="Arrangementer" link="/arrangementer" linkText="Se alle arrangementer" />
|
<SectionHeader heading="Arrangementer" link="/arrangementer" linkText="Se alle arrangementer" />
|
||||||
<ul className={styles.eventList}>
|
<ul className={styles.eventList}>
|
||||||
{events.slice(0, 3).map((event, index) => (
|
{events.slice(0, 3).map((event) => (
|
||||||
<EventItem
|
<EventItem
|
||||||
key={event.id}
|
key={event.id}
|
||||||
event={event}
|
event={event}
|
||||||
mode="list"
|
mode="list"
|
||||||
imageLoading="eager"
|
imageLoading="eager"
|
||||||
imageFetchPriority={index < 2 ? "high" : undefined}
|
imageFetchPriority="high"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -25,8 +25,12 @@ export const Header = () => {
|
|||||||
const { replace } = useRouter();
|
const { replace } = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
// Only enable the menu's slide/fade animations once the user has interacted,
|
||||||
|
// so they don't play on first page load
|
||||||
|
const [hasInteracted, setHasInteracted] = useState(false);
|
||||||
|
|
||||||
function toggleMenu() {
|
function toggleMenu() {
|
||||||
|
setHasInteracted(true);
|
||||||
setShowMenu(!showMenu);
|
setShowMenu(!showMenu);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +65,7 @@ export const Header = () => {
|
|||||||
<header
|
<header
|
||||||
className={styles.header}
|
className={styles.header}
|
||||||
data-show={showMenu}
|
data-show={showMenu}
|
||||||
|
data-animate={hasInteracted}
|
||||||
data-small={!isInView}
|
data-small={!isInView}
|
||||||
>
|
>
|
||||||
<Link href="/" aria-label="Hjem">
|
<Link href="/" aria-label="Hjem">
|
||||||
|
|||||||
@@ -54,6 +54,12 @@
|
|||||||
transition: transform .6s ease;
|
transition: transform .6s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-animate=false] {
|
||||||
|
.mainMenu {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -282,4 +288,4 @@
|
|||||||
.logo {
|
.logo {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
background: var(--color-goldenBeige);
|
||||||
|
color: inherit;
|
||||||
|
padding: 0 .05em;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchField {
|
.searchField {
|
||||||
@@ -29,11 +36,52 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resultItem {
|
.resultItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-m);
|
||||||
border-top: var(--border);
|
border-top: var(--border);
|
||||||
margin-top: var(--spacing-m);
|
margin-top: var(--spacing-m);
|
||||||
padding-top: var(--spacing-s);
|
padding-top: var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resultBody {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-chateauBlue-05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fetching {
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
flex: 0 0 5rem;
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.suphead {
|
.suphead {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: var(--font-size-caption);
|
font-size: var(--font-size-caption);
|
||||||
@@ -48,4 +96,4 @@
|
|||||||
|
|
||||||
.noResults {
|
.noResults {
|
||||||
margin: var(--spacing-s) 0 var(--spacing-section-bottom);
|
margin: var(--spacing-s) 0 var(--spacing-section-bottom);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -20,7 +20,7 @@ type Documents = {
|
|||||||
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": typeof types.AllAssociationSlugsDocument,
|
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": typeof types.AllAssociationSlugsDocument,
|
||||||
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": typeof types.AllVenueSlugsDocument,
|
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": typeof types.AllVenueSlugsDocument,
|
||||||
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": typeof types.PreviewPageDocument,
|
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": typeof types.PreviewPageDocument,
|
||||||
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n ": typeof types.SearchDocument,
|
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n ": typeof types.SearchDocument,
|
||||||
"\n fragment AssociationIndex on AssociationIndex {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n }\n": typeof types.AssociationIndexFragmentDoc,
|
"\n fragment AssociationIndex on AssociationIndex {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n }\n": typeof types.AssociationIndexFragmentDoc,
|
||||||
"\n fragment Association on AssociationPage {\n __typename\n id\n slug\n title\n seoTitle\n searchDescription\n excerpt\n lead\n body {\n ...Blocks\n }\n logo {\n url\n width\n height\n }\n associationType\n websiteUrl\n }\n": typeof types.AssociationFragmentDoc,
|
"\n fragment Association on AssociationPage {\n __typename\n id\n slug\n title\n seoTitle\n searchDescription\n excerpt\n lead\n body {\n ...Blocks\n }\n logo {\n url\n width\n height\n }\n associationType\n websiteUrl\n }\n": typeof types.AssociationFragmentDoc,
|
||||||
"\n query allAssociations {\n index: associationIndex {\n ... on AssociationIndex {\n ...AssociationIndex\n }\n }\n associations: pages(\n contentType: \"associations.AssociationPage\"\n limit: 1000\n ) {\n ... on AssociationPage {\n ...Association\n }\n }\n }\n": typeof types.AllAssociationsDocument,
|
"\n query allAssociations {\n index: associationIndex {\n ... on AssociationIndex {\n ...AssociationIndex\n }\n }\n associations: pages(\n contentType: \"associations.AssociationPage\"\n limit: 1000\n ) {\n ... on AssociationPage {\n ...Association\n }\n }\n }\n": typeof types.AllAssociationsDocument,
|
||||||
@@ -88,7 +88,7 @@ const documents: Documents = {
|
|||||||
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": types.AllAssociationSlugsDocument,
|
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": types.AllAssociationSlugsDocument,
|
||||||
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": types.AllVenueSlugsDocument,
|
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": types.AllVenueSlugsDocument,
|
||||||
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": types.PreviewPageDocument,
|
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": types.PreviewPageDocument,
|
||||||
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n ": types.SearchDocument,
|
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n ": types.SearchDocument,
|
||||||
"\n fragment AssociationIndex on AssociationIndex {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n }\n": types.AssociationIndexFragmentDoc,
|
"\n fragment AssociationIndex on AssociationIndex {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n }\n": types.AssociationIndexFragmentDoc,
|
||||||
"\n fragment Association on AssociationPage {\n __typename\n id\n slug\n title\n seoTitle\n searchDescription\n excerpt\n lead\n body {\n ...Blocks\n }\n logo {\n url\n width\n height\n }\n associationType\n websiteUrl\n }\n": types.AssociationFragmentDoc,
|
"\n fragment Association on AssociationPage {\n __typename\n id\n slug\n title\n seoTitle\n searchDescription\n excerpt\n lead\n body {\n ...Blocks\n }\n logo {\n url\n width\n height\n }\n associationType\n websiteUrl\n }\n": types.AssociationFragmentDoc,
|
||||||
"\n query allAssociations {\n index: associationIndex {\n ... on AssociationIndex {\n ...AssociationIndex\n }\n }\n associations: pages(\n contentType: \"associations.AssociationPage\"\n limit: 1000\n ) {\n ... on AssociationPage {\n ...Association\n }\n }\n }\n": types.AllAssociationsDocument,
|
"\n query allAssociations {\n index: associationIndex {\n ... on AssociationIndex {\n ...AssociationIndex\n }\n }\n associations: pages(\n contentType: \"associations.AssociationPage\"\n limit: 1000\n ) {\n ... on AssociationPage {\n ...Association\n }\n }\n }\n": types.AllAssociationsDocument,
|
||||||
@@ -191,7 +191,7 @@ export function graphql(source: "\n query previewPage($token: String!) {\n p
|
|||||||
/**
|
/**
|
||||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||||
*/
|
*/
|
||||||
export function graphql(source: "\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n "): (typeof documents)["\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n "];
|
export function graphql(source: "\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n "): (typeof documents)["\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n "];
|
||||||
/**
|
/**
|
||||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+16
-16
File diff suppressed because one or more lines are too long
@@ -95,6 +95,44 @@ export function groupConsecutiveDates(dates: string[]): string[][] {
|
|||||||
return groupedDates;
|
return groupedDates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatOccurrenceMonths(starts: string[]): string {
|
||||||
|
if (starts.length === 0) return "";
|
||||||
|
|
||||||
|
const months = unique(
|
||||||
|
starts.map((s) => format(toLocalTime(s), "yyyy-MM"))
|
||||||
|
).sort() as string[];
|
||||||
|
|
||||||
|
const monthIndex = (ym: string) => {
|
||||||
|
const [y, m] = ym.split("-").map(Number);
|
||||||
|
return y * 12 + (m - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups: string[][] = [];
|
||||||
|
for (const ym of months) {
|
||||||
|
const last = groups[groups.length - 1];
|
||||||
|
if (last && monthIndex(ym) === monthIndex(last[last.length - 1]) + 1) {
|
||||||
|
last.push(ym);
|
||||||
|
} else {
|
||||||
|
groups.push([ym]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
.map((g) => {
|
||||||
|
const first = parse(g[0], "yyyy-MM", new Date());
|
||||||
|
if (g.length === 1) {
|
||||||
|
return formatDate(first, "MMMM yyyy");
|
||||||
|
}
|
||||||
|
const last = parse(g[g.length - 1], "yyyy-MM", new Date());
|
||||||
|
const firstFmt =
|
||||||
|
first.getFullYear() === last.getFullYear()
|
||||||
|
? formatDate(first, "MMMM")
|
||||||
|
: formatDate(first, "MMMM yyyy");
|
||||||
|
return `${firstFmt} – ${formatDate(last, "MMMM yyyy")}`;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDateRange(dates: string[]): string {
|
export function formatDateRange(dates: string[]): string {
|
||||||
if (dates.length === 1) {
|
if (dates.length === 1) {
|
||||||
return formatDate(dates[0], "d. MMMM");
|
return formatDate(dates[0], "d. MMMM");
|
||||||
|
|||||||
Reference in New Issue
Block a user