Redesign the calendar with month, week and agenda views (#531)
This commit is contained in:
parent
846480b71e
commit
9e9c38f9b2
17 changed files with 1310 additions and 156 deletions
|
|
@ -753,7 +753,7 @@ Acceptance:
|
||||||
- The create form requires a title and a start/end time.
|
- The create form requires a title and a start/end time.
|
||||||
- Submitting closes the modal and lists the event under its date on the Calendar
|
- Submitting closes the modal and lists the event under its date on the Calendar
|
||||||
page.
|
page.
|
||||||
- The calendar opens scrolled to today or the next upcoming event.
|
- Switching to the Agenda view scrolls to today or the next upcoming event.
|
||||||
|
|
||||||
### US-047 — Manage your own calendar event
|
### US-047 — Manage your own calendar event
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,9 @@ test("US-046 create and browse a calendar event", async ({seed, as}) => {
|
||||||
const {url} = scenario.space("space")
|
const {url} = scenario.space("space")
|
||||||
const page = await as(users.alice, `${spacePath(url)}/calendar`)
|
const page = await as(users.alice, `${spacePath(url)}/calendar`)
|
||||||
|
|
||||||
|
// This story is about the scrollable list spanning past and future events, not the default view
|
||||||
|
await page.getByRole("button", {name: "Agenda", exact: true}).click()
|
||||||
|
|
||||||
const cards = page.getByRole("link").filter({hasText: "Posted by"})
|
const cards = page.getByRole("link").filter({hasText: "Posted by"})
|
||||||
const card = (title: string) => page.getByRole("link").filter({hasText: title})
|
const card = (title: string) => page.getByRole("link").filter({hasText: title})
|
||||||
|
|
||||||
|
|
@ -204,8 +207,9 @@ test("US-047 manage your own calendar event", async ({seed, as}) => {
|
||||||
|
|
||||||
await openCard(page.getByRole("link").filter({hasText: "Harvest Supper"}), "Harvest Supper")
|
await openCard(page.getByRole("link").filter({hasText: "Harvest Supper"}), "Harvest Supper")
|
||||||
|
|
||||||
// The event has no replies, so the page carries exactly one feature card.
|
// The hero card (date, header, meta, actions) is always the first feature card on the page;
|
||||||
const eventCard = page.locator(".card.z-feature")
|
// the About tab renders its own feature card below it for the description.
|
||||||
|
const eventCard = page.locator(".card.z-feature").first()
|
||||||
|
|
||||||
await expect(page.getByRole("heading", {name: "Harvest Supper", exact: true})).toBeVisible()
|
await expect(page.getByRole("heading", {name: "Harvest Supper", exact: true})).toBeVisible()
|
||||||
await expect(eventCard).toContainText(await longDate(page, at(-2, DAY)))
|
await expect(eventCard).toContainText(await longDate(page, at(-2, DAY)))
|
||||||
|
|
@ -773,18 +777,20 @@ test("US-052 comment on and react to community posts", async ({seed, as}) => {
|
||||||
|
|
||||||
await openCard(bob.getByRole("link").filter({hasText: "Autumn Fair"}), "Autumn Fair")
|
await openCard(bob.getByRole("link").filter({hasText: "Autumn Fair"}), "Autumn Fair")
|
||||||
|
|
||||||
const eventCard = bob.locator(".card.z-feature").filter({hasText: "Stalls, cider"})
|
// The hero card carries reactions; the description sits in its own card under the About tab.
|
||||||
|
const heroCard = bob.locator(".card.z-feature").first()
|
||||||
|
|
||||||
await expect(eventCard).toBeVisible()
|
await expect(bob.getByText("Stalls, cider and a tug of war.")).toBeVisible()
|
||||||
|
|
||||||
await pickParty(bob, emojiButton(eventCard))
|
await pickParty(bob, emojiButton(heroCard))
|
||||||
|
|
||||||
const bobsPill = eventCard.getByRole("button", {name: PARTY})
|
const bobsPill = heroCard.getByRole("button", {name: PARTY})
|
||||||
|
|
||||||
await expect(bobsPill).toHaveCount(1)
|
await expect(bobsPill).toHaveCount(1)
|
||||||
await expect(bobsPill).toHaveClass(/button-primary/)
|
await expect(bobsPill).toHaveClass(/button-primary/)
|
||||||
await expect(bobsPill).toHaveAttribute("data-tip", "Bob Barker reacted")
|
await expect(bobsPill).toHaveAttribute("data-tip", "Bob Barker reacted")
|
||||||
|
|
||||||
|
await bob.getByRole("button", {name: /^Discussion/}).click()
|
||||||
await bob.getByRole("button", {name: "Leave comment"}).click()
|
await bob.getByRole("button", {name: "Leave comment"}).click()
|
||||||
|
|
||||||
const reply = bob.locator("form").filter({has: bob.locator(".note-editor")})
|
const reply = bob.locator("form").filter({has: bob.locator(".note-editor")})
|
||||||
|
|
@ -798,12 +804,15 @@ test("US-052 comment on and react to community posts", async ({seed, as}) => {
|
||||||
|
|
||||||
await openCard(alice.getByRole("link").filter({hasText: "Autumn Fair"}), "Autumn Fair")
|
await openCard(alice.getByRole("link").filter({hasText: "Autumn Fair"}), "Autumn Fair")
|
||||||
|
|
||||||
const alicesView = alice.locator(".card.z-feature").filter({hasText: "Stalls, cider"})
|
const alicesHeroCard = alice.locator(".card.z-feature").first()
|
||||||
|
|
||||||
await expect(alicesView.getByRole("button", {name: PARTY})).toHaveAttribute(
|
await expect(alicesHeroCard.getByRole("button", {name: PARTY})).toHaveAttribute(
|
||||||
"data-tip",
|
"data-tip",
|
||||||
"Bob Barker reacted",
|
"Bob Barker reacted",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await alice.getByRole("button", {name: /^Discussion/}).click()
|
||||||
|
|
||||||
await expect(alice.getByText("Is there parking at The Green?")).toBeVisible()
|
await expect(alice.getByText("Is there parking at The Green?")).toBeVisible()
|
||||||
|
|
||||||
await alice.getByRole("button", {name: "Leave comment"}).click()
|
await alice.getByRole("button", {name: "Leave comment"}).click()
|
||||||
|
|
|
||||||
297
src/app/calendar.ts
Normal file
297
src/app/calendar.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import {
|
||||||
|
HOUR,
|
||||||
|
LOCALE,
|
||||||
|
dateToSeconds,
|
||||||
|
int,
|
||||||
|
pushToMapKey,
|
||||||
|
secondsToDate,
|
||||||
|
sortBy,
|
||||||
|
uniqBy,
|
||||||
|
} from "@welshman/lib"
|
||||||
|
import {EVENT_RSVP, getAddress, makeEvent, tagSpec, tagValue} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {TimeEvent} from "@welshman/domain"
|
||||||
|
import {synced} from "@welshman/store"
|
||||||
|
import {deletes, reader, relays, thunks} from "@app/core"
|
||||||
|
import {deriveEvents} from "@app/repository"
|
||||||
|
import {PROTECTED, ROOM} from "@app/rooms"
|
||||||
|
import {kv} from "@app/storage"
|
||||||
|
|
||||||
|
// Views
|
||||||
|
|
||||||
|
export type CalendarView = "agenda" | "week" | "month"
|
||||||
|
|
||||||
|
// People tend to stick with one way of reading a calendar, so open it the way they left it
|
||||||
|
export const calendarView = synced<CalendarView>({
|
||||||
|
key: "calendarView",
|
||||||
|
defaultValue: "month",
|
||||||
|
storage: kv,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
|
||||||
|
const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
|
||||||
|
export const addDays = (date: Date, days: number) => {
|
||||||
|
const result = startOfDay(date)
|
||||||
|
|
||||||
|
result.setDate(result.getDate() + days)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isSameDay = (a: Date, b: Date) =>
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate()
|
||||||
|
|
||||||
|
const startOfWeek = (date: Date) => addDays(date, -date.getDay())
|
||||||
|
|
||||||
|
export const isToday = (date: Date) => isSameDay(date, new Date())
|
||||||
|
|
||||||
|
export const isCurrentMonth = (date: Date) => {
|
||||||
|
const today = new Date()
|
||||||
|
|
||||||
|
return date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isCurrentWeek = (date: Date) => isSameDay(startOfWeek(date), startOfWeek(new Date()))
|
||||||
|
|
||||||
|
// Six rows so the grid's height doesn't change from month to month
|
||||||
|
export const getMonthDays = (date: Date) => {
|
||||||
|
const start = startOfWeek(new Date(date.getFullYear(), date.getMonth(), 1))
|
||||||
|
|
||||||
|
return Array.from({length: 42}, (_, index) => addDays(start, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWeekDays = (date: Date) => {
|
||||||
|
const start = startOfWeek(date)
|
||||||
|
|
||||||
|
return Array.from({length: 7}, (_, index) => addDays(start, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addMonths = (date: Date, months: number) =>
|
||||||
|
new Date(date.getFullYear(), date.getMonth() + months, 1)
|
||||||
|
|
||||||
|
export const makeDayKey = (date: Date) =>
|
||||||
|
`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
|
||||||
|
|
||||||
|
export const formatMonth = (date: Date) =>
|
||||||
|
Intl.DateTimeFormat(LOCALE, {month: "long", year: "numeric"}).format(date)
|
||||||
|
|
||||||
|
export const formatWeekday = (date: Date) =>
|
||||||
|
Intl.DateTimeFormat(LOCALE, {weekday: "short"}).format(date)
|
||||||
|
|
||||||
|
export const formatDay = (date: Date) =>
|
||||||
|
Intl.DateTimeFormat(LOCALE, {weekday: "long", month: "long", day: "numeric"}).format(date)
|
||||||
|
|
||||||
|
export const formatWeekRange = (date: Date) => {
|
||||||
|
const days = getWeekDays(date)
|
||||||
|
const first = Intl.DateTimeFormat(LOCALE, {month: "short", day: "numeric"}).format(days[0])
|
||||||
|
const last = Intl.DateTimeFormat(LOCALE, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(days[6])
|
||||||
|
|
||||||
|
return `${first} – ${last}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A day picked out of a grid carries no time of day, so start it at a neutral hour
|
||||||
|
export const makeDayRange = (day: Date) => {
|
||||||
|
const start = dateToSeconds(new Date(day.getFullYear(), day.getMonth(), day.getDate(), 12))
|
||||||
|
|
||||||
|
return {start, end: start + int(1, HOUR)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar events
|
||||||
|
|
||||||
|
export const getEventStart = (event: TrustedEvent) => reader(TimeEvent)(event).start()
|
||||||
|
|
||||||
|
// NIP-52 settled on `title`, but events from before that still carry `name`.
|
||||||
|
export const getEventTitle = (event: TrustedEvent) =>
|
||||||
|
reader(TimeEvent)(event).title() ?? tagValue(tagSpec("name"), event.tags)
|
||||||
|
|
||||||
|
// An event covering several days is only introduced on the first of them; the rest continue it.
|
||||||
|
export const startsOnDay = (event: TrustedEvent, day: Date) => {
|
||||||
|
const start = getEventStart(event)
|
||||||
|
|
||||||
|
return Boolean(start && isSameDay(secondsToDate(start), day))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-day events show up on each day they cover, in start order within each day. A malformed
|
||||||
|
// end date could span an unbounded number of days, so stop after a year.
|
||||||
|
export const groupEventsByDay = (events: TrustedEvent[]) => {
|
||||||
|
const result = new Map<string, TrustedEvent[]>()
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const timeEvent = reader(TimeEvent)(event)
|
||||||
|
const start = timeEvent.start()
|
||||||
|
|
||||||
|
if (start) {
|
||||||
|
const lastDay = startOfDay(secondsToDate(timeEvent.end() ?? start))
|
||||||
|
|
||||||
|
let day = startOfDay(secondsToDate(start))
|
||||||
|
|
||||||
|
for (let index = 0; day <= lastDay && index < 366; index++) {
|
||||||
|
pushToMapKey(result, makeDayKey(day), event)
|
||||||
|
day = addDays(day, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, dayEvents] of result) {
|
||||||
|
result.set(
|
||||||
|
key,
|
||||||
|
sortBy(event => getEventStart(event) ?? 0, dayEvents),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether an event spans more than one calendar day — see layoutMultiDayBars.
|
||||||
|
export const isMultiDayEvent = (event: TrustedEvent) => {
|
||||||
|
const timeEvent = reader(TimeEvent)(event)
|
||||||
|
const start = timeEvent.start()
|
||||||
|
|
||||||
|
if (!start) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isSameDay(secondsToDate(start), secondsToDate(timeEvent.end() ?? start))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSingleDayEvents = (events: TrustedEvent[]) =>
|
||||||
|
events.filter(event => !isMultiDayEvent(event))
|
||||||
|
|
||||||
|
export type CalendarBar = {
|
||||||
|
event: TrustedEvent
|
||||||
|
startCol: number
|
||||||
|
span: number
|
||||||
|
lane: number
|
||||||
|
continuesBefore: boolean
|
||||||
|
continuesAfter: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysApart = (a: Date, b: Date) =>
|
||||||
|
Math.round((a.getTime() - b.getTime()) / (24 * 60 * 60 * 1000))
|
||||||
|
|
||||||
|
// Lays multi-day events spanning `days` out as bars instead of a chip per day, packing
|
||||||
|
// overlapping ones into as few vertical lanes as possible.
|
||||||
|
export const layoutMultiDayBars = (
|
||||||
|
days: Date[],
|
||||||
|
eventsByDay: Map<string, TrustedEvent[]>,
|
||||||
|
): CalendarBar[] => {
|
||||||
|
const windowStart = days[0]
|
||||||
|
const windowEnd = days[days.length - 1]
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const spans: Omit<CalendarBar, "lane">[] = []
|
||||||
|
|
||||||
|
for (const day of days) {
|
||||||
|
for (const event of eventsByDay.get(makeDayKey(day)) ?? []) {
|
||||||
|
if (seen.has(event.id) || !isMultiDayEvent(event)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(event.id)
|
||||||
|
|
||||||
|
const timeEvent = reader(TimeEvent)(event)
|
||||||
|
const start = timeEvent.start()!
|
||||||
|
const eventStart = startOfDay(secondsToDate(start))
|
||||||
|
const eventEnd = startOfDay(secondsToDate(timeEvent.end() ?? start))
|
||||||
|
const startCol = Math.max(0, daysApart(eventStart, windowStart))
|
||||||
|
const endCol = Math.min(days.length - 1, daysApart(eventEnd, windowStart))
|
||||||
|
|
||||||
|
spans.push({
|
||||||
|
event,
|
||||||
|
startCol,
|
||||||
|
span: endCol - startCol + 1,
|
||||||
|
continuesBefore: eventStart < windowStart,
|
||||||
|
continuesAfter: eventEnd > windowEnd,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const laneEnds: number[] = []
|
||||||
|
const bars: CalendarBar[] = []
|
||||||
|
|
||||||
|
for (const span of sortBy(s => s.startCol, spans)) {
|
||||||
|
let lane = laneEnds.findIndex(end => end < span.startCol)
|
||||||
|
|
||||||
|
if (lane === -1) {
|
||||||
|
lane = laneEnds.length
|
||||||
|
}
|
||||||
|
|
||||||
|
laneEnds[lane] = span.startCol + span.span - 1
|
||||||
|
bars.push({...span, lane})
|
||||||
|
}
|
||||||
|
|
||||||
|
return bars
|
||||||
|
}
|
||||||
|
|
||||||
|
// RSVPs (NIP-52)
|
||||||
|
|
||||||
|
export enum RsvpStatus {
|
||||||
|
Accepted = "accepted",
|
||||||
|
Declined = "declined",
|
||||||
|
Tentative = "tentative",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRsvpFilter = (event: TrustedEvent) => ({
|
||||||
|
kinds: [EVENT_RSVP],
|
||||||
|
"#a": [getAddress(event)],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deriveRsvps = (event: TrustedEvent) => deriveEvents([makeRsvpFilter(event)])
|
||||||
|
|
||||||
|
export const getRsvpStatus = (rsvp: TrustedEvent) => tagValue(tagSpec("status"), rsvp.tags)
|
||||||
|
|
||||||
|
// An RSVP replaces the sender's previous one, but a relay can still be holding both, so the
|
||||||
|
// newest per person is the one that counts.
|
||||||
|
export const getRsvpsByStatus = (rsvps: TrustedEvent[]) => {
|
||||||
|
const latest = uniqBy(
|
||||||
|
rsvp => rsvp.pubkey,
|
||||||
|
sortBy(rsvp => -rsvp.created_at, rsvps),
|
||||||
|
)
|
||||||
|
|
||||||
|
const byStatus = (status: RsvpStatus) =>
|
||||||
|
latest.filter(rsvp => getRsvpStatus(rsvp) === status).map(rsvp => rsvp.pubkey)
|
||||||
|
|
||||||
|
return {
|
||||||
|
latest,
|
||||||
|
accepted: byStatus(RsvpStatus.Accepted),
|
||||||
|
tentative: byStatus(RsvpStatus.Tentative),
|
||||||
|
declined: byStatus(RsvpStatus.Declined),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An RSVP is addressable, so identifying it by its target makes a new one supersede the old.
|
||||||
|
export const publishRsvp = async (url: string, event: TrustedEvent, status: RsvpStatus) => {
|
||||||
|
const address = getAddress(event)
|
||||||
|
const h = tagValue(tagSpec(ROOM), event.tags)
|
||||||
|
const tags = [
|
||||||
|
["a", address, url],
|
||||||
|
["e", event.id, url],
|
||||||
|
["d", address],
|
||||||
|
["status", status],
|
||||||
|
["p", event.pubkey],
|
||||||
|
]
|
||||||
|
|
||||||
|
if (await relays.get().hasNip(url, 70)) {
|
||||||
|
tags.push(PROTECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h) {
|
||||||
|
tags.push([ROOM, h])
|
||||||
|
}
|
||||||
|
|
||||||
|
return thunks.get().publish({relays: [url], event: makeEvent(EVENT_RSVP, {tags})})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const retractRsvp = async (url: string, rsvp: TrustedEvent) => {
|
||||||
|
const protect = await relays.get().hasNip(url, 70)
|
||||||
|
const command = await deletes.get().deleteEvent(rsvp, writer => writer.setProtected(protect))
|
||||||
|
|
||||||
|
return command.publishToRelays([url])
|
||||||
|
}
|
||||||
156
src/app/components/CalendarAgenda.svelte
Normal file
156
src/app/components/CalendarAgenda.svelte
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {formatTimestampAsDate, now} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {fly} from "@lib/transition"
|
||||||
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
||||||
|
import type {FeedContext} from "@app/feeds"
|
||||||
|
import {getEventStart} from "@app/calendar"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
events: TrustedEvent[]
|
||||||
|
element?: HTMLElement
|
||||||
|
loading: boolean
|
||||||
|
context: FeedContext
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, events, element, loading, context}: Props = $props()
|
||||||
|
|
||||||
|
let contentEl: HTMLElement | undefined = $state()
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
event: TrustedEvent
|
||||||
|
dateDisplay?: string
|
||||||
|
isFirstFutureEvent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = $derived.by(() => {
|
||||||
|
const todayDateDisplay = formatTimestampAsDate(now())
|
||||||
|
|
||||||
|
let haveISeenTheFuture = false
|
||||||
|
let prevDateDisplay: string
|
||||||
|
|
||||||
|
return events
|
||||||
|
.filter(event => getEventStart(event))
|
||||||
|
.map<Item>(event => {
|
||||||
|
const start = getEventStart(event)!
|
||||||
|
const newDateDisplay = formatTimestampAsDate(start)
|
||||||
|
const dateDisplay = prevDateDisplay === newDateDisplay ? undefined : newDateDisplay
|
||||||
|
const isFuture = todayDateDisplay === newDateDisplay || start > now()
|
||||||
|
const isFirstFutureEvent = !haveISeenTheFuture && isFuture
|
||||||
|
|
||||||
|
prevDateDisplay = newDateDisplay
|
||||||
|
haveISeenTheFuture = isFuture
|
||||||
|
|
||||||
|
return {event, dateDisplay, isFirstFutureEvent}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let previousScrollHeight = 0
|
||||||
|
let prevFirstEventId = ""
|
||||||
|
let initialScrollDone = false
|
||||||
|
|
||||||
|
// The item an in-progress centering has settled on. A profile name or a reaction count that
|
||||||
|
// resolves after the item has already rendered shifts everything below it, so this stays
|
||||||
|
// pinned — and gets re-centered by the observer below — until the visitor scrolls themselves.
|
||||||
|
let pinnedTarget: HTMLElement | undefined = undefined
|
||||||
|
|
||||||
|
// offsetTop is relative to the nearest positioned ancestor, which the scroll container itself
|
||||||
|
// isn't — comparing bounding rects instead keeps this correct regardless of that
|
||||||
|
const recenter = (target: HTMLElement) => {
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const elementRect = element.getBoundingClientRect()
|
||||||
|
const targetRect = target.getBoundingClientRect()
|
||||||
|
|
||||||
|
element.scrollTop +=
|
||||||
|
targetRect.top - elementRect.top - element.clientHeight / 2 + targetRect.height / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!element || !contentEl) return
|
||||||
|
|
||||||
|
const unpin = () => {
|
||||||
|
pinnedTarget = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// A resolved profile name, a reaction count, an RSVP tally, an avatar image — all arrive well
|
||||||
|
// after the item itself renders and can reflow the whole list, and none of them touch
|
||||||
|
// `events`, so nothing here re-runs the effect below on its own. A ResizeObserver catches
|
||||||
|
// every one of those (unlike a MutationObserver, which misses an image's own load-driven
|
||||||
|
// reflow) and keeps the pin centered until the visitor scrolls themselves.
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (pinnedTarget) recenter(pinnedTarget)
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(contentEl)
|
||||||
|
element.addEventListener("wheel", unpin, {passive: true})
|
||||||
|
element.addEventListener("touchmove", unpin, {passive: true})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
element.removeEventListener("wheel", unpin)
|
||||||
|
element.removeEventListener("touchmove", unpin)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (items.length === 0 || !element) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialScrollDone) {
|
||||||
|
// If new events are prepended, adjust the scroll position so that the viewport content remains anchored
|
||||||
|
if (prevFirstEventId && items[0].event.id !== prevFirstEventId) {
|
||||||
|
const delta = element.scrollHeight - previousScrollHeight
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
element.scrollTop += delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const {event} =
|
||||||
|
items.find(({event}) => getEventStart(event)! >= now()) ?? items[items.length - 1]
|
||||||
|
const target = document.querySelector<HTMLElement>(".calendar-event-" + event.id)
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
// On initial load, center the scroll container on today's date (or the next available event)
|
||||||
|
recenter(target)
|
||||||
|
pinnedTarget = target
|
||||||
|
initialScrollDone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousScrollHeight = element.scrollHeight
|
||||||
|
prevFirstEventId = items[0].event.id
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={contentEl} class="flex flex-col gap-2">
|
||||||
|
{#each items as { event, dateDisplay, isFirstFutureEvent } (event.id)}
|
||||||
|
<div class="flex flex-col gap-2 calendar-event-{event.id}">
|
||||||
|
{#if isFirstFutureEvent}
|
||||||
|
<div class="flex items-center gap-2 p-2">
|
||||||
|
<div class="h-px grow bg-primary text-primary-content"></div>
|
||||||
|
<p class="text-xs uppercase text-primary">Today</p>
|
||||||
|
<div class="h-px grow bg-primary text-primary-content"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if dateDisplay}
|
||||||
|
<Divider>{dateDisplay}</Divider>
|
||||||
|
{/if}
|
||||||
|
<CalendarEventItem {url} {event} {context} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if loading}
|
||||||
|
<p class="flex h-10 items-center justify-center py-20" transition:fly>
|
||||||
|
<Spinner {loading} />
|
||||||
|
</p>
|
||||||
|
{:else if items.length === 0}
|
||||||
|
<p class="flex h-10 items-center justify-center py-20" transition:fly>No events found.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="flex h-10 items-center justify-center py-20" transition:fly>That's all!</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
57
src/app/components/CalendarDay.svelte
Normal file
57
src/app/components/CalendarDay.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type {Readable} from "svelte/store"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import Add from "@assets/icons/add.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
||||||
|
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
|
||||||
|
import type {FeedContext} from "@app/feeds"
|
||||||
|
import {formatDay, groupEventsByDay, makeDayKey} from "@app/calendar"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
date: Date
|
||||||
|
events: Readable<TrustedEvent[]>
|
||||||
|
context: FeedContext
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, date, events, context}: Props = $props()
|
||||||
|
|
||||||
|
// A modal's props are frozen at the time it's pushed, so this has to re-derive from the feed's
|
||||||
|
// own store rather than receive a pre-filtered list — otherwise creating an event from here
|
||||||
|
// would leave the modal showing the stale, empty list it opened with.
|
||||||
|
const dayEvents = $derived(groupEventsByDay($events).get(makeDayKey(date)) ?? [])
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const makeEvent = () => pushModal(CalendarEventCreate, {url, date}, {nested: true})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>{formatDay(date)}</ModalTitle>
|
||||||
|
<ModalSubtitle>
|
||||||
|
{dayEvents.length === 1 ? "1 event" : `${dayEvents.length} events`}
|
||||||
|
</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
{#each dayEvents as event (event.id)}
|
||||||
|
<CalendarEventItem {url} {event} {context} />
|
||||||
|
{/each}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="button button-link" onclick={back}>Go back</Button>
|
||||||
|
<Button class="button button-primary" onclick={makeEvent}>
|
||||||
|
<Icon icon={Add} />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
44
src/app/components/CalendarEventChip.svelte
Normal file
44
src/app/components/CalendarEventChip.svelte
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import {formatTimestampAsTime} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getAddress} from "@welshman/util"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import {getEventStart, getEventTitle, startsOnDay} from "@app/calendar"
|
||||||
|
import {makeCalendarPath} from "@app/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
day?: Date
|
||||||
|
continuesAfter?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event, day, continuesAfter = false, class: className}: Props = $props()
|
||||||
|
|
||||||
|
const start = getEventStart(event)
|
||||||
|
|
||||||
|
// Showing the start time on a day the event merely runs through would misdate it
|
||||||
|
const continuesBefore = day ? !startsOnDay(event, day) : false
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={makeCalendarPath(url, getAddress(event))}
|
||||||
|
class={cx(
|
||||||
|
"flex h-6 min-w-0 items-center gap-x-1 bg-primary/10 px-1.5 text-xs text-primary transition-colors hover:bg-primary hover:text-primary-content",
|
||||||
|
// Only round the corners that are the event's real start/end, not a clipped row edge
|
||||||
|
continuesBefore ? "rounded-l-none" : "rounded-l-lg",
|
||||||
|
continuesAfter ? "rounded-r-none" : "rounded-r-lg",
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
{#if continuesBefore}
|
||||||
|
<span class="shrink-0 opacity-75" title="Continues from an earlier day">→</span>
|
||||||
|
{:else if start}
|
||||||
|
<span class="shrink-0 opacity-75">{formatTimestampAsTime(start)}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="min-w-0 truncate">{getEventTitle(event)}</span>
|
||||||
|
{#if continuesAfter}
|
||||||
|
<span class="ml-auto shrink-0 opacity-75" title="Continues to a later day">→</span>
|
||||||
|
{/if}
|
||||||
|
</Link>
|
||||||
|
|
@ -3,17 +3,22 @@
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
|
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
|
||||||
|
import {makeDayRange} from "@app/calendar"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
h?: string
|
h?: string
|
||||||
shareToChat?: boolean
|
shareToChat?: boolean
|
||||||
|
date?: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h, shareToChat = false}: Props = $props()
|
const {url, h, shareToChat = false, date}: Props = $props()
|
||||||
|
|
||||||
|
// Creating from a day in the grid starts the event on that day
|
||||||
|
const range = date ? makeDayRange(date) : undefined
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarEventForm {url} {h} {shareToChat}>
|
<CalendarEventForm {url} {h} {shareToChat} defaultStart={range?.start} defaultEnd={range?.end}>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Create an Event</ModalTitle>
|
<ModalTitle>Create an Event</ModalTitle>
|
||||||
|
|
|
||||||
78
src/app/components/CalendarEventDiscussion.svelte
Normal file
78
src/app/components/CalendarEventDiscussion.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {sortBy} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
|
||||||
|
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
|
import CommentActions from "@app/components/CommentActions.svelte"
|
||||||
|
import EventReply from "@app/components/EventReply.svelte"
|
||||||
|
import type {FeedContext} from "@app/feeds"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
replies: TrustedEvent[]
|
||||||
|
context: FeedContext
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event, replies, context}: Props = $props()
|
||||||
|
|
||||||
|
const expand = () => {
|
||||||
|
showAll = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openReply = () => {
|
||||||
|
showReply = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeReply = () => {
|
||||||
|
showReply = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let showAll = $state(false)
|
||||||
|
let showReply = $state(false)
|
||||||
|
|
||||||
|
const ordered = $derived(sortBy(reply => reply.created_at, replies))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 sm:gap-4">
|
||||||
|
{#if ordered.length === 0 && !showReply}
|
||||||
|
<div class="flex flex-col items-center gap-3 py-6">
|
||||||
|
<p class="opacity-75">No comments yet — start the conversation.</p>
|
||||||
|
<Button class="button button-primary" onclick={openReply}>
|
||||||
|
<Icon icon={Reply} />
|
||||||
|
Leave comment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if !showAll && ordered.length > 4}
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Button class="button button-link" onclick={expand}>
|
||||||
|
<Icon icon={SortVertical} />
|
||||||
|
Show all {ordered.length} replies
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each ordered.slice(0, showAll ? undefined : 4) as reply (reply.id)}
|
||||||
|
<NoteCard event={reply} {url} class="card z-feature w-full">
|
||||||
|
<div class="ml-12 flex flex-col gap-3">
|
||||||
|
<NoteContent showEntire event={reply} {url} />
|
||||||
|
<CommentActions event={reply} {url} {context} />
|
||||||
|
</div>
|
||||||
|
</NoteCard>
|
||||||
|
{/each}
|
||||||
|
{#if showReply}
|
||||||
|
<EventReply {url} {event} onClose={closeReply} onSubmit={closeReply} />
|
||||||
|
{:else}
|
||||||
|
<div class="flex justify-end px-2 pb-2">
|
||||||
|
<Button class="button button-primary" onclick={openReply}>
|
||||||
|
<Icon icon={Reply} />
|
||||||
|
Leave comment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -39,9 +39,19 @@
|
||||||
shareToChat?: boolean
|
shareToChat?: boolean
|
||||||
header: Snippet
|
header: Snippet
|
||||||
initialValues?: Values
|
initialValues?: Values
|
||||||
|
defaultStart?: number
|
||||||
|
defaultEnd?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
|
let {
|
||||||
|
url,
|
||||||
|
h,
|
||||||
|
shareToChat = false,
|
||||||
|
header,
|
||||||
|
initialValues,
|
||||||
|
defaultStart,
|
||||||
|
defaultEnd,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
|
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
|
||||||
|
|
||||||
|
|
@ -129,8 +139,9 @@
|
||||||
const d = $state(initialValues?.d ?? randomId())
|
const d = $state(initialValues?.d ?? randomId())
|
||||||
let title = $state(initialValues?.title ?? "")
|
let title = $state(initialValues?.title ?? "")
|
||||||
let location = $state(initialValues?.location ?? "")
|
let location = $state(initialValues?.location ?? "")
|
||||||
let start: number | undefined = $state(initialValues?.start)
|
// A day chosen before the form opened is more current than whatever the draft was left on
|
||||||
let end: number | undefined = $state(initialValues?.end)
|
let start: number | undefined = $state(defaultStart ?? initialValues?.start)
|
||||||
|
let end: number | undefined = $state(defaultEnd ?? initialValues?.end)
|
||||||
let content = $state(initialValues?.content ?? "")
|
let content = $state(initialValues?.content ?? "")
|
||||||
|
|
||||||
const onChange = (json: object) => {
|
const onChange = (json: object) => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/lib"
|
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {tagSpec, tagValue} from "@welshman/util"
|
|
||||||
import {TimeEvent} from "@welshman/domain"
|
import {TimeEvent} from "@welshman/domain"
|
||||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {reader} from "@app/core"
|
import {reader} from "@app/core"
|
||||||
|
import {getEventTitle} from "@app/calendar"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
|
|
@ -15,8 +15,7 @@
|
||||||
|
|
||||||
const timeEvent = $derived(reader(TimeEvent)(event))
|
const timeEvent = $derived(reader(TimeEvent)(event))
|
||||||
|
|
||||||
// NIP-52 settled on `title`, but events from before that still carry `name`.
|
const title = $derived(getEventTitle(event))
|
||||||
const title = $derived(timeEvent.title() ?? tagValue(tagSpec("name"), event.tags))
|
|
||||||
const start = $derived(timeEvent.start())
|
const start = $derived(timeEvent.start())
|
||||||
const end = $derived(timeEvent.end())
|
const end = $derived(timeEvent.end())
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
46
src/app/components/CalendarEventPeople.svelte
Normal file
46
src/app/components/CalendarEventPeople.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import Profile from "@app/components/Profile.svelte"
|
||||||
|
import {getRsvpsByStatus} from "@app/calendar"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
rsvps: TrustedEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event, rsvps}: Props = $props()
|
||||||
|
|
||||||
|
const people = $derived(getRsvpsByStatus(rsvps))
|
||||||
|
|
||||||
|
const sections = $derived(
|
||||||
|
[
|
||||||
|
{label: "Going", pubkeys: people.accepted},
|
||||||
|
{label: "Maybe", pubkeys: people.tentative},
|
||||||
|
{label: "Can't go", pubkeys: people.declined},
|
||||||
|
].filter(section => section.pubkeys.length > 0),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p class="text-xs uppercase opacity-75">Host</p>
|
||||||
|
<div class="card">
|
||||||
|
<Profile pubkey={event.pubkey} {url} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#each sections as section (section.label)}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p class="text-xs uppercase opacity-75">{section.label} · {section.pubkeys.length}</p>
|
||||||
|
{#each section.pubkeys as pubkey (pubkey)}
|
||||||
|
<div class="card">
|
||||||
|
<Profile {pubkey} {url} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="flex items-center justify-center py-6 opacity-75">
|
||||||
|
No one has responded yet — be the first.
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
123
src/app/components/CalendarMonth.svelte
Normal file
123
src/app/components/CalendarMonth.svelte
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import type {Readable} from "svelte/store"
|
||||||
|
import {LOCALE, chunk} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import CalendarDay from "@app/components/CalendarDay.svelte"
|
||||||
|
import CalendarEventChip from "@app/components/CalendarEventChip.svelte"
|
||||||
|
import type {FeedContext} from "@app/feeds"
|
||||||
|
import {
|
||||||
|
formatDay,
|
||||||
|
formatWeekday,
|
||||||
|
getMonthDays,
|
||||||
|
getSingleDayEvents,
|
||||||
|
isToday,
|
||||||
|
layoutMultiDayBars,
|
||||||
|
makeDayKey,
|
||||||
|
} from "@app/calendar"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
events: Readable<TrustedEvent[]>
|
||||||
|
eventsByDay: Map<string, TrustedEvent[]>
|
||||||
|
date: Date
|
||||||
|
context: FeedContext
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, events, eventsByDay, date, context}: Props = $props()
|
||||||
|
|
||||||
|
// A cell only has room for a few events, so the rest of the day lives in a modal. The feed's
|
||||||
|
// own store is passed through rather than a pre-filtered list, since a modal's props are
|
||||||
|
// frozen at push time and wouldn't pick up an event created from inside it.
|
||||||
|
const showDay = (day: Date) =>
|
||||||
|
pushModal(CalendarDay, {
|
||||||
|
url,
|
||||||
|
date: day,
|
||||||
|
events,
|
||||||
|
context,
|
||||||
|
})
|
||||||
|
|
||||||
|
const weeks = $derived(chunk(7, getMonthDays(date)))
|
||||||
|
const weekBars = $derived(weeks.map(week => layoutMultiDayBars(week, eventsByDay)))
|
||||||
|
|
||||||
|
// Shared across every row so the grid stays a uniform height, not just tallest-in-its-own-week
|
||||||
|
const laneCount = $derived(
|
||||||
|
weekBars.reduce(
|
||||||
|
(max, bars) =>
|
||||||
|
Math.max(
|
||||||
|
max,
|
||||||
|
bars.reduce((m, bar) => Math.max(m, bar.lane + 1), 0),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-2xl border border-solid border-line bg-surface">
|
||||||
|
<div class="grid grid-cols-7 bg-surface-more">
|
||||||
|
{#each weeks[0] as day (day.getTime())}
|
||||||
|
<div class="p-2 text-center text-xs uppercase opacity-75">{formatWeekday(day)}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#each weeks as week, weekIndex (week[0].getTime())}
|
||||||
|
{@const bars = weekBars[weekIndex]}
|
||||||
|
<div
|
||||||
|
class="grid min-h-24 grid-cols-7 gap-y-1 border-t border-solid border-line pb-1"
|
||||||
|
style="grid-template-rows: auto {laneCount > 0 ? `repeat(${laneCount}, 1.5rem)` : ''} 1fr;">
|
||||||
|
{#each week as day, col (day.getTime())}
|
||||||
|
<Button
|
||||||
|
class={cx(
|
||||||
|
"mx-1 mt-1 self-start justify-self-start rounded-full px-1.5 text-xs transition-colors",
|
||||||
|
{
|
||||||
|
"bg-primary text-primary-content": isToday(day),
|
||||||
|
"hover:bg-surface-more": !isToday(day),
|
||||||
|
"opacity-40": day.getMonth() !== date.getMonth(),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style="grid-column: {col + 1}; grid-row: 1;"
|
||||||
|
aria-label={formatDay(day)}
|
||||||
|
onclick={() => showDay(day)}>
|
||||||
|
{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(day)}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
{#each bars as bar (bar.event.id)}
|
||||||
|
<div
|
||||||
|
class={cx("flex min-w-0 items-stretch", {
|
||||||
|
"pl-1": !bar.continuesBefore,
|
||||||
|
"pr-1": !bar.continuesAfter,
|
||||||
|
})}
|
||||||
|
style="grid-column: {bar.startCol + 1} / {bar.startCol +
|
||||||
|
1 +
|
||||||
|
bar.span}; grid-row: {bar.lane + 2};">
|
||||||
|
<CalendarEventChip
|
||||||
|
{url}
|
||||||
|
event={bar.event}
|
||||||
|
day={week[bar.startCol]}
|
||||||
|
continuesAfter={bar.continuesAfter}
|
||||||
|
class="w-full" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#each week as day, col (day.getTime())}
|
||||||
|
{@const dayEvents = getSingleDayEvents(eventsByDay.get(makeDayKey(day)) ?? [])}
|
||||||
|
<div
|
||||||
|
class={cx("flex min-w-0 flex-col gap-1 px-1", {
|
||||||
|
"opacity-40": day.getMonth() !== date.getMonth(),
|
||||||
|
})}
|
||||||
|
style="grid-column: {col + 1}; grid-row: {laneCount + 2} / -1;">
|
||||||
|
{#each dayEvents.slice(0, 3) as event (event.id)}
|
||||||
|
<CalendarEventChip {url} {event} {day} />
|
||||||
|
{/each}
|
||||||
|
{#if dayEvents.length > 3}
|
||||||
|
<Button
|
||||||
|
class="px-1.5 text-left text-xs opacity-75 hover:opacity-100"
|
||||||
|
onclick={() => showDay(day)}>
|
||||||
|
+{dayEvents.length - 3} more
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
104
src/app/components/CalendarRsvp.svelte
Normal file
104
src/app/components/CalendarRsvp.svelte
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||||
|
import QuestionCircle from "@assets/icons/question-circle.svg?dataurl"
|
||||||
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
|
import {user} from "@app/core"
|
||||||
|
import {
|
||||||
|
RsvpStatus,
|
||||||
|
getRsvpStatus,
|
||||||
|
getRsvpsByStatus,
|
||||||
|
publishRsvp,
|
||||||
|
retractRsvp,
|
||||||
|
} from "@app/calendar"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
rsvps: TrustedEvent[]
|
||||||
|
onShowPeople?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event, rsvps, onShowPeople}: Props = $props()
|
||||||
|
|
||||||
|
const setStatus = async (status: RsvpStatus) => {
|
||||||
|
if (loading) return
|
||||||
|
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (ownRsvp && getRsvpStatus(ownRsvp) === status) {
|
||||||
|
await retractRsvp(url, ownRsvp)
|
||||||
|
} else {
|
||||||
|
await publishRsvp(url, event, status)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accept = () => setStatus(RsvpStatus.Accepted)
|
||||||
|
|
||||||
|
const maybe = () => setStatus(RsvpStatus.Tentative)
|
||||||
|
|
||||||
|
const decline = () => setStatus(RsvpStatus.Declined)
|
||||||
|
|
||||||
|
const statusClass = (status: RsvpStatus) =>
|
||||||
|
cx("button button-xs join-item", ownStatus === status ? "button-primary" : "button-neutral")
|
||||||
|
|
||||||
|
let loading = $state(false)
|
||||||
|
|
||||||
|
const people = $derived(getRsvpsByStatus(rsvps))
|
||||||
|
const ownRsvp = $derived(people.latest.find(rsvp => rsvp.pubkey === $user.pubkey))
|
||||||
|
const ownStatus = $derived(ownRsvp ? getRsvpStatus(ownRsvp) : undefined)
|
||||||
|
const attending = $derived([...people.accepted, ...people.tentative])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm opacity-75">Are you going?</span>
|
||||||
|
<div class="join">
|
||||||
|
<Button class={statusClass(RsvpStatus.Accepted)} disabled={loading} onclick={accept}>
|
||||||
|
<Icon icon={CheckCircle} size={4} />
|
||||||
|
Going
|
||||||
|
</Button>
|
||||||
|
<Button class={statusClass(RsvpStatus.Tentative)} disabled={loading} onclick={maybe}>
|
||||||
|
<Icon icon={QuestionCircle} size={4} />
|
||||||
|
Maybe
|
||||||
|
</Button>
|
||||||
|
<Button class={statusClass(RsvpStatus.Declined)} disabled={loading} onclick={decline}>
|
||||||
|
<Icon icon={CloseCircle} size={4} />
|
||||||
|
Can't go
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if loading}
|
||||||
|
<Spinner size="xs" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if attending.length > 0}
|
||||||
|
{@const summary = [
|
||||||
|
`${people.accepted.length} going`,
|
||||||
|
people.tentative.length > 0 ? `${people.tentative.length} maybe` : undefined,
|
||||||
|
people.declined.length > 0 ? `${people.declined.length} can't go` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
<!-- The roll-up is the natural way into the guest list, so make it the link to it -->
|
||||||
|
{#if onShowPeople}
|
||||||
|
<Button class="flex items-center gap-2 self-start text-sm" onclick={onShowPeople}>
|
||||||
|
<ProfileCircles pubkeys={attending} size={6} />
|
||||||
|
<span class="opacity-75 hover:opacity-100">{summary}</span>
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<ProfileCircles pubkeys={attending} size={6} />
|
||||||
|
<span class="opacity-75">{summary}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
134
src/app/components/CalendarWeek.svelte
Normal file
134
src/app/components/CalendarWeek.svelte
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import type {Readable} from "svelte/store"
|
||||||
|
import {LOCALE} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import CalendarDay from "@app/components/CalendarDay.svelte"
|
||||||
|
import CalendarEventChip from "@app/components/CalendarEventChip.svelte"
|
||||||
|
import type {FeedContext} from "@app/feeds"
|
||||||
|
import {
|
||||||
|
formatDay,
|
||||||
|
formatWeekday,
|
||||||
|
getSingleDayEvents,
|
||||||
|
getWeekDays,
|
||||||
|
isToday,
|
||||||
|
layoutMultiDayBars,
|
||||||
|
makeDayKey,
|
||||||
|
} from "@app/calendar"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
events: Readable<TrustedEvent[]>
|
||||||
|
eventsByDay: Map<string, TrustedEvent[]>
|
||||||
|
date: Date
|
||||||
|
context: FeedContext
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, events, eventsByDay, date, context}: Props = $props()
|
||||||
|
|
||||||
|
// The feed's own store is passed through rather than a pre-filtered list, since a modal's
|
||||||
|
// props are frozen at push time and wouldn't pick up an event created from inside it.
|
||||||
|
const showDay = (day: Date) =>
|
||||||
|
pushModal(CalendarDay, {
|
||||||
|
url,
|
||||||
|
date: day,
|
||||||
|
events,
|
||||||
|
context,
|
||||||
|
})
|
||||||
|
|
||||||
|
const days = $derived(getWeekDays(date))
|
||||||
|
const bars = $derived(layoutMultiDayBars(days, eventsByDay))
|
||||||
|
const laneCount = $derived(bars.reduce((max, bar) => Math.max(max, bar.lane + 1), 0))
|
||||||
|
const lanes = $derived(Array.from({length: laneCount}, (_, i) => i))
|
||||||
|
|
||||||
|
const dayHeaderClass = (day: Date) =>
|
||||||
|
cx("rounded-full px-1.5 text-sm", {"bg-primary text-primary-content": isToday(day)})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Sized to the space this component has, not the viewport, so it stacks by content width -->
|
||||||
|
<div class="@container overflow-hidden rounded-2xl border border-solid border-line">
|
||||||
|
<div class="flex flex-col gap-px bg-line @3xl:hidden">
|
||||||
|
{#each days as day (day.getTime())}
|
||||||
|
{@const dayEvents = eventsByDay.get(makeDayKey(day)) ?? []}
|
||||||
|
<div
|
||||||
|
class={cx("flex min-h-16 flex-col gap-1 bg-surface p-2", {
|
||||||
|
"border border-solid border-primary": isToday(day),
|
||||||
|
})}>
|
||||||
|
<Button
|
||||||
|
class="flex items-baseline gap-2 self-start rounded-lg px-1 transition-colors hover:bg-surface-more"
|
||||||
|
aria-label={formatDay(day)}
|
||||||
|
onclick={() => showDay(day)}>
|
||||||
|
<span class="text-xs uppercase opacity-75">{formatWeekday(day)}</span>
|
||||||
|
<span class={dayHeaderClass(day)}>
|
||||||
|
{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(day)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
{#each dayEvents as event (event.id)}
|
||||||
|
<CalendarEventChip {url} {event} {day} />
|
||||||
|
{:else}
|
||||||
|
<span class="px-1 text-xs opacity-40">—</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="hidden grid-cols-7 gap-x-px bg-line @3xl:grid"
|
||||||
|
style="grid-template-rows: auto {laneCount > 0 ? `repeat(${laneCount}, 1.75rem)` : ''} 1fr;">
|
||||||
|
<!-- The header and multi-day-bar rows don't otherwise paint a background of their own, so the
|
||||||
|
gap-x-px/bg-line hairline trick that separates the day columns below would instead leak
|
||||||
|
through as a wide band behind them -->
|
||||||
|
{#each days as day, col (day.getTime())}
|
||||||
|
<div class="bg-surface" style="grid-column: {col + 1}; grid-row: 1;"></div>
|
||||||
|
{/each}
|
||||||
|
{#each lanes as lane (lane)}
|
||||||
|
{#each days as day, col (day.getTime())}
|
||||||
|
<div class="bg-surface" style="grid-column: {col + 1}; grid-row: {lane + 2};"></div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
{#each days as day, col (day.getTime())}
|
||||||
|
<Button
|
||||||
|
class="mx-1 mt-1 flex items-baseline gap-2 self-start justify-self-start rounded-lg px-1 transition-colors hover:bg-surface-more"
|
||||||
|
style="grid-column: {col + 1}; grid-row: 1;"
|
||||||
|
aria-label={formatDay(day)}
|
||||||
|
onclick={() => showDay(day)}>
|
||||||
|
<span class="text-xs uppercase opacity-75">{formatWeekday(day)}</span>
|
||||||
|
<span class={dayHeaderClass(day)}>
|
||||||
|
{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(day)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
{#each bars as bar (bar.event.id)}
|
||||||
|
<div
|
||||||
|
class={cx("flex min-w-0 items-center py-0.5", {
|
||||||
|
"pl-1": !bar.continuesBefore,
|
||||||
|
"pr-1": !bar.continuesAfter,
|
||||||
|
})}
|
||||||
|
style="grid-column: {bar.startCol + 1} / {bar.startCol +
|
||||||
|
1 +
|
||||||
|
bar.span}; grid-row: {bar.lane + 2};">
|
||||||
|
<CalendarEventChip
|
||||||
|
{url}
|
||||||
|
event={bar.event}
|
||||||
|
day={days[bar.startCol]}
|
||||||
|
continuesAfter={bar.continuesAfter}
|
||||||
|
class="w-full" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#each days as day, col (day.getTime())}
|
||||||
|
{@const dayEvents = getSingleDayEvents(eventsByDay.get(makeDayKey(day)) ?? [])}
|
||||||
|
<div
|
||||||
|
class={cx("flex min-h-96 flex-col gap-1 bg-surface px-1 pt-1 pb-2", {
|
||||||
|
"border border-solid border-primary": isToday(day),
|
||||||
|
})}
|
||||||
|
style="grid-column: {col + 1}; grid-row: {laneCount + 2} / -1;">
|
||||||
|
{#each dayEvents as event (event.id)}
|
||||||
|
<CalendarEventChip {url} {event} {day} />
|
||||||
|
{:else}
|
||||||
|
<span class="px-1 text-xs opacity-40">—</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -789,6 +789,9 @@ export const makeCalendarFeed = ({
|
||||||
events,
|
events,
|
||||||
loadOlder,
|
loadOlder,
|
||||||
loadNewer,
|
loadNewer,
|
||||||
|
// The month and week views jump to arbitrary ranges rather than scrolling through them, and
|
||||||
|
// wait on the request so they can show progress for the range on screen
|
||||||
|
load: loadTimeframe,
|
||||||
cleanup: () => {
|
cleanup: () => {
|
||||||
controller.abort()
|
controller.abort()
|
||||||
unsubscribers.forEach(call)
|
unsubscribers.forEach(call)
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,39 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy, onMount} from "svelte"
|
import {onDestroy, onMount} from "svelte"
|
||||||
|
import cx from "classnames"
|
||||||
import type {Readable} from "svelte/store"
|
import type {Readable} from "svelte/store"
|
||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {now, last, formatTimestampAsDate} from "@welshman/lib"
|
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {EVENT_TIME, tagValue, tagSpec} from "@welshman/util"
|
import {EVENT_TIME} from "@welshman/util"
|
||||||
import {fly} from "@lib/transition"
|
|
||||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||||
import Add from "@assets/icons/add.svg?dataurl"
|
import Add from "@assets/icons/add.svg?dataurl"
|
||||||
|
import {fade} from "@lib/transition"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Divider from "@lib/components/Divider.svelte"
|
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
import CalendarAgenda from "@app/components/CalendarAgenda.svelte"
|
||||||
|
import CalendarMonth from "@app/components/CalendarMonth.svelte"
|
||||||
|
import CalendarWeek from "@app/components/CalendarWeek.svelte"
|
||||||
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
|
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import type {CalendarView} from "@app/calendar"
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
addMonths,
|
||||||
|
calendarView,
|
||||||
|
formatMonth,
|
||||||
|
formatWeekRange,
|
||||||
|
getMonthDays,
|
||||||
|
getWeekDays,
|
||||||
|
groupEventsByDay,
|
||||||
|
isCurrentMonth,
|
||||||
|
isCurrentWeek,
|
||||||
|
} from "@app/calendar"
|
||||||
|
import {getModal, pushModal} from "@app/modal"
|
||||||
import {decodeRelay} from "@app/relays"
|
import {decodeRelay} from "@app/relays"
|
||||||
import {makeCommentFilter} from "@app/content"
|
import {makeCommentFilter} from "@app/content"
|
||||||
import {makeCalendarFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
|
import {makeCalendarFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
|
||||||
|
|
@ -28,7 +43,46 @@
|
||||||
|
|
||||||
const makeEvent = () => pushModal(CalendarEventCreate, {url})
|
const makeEvent = () => pushModal(CalendarEventCreate, {url})
|
||||||
|
|
||||||
const getStart = (event: TrustedEvent) => parseInt(tagValue(tagSpec("start"), event.tags) || "")
|
const showView = (target: CalendarView) => () => calendarView.set(target)
|
||||||
|
|
||||||
|
const showToday = () => {
|
||||||
|
cursor = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showPrevious = () => {
|
||||||
|
cursor = view === "month" ? addMonths(cursor, -1) : addDays(cursor, -7)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showNext = () => {
|
||||||
|
cursor = view === "month" ? addMonths(cursor, 1) : addDays(cursor, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paging a month at a time is a lot of clicking, so mirror what other calendars bind
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
|
||||||
|
if (view === "agenda" || getModal() || event.metaKey || event.ctrlKey || event.altKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
target?.isContentEditable ||
|
||||||
|
["INPUT", "TEXTAREA", "SELECT"].includes(target?.tagName ?? "")
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowLeft") showPrevious()
|
||||||
|
if (event.key === "ArrowRight") showNext()
|
||||||
|
if (event.key === "t") showToday()
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewClass = (target: CalendarView) =>
|
||||||
|
cx(
|
||||||
|
"button join-item",
|
||||||
|
isNarrow ? "button-xs" : "button-sm",
|
||||||
|
view === target ? "button-primary" : "button-neutral",
|
||||||
|
)
|
||||||
|
|
||||||
let element: HTMLElement | undefined = $state()
|
let element: HTMLElement | undefined = $state()
|
||||||
onDestroy(context.cleanup)
|
onDestroy(context.cleanup)
|
||||||
|
|
@ -41,67 +95,46 @@
|
||||||
// between each of them would read as broken.
|
// between each of them would read as broken.
|
||||||
const loading = $derived(!$older || $older.status !== "exhausted")
|
const loading = $derived(!$older || $older.status !== "exhausted")
|
||||||
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
||||||
|
let feed: ReturnType<typeof makeCalendarFeed> | undefined = $state()
|
||||||
|
let cursor = $state(new Date())
|
||||||
|
let rangeLoading = $state(false)
|
||||||
|
let latestRange = 0
|
||||||
|
let width = $state(0)
|
||||||
|
|
||||||
type Item = {
|
// Month renders poorly in a narrow column, so that gets week — without touching the preference
|
||||||
event: TrustedEvent
|
const isNarrow = $derived(width > 0 && width < 768)
|
||||||
dateDisplay?: string
|
const view = $derived(isNarrow && $calendarView === "month" ? "week" : $calendarView)
|
||||||
isFirstFutureEvent?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = $derived.by(() => {
|
const isCurrentRange = $derived(view === "month" ? isCurrentMonth(cursor) : isCurrentWeek(cursor))
|
||||||
const todayDateDisplay = formatTimestampAsDate(now())
|
|
||||||
|
|
||||||
let haveISeenTheFuture = false
|
const rangeLabel = $derived(view === "month" ? formatMonth(cursor) : formatWeekRange(cursor))
|
||||||
let prevDateDisplay: string
|
|
||||||
|
|
||||||
return $events
|
const visibleDays = $derived(view === "month" ? getMonthDays(cursor) : getWeekDays(cursor))
|
||||||
.filter(event => !isNaN(getStart(event)))
|
|
||||||
.map<Item>(event => {
|
|
||||||
const newDateDisplay = formatTimestampAsDate(getStart(event))
|
|
||||||
const dateDisplay = prevDateDisplay === newDateDisplay ? undefined : newDateDisplay
|
|
||||||
const isFuture = todayDateDisplay === newDateDisplay || getStart(event) > now()
|
|
||||||
const isFirstFutureEvent = !haveISeenTheFuture && isFuture
|
|
||||||
|
|
||||||
prevDateDisplay = newDateDisplay
|
// Grouped once here so both the grid and the feed's range query agree on what is on screen
|
||||||
haveISeenTheFuture = isFuture
|
const eventsByDay = $derived(groupEventsByDay($events))
|
||||||
|
|
||||||
return {event, dateDisplay, isFirstFutureEvent}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
let previousScrollHeight = 0
|
|
||||||
let prevFirstEventId = ""
|
|
||||||
let centeredEventId = ""
|
|
||||||
|
|
||||||
|
// The month and week grids show a fixed range instead of scrolling into one, so ask for it directly
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (items.length === 0) {
|
if (feed && view !== "agenda") {
|
||||||
return
|
const days = visibleDays
|
||||||
|
const range = ++latestRange
|
||||||
|
|
||||||
|
rangeLoading = true
|
||||||
|
|
||||||
|
feed
|
||||||
|
.load(days[0].getTime() / 1000, addDays(days[days.length - 1], 1).getTime() / 1000)
|
||||||
|
.finally(() => {
|
||||||
|
// Paging faster than the relay answers would otherwise clear a newer range's spinner
|
||||||
|
if (range === latestRange) {
|
||||||
|
rangeLoading = false
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const {event} = items.find(({event}) => getStart(event) >= now()) || last(items)
|
|
||||||
const card = document.querySelector(".calendar-event-" + event.id)
|
|
||||||
|
|
||||||
// The feed arrives in batches, so the first one may hold nothing that hasn't happened yet.
|
|
||||||
// Centering again each time a nearer event turns up settles on the right one — and it stops
|
|
||||||
// once they have all arrived, because loading further out never changes which is next.
|
|
||||||
if (event.id !== centeredEventId && card instanceof HTMLElement) {
|
|
||||||
element!.scrollTop = card.offsetTop - element!.clientHeight / 2 + card.clientHeight / 2
|
|
||||||
centeredEventId = event.id
|
|
||||||
} else if (prevFirstEventId && items[0].event.id !== prevFirstEventId) {
|
|
||||||
// Older events prepended above the viewport would otherwise carry its contents down with them
|
|
||||||
const delta = element!.scrollHeight - previousScrollHeight
|
|
||||||
|
|
||||||
if (delta > 0) {
|
|
||||||
element!.scrollTop += delta
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previousScrollHeight = element!.scrollHeight
|
|
||||||
prevFirstEventId = items[0].event.id
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const feed = makeCalendarFeed({
|
feed = makeCalendarFeed({
|
||||||
relays: [url],
|
relays: [url],
|
||||||
onEvent: context.add,
|
onEvent: context.add,
|
||||||
filters: [{kinds: [EVENT_TIME]}, makeCommentFilter([EVENT_TIME])],
|
filters: [{kinds: [EVENT_TIME]}, makeCommentFilter([EVENT_TIME])],
|
||||||
|
|
@ -116,11 +149,13 @@
|
||||||
return () => {
|
return () => {
|
||||||
older?.stop()
|
older?.stop()
|
||||||
newer?.stop()
|
newer?.stop()
|
||||||
feed.cleanup()
|
feed?.cleanup()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeyDown} />
|
||||||
|
|
||||||
<SpaceBar>
|
<SpaceBar>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<Icon icon={CalendarMinimalistic} />
|
<Icon icon={CalendarMinimalistic} />
|
||||||
|
|
@ -129,6 +164,25 @@
|
||||||
<strong>Calendar</strong>
|
<strong>Calendar</strong>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
{#snippet action()}
|
||||||
|
<div class="join">
|
||||||
|
<Button
|
||||||
|
class={viewClass("agenda")}
|
||||||
|
aria-pressed={view === "agenda"}
|
||||||
|
onclick={showView("agenda")}>
|
||||||
|
Agenda
|
||||||
|
</Button>
|
||||||
|
<Button class={viewClass("week")} aria-pressed={view === "week"} onclick={showView("week")}>
|
||||||
|
Week
|
||||||
|
</Button>
|
||||||
|
{#if !isNarrow}
|
||||||
|
<Button
|
||||||
|
class={viewClass("month")}
|
||||||
|
aria-pressed={view === "month"}
|
||||||
|
onclick={showView("month")}>
|
||||||
|
Month
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<Button class="button button-primary button-sm" onclick={makeEvent}>
|
<Button class="button button-primary button-sm" onclick={makeEvent}>
|
||||||
<Icon icon={Add} />
|
<Icon icon={Add} />
|
||||||
Create
|
Create
|
||||||
|
|
@ -137,28 +191,42 @@
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:px-4">
|
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:px-4">
|
||||||
{#each items as { event, dateDisplay, isFirstFutureEvent }, i (event.id)}
|
<div bind:clientWidth={width} class="flex flex-col gap-2">
|
||||||
<div class="flex flex-col gap-2 calendar-event-{event.id}">
|
{#if view !== "agenda"}
|
||||||
{#if isFirstFutureEvent}
|
<div class="flex flex-wrap items-center gap-1 pt-2">
|
||||||
<div class="flex items-center gap-2 p-2">
|
<Button
|
||||||
<div class="h-px grow bg-primary text-primary-content"></div>
|
class="button button-neutral button-xs"
|
||||||
<p class="text-xs uppercase text-primary">Today</p>
|
aria-label="Show the previous {view}"
|
||||||
<div class="h-px grow bg-primary text-primary-content"></div>
|
onclick={showPrevious}>
|
||||||
</div>
|
<Icon icon={AltArrowLeft} size={4} />
|
||||||
{/if}
|
</Button>
|
||||||
{#if dateDisplay}
|
<span class="w-48 text-center font-bold">{rangeLabel}</span>
|
||||||
<Divider>{dateDisplay}</Divider>
|
<Button
|
||||||
{/if}
|
class="button button-neutral button-xs"
|
||||||
<CalendarEventItem {url} {event} {context} />
|
aria-label="Show the next {view}"
|
||||||
</div>
|
onclick={showNext}>
|
||||||
{/each}
|
<Icon icon={AltArrowRight} size={4} />
|
||||||
{#if loading}
|
</Button>
|
||||||
<p class="flex h-10 items-center justify-center py-20" transition:fly>
|
<Button
|
||||||
<Spinner {loading}>Looking for events...</Spinner>
|
class="button button-neutral button-xs"
|
||||||
</p>
|
disabled={isCurrentRange}
|
||||||
{:else if items.length === 0}
|
onclick={showToday}>
|
||||||
<p class="flex h-10 items-center justify-center py-20" transition:fly>No events found.</p>
|
Today
|
||||||
{:else}
|
</Button>
|
||||||
<p class="flex h-10 items-center justify-center py-20" transition:fly>That's all!</p>
|
<!-- Fixed width so its appearance doesn't nudge the buttons beside it -->
|
||||||
{/if}
|
<span class="flex w-4 items-center justify-center">
|
||||||
|
{#if rangeLoading}
|
||||||
|
<span class="spinner spinner-xs" transition:fade={{duration: 150}}></span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if view === "agenda"}
|
||||||
|
<CalendarAgenda {url} events={$events} {element} {loading} {context} />
|
||||||
|
{:else if view === "week"}
|
||||||
|
<CalendarWeek {url} {events} {eventsByDay} date={cursor} {context} />
|
||||||
|
{:else}
|
||||||
|
<CalendarMonth {url} {events} {eventsByDay} date={cursor} {context} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,30 @@
|
||||||
|
<script module lang="ts">
|
||||||
|
type EventTab = "about" | "discussion" | "people"
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy} from "svelte"
|
import {onDestroy} from "svelte"
|
||||||
import {derived} from "svelte/store"
|
import cx from "classnames"
|
||||||
|
import {derived, readable} from "svelte/store"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {sortBy, sleep} from "@welshman/lib"
|
import {sleep} from "@welshman/lib"
|
||||||
import type {MakeNonOptional} from "@welshman/lib"
|
import type {MakeNonOptional} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getCommentFiltersForRoot} from "@welshman/util"
|
import {getCommentFiltersForRoot} from "@welshman/util"
|
||||||
import {TimeEvent} from "@welshman/domain"
|
import {TimeEvent} from "@welshman/domain"
|
||||||
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
|
|
||||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import NoteContent from "@app/components/NoteContent.svelte"
|
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
|
||||||
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
|
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
|
||||||
import CommentActions from "@app/components/CommentActions.svelte"
|
|
||||||
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
||||||
import CalendarEventMeta from "@app/components/CalendarEventMeta.svelte"
|
import CalendarEventMeta from "@app/components/CalendarEventMeta.svelte"
|
||||||
import CalendarEventDate from "@app/components/CalendarEventDate.svelte"
|
import CalendarEventDate from "@app/components/CalendarEventDate.svelte"
|
||||||
import EventReply from "@app/components/EventReply.svelte"
|
import CalendarEventDiscussion from "@app/components/CalendarEventDiscussion.svelte"
|
||||||
|
import CalendarEventPeople from "@app/components/CalendarEventPeople.svelte"
|
||||||
|
import CalendarRsvp from "@app/components/CalendarRsvp.svelte"
|
||||||
|
import {deriveRsvps, getRsvpsByStatus, makeRsvpFilter} from "@app/calendar"
|
||||||
import {network, reader} from "@app/core"
|
import {network, reader} from "@app/core"
|
||||||
import {deriveEvent, deriveEvents} from "@app/repository"
|
import {deriveEvent, deriveEvents} from "@app/repository"
|
||||||
import {makeFeedContext} from "@app/feeds"
|
import {makeFeedContext} from "@app/feeds"
|
||||||
|
|
@ -34,25 +37,21 @@
|
||||||
const timeEvent = derived(event, $event => ($event ? reader(TimeEvent)($event) : undefined))
|
const timeEvent = derived(event, $event => ($event ? reader(TimeEvent)($event) : undefined))
|
||||||
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
|
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
|
||||||
const replies = $derived(deriveEvents(filters))
|
const replies = $derived(deriveEvents(filters))
|
||||||
|
const rsvps = $derived($event ? deriveRsvps($event) : readable<TrustedEvent[]>([]))
|
||||||
|
const people = $derived(getRsvpsByStatus($rsvps))
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const openReply = () => {
|
const showTab = (target: EventTab) => () => {
|
||||||
showReply = true
|
tab = target
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeReply = () => {
|
const tabClass = (target: EventTab) =>
|
||||||
showReply = false
|
cx("button button-sm join-item grow", tab === target ? "button-primary" : "button-neutral")
|
||||||
}
|
|
||||||
|
|
||||||
const expand = () => {
|
|
||||||
showAll = true
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(context.cleanup)
|
onDestroy(context.cleanup)
|
||||||
|
|
||||||
let showAll = $state(false)
|
let tab = $state<EventTab>("about")
|
||||||
let showReply = $state(false)
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (filters.length > 0) {
|
if (filters.length > 0) {
|
||||||
|
|
@ -63,6 +62,20 @@
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($event) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
$network.request({
|
||||||
|
relays: [url],
|
||||||
|
filters: [makeRsvpFilter($event)],
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SpaceBar {back}>
|
<SpaceBar {back}>
|
||||||
|
|
@ -73,47 +86,54 @@
|
||||||
|
|
||||||
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
||||||
{#if $event}
|
{#if $event}
|
||||||
<div class="card flex flex-col gap-3 z-feature">
|
<div class="card z-feature flex items-start gap-4">
|
||||||
<div class="flex items-start gap-4">
|
<CalendarEventDate event={$event} />
|
||||||
<CalendarEventDate event={$event} />
|
<div class="flex min-w-0 grow flex-col gap-2">
|
||||||
<div class="flex min-w-0 grow flex-col gap-1">
|
<CalendarEventHeader event={$event} />
|
||||||
<CalendarEventHeader event={$event} />
|
<CalendarEventMeta event={$event} {url} />
|
||||||
<CalendarEventMeta event={$event} {url} />
|
<CalendarRsvp {url} event={$event} rsvps={$rsvps} onShowPeople={showTab("people")} />
|
||||||
<div class="flex py-2 opacity-50">
|
|
||||||
<div class="h-px grow opacity-25" style="background-color: var(--line)"></div>
|
|
||||||
</div>
|
|
||||||
<Content showEntire event={$event} {url} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex w-full flex-col justify-end sm:flex-row">
|
|
||||||
<CalendarEventActions showRoom {url} event={$event} {context} />
|
<CalendarEventActions showRoom {url} event={$event} {context} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if !showAll && $replies.length > 4}
|
<!-- One event carries three unrelated conversations, so give each its own tab instead of
|
||||||
<div class="flex justify-center">
|
stacking them all down the page -->
|
||||||
<Button class="button button-link" onclick={expand}>
|
<div class="join w-full sm:w-auto sm:self-start">
|
||||||
<Icon icon={SortVertical} />
|
<Button class={tabClass("about")} aria-pressed={tab === "about"} onclick={showTab("about")}>
|
||||||
Show all {$replies.length} replies
|
About
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
class={tabClass("discussion")}
|
||||||
|
aria-pressed={tab === "discussion"}
|
||||||
|
onclick={showTab("discussion")}>
|
||||||
|
Discussion
|
||||||
|
{#if $replies.length > 0}
|
||||||
|
<span class="opacity-75">{$replies.length}</span>
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class={tabClass("people")}
|
||||||
|
aria-pressed={tab === "people"}
|
||||||
|
onclick={showTab("people")}>
|
||||||
|
People
|
||||||
|
{#if people.latest.length > 0}
|
||||||
|
<span class="opacity-75">{people.latest.length}</span>
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if tab === "about"}
|
||||||
|
<div class="card z-feature flex flex-col gap-3">
|
||||||
|
{#if $event.content.trim()}
|
||||||
|
<Content showEntire event={$event} {url} />
|
||||||
|
{:else}
|
||||||
|
<p class="flex items-center justify-center py-6 opacity-75">
|
||||||
|
The host hasn't added a description.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{:else if tab === "discussion"}
|
||||||
{#each sortBy(e => e.created_at, $replies).slice(0, showAll ? undefined : 4) as reply (reply.id)}
|
<CalendarEventDiscussion {url} event={$event} replies={$replies} {context} />
|
||||||
<NoteCard event={reply} {url} class="card z-feature w-full">
|
|
||||||
<div class="flex flex-col gap-3 ml-12">
|
|
||||||
<NoteContent showEntire event={reply} {url} />
|
|
||||||
<CommentActions event={reply} {url} {context} />
|
|
||||||
</div>
|
|
||||||
</NoteCard>
|
|
||||||
{/each}
|
|
||||||
{#if showReply}
|
|
||||||
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} />
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex justify-end px-2 pb-2">
|
<CalendarEventPeople {url} event={$event} rsvps={$rsvps} />
|
||||||
<Button class="button button-primary" onclick={openReply}>
|
|
||||||
<Icon icon={Reply} />
|
|
||||||
Leave comment
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex justify-center py-20">
|
<div class="flex justify-center py-20">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue