Compare commits

...
3 Commits
15 changed files with 247 additions and 111 deletions
@@ -2,6 +2,7 @@ import { AssociationFragment } from "@/gql/graphql";
import { Image } from "@/components/general/Image";
import styles from './associationHeader.module.scss';
import { Breadcrumb } from "../general/Breadcrumb";
import { RichText } from "@/components/general/RichText";
import { Icon } from "../general/Icon";
export const AssociationHeader = ({
@@ -15,10 +16,9 @@ export const AssociationHeader = ({
<Breadcrumb link="/foreninger" text={association.associationType ? association.associationType : "Foreninger"} />
<h1>{association.title}</h1>
{association.lead && (
<div
className="lead"
dangerouslySetInnerHTML={{ __html: association.lead }}
/>
<div className="lead">
<RichText content={association.lead} />
</div>
)}
{association.websiteUrl && (
<a className="button" href={association.websiteUrl} target="_blank">
+137 -53
View File
@@ -1,84 +1,168 @@
import dynamic from "next/dynamic";
import { RichTextBlock } from "./RichTextBlock";
import { ImageWithTextBlock } from "./ImageWithTextBlock";
import { HorizontalRuleBlock } from "./HorizontalRuleBlock";
const ImageSliderBlock = dynamic(() =>
import("./ImageSliderBlock").then((m) => m.ImageSliderBlock)
);
import { FeaturedBlock } from "./FeaturedBlock";
import type {
AccordionBlockFragment,
ContactEntityBlockFragment,
ContactListBlockFragment,
ContactSectionBlockFragment,
ContactSubsectionBlockFragment,
EmbedBlockFragment,
FactBoxBlockFragment,
FeaturedBlockFragment,
HorizontalRuleBlockFragment,
ImageSliderBlockFragment,
ImageWithTextBlockFragment,
PageSectionBlockFragment,
RichTextBlockFragment,
ScheduleBlockFragment,
} from "@/gql/graphql";
import { AccordionBlock } from "./AccordionBlock";
import { ContactEntityBlock } from "./ContactEntityBlock";
import { ContactListBlock } from "./ContactListBlock";
import { ContactSectionBlock, ContactSubsectionBlock } from "./ContactSection";
import { EmbedBlock } from "./EmbedBlock";
import { FactBoxBlock } from "./FactBoxBlock";
import { ScheduleBlock } from "./ScheduleBlock";
import { PageSectionBlock, PageSectionNavigationBlock } from "./PageSection";
import { ContactSectionBlock, ContactSubsectionBlock } from "./ContactSection";
import { ContactListBlock } from "./ContactListBlock";
import { ContactEntityBlock } from "./ContactEntityBlock";
import { FeaturedBlock } from "./FeaturedBlock";
import { HorizontalRuleBlock } from "./HorizontalRuleBlock";
import { ImageWithTextBlock } from "./ImageWithTextBlock";
import { NeufAddressSectionBlock } from "./NeufAddressSectionBlock";
import { OpeningHoursSectionBlock } from "./OpeningHoursSectionBlock";
import { PageSectionBlock, PageSectionNavigationBlock } from "./PageSection";
import { RichTextBlock } from "./RichTextBlock";
import { ScheduleBlock } from "./ScheduleBlock";
export const Blocks = ({ blocks, pageContent }: { blocks: any, pageContent?: boolean }) => {
const sections = blocks.filter(
(block: any) => block?.__typename === "PageSectionBlock"
const ImageSliderBlock = dynamic(() =>
import("./ImageSliderBlock").then((m) => m.ImageSliderBlock),
);
return blocks.map((block: any) => {
switch (block?.blockType) {
export type StreamFieldBlock = {
id?: string | null;
blockType?: string;
__typename?: string;
[key: string]: unknown;
};
export const Blocks = ({
blocks,
pageContent,
}: {
blocks?: Array<StreamFieldBlock | null> | null;
pageContent?: boolean;
}) => {
const sections = (blocks ?? []).filter(
(block) => block?.__typename === "PageSectionBlock",
) as PageSectionBlockFragment[];
return (blocks ?? []).map((block) => {
if (!block) {
return null;
}
switch (block.blockType) {
case "RichTextBlock":
return <RichTextBlock key={block.id} block={block} />;
break;
return (
<RichTextBlock
key={block.id}
block={block as RichTextBlockFragment}
/>
);
case "ImageWithTextBlock":
return <ImageWithTextBlock key={block.id} block={block} />;
break;
return (
<ImageWithTextBlock
key={block.id}
block={block as ImageWithTextBlockFragment}
/>
);
case "EmbedBlock":
return <EmbedBlock key={block.id} block={block} />;
break;
return (
<EmbedBlock key={block.id} block={block as EmbedBlockFragment} />
);
case "ImageSliderBlock":
return <ImageSliderBlock key={block.id} block={block} pageContent />;
break;
return (
<ImageSliderBlock
key={block.id}
block={block as ImageSliderBlockFragment}
pageContent
/>
);
case "HorizontalRuleBlock":
return <HorizontalRuleBlock key={block.id} block={block} />;
break;
return (
<HorizontalRuleBlock
key={block.id}
block={block as HorizontalRuleBlockFragment}
/>
);
case "FeaturedBlock":
return <FeaturedBlock key={block.id} block={block} />;
break;
return (
<FeaturedBlock
key={block.id}
block={block as FeaturedBlockFragment}
/>
);
case "AccordionBlock":
return <AccordionBlock key={block.id} block={block} />;
break;
return (
<AccordionBlock
key={block.id}
block={block as AccordionBlockFragment}
/>
);
case "FactBoxBlock":
return <FactBoxBlock key={block.id} block={block} />;
break;
return (
<FactBoxBlock key={block.id} block={block as FactBoxBlockFragment} />
);
case "ScheduleBlock":
return <ScheduleBlock key={block.id} block={block} />;
break;
return (
<ScheduleBlock
key={block.id}
block={block as ScheduleBlockFragment}
/>
);
case "PageSectionBlock":
return <PageSectionBlock key={block.id} block={block} />;
break;
return (
<PageSectionBlock
key={block.id}
block={block as PageSectionBlockFragment}
/>
);
case "PageSectionNavigationBlock":
return <PageSectionNavigationBlock sections={sections} />;
break;
return (
<PageSectionNavigationBlock key={block.id} sections={sections} />
);
case "ContactSectionBlock":
return <ContactSectionBlock key={block.id} block={block} />;
break;
return (
<ContactSectionBlock
key={block.id}
block={block as ContactSectionBlockFragment}
/>
);
case "ContactSubsectionBlock":
return <ContactSubsectionBlock key={block.id} block={block} />;
break;
return (
<ContactSubsectionBlock
key={block.id}
block={block as ContactSubsectionBlockFragment}
/>
);
case "ContactListBlock":
return <ContactListBlock key={block.id} block={block} />;
break;
return (
<ContactListBlock
key={block.id}
block={block as ContactListBlockFragment}
/>
);
case "ContactEntityBlock":
return <ContactEntityBlock key={block.id} block={block} />;
break;
return (
<ContactEntityBlock
key={block.id}
block={block as ContactEntityBlockFragment}
/>
);
case "NeufAddressSectionBlock":
return <NeufAddressSectionBlock />;
break;
return <NeufAddressSectionBlock key={block.id} />;
case "OpeningHoursSectionBlock":
return <OpeningHoursSectionBlock />;
break;
return <OpeningHoursSectionBlock key={block.id} />;
default:
console.log("unsupported block", block);
return <div>Unsupported block type {block?.blockType}</div>;
return (
<div key={block.id}>Unsupported block type {block.blockType}</div>
);
}
});
};
+7 -8
View File
@@ -3,6 +3,7 @@ import {
type ContactSectionBlockFragment,
type ContactSubsectionBlockFragment,
} from "@/gql/graphql";
import { RichText } from "@/components/general/RichText";
import styles from "./contactSection.module.scss";
import { Blocks } from "./Blocks";
@@ -37,10 +38,9 @@ export const ContactSectionBlock = ({
<section className={styles.contactSection}>
<h2 className={styles.heading}>{block.title}</h2>
{block.text && (
<p
className={styles.intro}
dangerouslySetInnerHTML={{ __html: block.text }}
/>
<div className={styles.intro}>
<RichText content={block.text} />
</div>
)}
<Blocks blocks={block.blocks} />
</section>
@@ -56,10 +56,9 @@ export const ContactSubsectionBlock = ({
<section className={styles.contactSubsection}>
<h3 className={styles.heading}>{block.title}</h3>
{block.text && (
<p
className={styles.intro}
dangerouslySetInnerHTML={{ __html: block.text }}
/>
<div className={styles.intro}>
<RichText content={block.text} />
</div>
)}
<Blocks blocks={block.blocks} />
</section>
+4 -4
View File
@@ -1,5 +1,6 @@
import { graphql } from "@/gql";
import { type FactBoxBlockFragment } from "@/gql/graphql";
import { RichText } from "@/components/general/RichText";
import styles from "./factBoxBlock.module.scss";
const FactBoxBlockFragmentDefinition = graphql(`
@@ -23,10 +24,9 @@ export const FactBoxBlock = ({
className={styles.factBox}
data-background-color={block.backgroundColor ?? ""}
>
<div
className={styles.factBoxContent}
dangerouslySetInnerHTML={{ __html: block.factBoxBody }}
/>
<div className={styles.factBoxContent}>
<RichText content={block.factBoxBody} />
</div>
</div>
</section>
);
+9 -2
View File
@@ -2,7 +2,9 @@ import { graphql, unmaskFragment } from "@/gql";
import { type FeaturedBlockFragment } from "@/gql/graphql";
import Link from "next/link";
import { Image } from "@/components/general/Image";
import { RichText } from "@/components/general/RichText";
import { ImageFragmentDefinition } from "@/lib/common";
import { internalHref } from "@/lib/links";
import styles from "./featuredBlock.module.scss";
const FeaturedBlockFragmentDefinition = graphql(`
@@ -44,6 +46,9 @@ export const FeaturedBlock = ({
);
// TODO: fetch image from target page
const pageUrl = block.featuredPage.url;
const featuredHref = pageUrl ? internalHref(pageUrl) ?? pageUrl : "#";
return (
<div
className={styles.featuredBlock}
@@ -51,8 +56,10 @@ export const FeaturedBlock = ({
>
<div className={styles.text}>
<h2>{block.title}</h2>
<div dangerouslySetInnerHTML={{ __html: block.featuredBlockText }} />
<Link href={block.featuredPage.url ?? "#"}>
<div>
<RichText content={block.featuredBlockText} />
</div>
<Link href={featuredHref}>
{block.linkText} &rarr;
</Link>
</div>
+7 -12
View File
@@ -1,5 +1,6 @@
import { graphql, unmaskFragment } from "@/gql";
import { type ScheduleBlockFragment } from "@/gql/graphql";
import { RichText } from "@/components/general/RichText";
import styles from "./scheduleBlock.module.scss";
const ScheduleItemFragmentDefinition = graphql(`
@@ -45,19 +46,13 @@ export const ScheduleBlock = ({
<div key={index} className={styles.item}>
<dt className={styles.time}>{scheduleItem.time}</dt>
<dd className={styles.body}>
<div
className={styles.title}
dangerouslySetInnerHTML={{
__html: scheduleItem.scheduleItemTitle,
}}
/>
<div className={styles.title}>
<RichText content={scheduleItem.scheduleItemTitle} />
</div>
{scheduleItem.description && (
<div
className={styles.description}
dangerouslySetInnerHTML={{
__html: scheduleItem.description,
}}
/>
<div className={styles.description}>
<RichText content={scheduleItem.description} />
</div>
)}
</dd>
</div>
+4 -4
View File
@@ -5,6 +5,7 @@ import { EventDetails } from "@/components/events/EventDetails";
import { EventHeader } from "@/components/events/EventHeader";
import { BgPig } from "@/components/general/BgPig";
import { PageContent } from "@/components/general/PageContent";
import { RichText } from "@/components/general/RichText";
import { getEventPig } from "@/lib/event";
const eventBySlugQuery = graphql(`
@@ -45,10 +46,9 @@ export function EventPageView({ event }: EventPageViewProps) {
<EventHeader event={event} />
<EventDetails event={event} />
{event.lead && (
<div
className="lead event-lead"
dangerouslySetInnerHTML={{ __html: event.lead }}
/>
<div className="lead event-lead">
<RichText content={event.lead} />
</div>
)}
<PageContent blocks={event.body} />
</main>
+8 -2
View File
@@ -1,7 +1,13 @@
import { Blocks } from "@/components/blocks/Blocks";
import { Blocks, type StreamFieldBlock } from "@/components/blocks/Blocks";
import styles from './pageContent.module.scss';
export const PageContent = ({ blocks, className }: { blocks: any; className?: string }) => {
export const PageContent = ({
blocks,
className,
}: {
blocks?: Array<StreamFieldBlock | null> | null;
className?: string;
}) => {
return (
<div className={`${styles.pageContent}${className ? ` ${className}` : ''}`}>
<Blocks blocks={blocks} pageContent />
+4 -1
View File
@@ -1,3 +1,4 @@
import { RichText } from "@/components/general/RichText";
import styles from "./pageHeader.module.scss";
export const PageHeader = ({
@@ -13,7 +14,9 @@ export const PageHeader = ({
<div className={`${styles.pageHeader} ${align && styles[align]}`}>
<h1 className={styles.title}>{heading}</h1>
{lead && (
<div className="lead" dangerouslySetInnerHTML={{ __html: lead }} />
<div className="lead">
<RichText content={lead} />
</div>
)}
</div>
);
+22 -9
View File
@@ -4,26 +4,39 @@ import parse, {
type DOMNode,
type HTMLReactParserOptions,
} from "html-react-parser";
import Link from "next/link";
import { ButtonLink } from "@/components/general/ButtonLink";
import { internalHref } from "@/lib/links";
/**
* Renders CMS rich text (HTML) as React, swapping inline button markers
* (`<a class="button">` from the button-link Draftail feature) for the
* ButtonLink component. Everything else renders as plain HTML.
* Renders CMS rich text (HTML) as React. Everything renders as plain HTML,
* except:
*
* - inline button markers (`<a class="button">` from the button-link Draftail
* feature) become the ButtonLink component
* - links pointing at this site become next/link
*/
const options: HTMLReactParserOptions = {
replace: (node) => {
if (
node instanceof Element &&
node.name === "a" &&
node.attribs.class?.split(/\s+/).includes("button")
) {
if (!(node instanceof Element) || node.name !== "a") {
return;
}
const href = node.attribs.href ?? "#";
const internal = internalHref(href);
if (node.attribs.class?.split(/\s+/).includes("button")) {
return (
<ButtonLink href={node.attribs.href ?? "#"}>
<ButtonLink href={internal ?? href}>
{domToReact(node.children as DOMNode[], options)}
</ButtonLink>
);
}
if (internal) {
return (
<Link href={internal}>
{domToReact(node.children as DOMNode[], options)}
</Link>
);
}
},
};
+4 -4
View File
@@ -4,6 +4,7 @@ import { getClient } from "@/app/client";
import { Breadcrumb } from "@/components/general/Breadcrumb";
import { ImageFigure } from "@/components/general/Image";
import { PageContent } from "@/components/general/PageContent";
import { RichText } from "@/components/general/RichText";
import { formatDate } from "@/lib/date";
const newsBySlugQuery = graphql(`
@@ -47,10 +48,9 @@ export function NewsPageView({ news }: NewsPageViewProps) {
/>
<h1 className="news-title">{news.title}</h1>
{news.lead && (
<div
className="lead"
dangerouslySetInnerHTML={{ __html: news.lead }}
/>
<div className="lead">
<RichText content={news.lead} />
</div>
)}
{featuredImage && (
<ImageFigure
+4 -4
View File
@@ -2,6 +2,7 @@ import { graphql, unmaskFragment } from "@/gql";
import { type SponsorFragment } from "@/gql/graphql";
import { Image } from "../general/Image";
import { ImageFragmentDefinition } from "@/lib/common";
import { RichText } from "@/components/general/RichText";
import styles from "./sponsorList.module.scss";
export const SponsorFragmentDefinition = graphql(`
@@ -35,10 +36,9 @@ const SponsorItem = ({ sponsor }: { sponsor: SponsorFragment }) => {
<div className={styles.text}>
<h2>{name}</h2>
{text && (
<p
className={styles.sponsorText}
dangerouslySetInnerHTML={{ __html: text }}
/>
<div className={styles.sponsorText}>
<RichText content={text} />
</div>
)}
{website && (
<p className={styles.website}>
+4 -1
View File
@@ -1,3 +1,4 @@
import { RichText } from "@/components/general/RichText";
import styles from "./studioHeader.module.scss";
export const StudioHeader = ({
@@ -54,7 +55,9 @@ export const StudioHeader = ({
</div>
</div>
{lead && (
<div className={`lead ${styles.lead}`} dangerouslySetInnerHTML={{ __html: lead }} />
<div className={`lead ${styles.lead}`}>
<RichText content={lead} />
</div>
)}
</div>
);
+8 -1
View File
@@ -1,8 +1,15 @@
import { fromZonedTime } from "date-fns-tz";
import Link from "next/link";
import { Icon } from "../general/Icon";
import styles from "./studioShortcut.module.scss";
// Hidden from this date
// Note that home page revalidation happens at midnight
const showUntil = fromZonedTime("2026-08-16T00:00:00", "Europe/Oslo");
export const StudioShortcut = () => {
if (new Date() >= showUntil) return null;
return (
<div className={styles.studioShortcut}>
<Link href="/studio" className={styles.button}>
@@ -12,7 +19,7 @@ export const StudioShortcut = () => {
alt="STUDiO"
/>
<div className={styles.text}>
<span className={styles.heading}>Alt om studentfestivalen i Oslo</span><br />
<span className={styles.heading}>Alt om Studentfestivalen i Oslo</span><br />
<span>Gratis arrangementer hele uka!</span>
</div>
<div className={styles.icon}>
+19
View File
@@ -0,0 +1,19 @@
const INTERNAL_ORIGINS = [
process.env.URL,
"https://neuf.no",
].filter(Boolean) as string[];
/** Root-relative href if the URL points at this site, else null. */
export function internalHref(href: string): string | null {
if (href.startsWith("/")) {
return href;
}
const origin = INTERNAL_ORIGINS.find(
(o) => href === o || href.startsWith(o + "/"),
);
if (!origin) {
return null;
}
const url = new URL(href);
return url.pathname + url.search + url.hash;
}