Compare commits
5 commits
3e836bb016
...
7fed715d6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fed715d6b | ||
|
|
a86eee52ce | ||
|
|
24bf6d352d | ||
|
|
7177ce08ec | ||
|
|
48548e27f0 |
8 changed files with 111 additions and 43 deletions
|
|
@ -172,11 +172,13 @@ docker restart postgres
|
||||||
Use these playbooks to deploy from your local machine — no Forgejo runner needed.
|
Use these playbooks to deploy from your local machine — no Forgejo runner needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd infra/ansible
|
cd ansible
|
||||||
|
|
||||||
# Deploy both environments (git pull once, then build+deploy each sequentially)
|
# Deploy both environments (git pull once, then build+deploy each sequentially)
|
||||||
ansible-playbook playbooks/deploy.yml --ask-vault-pass
|
ansible-playbook playbooks/deploy.yml --ask-vault-pass
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
# Deploy staging only
|
# Deploy staging only
|
||||||
ansible-playbook playbooks/deploy-staging.yml --ask-vault-pass
|
ansible-playbook playbooks/deploy-staging.yml --ask-vault-pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export const EventOccurrences: CollectionConfig = {
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
defaultColumns: ['date', 'event', 'cancelled'],
|
defaultColumns: ['date', 'event', 'cancelled'],
|
||||||
hidden: false,
|
hidden: true,
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: () => true,
|
read: () => true,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,24 @@
|
||||||
|
import Image from 'next/image'
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
import { Logo } from '@/components/Logo/Logo'
|
import { Logo } from '@/components/Logo/Logo'
|
||||||
|
import type { Media } from '@/payload-types'
|
||||||
import styles from "./styles.module.scss"
|
import styles from "./styles.module.scss"
|
||||||
|
|
||||||
|
const backgroundSizeToObjectFit: Record<
|
||||||
|
'cover' | 'contain' | 'auto',
|
||||||
|
CSSProperties['objectFit']
|
||||||
|
> = {
|
||||||
|
cover: 'cover',
|
||||||
|
contain: 'contain',
|
||||||
|
auto: 'none',
|
||||||
|
}
|
||||||
|
|
||||||
export interface BannerProps {
|
export interface BannerProps {
|
||||||
textLine1?: string | null
|
textLine1?: string | null
|
||||||
textLine2?: string | null
|
textLine2?: string | null
|
||||||
textLine3?: string | null
|
textLine3?: string | null
|
||||||
backgroundColor?: string | null
|
backgroundColor?: string | null
|
||||||
backgroundImage?: string | null
|
backgroundImage?: Media | null
|
||||||
backgroundPosition?:
|
backgroundPosition?:
|
||||||
| 'center center'
|
| 'center center'
|
||||||
| 'top center'
|
| 'top center'
|
||||||
|
|
@ -30,15 +42,27 @@ export const Banner = ({
|
||||||
backgroundPosition = 'center center',
|
backgroundPosition = 'center center',
|
||||||
backgroundSize = 'cover',
|
backgroundSize = 'cover',
|
||||||
}: BannerProps) => {
|
}: BannerProps) => {
|
||||||
const bannerStyle: React.CSSProperties = {
|
const bannerStyle: CSSProperties = {
|
||||||
...(backgroundColor && { backgroundColor }),
|
...(backgroundColor && { backgroundColor }),
|
||||||
...(backgroundImage && { backgroundImage: `url(${backgroundImage})` }),
|
|
||||||
...(backgroundPosition && { backgroundPosition }),
|
|
||||||
...(backgroundSize && { backgroundSize }),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.banner} style={bannerStyle}>
|
<div className={styles.banner} style={bannerStyle}>
|
||||||
|
{backgroundImage?.url && (
|
||||||
|
<Image
|
||||||
|
src={backgroundImage.url}
|
||||||
|
alt={backgroundImage.alt ?? ''}
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="100vw"
|
||||||
|
unoptimized
|
||||||
|
className={styles.backgroundImage}
|
||||||
|
style={{
|
||||||
|
objectFit: backgroundSizeToObjectFit[backgroundSize ?? 'cover'],
|
||||||
|
objectPosition: backgroundPosition ?? 'center center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<Logo color={"#ffffff33"} height={200} />
|
<Logo color={"#ffffff33"} height={200} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,17 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 634px;
|
height: 634px;
|
||||||
background-color: $shade1;
|
background-color: $shade1;
|
||||||
background-image: url("banner2.jpg");
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center center;
|
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backgroundImage {
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
left: 30px;
|
left: 30px;
|
||||||
}
|
}
|
||||||
|
|
@ -21,6 +24,7 @@
|
||||||
color: $white;
|
color: $white;
|
||||||
font-family: var(--header-font);
|
font-family: var(--header-font);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
bottom: 50px;
|
bottom: 50px;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 50vw;
|
width: 50vw;
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,5 @@
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 1200px
|
max-width: 1000px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,10 +148,9 @@ export function Blocks({ content }: BlocksProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.blockType === 'banner') {
|
if (item.blockType === 'banner') {
|
||||||
const bannerImageUrl =
|
const backgroundImage =
|
||||||
typeof item.backgroundImage === 'object' &&
|
typeof item.backgroundImage === 'object'
|
||||||
item.backgroundImage?.url
|
? item.backgroundImage
|
||||||
? item.backgroundImage.url
|
|
||||||
: undefined
|
: undefined
|
||||||
return (
|
return (
|
||||||
<Banner
|
<Banner
|
||||||
|
|
@ -160,7 +159,7 @@ export function Blocks({ content }: BlocksProps) {
|
||||||
textLine2={item.textLine2}
|
textLine2={item.textLine2}
|
||||||
textLine3={item.textLine3}
|
textLine3={item.textLine3}
|
||||||
backgroundColor={item.backgroundColor}
|
backgroundColor={item.backgroundColor}
|
||||||
backgroundImage={bannerImageUrl}
|
backgroundImage={backgroundImage}
|
||||||
backgroundPosition={item.backgroundPosition}
|
backgroundPosition={item.backgroundPosition}
|
||||||
backgroundSize={item.backgroundSize}
|
backgroundSize={item.backgroundSize}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { describeRecurrence } from './recurrenceDescription'
|
import { describeRecurrence } from './recurrenceDescription'
|
||||||
|
|
||||||
// Reference: 2026-04-13 local time = Monday, 2nd Monday of April 2026.
|
// All test dates fall inside EU DST (Mar 29 – Oct 25, 2026), so Berlin is UTC+2.
|
||||||
const tuesdayFirstOfMonth = new Date(2026, 3, 7, 18, 30).toISOString() // 2026-04-07 is a Tuesday, 1st of month
|
// We spell the UTC instant explicitly so tests are timezone-independent.
|
||||||
const monday2ndOfMonth = new Date(2026, 3, 13, 18, 30).toISOString() // 2026-04-13
|
const tuesdayFirstOfMonth = '2026-04-07T16:30:00.000Z' // Tue 2026-04-07 18:30 Berlin
|
||||||
const sunday13thOfMonth = new Date(2026, 8, 13, 10, 0).toISOString() // 2026-09-13 is a Sunday
|
const monday2ndOfMonth = '2026-04-13T16:30:00.000Z' // Mon 2026-04-13 18:30 Berlin
|
||||||
const monday5thOfMonth = new Date(2026, 2, 30, 20, 0).toISOString() // 2026-03-30
|
const sunday13thOfMonth = '2026-09-13T08:00:00.000Z' // Sun 2026-09-13 10:00 Berlin
|
||||||
|
const monday5thOfMonth = '2026-03-30T18:00:00.000Z' // Mon 2026-03-30 20:00 Berlin
|
||||||
|
|
||||||
describe('describeRecurrence', () => {
|
describe('describeRecurrence', () => {
|
||||||
it('returns undefined for none / missing recurrenceType', () => {
|
it('returns undefined for none / missing recurrenceType', () => {
|
||||||
|
|
@ -14,7 +15,7 @@ describe('describeRecurrence', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('describes daily with time from event.date', () => {
|
it('describes daily with time from event.date', () => {
|
||||||
const dateAt10 = new Date(2026, 3, 13, 10, 0).toISOString()
|
const dateAt10 = '2026-04-13T08:00:00.000Z' // 10:00 Berlin
|
||||||
expect(describeRecurrence({ recurrenceType: 'daily', date: dateAt10 })).toBe(
|
expect(describeRecurrence({ recurrenceType: 'daily', date: dateAt10 })).toBe(
|
||||||
'Täglich um 10:00 Uhr',
|
'Täglich um 10:00 Uhr',
|
||||||
)
|
)
|
||||||
|
|
@ -85,7 +86,7 @@ describe('describeRecurrence', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pads minutes correctly (08:05, not 8:5)', () => {
|
it('pads minutes correctly (08:05, not 8:5)', () => {
|
||||||
const dateEarly = new Date(2026, 3, 13, 8, 5).toISOString()
|
const dateEarly = '2026-04-13T06:05:00.000Z' // 08:05 Berlin
|
||||||
expect(describeRecurrence({ recurrenceType: 'daily', date: dateEarly })).toBe(
|
expect(describeRecurrence({ recurrenceType: 'daily', date: dateEarly })).toBe(
|
||||||
'Täglich um 08:05 Uhr',
|
'Täglich um 08:05 Uhr',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -28,18 +28,55 @@ const WEEK_OF_MONTH_LABEL: Record<string, string> = {
|
||||||
last: 'letzten',
|
last: 'letzten',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match the clamp in eventRecurrence.ts so the label on the Event page
|
const BERLIN_TZ = 'Europe/Berlin'
|
||||||
// agrees with the rule the generator actually produces.
|
|
||||||
const weekOfMonthN = (d: Date): number => Math.min(Math.ceil(d.getDate() / 7), 4)
|
|
||||||
|
|
||||||
const formatTime = (d: Date): string => {
|
const WEEKDAY_EN_TO_INDEX: Record<string, number> = {
|
||||||
const hh = String(d.getHours()).padStart(2, '0')
|
Sunday: 0,
|
||||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
Monday: 1,
|
||||||
return `${hh}:${mm}`
|
Tuesday: 2,
|
||||||
|
Wednesday: 3,
|
||||||
|
Thursday: 4,
|
||||||
|
Friday: 5,
|
||||||
|
Saturday: 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTime = (description: string, d: Date | null): string =>
|
const berlinFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||||
d ? `${description} um ${formatTime(d)} Uhr` : description
|
timeZone: BERLIN_TZ,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'long',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
})
|
||||||
|
|
||||||
|
type BerlinParts = {
|
||||||
|
hour: string
|
||||||
|
minute: string
|
||||||
|
day: number
|
||||||
|
weekdayIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBerlinParts = (d: Date): BerlinParts => {
|
||||||
|
const parts = berlinFormatter.formatToParts(d)
|
||||||
|
const get = (type: string) =>
|
||||||
|
parts.find((p) => p.type === type)?.value ?? ''
|
||||||
|
return {
|
||||||
|
hour: get('hour'),
|
||||||
|
minute: get('minute'),
|
||||||
|
day: Number(get('day')),
|
||||||
|
weekdayIndex: WEEKDAY_EN_TO_INDEX[get('weekday')] ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match the clamp in eventRecurrence.ts so the label on the Event page
|
||||||
|
// agrees with the rule the generator actually produces.
|
||||||
|
const weekOfMonthN = (dayOfMonth: number): number =>
|
||||||
|
Math.min(Math.ceil(dayOfMonth / 7), 4)
|
||||||
|
|
||||||
|
const formatTime = (p: BerlinParts): string => `${p.hour}:${p.minute}`
|
||||||
|
|
||||||
|
const withTime = (description: string, p: BerlinParts | null): string =>
|
||||||
|
p ? `${description} um ${formatTime(p)} Uhr` : description
|
||||||
|
|
||||||
type RecurrenceRule = NonNullable<Event['recurrenceRules']>[number]
|
type RecurrenceRule = NonNullable<Event['recurrenceRules']>[number]
|
||||||
|
|
||||||
|
|
@ -78,33 +115,34 @@ export const describeRecurrence = (
|
||||||
|
|
||||||
const parsed = event.date ? new Date(event.date) : null
|
const parsed = event.date ? new Date(event.date) : null
|
||||||
const d = parsed && !Number.isNaN(parsed.getTime()) ? parsed : null
|
const d = parsed && !Number.isNaN(parsed.getTime()) ? parsed : null
|
||||||
|
const p = d ? getBerlinParts(d) : null
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'daily':
|
case 'daily':
|
||||||
return withTime('Täglich', d)
|
return withTime('Täglich', p)
|
||||||
case 'weekly':
|
case 'weekly':
|
||||||
return withTime(d ? `Jeden ${WEEKDAYS_DE[d.getDay()]}` : 'Wöchentlich', d)
|
return withTime(p ? `Jeden ${WEEKDAYS_DE[p.weekdayIndex]}` : 'Wöchentlich', p)
|
||||||
case 'biweekly':
|
case 'biweekly':
|
||||||
return withTime(
|
return withTime(
|
||||||
d ? `Alle 2 Wochen, ${WEEKDAYS_DE[d.getDay()]}` : 'Alle 2 Wochen',
|
p ? `Alle 2 Wochen, ${WEEKDAYS_DE[p.weekdayIndex]}` : 'Alle 2 Wochen',
|
||||||
d,
|
p,
|
||||||
)
|
)
|
||||||
case 'monthlyByDate':
|
case 'monthlyByDate':
|
||||||
return withTime(d ? `Monatlich am ${d.getDate()}.` : 'Monatlich', d)
|
return withTime(p ? `Monatlich am ${p.day}.` : 'Monatlich', p)
|
||||||
case 'monthlyByWeekday':
|
case 'monthlyByWeekday':
|
||||||
return withTime(
|
return withTime(
|
||||||
d
|
p
|
||||||
? `Monatlich am ${weekOfMonthN(d)}. ${WEEKDAYS_DE[d.getDay()]}`
|
? `Monatlich am ${weekOfMonthN(p.day)}. ${WEEKDAYS_DE[p.weekdayIndex]}`
|
||||||
: 'Monatlich',
|
: 'Monatlich',
|
||||||
d,
|
p,
|
||||||
)
|
)
|
||||||
case 'custom': {
|
case 'custom': {
|
||||||
const rules = event.recurrenceRules ?? []
|
const rules = event.recurrenceRules ?? []
|
||||||
const parts = rules
|
const parts = rules
|
||||||
.map(describeCustomRule)
|
.map(describeCustomRule)
|
||||||
.filter((p): p is string => Boolean(p))
|
.filter((part): part is string => Boolean(part))
|
||||||
if (parts.length === 0) return withTime('Benutzerdefiniert', d)
|
if (parts.length === 0) return withTime('Benutzerdefiniert', p)
|
||||||
return withTime(parts.join(' · '), d)
|
return withTime(parts.join(' · '), p)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return undefined
|
return undefined
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue