Skip to content

Commit c8a13c5

Browse files
Merge pull request #149 from Project-insert-name/105-ics-subscribe
Legge til arrangement i kalender og subscribe på endringer.
2 parents 0ecc75c + a9def03 commit c8a13c5

33 files changed

Lines changed: 1257 additions & 1507 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { getEventBySlug } from "@/sanity/queries/event"
2+
import { createIcsEvent } from "@/utils/ics"
3+
4+
export const revalidate = 30 // 30 sek
5+
6+
interface Params {
7+
params: {
8+
slug: string
9+
}
10+
}
11+
12+
/**
13+
* Henter et arrangement fra sanity og konverterer det til en ics fil.
14+
* Dersom URL ikke ender med .ics, returneres en 400 feil.
15+
* Hvis event ikke finnes, returneres en 404 feil.
16+
* @param _ Request objektet
17+
* @param slug Parametre fra URL, slugen til arrangementet. Inkluderer .ics
18+
* @returns En response med ics filen, hvis alt gikk bra. Ellers en feil response.
19+
*/
20+
export async function GET(_: Request, { params: { slug } }: Params): Promise<Response> {
21+
const event = await getEventBySlug(slug)
22+
23+
if (!event) {
24+
return Response.json(null, {
25+
status: 404,
26+
statusText: "Event not found",
27+
})
28+
}
29+
30+
const icsEvent = await createIcsEvent(event)
31+
return new Response(icsEvent, {
32+
headers: {
33+
"Content-Type": "text/calendar",
34+
"Content-Disposition": `attachment; filename="${event.title}.ics"`,
35+
},
36+
})
37+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { type NextRequest } from "next/server"
2+
import { getEventsFrom, getTimeWithOffset } from "@/sanity/queries/event"
3+
import { createIcsEvents } from "@/utils/ics"
4+
5+
export const revalidate = 30 // 30 sek
6+
7+
/**
8+
* Henter alle fremtidige arrangementer og konverterer dem til en ics fil.
9+
* Kan filtreres etter:
10+
* - type: Typen arrangementer som skal hentes ut. Default er alle typer.
11+
* - from: Tidspunktet som arrangementene skal hentes fra. Default er nå.
12+
* - limit: Antall arrangementer som skal hentes ut. Default er 100.
13+
* @example fetch("/api/arrangement/ical?type=bedpres&from=2022-01-01&limit=5") // Henter de fem neste bedriftspresentasjonene fra 2022
14+
* @param request Request objektet som inneholder query parametre og annen informasjon
15+
* @returns En response med ics filen, hvis alt gikk bra. Ellers en feil response.
16+
*/
17+
export async function GET(request: NextRequest): Promise<Response> {
18+
const params = request.nextUrl.searchParams
19+
const type = params.get("type")
20+
const time = params.get("from") ?? getTimeWithOffset()
21+
const limit = params.get("limit")
22+
23+
const events = await getEventsFrom(time, {
24+
limit: limit ? parseInt(limit) : 100,
25+
type: type ?? undefined,
26+
})
27+
28+
const icsEvents = createIcsEvents(events)
29+
return new Response(icsEvents, {
30+
headers: {
31+
"Content-Type": "text/calendar",
32+
"Content-Disposition": `attachment; filename="Root arrangementer.ics"`,
33+
},
34+
})
35+
}
Lines changed: 14 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
import { defaultEventDuration, getEventBySlug } from "@/sanity/queries/event"
2-
import { isFuture, toDateTuple } from "@/utils/dateUtils"
1+
import { getEventBySlug } from "@/sanity/queries/event"
2+
import { isFuture } from "@/utils/dateUtils"
33
import { bigIconSize, DateIcon, TimeIcon } from "@/components/icons/icon"
4-
import { notFound } from "next/navigation"
4+
import { notFound, redirect } from "next/navigation"
55
import SingleInfoCard from "@/components/cards/singleInfoCard"
66
import { type Metadata } from "next"
7-
import { createEvent } from "ics"
8-
import { getEventTypeLabel } from "@/sanity/lib/utils"
9-
import type { RootEvent } from "@/sanity/types"
10-
import IcsButton from "@/components/buttons/icsButton"
117
import { Date, Time } from "@/components/date"
128
import { toPlainText } from "@portabletext/react"
9+
import { createIcsEvent } from "@/utils/ics"
10+
import AddToCalendarDropdown from "@/components/dropdown/addToCalendarDropdown"
1311

1412
interface Params {
1513
slug: string
@@ -23,6 +21,10 @@ export const revalidate = 30 // 30 sek
2321
* @param params Parametre fra URL
2422
*/
2523
const EventPage: AsyncPage<Params> = async ({ params }) => {
24+
if (params.slug.endsWith(".ics")) {
25+
return redirect(`/api/arrangement/ical/${params.slug.replace(".ics", "")}`)
26+
}
27+
2628
const event = await getEventBySlug(params.slug)
2729

2830
if (!event) return notFound()
@@ -32,6 +34,7 @@ const EventPage: AsyncPage<Params> = async ({ params }) => {
3234
icsEvent = createIcsEvent(event)
3335
}
3436

37+
// TODO støtte for å lagre event i kalender uten å subscribe. F.eks på samme måte som https://github.com/add2cal/add-to-calendar-button
3538
return (
3639
<SingleInfoCard
3740
title={event.title}
@@ -47,10 +50,9 @@ const EventPage: AsyncPage<Params> = async ({ params }) => {
4750
<>
4851
<TimeAndDate startTime={event.start_time} />
4952
{icsEvent && (
50-
<IcsButton
51-
filename={event.title}
52-
data={icsEvent}
53-
aria-label={"Legg til arrangement i kalender"}
53+
<AddToCalendarDropdown
54+
eventUrl={`${process.env.NEXT_PUBLIC_BASE_URL}/api/arrangement/ical/${params.slug}`}
55+
restrict={["copy", "ics"]}
5456
/>
5557
)}
5658
</>
@@ -81,7 +83,7 @@ const TimeAndDate: Component<{ startTime: string }> = ({ startTime }) => (
8183
export async function generateMetadata({ params }: PageProps<Params>): Promise<Metadata> {
8284
const event = await getEventBySlug(params.slug)
8385

84-
if (!event) return notFound()
86+
if (!event) return {}
8587

8688
return {
8789
title: `${event.title} | Root Linjeforening`,
@@ -90,37 +92,3 @@ export async function generateMetadata({ params }: PageProps<Params>): Promise<M
9092
: "Arrangement arrangert av Root Linjeforening",
9193
}
9294
}
93-
94-
/**
95-
* Lager en string på ics format basert på et arrangement.
96-
* Dersom sluttidspunkt ikke er definert, settes varigheten til 2 timer.
97-
* @param event Arrangementet som skal konverteres til ics format
98-
* @returns En string på ics format
99-
* @see https://www.npmjs.com/package/ics
100-
*/
101-
function createIcsEvent(event: RootEvent): string | undefined {
102-
let icsEvent: string | undefined = undefined
103-
const end = event.end_time
104-
? { end: toDateTuple(event.end_time) }
105-
: { duration: { hours: defaultEventDuration } }
106-
createEvent(
107-
{
108-
title: event.title,
109-
description: event.description_block ? toPlainText(event.description_block) : "",
110-
location: event.address_text,
111-
start: toDateTuple(event.start_time),
112-
...end,
113-
url: `${process.env.NEXT_PUBLIC_BASE_URL}/arrangement/${event.slug.current}`,
114-
organizer: { name: "Root Linjeforening", email: process.env.NEXT_PUBLIC_EMAIL },
115-
categories: ["Root Linjeforening", "Arrangement", getEventTypeLabel(event.type)],
116-
},
117-
(error, value) => {
118-
if (error) {
119-
console.error(error)
120-
} else {
121-
icsEvent = value
122-
}
123-
},
124-
)
125-
return icsEvent
126-
}

app/(root)/arrangement/eventCardPaginated.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const EventCardPaginated: Component<EventCardPaginatedProps> = ({
6767
return (
6868
<InfoCard
6969
cardTitle={cardTitle}
70+
className={"w-full sm:w-[550px]"}
7071
bottom={
7172
showButton ? (
7273
<ButtonAndProgress loading={loading} onClick={fetchMoreEvents} />

app/(root)/arrangement/page.tsx

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { getFutureEvents, getPastAndFutureEvents, getPastEvents } from "@/sanity/queries/event"
22
import EventCardPaginated from "@/app/(root)/arrangement/eventCardPaginated"
33
import { type Metadata } from "next"
4+
import CalendarModal from "@/components/modals/calendarModal"
5+
import FloatingMenu from "@/components/floatingMenu"
6+
import { Card } from "@nextui-org/card"
47

58
export const metadata: Metadata = {
69
title: "Arrangementer | Root Linjeforening",
@@ -23,28 +26,35 @@ const className = "sm:w-[550px] w-full mx-1 h-min"
2326
const EventsPage: AsyncPage = async () => {
2427
const { past, future } = await getPastAndFutureEvents(nrOfEvents)
2528
return (
26-
<div className={"flex flex-wrap items-baseline justify-center gap-5"}>
27-
<EventCardPaginated
28-
cardTitle={"Kommende arrangementer"}
29-
className={className}
30-
initial={future}
31-
minEvents={nrOfEvents}
32-
fetchMore={async (limit, lastEventStartTime) => {
33-
// Spesifiserer at denne funksjonen skal kjøres på serveren, selv om den blir kalt fra klienten.
34-
"use server"
35-
return getFutureEvents(limit, lastEventStartTime)
36-
}}
37-
/>
38-
<EventCardPaginated
39-
cardTitle={"Tidligere arrangementer"}
40-
className={className}
41-
initial={past}
42-
minEvents={nrOfEvents}
43-
fetchMore={async (limit, lastEventStartTime) => {
44-
"use server"
45-
return getPastEvents(limit, lastEventStartTime)
46-
}}
47-
/>
29+
<div className={"pt-3 sm:px-10"}>
30+
<FloatingMenu>
31+
<Card className={"rounded-lg p-2"}>
32+
<CalendarModal />
33+
</Card>
34+
</FloatingMenu>
35+
<div className={"flex flex-wrap items-baseline justify-center gap-5 sm:p-5"}>
36+
<EventCardPaginated
37+
cardTitle={"Kommende arrangementer"}
38+
className={className}
39+
initial={future}
40+
minEvents={nrOfEvents}
41+
fetchMore={async (limit, lastStartTime) => {
42+
// Spesifiserer at denne funksjonen skal kjøres på serveren, selv om den blir kalt fra klienten.
43+
"use server"
44+
return getFutureEvents(limit, lastStartTime)
45+
}}
46+
/>
47+
<EventCardPaginated
48+
cardTitle={"Tidligere arrangementer"}
49+
className={className}
50+
initial={past}
51+
minEvents={nrOfEvents}
52+
fetchMore={async (limit, lastStartTime) => {
53+
"use server"
54+
return getPastEvents(limit, lastStartTime)
55+
}}
56+
/>
57+
</div>
4858
</div>
4959
)
5060
}

app/(root)/globals.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,5 @@ interface ButtonProps extends ChildProps {
6767
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
6868
type?: "button" | "submit" | "reset"
6969
disabled?: boolean
70+
endContent?: React.ReactNode
7071
}

app/(root)/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Providers } from "@/app/(root)/providers"
77

88
/**
99
* Inneholder tittel som vises i fanen til nettleseren og beskrivelse som vises i søkeresultater.
10-
*
10+
* Keywords brukes for å forbedre søkemotoroptimalisering.
1111
* @see https://nextjs.org/docs/app/building-your-application/optimizing/metadata
1212
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta
1313
*/

components/buttons/button.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Button as _Button, Link } from "@nextui-org/react"
33
import { LinkIcon } from "@heroicons/react/24/outline"
44
import { defaultIconSize } from "@/components/icons/icon"
55

6-
const buttonClassNames = "min-w-[100px] rounded-2xl bg-root-primary p-3 text-white"
6+
const buttonClassNames = "min-w-[100px] bg-root-primary p-3 text-white"
77

88
/**
99
* En stylet knapp i rootBlue farge og hvit tekst.
@@ -12,7 +12,7 @@ const buttonClassNames = "min-w-[100px] rounded-2xl bg-root-primary p-3 text-whi
1212
* @param props Props som skal sendes til button-elementet, blant annet onClick, disabled, etc.
1313
*/
1414
export const Button: Component<ButtonProps> = ({ children, className, ...props }) => (
15-
<_Button className={`${buttonClassNames} ${className}`} {...props}>
15+
<_Button radius={"lg"} className={`${buttonClassNames} ${className}`} {...props}>
1616
{children}
1717
</_Button>
1818
)
@@ -31,6 +31,7 @@ export const LinkButton: Component<{ href?: string } & ChildProps> = ({
3131
}) => (
3232
<_Button
3333
as={Link}
34+
radius={"lg"}
3435
className={`flex-center !text-white hover:text-white ${buttonClassNames} ${className}`}
3536
{...props}>
3637
{children}
@@ -53,6 +54,7 @@ export const ExternalLinkButton: Component<{ href?: string; iconWidth?: number }
5354
}) => (
5455
<_Button
5556
as={Link}
57+
radius={"lg"}
5658
className={`flex-center gap-2 hover:text-white dark:text-white ${buttonClassNames} ${className}`}
5759
isExternal
5860
showAnchorIcon

components/buttons/darkModeToggle.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const DarkModeToggle = () => {
3131
key={theme}
3232
onClick={() => setTheme(theme)}
3333
size="sm"
34+
aria-label={text}
3435
className={selectedTheme === theme ? "bg-root-primary text-white" : undefined}>
3536
{icon}
3637
{text}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"use client"
2+
import Dropdown, { type Key } from "@/components/dropdown/dropdown"
3+
import { ChevronDownIcon } from "@heroicons/react/24/outline"
4+
import { defaultIconSize } from "@/components/icons/icon"
5+
import { useMemo, useState } from "react"
6+
import { Tooltip } from "@nextui-org/react"
7+
8+
type CalendarKey = "google" | "outlook" | "apple" | "ics" | "copy"
9+
10+
const subscribeItems: { key: CalendarKey; label: string; getLink: (url: string) => string }[] = [
11+
{
12+
key: "google",
13+
label: "Google Kalender",
14+
getLink: (url: string) => `https://calendar.google.com/calendar/u/0/r?cid=${url}`,
15+
},
16+
{
17+
key: "outlook",
18+
label: "Outlook",
19+
getLink: (url: string) => `https://outlook.live.com/calendar/0/addfromweb/?url=${url}`,
20+
},
21+
{
22+
key: "apple",
23+
label: "Apple Kalender",
24+
getLink: (url: string) => url.replace(/http(s)?:\/\//i, "webcal://"),
25+
},
26+
{ key: "ics", label: "iCal-fil", getLink: (url: string) => url },
27+
{ key: "copy", label: "Kopier lenke", getLink: (url: string) => url },
28+
]
29+
30+
interface AddToCalendarDropdownProps extends DefaultProps {
31+
eventUrl: string
32+
restrict?: CalendarKey[]
33+
}
34+
35+
const AddToCalendarDropdown: Component<AddToCalendarDropdownProps> = ({
36+
eventUrl,
37+
restrict,
38+
...props
39+
}) => {
40+
const [showTooltip, setShowTooltip] = useState(false)
41+
42+
const items = useMemo(
43+
() => subscribeItems.filter(item => !restrict || restrict?.includes(item.key)),
44+
[restrict],
45+
)
46+
47+
function onAction(key: Key) {
48+
if (key === "copy") {
49+
void navigator.clipboard.writeText(eventUrl)
50+
setShowTooltip(true)
51+
setTimeout(() => setShowTooltip(false), 2000)
52+
return
53+
}
54+
const link = items.find(item => item.key === key)?.getLink(eventUrl)
55+
if (link) {
56+
window.open(link, "_blank")
57+
}
58+
}
59+
60+
return (
61+
<Tooltip content={"Lenke kopiert"} isOpen={showTooltip}>
62+
<div className={"w-min"}>
63+
<Dropdown
64+
buttonProps={{
65+
endContent: <ChevronDownIcon width={defaultIconSize} />,
66+
"aria-label": "Legg til i kalender",
67+
}}
68+
label={"Legg til i kalender"}
69+
items={items}
70+
onAction={onAction}
71+
{...props}
72+
/>
73+
</div>
74+
</Tooltip>
75+
)
76+
}
77+
78+
export default AddToCalendarDropdown

0 commit comments

Comments
 (0)