Refine live control and show overview

This commit is contained in:
hnzio committed 2026-10-24 09:45:01 +02:00
1 parent ec596d442e
commit b1474bc33d
16 files changed
+1580 -405

No files matched your search

+562 -109
View File
@@ -1002,11 +1002,9 @@ textarea:focus {
display: grid;
position: relative;
z-index: 60;
grid-template-columns: minmax(12rem, 1fr) auto minmax(8rem, 0.55fr) auto;
align-items: center;
gap: 0.65rem;
gap: 0.4rem;
min-width: 0;
padding: 0.55rem 0.65rem;
padding: 0.45rem 0.55rem;
border: 1px solid color-mix(in srgb, var(--gold) 24%, var(--line));
border-radius: 0.9rem;
background: var(--glass);
@@ -1014,22 +1012,41 @@ textarea:focus {
backdrop-filter: blur(20px) saturate(1.08);
}
.live-section-context,
.live-output-state {
.live-context-row,
.live-status-row {
display: grid;
min-width: 0;
align-items: center;
}
.live-context-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
}
.live-status-row {
grid-template-columns: minmax(12rem, 1fr) auto auto;
gap: 0.45rem;
padding-top: 0.35rem;
border-top: 1px solid var(--line);
}
.live-output-state,
.live-context-summary,
.live-next-inline {
display: flex;
min-width: 0;
align-items: center;
gap: 0.55rem;
}
.live-section-context > div,
.live-output-state > div {
display: grid;
min-width: 0;
}
.live-section-context small,
.live-output-state small,
.live-next-inline small,
.section-countdown span {
color: var(--text-faint);
font-size: 0.59rem;
@@ -1038,7 +1055,6 @@ textarea:focus {
text-transform: uppercase;
}
.live-section-context strong,
.live-output-state strong {
overflow: hidden;
font-size: 0.75rem;
@@ -1046,6 +1062,67 @@ textarea:focus {
white-space: nowrap;
}
.live-context-summary {
gap: 0.45rem;
}
.live-context-title {
display: block;
min-width: 0;
max-width: min(34rem, 42vw);
padding: 0.28rem 0.34rem;
border: 0;
border-radius: 0.42rem;
background: transparent;
color: var(--text);
cursor: pointer;
font: inherit;
font-size: 0.75rem;
font-weight: 760;
text-align: left;
}
.live-context-title span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.live-context-title:hover,
.live-context-title:focus-visible,
.live-context-title.expanded {
background: var(--panel-soft);
}
.live-context-title:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.live-context-title.expanded span {
overflow: visible;
white-space: normal;
}
.live-next-inline {
flex: 1 1 20rem;
gap: 0.2rem;
padding-left: 0.45rem;
border-left: 1px solid var(--line);
}
.live-next-inline small {
flex: 0 0 auto;
letter-spacing: 0;
text-transform: none;
}
.live-next-inline .live-context-title {
flex: 1 1 auto;
font-size: 0.7rem;
}
.live-pulse {
width: 0.48rem;
height: 0.48rem;
@@ -1089,23 +1166,69 @@ textarea:focus {
background: var(--text-faint);
}
.live-output-state.active > span {
.live-output-control.active .live-output-state > span {
background: var(--accent);
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--accent) 12%, transparent);
}
.live-mobile-run-button {
display: none;
.live-output-control {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.55rem;
}
.live-menu {
position: relative;
z-index: 40;
width: fit-content;
justify-self: end;
.live-output-control .live-output-state {
min-width: 0;
}
.live-menu > summary {
.live-output-control form {
display: flex;
flex: 0 0 auto;
}
.live-output-control button {
padding: 0.32rem 0.48rem;
font-size: 0.62rem;
}
.live-run-button {
display: flex;
min-height: 2rem;
align-items: center;
gap: 0.38rem;
padding: 0.35rem 0.55rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
font-size: 0.67rem;
font-weight: 740;
}
.live-run-button:hover,
.live-run-button.active {
border-color: color-mix(in srgb, var(--accent) 58%, var(--line));
background: color-mix(in srgb, var(--accent) 9%, var(--panel-soft));
color: var(--accent-strong);
}
.live-run-button:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.live-run-button .app-icon:last-child {
transition: transform 140ms ease;
}
.live-run-button.active .app-icon:last-child {
transform: rotate(180deg);
}
.live-menu-trigger {
width: fit-content;
padding: 0.48rem 0.65rem;
border: 1px solid var(--line);
@@ -1114,17 +1237,27 @@ textarea:focus {
cursor: pointer;
font-size: 0.7rem;
font-weight: 750;
list-style: none;
}
.live-menu-trigger:hover,
.live-menu-trigger[aria-expanded='true'] {
border-color: color-mix(in srgb, var(--accent) 58%, var(--line));
background: color-mix(in srgb, var(--accent) 9%, var(--panel-soft));
}
.live-menu-trigger:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.live-menu-popover {
position: absolute;
z-index: 50;
top: calc(100% + 0.4rem);
right: 0;
position: fixed;
z-index: 200;
top: max(3.5rem, calc(env(safe-area-inset-top) + 3rem));
right: max(0.65rem, env(safe-area-inset-right));
display: grid;
width: min(22rem, calc(100vw - 1.3rem));
max-height: calc(100dvh - 5rem);
max-height: calc(100dvh - 4rem - env(safe-area-inset-top));
overflow-y: auto;
padding: 0.7rem;
border: 1px solid var(--line-strong);
@@ -1132,6 +1265,29 @@ textarea:focus {
background: var(--glass-strong);
box-shadow: var(--shadow-large);
backdrop-filter: blur(24px);
overscroll-behavior: contain;
}
.live-menu-show small,
.live-menu-show strong {
display: block;
}
.live-menu-show small {
color: var(--text-faint);
font-size: 0.6rem;
font-weight: 720;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.live-menu-popover .live-menu-show > strong {
margin-top: 0.15rem;
color: var(--text);
font-size: 0.78rem;
letter-spacing: normal;
overflow-wrap: anywhere;
text-transform: none;
}
.live-menu-popover section {
@@ -1231,39 +1387,42 @@ textarea:focus {
}
.live-piece-heading {
padding-bottom: 0.85rem;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: baseline;
gap: 0.35rem 0.65rem;
padding-bottom: 0.5rem;
}
.live-kicker {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
.live-piece-position {
color: var(--accent);
font-size: 0.65rem;
font-weight: 750;
letter-spacing: 0.055em;
text-transform: uppercase;
white-space: nowrap;
}
.live-kicker strong {
.live-piece-time {
color: var(--text-muted);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.live-current h1 {
max-width: 30ch;
margin: 0.45rem 0 0;
font-size: clamp(1.45rem, 2.7vw, 2.55rem);
max-width: none;
margin: 0;
font-size: clamp(1.2rem, 2.2vw, 1.9rem);
font-weight: 690;
letter-spacing: -0.045em;
line-height: 1.04;
letter-spacing: -0.035em;
line-height: 1.08;
overflow-wrap: anywhere;
}
.live-script-section {
max-width: 58rem;
padding: 1rem 0;
padding: 0.75rem 0;
border-top: 1px solid var(--line);
}
@@ -1510,6 +1669,14 @@ textarea:focus {
background: color-mix(in srgb, var(--gold) 10%, var(--panel));
}
.live-run-item.selected .live-run-select {
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.live-run-item.presented {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 65%, transparent);
}
.live-run-item.done {
opacity: 0.68;
}
@@ -1519,7 +1686,7 @@ textarea:focus {
opacity: 0.62;
}
.live-run-item > form:first-child {
.live-run-item > .live-run-select {
min-width: 0;
}
@@ -1602,6 +1769,15 @@ textarea:focus {
opacity: 1;
}
.live-run-select:not(:disabled):hover {
background: color-mix(in srgb, var(--accent) 10%, var(--panel-soft));
}
.live-run-select:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -2px;
}
.live-run-select strong,
.live-reserve-item strong {
font-size: 0.67rem;
@@ -1673,9 +1849,8 @@ textarea:focus {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
padding: 0.55rem 0.65rem;
justify-content: flex-end;
padding: 0.4rem 0.55rem;
border: 1px solid color-mix(in srgb, var(--gold) 28%, var(--line));
border-radius: 0.9rem;
background: var(--glass-strong);
@@ -1683,30 +1858,12 @@ textarea:focus {
backdrop-filter: blur(22px) saturate(1.08);
}
.live-next-summary {
display: grid;
min-width: 0;
}
.live-next-summary span {
color: var(--accent);
font-size: 0.58rem;
font-weight: 760;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.live-next-summary strong {
overflow: hidden;
font-size: 0.72rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.live-footer-actions {
display: flex;
display: grid;
width: min(100%, 42rem);
align-items: center;
justify-content: flex-end;
grid-template-columns: repeat(4, minmax(0, auto));
justify-content: end;
gap: 0.4rem;
}
@@ -1715,6 +1872,7 @@ textarea:focus {
}
.live-footer-actions button {
min-height: 2.2rem;
white-space: nowrap;
}
@@ -1748,7 +1906,7 @@ textarea:focus {
}
.live-piece-heading {
padding-bottom: 0.55rem;
padding-bottom: 0.4rem;
}
.live-script-section {
@@ -1764,50 +1922,54 @@ textarea:focus {
}
.live-command-header {
grid-template-areas:
'context menu'
'countdown output'
'run run';
gap: 0.2rem;
padding: 0.32rem 0.4rem;
}
.live-context-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.4rem;
padding: 0.45rem;
}
.live-section-context {
grid-area: context;
.live-context-summary {
gap: 0.25rem;
}
.section-countdown {
grid-area: countdown;
min-width: 0;
text-align: left;
.live-context-title {
max-width: 34vw;
padding-inline: 0.25rem;
}
.live-output-state {
grid-area: output;
min-width: 0;
.live-next-inline {
gap: 0.12rem;
padding-left: 0.25rem;
}
.live-status-row {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.3rem;
padding-top: 0.2rem;
}
.live-output-state strong {
max-width: 8rem;
}
.live-output-control {
gap: 0.3rem;
}
.live-output-control button {
padding-inline: 0.35rem;
}
.live-menu {
grid-area: menu;
.section-countdown {
min-width: 5.5rem;
padding: 0.28rem 0.4rem;
}
.live-mobile-run-button {
display: block;
grid-area: run;
width: 100%;
padding: 0.42rem 0.55rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--panel-soft);
color: var(--text);
font-size: 0.67rem;
font-weight: 740;
}
.live-desktop-rail-toggle {
display: none;
.live-run-button {
padding-inline: 0.45rem;
}
.live-workspace,
@@ -1855,7 +2017,11 @@ textarea:focus {
}
.live-current h1 {
font-size: clamp(1.35rem, 7vw, 2rem);
font-size: clamp(1.15rem, 5.5vw, 1.65rem);
}
.live-piece-heading {
gap: 0.3rem 0.45rem;
}
.live-support-actions {
@@ -1869,18 +2035,13 @@ textarea:focus {
}
.live-action-bar {
display: grid;
gap: 0.4rem;
padding: 0.45rem;
}
.live-next-summary strong {
font-size: 0.68rem;
padding: 0.35rem;
}
.live-footer-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
grid-template-columns: repeat(4, minmax(0, 1fr));
justify-content: stretch;
gap: 0.3rem;
}
@@ -1897,16 +2058,41 @@ textarea:focus {
font-size: 0.64rem;
}
.live-clear-form {
grid-column: 1 / -1;
}
.live-finished-label {
grid-column: 1 / -1;
text-align: center;
}
}
@media (max-width: 23rem) {
.live-context-summary {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
}
.live-context-title {
max-width: none;
}
.live-next-inline {
grid-column: 2;
padding-left: 0;
border-left: 0;
}
.live-status-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.live-output-control {
grid-column: 1 / -1;
}
.live-footer-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.heinzlive-output {
min-height: 100vh;
padding: clamp(2rem, 6vw, 6rem);
@@ -3941,6 +4127,42 @@ textarea:focus {
max-width: 78rem;
}
.shows-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
}
.show-view-toggle {
display: flex;
padding: 0.2rem;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--panel-soft);
}
.show-view-toggle button {
display: flex;
min-height: 2.2rem;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.65rem;
border: 0;
border-radius: 0.58rem;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 0.7rem;
font-weight: 740;
}
.show-view-toggle button[aria-pressed='true'] {
background: var(--panel);
box-shadow: var(--shadow-soft);
color: var(--gold-strong);
}
.show-create-panel,
.show-library,
.show-settings {
@@ -4157,6 +4379,237 @@ textarea:focus {
font-size: 0.78rem;
}
.show-month-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.7rem;
}
.show-month-toolbar h3 {
margin: 0.15rem 0 0;
font-size: 1.15rem;
}
.show-month-navigation {
display: flex;
align-items: center;
gap: 0.3rem;
}
.show-month-navigation a {
display: grid;
min-width: 2.2rem;
min-height: 2.2rem;
place-items: center;
padding: 0.35rem 0.55rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--panel-soft);
font-size: 0.68rem;
font-weight: 730;
}
.show-calendar-weekdays,
.show-calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.show-calendar-weekdays {
border: 1px solid var(--line);
border-bottom: 0;
border-radius: 0.8rem 0.8rem 0 0;
background: var(--panel-soft);
}
.show-calendar-weekdays span {
padding: 0.45rem;
color: var(--text-faint);
font-size: 0.62rem;
font-weight: 750;
text-align: center;
}
.show-calendar-grid {
overflow: hidden;
border-top: 1px solid var(--line);
border-left: 1px solid var(--line);
border-radius: 0 0 0.8rem 0.8rem;
}
.show-calendar-day {
min-width: 0;
min-height: 7.5rem;
padding: 0.4rem;
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--panel-soft) 75%, transparent);
}
.show-calendar-day.outside {
min-height: 3.25rem;
background: color-mix(in srgb, var(--panel-soft) 32%, transparent);
color: var(--text-faint);
}
.show-calendar-day > strong {
display: grid;
width: 1.55rem;
height: 1.55rem;
place-items: center;
border-radius: 50%;
font-size: 0.68rem;
}
.show-calendar-day.today > strong {
background: var(--gold);
color: #152238;
}
.show-calendar-events {
display: grid;
gap: 0.3rem;
margin-top: 0.35rem;
}
.show-calendar-event {
display: grid;
min-width: 0;
gap: 0.08rem;
padding: 0.38rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--panel);
}
.show-calendar-event:hover {
border-color: var(--accent);
background: var(--panel-strong);
}
.show-calendar-event.live,
.show-calendar-event.ready {
border-left: 0.2rem solid var(--accent);
}
.show-calendar-event time,
.show-calendar-event small {
color: var(--text-faint);
font-size: 0.54rem;
}
.show-calendar-event span {
font-size: 0.65rem;
font-weight: 730;
overflow-wrap: anywhere;
}
.show-calendar-event small {
text-transform: uppercase;
}
.show-agenda {
display: none;
}
.show-undated {
margin-top: 0.8rem;
}
@media (max-width: 52rem) {
.shows-heading {
align-items: stretch;
flex-direction: column;
}
.show-view-toggle,
.show-view-toggle button {
width: 100%;
}
.show-view-toggle button {
justify-content: center;
}
.show-calendar {
display: none;
}
.show-agenda {
display: grid;
gap: 0.65rem;
}
.show-agenda-day {
display: grid;
grid-template-columns: minmax(7rem, 0.35fr) minmax(0, 1fr);
gap: 0.65rem;
padding: 0.65rem 0;
border-top: 1px solid var(--line);
}
.show-agenda-day h3 {
margin: 0;
font-size: 0.78rem;
text-transform: capitalize;
}
.show-agenda-day > div {
display: grid;
gap: 0.35rem;
}
.show-agenda-event {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.6rem;
padding: 0.65rem;
border: 1px solid var(--line);
border-radius: 0.65rem;
background: var(--panel-soft);
}
.show-agenda-event time {
color: var(--accent-text);
font-size: 0.7rem;
font-weight: 740;
}
.show-agenda-event span,
.show-agenda-event small {
display: grid;
min-width: 0;
}
.show-agenda-event strong {
overflow-wrap: anywhere;
}
.show-agenda-event small {
margin-top: 0.15rem;
color: var(--text-faint);
font-size: 0.58rem;
text-transform: uppercase;
}
}
@media (max-width: 32rem) {
.show-month-toolbar {
align-items: stretch;
flex-direction: column;
}
.show-month-navigation {
justify-content: space-between;
}
.show-agenda-day {
grid-template-columns: minmax(0, 1fr);
}
}
.show-settings {
margin-bottom: 0.75rem;
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import {
berlinDateKey,
buildCalendarDays,
formatMonthLabel,
normalizeMonthKey,
shiftMonth
} from './calendar.js';
describe('show calendar', () => {
it('groups UTC instants by their Berlin civil date', () => {
expect(berlinDateKey('2026-09-30T22:30:00.000Z')).toBe('2026-10-01');
expect(berlinDateKey('2026-12-31T23:30:00.000Z')).toBe('2027-01-01');
});
it('builds a Monday-first six-week month without dropping boundary days', () => {
const days = buildCalendarDays('2026-10', '2026-10-24');
expect(days).toHaveLength(42);
expect(days[0]).toMatchObject({ date: '2026-09-28', inSelectedMonth: false });
expect(days.find((day) => day.date === '2026-10-24')?.isToday).toBe(true);
expect(days.at(-1)?.date).toBe('2026-11-08');
});
it('moves across year boundaries and validates month keys', () => {
expect(shiftMonth('2026-12', 1)).toBe('2027-01');
expect(shiftMonth('2026-01', -1)).toBe('2025-12');
expect(normalizeMonthKey('2026-13', '2026-10')).toBe('2026-10');
expect(normalizeMonthKey('2026-02', '2026-10')).toBe('2026-02');
expect(formatMonthLabel('2026-10')).toBe('Oktober 2026');
});
});
+75
View File
@@ -0,0 +1,75 @@
export const BERLIN_TIME_ZONE = 'Europe/Berlin';
export type CalendarDay = {
date: string;
dayNumber: number;
inSelectedMonth: boolean;
isToday: boolean;
};
const berlinDateFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: BERLIN_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
function padded(value: number) {
return String(value).padStart(2, '0');
}
function dateParts(value: string | Date) {
const parts = berlinDateFormatter.formatToParts(
typeof value === 'string' ? new Date(value) : value
);
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((part) => part.type === type)?.value);
return { year: read('year'), month: read('month'), day: read('day') };
}
export function berlinDateKey(value: string | Date) {
const parts = dateParts(value);
return `${parts.year}-${padded(parts.month)}-${padded(parts.day)}`;
}
export function berlinMonthKey(value: string | Date) {
return berlinDateKey(value).slice(0, 7);
}
export function normalizeMonthKey(value: string | null | undefined, fallback: string) {
if (!value || !/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) return fallback;
return value;
}
export function shiftMonth(monthKey: string, delta: number) {
const [year, month] = monthKey.split('-').map(Number);
const shifted = new Date(Date.UTC(year, month - 1 + delta, 1));
return `${shifted.getUTCFullYear()}-${padded(shifted.getUTCMonth() + 1)}`;
}
export function formatMonthLabel(monthKey: string) {
const [year, month] = monthKey.split('-').map(Number);
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'UTC',
month: 'long',
year: 'numeric'
}).format(new Date(Date.UTC(year, month - 1, 1)));
}
export function buildCalendarDays(monthKey: string, todayKey: string): CalendarDay[] {
const [year, month] = monthKey.split('-').map(Number);
const first = new Date(Date.UTC(year, month - 1, 1));
const mondayOffset = (first.getUTCDay() + 6) % 7;
const start = new Date(Date.UTC(year, month - 1, 1 - mondayOffset));
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start);
date.setUTCDate(start.getUTCDate() + index);
const dateKey = `${date.getUTCFullYear()}-${padded(date.getUTCMonth() + 1)}-${padded(date.getUTCDate())}`;
return {
date: dateKey,
dayNumber: date.getUTCDate(),
inSelectedMonth: dateKey.startsWith(monthKey),
isToday: dateKey === todayKey
};
});
}
+15
View File
@@ -70,6 +70,21 @@ afterEach(async () => {
});
describe('authentication', () => {
it('stores the show overview preference per user', async () => {
const { createFirstAdmin, getShowOverviewView, setShowOverviewView } =
await import('./auth.js');
const user = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'calendar@example.test',
displayName: 'Kalender Test',
password: 'a-valid-password-123'
});
expect(getShowOverviewView(user.id)).toBe('list');
expect(setShowOverviewView(user.id, 'calendar')).toBe('calendar');
expect(getShowOverviewView(user.id)).toBe('calendar');
expect(() => setShowOverviewView(user.id, 'invalid')).toThrow('INVALID_VIEW');
});
it('creates the first Barkeeper once and stores no plaintext secret', async () => {
const { createFirstAdmin } = await import('./auth.js');
const password = 'Ein-sehr-langes-Testpasswort';
+18
View File
@@ -26,6 +26,7 @@ const PASSWORD_OPTIONS = {
} as const;
export type Role = 'barkeeper' | 'thekencrew';
export type ShowOverviewView = 'list' | 'calendar';
export type AuthUser = {
id: string;
email: string;
@@ -66,6 +67,23 @@ export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function getShowOverviewView(userId: string): ShowOverviewView {
const row = getDatabase()
.prepare('SELECT show_overview_view FROM users WHERE id = ?')
.get(userId) as { show_overview_view: ShowOverviewView } | undefined;
if (!row) throw new Error('NOT_FOUND');
return row.show_overview_view;
}
export function setShowOverviewView(userId: string, view: string): ShowOverviewView {
if (view !== 'list' && view !== 'calendar') throw new Error('INVALID_VIEW');
const result = getDatabase()
.prepare('UPDATE users SET show_overview_view = ?, updated_at_utc = ? WHERE id = ?')
.run(view, nowUtc(), userId);
if (!result.changes) throw new Error('NOT_FOUND');
return view;
}
export function validEmail(email: string): boolean {
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 11,
migrations: 12,
pieces: 6,
shows: 2,
usages: 5,
+319 -179
View File
@@ -11,23 +11,38 @@
let promptSize = $state(1.55);
let showRail = $state(true);
let mobileRailOpen = $state(false);
let isMobile = $state(false);
let showTimes = $state(true);
let menuOpen = $state(false);
let expandedContext = $state<'section' | 'next' | null>(null);
let popupMessage = $state('');
let now = $state(Date.now());
let loadedAt = $state(Date.now());
let currentId = $state(untrack(() => data.live.current?.id ?? null));
let loadedRevision = $state(untrack(() => data.live.liveRevision));
let viewedUsageId = $state(untrack(() => data.live.current?.id ?? null));
let pieceScroller = $state<HTMLElement>();
let railScroller = $state<HTMLElement>();
let liveRail = $state<HTMLElement>();
let mobileRunButton = $state<HTMLButtonElement>();
let railCloseButton = $state<HTMLButtonElement>();
let liveMenuTrigger = $state<HTMLButtonElement>();
let liveMenuPopover = $state<HTMLElement>();
let scrollPositions: Record<string, number> = {};
let outputWindow: Window | null = null;
let allUsages = $derived(data.live.sections.flatMap((section) => section.usages));
let viewedUsage = $derived(
allUsages.find((usage) => usage.id === viewedUsageId) ?? data.live.current
);
let currentPosition = $derived(
data.live.current ? allUsages.findIndex((usage) => usage.id === data.live.current?.id) + 1 : 0
);
let viewedPosition = $derived(
viewedUsage ? allUsages.findIndex((usage) => usage.id === viewedUsage?.id) + 1 : 0
);
let previousUsage = $derived(viewedPosition > 1 ? allUsages[viewedPosition - 2] : null);
let viewingLiveCurrent = $derived(viewedUsage?.id === data.live.current?.id);
let runExpanded = $derived(isMobile ? mobileRailOpen : showRail);
let currentSection = $derived(
data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null
);
@@ -51,10 +66,19 @@
data.live.completedCount === data.live.totalCount
)
);
let currentSectionTitle = $derived(
currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie')
);
let nextTitle = $derived(
data.live.next?.title ?? (sessionComplete ? 'Sendungsende erreicht' : 'Kein weiteres Piece')
);
let currentActualSeconds = $derived(
(data.live.current?.actualDurationSeconds ?? 0) +
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
);
let viewedActualSeconds = $derived(
viewingLiveCurrent ? currentActualSeconds : (viewedUsage?.actualDurationSeconds ?? 0)
);
let sectionElapsedSeconds = $derived(
(data.live.sectionTimer?.elapsedSeconds ?? 0) +
(data.live.sectionTimer?.runningSinceUtc
@@ -80,6 +104,7 @@
loadedRevision = data.live.liveRevision;
loadedAt = Date.now();
if (changedPiece) {
viewedUsageId = nextCurrentId;
void tick().then(() => {
if (pieceScroller)
pieceScroller.scrollTop = nextCurrentId ? (scrollPositions[nextCurrentId] ?? 0) : 0;
@@ -121,19 +146,36 @@
localStorage.removeItem(key);
}
const mobileBreakpoint = matchMedia('(max-width: 46rem)');
isMobile = mobileBreakpoint.matches;
const handleBreakpointChange = (event: MediaQueryListEvent) => {
isMobile = event.matches;
if (!event.matches) void closeMobileRail(false);
};
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (menuOpen && !liveMenuPopover?.contains(target) && !liveMenuTrigger?.contains(target)) {
void closeLiveMenu();
}
if (
expandedContext &&
!(target instanceof Element && target.closest('.live-context-title'))
) {
expandedContext = null;
}
};
mobileBreakpoint.addEventListener('change', handleBreakpointChange);
document.addEventListener('click', handleDocumentClick);
const interval = setInterval(() => (now = Date.now()), 1000);
return () => {
mobileBreakpoint.removeEventListener('change', handleBreakpointChange);
document.removeEventListener('click', handleDocumentClick);
clearInterval(interval);
};
});
function persistSettings() {
if (pieceScroller && currentId) scrollPositions[currentId] = pieceScroller.scrollTop;
if (pieceScroller && viewedUsageId) scrollPositions[viewedUsageId] = pieceScroller.scrollTop;
localStorage.setItem(
`sendewerk-live-${data.live.show.id}`,
JSON.stringify({
@@ -155,7 +197,31 @@
persistSettings();
}
function toggleRunPanel() {
void closeLiveMenu(false);
if (isMobile) {
if (mobileRailOpen) void closeMobileRail();
else void openMobileRail();
return;
}
toggleDesktopRail();
}
function viewUsage(usageId: string) {
if (pieceScroller && viewedUsageId) scrollPositions[viewedUsageId] = pieceScroller.scrollTop;
viewedUsageId = usageId;
void tick().then(() => {
if (pieceScroller) pieceScroller.scrollTop = scrollPositions[usageId] ?? 0;
});
void closeMobileRail();
}
function viewPreviousUsage() {
if (previousUsage) viewUsage(previousUsage.id);
}
async function openMobileRail() {
await closeLiveMenu(false);
mobileRailOpen = true;
await tick();
railCloseButton?.focus();
@@ -169,12 +235,43 @@
}
function handleWindowKeydown(event: KeyboardEvent) {
if (mobileRailOpen && event.key === 'Escape') {
if (event.key !== 'Escape') return;
if (menuOpen) {
event.preventDefault();
void closeLiveMenu();
} else if (expandedContext) {
event.preventDefault();
expandedContext = null;
} else if (mobileRailOpen) {
event.preventDefault();
void closeMobileRail();
}
}
async function toggleLiveMenu() {
if (menuOpen) {
await closeLiveMenu();
return;
}
menuOpen = true;
expandedContext = null;
await tick();
liveMenuPopover
?.querySelector<HTMLElement>('button:not(:disabled), a[href]')
?.focus({ preventScroll: true });
}
async function closeLiveMenu(restoreFocus = true) {
if (!menuOpen) return;
menuOpen = false;
await tick();
if (restoreFocus) liveMenuTrigger?.focus({ preventScroll: true });
}
function toggleContext(name: 'section' | 'next') {
expandedContext = expandedContext === name ? null : name;
}
function trapRailFocus(event: KeyboardEvent) {
if (!mobileRailOpen || event.key !== 'Tab' || !liveRail) return;
const focusable = Array.from(
@@ -220,7 +317,7 @@
}
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
formElement.closest<HTMLDetailsElement>('.live-menu')?.removeAttribute('open');
void closeLiveMenu();
const submittedFromRail = Boolean(formElement.closest('.live-rail'));
const keepRailOpen = formElement.dataset.keepRailOpen === 'true';
const command = formElement.elements.namedItem('commandId');
@@ -238,8 +335,8 @@
};
};
const enhanceOutput: SubmitFunction = ({ formElement, submitter }) => {
formElement.closest<HTMLDetailsElement>('.live-menu')?.removeAttribute('open');
const enhanceOutput: SubmitFunction = ({ submitter }) => {
void closeLiveMenu();
popupMessage = '';
outputWindow = window.open('about:blank', 'heinzlive-output');
if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.';
@@ -268,114 +365,167 @@
<div class:without-rail={!showRail} class="live-page live-controller">
<header class="live-command-header" inert={mobileRailOpen}>
<div class="live-section-context">
<span class="live-pulse" aria-hidden="true"></span>
<div>
<small>{data.live.show.title}</small>
<strong>{currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie')}</strong>
</div>
</div>
<div
class:warning={hasActiveSectionTimer &&
sectionRemainingSeconds <= 120 &&
sectionRemainingSeconds >= 0}
class:overrun={hasActiveSectionTimer && sectionRemainingSeconds < 0}
class="section-countdown"
aria-live="off"
>
<span>Abschnitt</span>
<strong>
{#if hasActiveSectionTimer}
{sectionRemainingSeconds < 0
? `+${formatCountdown(Math.abs(sectionRemainingSeconds))}`
: formatCountdown(sectionRemainingSeconds)}
{:else}Kein Countdown{/if}
</strong>
</div>
<div class:active={Boolean(presentedUsage)} class="live-output-state" aria-live="polite">
<span aria-hidden="true"></span>
<div>
<small>Ausgabe</small>
<strong>{presentedUsage?.title ?? 'Leer'}</strong>
</div>
</div>
<button
bind:this={mobileRunButton}
class="live-mobile-run-button"
type="button"
aria-controls="live-run-sheet"
aria-expanded={mobileRailOpen}
onclick={openMobileRail}
>
Ablauf · Piece {currentPosition || data.live.completedCount}/{data.live.totalCount}
</button>
<details class="live-menu">
<summary>Menü</summary>
<div class="live-menu-popover">
<section>
<strong>Ansicht</strong>
<div class="font-control" aria-label="Prompter-Schriftgröße">
<button
type="button"
onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button
>
<button
type="button"
onclick={() => changePromptSize(0.1)}
aria-label="Schrift vergrößern">A+</button
>
</div>
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
</button>
<div class="live-context-row">
<div class="live-context-summary">
<span class="live-pulse" aria-hidden="true"></span>
<button
type="button"
class:expanded={expandedContext === 'section'}
class="live-context-title section"
aria-label={`Aktueller Abschnitt: ${currentSectionTitle}`}
aria-expanded={expandedContext === 'section'}
onclick={() => toggleContext('section')}
>
<span>{currentSectionTitle}</span>
</button>
<div class="live-next-inline">
<small>Danach:</small>
<button
type="button"
class="text-button live-desktop-rail-toggle"
onclick={toggleDesktopRail}
class:expanded={expandedContext === 'next'}
class="live-context-title next"
aria-label={`Danach: ${nextTitle}`}
aria-expanded={expandedContext === 'next'}
onclick={() => toggleContext('next')}
>
{showRail ? 'Ablauf ausblenden' : 'Ablauf einblenden'}
<span>{nextTitle}</span>
</button>
<ThemeToggle />
</section>
<section>
<strong>Ausgabekanal</strong>
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
</form>
<form method="POST" action={liveAction('rotateOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel erneuern</button>
</form>
<form method="POST" action={liveAction('clearOutput')} use:enhance={enhanceCommand}>
<button class="text-button danger-text" type="submit">Ausgabekanal trennen</button>
</form>
</section>
<section>
<strong>Sendung</strong>
{#if needsResume}<form
method="POST"
action={liveAction('start')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Live-Stand wiederaufnehmen</button>
</form>{/if}
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Liveansicht verlassen · Zur Planung</a
>
</section>
</div>
</div>
</details>
<button
bind:this={liveMenuTrigger}
class="live-menu-trigger"
type="button"
aria-haspopup="dialog"
aria-controls="live-menu-popover"
aria-expanded={menuOpen}
onclick={toggleLiveMenu}
>
Menü
</button>
</div>
<div class="live-status-row">
<div class:active={Boolean(presentedUsage)} class="live-output-control">
<div class="live-output-state" aria-live="polite">
<span aria-hidden="true"></span>
<div>
<small>Ausgabe</small>
<strong title={presentedUsage?.title ?? undefined}
>{presentedUsage?.title ?? 'Leer'}</strong
>
</div>
</div>
{#if data.live.presentationUsageId}<form
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value="" />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Präsentation leeren</button>
</form>{/if}
</div>
<div
class:warning={hasActiveSectionTimer &&
sectionRemainingSeconds <= 120 &&
sectionRemainingSeconds >= 0}
class:overrun={hasActiveSectionTimer && sectionRemainingSeconds < 0}
class="section-countdown"
aria-live="off"
>
<span>Abschnitt</span>
<strong>
{#if hasActiveSectionTimer}
{sectionRemainingSeconds < 0
? `+${formatCountdown(Math.abs(sectionRemainingSeconds))}`
: formatCountdown(sectionRemainingSeconds)}
{:else}Kein Countdown{/if}
</strong>
</div>
<button
bind:this={mobileRunButton}
class:active={runExpanded}
class="live-run-button"
type="button"
aria-controls="live-run-sheet"
aria-expanded={runExpanded}
onclick={toggleRunPanel}
>
<AppIcon name="list" />
<span>Ablauf · {currentPosition || data.live.completedCount}/{data.live.totalCount}</span>
<AppIcon name="chevron-right" />
</button>
</div>
</header>
{#if menuOpen}<div
bind:this={liveMenuPopover}
class="live-menu-popover"
id="live-menu-popover"
role="dialog"
aria-label="Regiemenü"
inert={mobileRailOpen}
>
<section class="live-menu-show">
<small>Sendung</small>
<strong>{data.live.show.title}</strong>
</section>
<section>
<strong>Ansicht</strong>
<div class="font-control" aria-label="Prompter-Schriftgröße">
<button
type="button"
onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button
>
<button
type="button"
onclick={() => changePromptSize(0.1)}
aria-label="Schrift vergrößern">A+</button
>
</div>
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
</button>
<ThemeToggle />
</section>
<section>
<strong>Ausgabekanal</strong>
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
</form>
<form method="POST" action={liveAction('rotateOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel erneuern</button>
</form>
<form method="POST" action={liveAction('clearOutput')} use:enhance={enhanceCommand}>
<button class="text-button danger-text" type="submit">Ausgabekanal trennen</button>
</form>
</section>
<section>
<strong>Sendung</strong>
{#if needsResume}<form
method="POST"
action={liveAction('start')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Live-Stand wiederaufnehmen</button>
</form>{/if}
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Liveansicht verlassen · Zur Planung</a
>
</section>
</div>{/if}
<div class="live-messages">
{#if form?.error}<div class="live-message form-message error" role="alert">
{form.error}
@@ -384,7 +534,7 @@
</div>
<div class="live-workspace">
{#if data.live.current}<article
{#if viewedUsage}<article
class="live-current"
style={`--prompt-size: ${promptSize}rem`}
aria-labelledby="current-title"
@@ -392,51 +542,43 @@
>
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
<div class="live-piece-heading">
<div class="live-kicker">
<span>Piece {currentPosition} von {data.live.totalCount}</span>
{#if showTimes}<strong>
{formatClock(currentActualSeconds)} / {formatClock(
data.live.current.durationSeconds
)}
</strong>{/if}
</div>
<h1 id="current-title">{data.live.current.title}</h1>
<span class="live-piece-position">Piece {viewedPosition}/{data.live.totalCount}</span>
<h1 id="current-title">{viewedUsage.title}</h1>
{#if showTimes}<strong class="live-piece-time">
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
</strong>{/if}
</div>
{#if data.live.current.introHtml}<section class="live-script-section live-intro">
{#if viewedUsage.introHtml}<section class="live-script-section live-intro">
<span>Einleitung</span>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="prompter-copy">{@html data.live.current.introHtml}</div>
<div class="prompter-copy">{@html viewedUsage.introHtml}</div>
</section>{/if}
{#if data.live.current.moderationHtml}<section
class="live-script-section moderation-card"
>
{#if viewedUsage.moderationHtml}<section class="live-script-section moderation-card">
<span>Moderationsstichpunkte</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.moderationHtml}</div>
<div class="live-markdown">{@html viewedUsage.moderationHtml}</div>
</section>{/if}
{#if data.live.current.transitionHtml}<section
class="live-script-section transition-card"
>
{#if viewedUsage.transitionHtml}<section class="live-script-section transition-card">
<span>Überleitung</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.transitionHtml}</div>
<div class="live-markdown">{@html viewedUsage.transitionHtml}</div>
</section>{/if}
{#if data.live.current.researchHtml || data.live.current.sources.length || data.live.current.tools.length || data.live.current.ownNotes}
{#if viewedUsage.researchHtml || viewedUsage.sources.length || viewedUsage.tools.length || viewedUsage.ownNotes}
<!-- External source and tool URLs are server-validated private snapshots. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<div class="live-support-actions" aria-label="Private Zusatzinformationen">
{#if data.live.current.researchHtml}<details class="research-details">
{#if viewedUsage.researchHtml}<details class="research-details">
<summary>Recherche</summary>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.researchHtml}</div>
<div class="live-markdown">{@html viewedUsage.researchHtml}</div>
</details>{/if}
{#if data.live.current.sources.length}<details class="live-materials">
<summary>Quellen · {data.live.current.sources.length}</summary>
{#if viewedUsage.sources.length}<details class="live-materials">
<summary>Quellen · {viewedUsage.sources.length}</summary>
<ul>
{#each data.live.current.sources as source (source.id)}<li>
{#each viewedUsage.sources as source (source.id)}<li>
<!-- The URL is a server-validated private HTTP(S) snapshot. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer"
@@ -448,10 +590,10 @@
</li>{/each}
</ul>
</details>{/if}
{#if data.live.current.tools.length}<details class="live-materials">
<summary>Werkzeuge · {data.live.current.tools.length}</summary>
{#if viewedUsage.tools.length}<details class="live-materials">
<summary>Werkzeuge · {viewedUsage.tools.length}</summary>
<ul>
{#each data.live.current.tools as tool (tool.id)}<li>
{#each viewedUsage.tools as tool (tool.id)}<li>
{#if tool.workUrl || tool.startUrl}<!-- The URL is a server-validated private HTTP(S) snapshot. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a
@@ -464,14 +606,14 @@
</li>{/each}
</ul>
</details>{/if}
{#if data.live.current.ownNotes}<details class="live-materials">
{#if viewedUsage.ownNotes}<details class="live-materials">
<summary>Eigene Notizen</summary>
<p>{data.live.current.ownNotes}</p>
<p>{viewedUsage.ownNotes}</p>
</details>{/if}
</div>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/if}
{#if !data.live.current.introHtml && !data.live.current.moderationHtml && !data.live.current.transitionHtml}
{#if !viewedUsage.introHtml && !viewedUsage.moderationHtml && !viewedUsage.transitionHtml}
<p class="live-empty-copy">Für dieses Piece sind keine Moderationstexte hinterlegt.</p>
{/if}
</div>
@@ -561,26 +703,27 @@
{#each section.usages as usage, usageIndex (usage.id)}
<div
class:current={usage.id === data.live.current?.id}
class:selected={usage.id === viewedUsage?.id}
class:presented={usage.id === data.live.presentationUsageId}
class:done={usage.status === 'done'}
class:skipped={usage.status === 'skipped'}
class="live-run-item"
data-current={usage.id === data.live.current?.id}
>
<form method="POST" action={liveAction('select')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="live-run-select"
type="submit"
disabled={!data.live.startedAtUtc || usage.id === data.live.current?.id}
aria-label={`${usage.title} als aktuelles Piece auswählen`}
<button
class="live-run-select"
type="button"
disabled={!data.live.startedAtUtc || usage.id === viewedUsage?.id}
aria-label={`${usage.title} in der Regie auswählen`}
onclick={() => viewUsage(usage.id)}
>
<strong>{usage.title}</strong>
<small
>{statusLabel(usage.status)}{usage.id === data.live.presentationUsageId
? ' · Ausgegeben'
: ''}</small
>
<strong>{usage.title}</strong>
<small>{statusLabel(usage.status)}</small>
</button>
</form>
</button>
<div class="live-run-actions">
{#if usage.status === 'open'}<details class="live-run-order">
<summary aria-label={`${usage.title} sortieren`}
@@ -737,37 +880,20 @@
</div>
<footer class="live-action-bar" inert={mobileRailOpen}>
<div class="live-next-summary">
<span>{data.live.current ? 'Nächstes Piece' : 'Live-Stand'}</span>
<strong>
{data.live.next?.title ??
(sessionComplete ? 'Sendungsende erreicht' : 'Kein weiteres Piece')}
</strong>
</div>
<div class="live-footer-actions">
{#if data.live.presentationUsageId}<form
class="live-clear-form"
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
{#if data.live.current && viewedUsage}<button
class="secondary-button"
type="button"
disabled={!previousUsage}
onclick={viewPreviousUsage}>Zurück</button
>
<form method="POST" action={liveAction('present')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value="" />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Präsentation leeren</button>
</form>{/if}
{#if data.live.current}<form
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={data.live.current.id} />
<input type="hidden" name="usageId" value={viewedUsage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">
{data.live.presentationUsageId === data.live.current.id
{data.live.presentationUsageId === viewedUsage.id
? 'Erneut präsentieren'
: 'Präsentieren'}
</button>
@@ -776,13 +902,27 @@
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">Überspringen</button>
<button
class="secondary-button"
type="submit"
disabled={!viewingLiveCurrent}
title={!viewingLiveCurrent
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
: undefined}>Überspringen</button
>
</form>
<form method="POST" action={liveAction('complete')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="primary-button" type="submit">
<button
class="primary-button"
type="submit"
disabled={!viewingLiveCurrent}
title={!viewingLiveCurrent
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
: undefined}
>
{data.live.next
? 'Erledigen & weiter'
: hasOtherOpenUsages
+30 -4
View File
@@ -1,6 +1,7 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js';
import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
function berlinLocalToUtc(value: string): string | null {
@@ -34,12 +35,37 @@ function berlinLocalToUtc(value: string): string | null {
return new Date(candidate).toISOString();
}
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { shows: listShows(), templates: listShowTemplates() };
export const load: PageServerLoad = ({ locals, url }) => {
const user = requireUser(locals.user);
const today = new Date();
const todayMonth = berlinMonthKey(today);
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
return {
shows: listShows(),
templates: listShowTemplates(),
view: getShowOverviewView(user.id),
selectedMonth,
previousMonth: shiftMonth(selectedMonth, -1),
nextMonth: shiftMonth(selectedMonth, 1),
todayMonth,
todayDate: berlinDateKey(today)
};
};
export const actions = {
setView: async ({ locals, request }) => {
const user = requireUser(locals.user);
const data = await request.formData();
const view = String(data.get('view') || '');
const todayMonth = berlinMonthKey(new Date());
const month = normalizeMonthKey(String(data.get('month') || ''), todayMonth);
try {
setShowOverviewView(user.id, view);
} catch {
return fail(400, { error: 'Die Ansicht konnte nicht gespeichert werden.' });
}
redirect(303, `/sendungen?month=${encodeURIComponent(month)}`);
},
create: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
+174 -32
View File
@@ -1,10 +1,35 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { berlinDateKey, buildCalendarDays, formatMonthLabel } from '#lib/calendar.js';
import { formatDuration } from '#lib/format.js';
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let scheduledShows = $derived(data.shows.filter((show) => show.startsAtUtc !== null));
let undatedShows = $derived(data.shows.filter((show) => show.startsAtUtc === null));
let calendarDays = $derived(buildCalendarDays(data.selectedMonth, data.todayDate));
let showsByDate = $derived.by(() => {
const grouped: Record<string, (typeof data.shows)[number][]> = {};
for (const show of scheduledShows) {
const key = berlinDateKey(show.startsAtUtc!);
const shows = grouped[key] ?? [];
shows.push(show);
grouped[key] = shows;
}
for (const shows of Object.values(grouped)) {
shows.sort((left, right) => left.startsAtUtc!.localeCompare(right.startsAtUtc!));
}
return grouped;
});
let agendaDays = $derived(
calendarDays.filter((day) => day.inSelectedMonth && (showsByDate[day.date]?.length ?? 0) > 0)
);
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
hour: '2-digit',
minute: '2-digit'
});
function berlinDate(value: string | null) {
if (!value) return 'Termin offen';
@@ -14,16 +39,45 @@
timeStyle: 'short'
}).format(new Date(value));
}
function berlinTime(value: string) {
return timeFormatter.format(new Date(value));
}
function agendaDate(value: string) {
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'UTC',
weekday: 'long',
day: '2-digit',
month: 'long'
}).format(new Date(`${value}T12:00:00Z`));
}
function statusLabel(status: string) {
if (status === 'ready') return 'Bereit';
if (status === 'draft') return 'Entwurf';
if (status === 'live') return 'Live';
return 'Abgeschlossen';
}
</script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head>
<div class="page-wrap shows-page">
<header class="page-heading">
<header class="page-heading shows-heading">
<div>
<h1>Sendungen</h1>
<p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p>
</div>
<form method="POST" action="?/setView" class="show-view-toggle" aria-label="Darstellung">
<input type="hidden" name="month" value={data.selectedMonth} />
<button type="submit" name="view" value="list" aria-pressed={data.view === 'list'}>
<AppIcon name="list" /> Liste
</button>
<button type="submit" name="view" value="calendar" aria-pressed={data.view === 'calendar'}>
<AppIcon name="calendar" /> Kalender
</button>
</form>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
@@ -73,41 +127,129 @@
<section class="show-library" aria-labelledby="show-library-title">
<header>
<h2 id="show-library-title">Geplante Sendungen</h2>
<h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2>
<span>{data.shows.length}</span>
</header>
{#if data.shows.length}
<div class="show-card-list">
{#each data.shows as show (show.id)}
<a class="show-card" href={resolve('/sendungen/[id]', { id: show.id })}>
<div>
<span class={`show-state ${show.status}`}
>{show.status === 'ready'
? 'Bereit'
: show.status === 'draft'
? 'Entwurf'
: show.status === 'live'
? 'Live'
: 'Abgeschlossen'}</span
>
<h3>{show.title}</h3>
<p>{berlinDate(show.startsAtUtc)} Uhr</p>
</div>
<div class="show-card-facts">
{#if show.sectionCount}<span><strong>{show.sectionCount}</strong> Abschnitte</span
>{/if}{#if show.totalDurationSeconds}<span
><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span
>{/if}<b aria-hidden="true"><AppIcon name="arrow-right" /></b>
</div>
</a>
{/each}
</div>
{#if data.view === 'list'}
{#if scheduledShows.length}<div class="show-card-list">
{#each scheduledShows as show (show.id)}<a
class="show-card"
href={resolve('/sendungen/[id]', { id: show.id })}
>
<div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
<h3>{show.title}</h3>
<p>{berlinDate(show.startsAtUtc)} Uhr</p>
</div>
<div class="show-card-facts">
{#if show.sectionCount}<span><strong>{show.sectionCount}</strong> Abschnitte</span
>{/if}{#if show.totalDurationSeconds}<span
><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span
>{/if}<b aria-hidden="true"><AppIcon name="arrow-right" /></b>
</div>
</a>{/each}
</div>{:else}<div class="empty-state compact">
<h2>Keine terminierten Sendungen</h2>
<p>Sendungen ohne Termin bleiben unten erreichbar.</p>
</div>{/if}
{:else}
<div class="empty-state">
<span aria-hidden="true"><AppIcon name="plus" /></span>
<h2>Noch keine Sendung</h2>
<p>Oben kann der erste Stream geplant werden.</p>
<div class="show-month-toolbar">
<div>
<p class="eyebrow">Monatsansicht</p>
<h3>{formatMonthLabel(data.selectedMonth)}</h3>
</div>
<!-- Query-only navigation stays on the resolved Sendungen route. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<nav aria-label="Kalendermonat" class="show-month-navigation">
<a
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.previousMonth)}`}
aria-label="Vorheriger Monat"><AppIcon name="chevron-left" /></a
>
<a href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.todayMonth)}`}>Heute</a
>
<a
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.nextMonth)}`}
aria-label="Nächster Monat"><AppIcon name="chevron-right" /></a
>
</nav>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</div>
<div class="show-calendar" aria-label={formatMonthLabel(data.selectedMonth)}>
<div class="show-calendar-weekdays" aria-hidden="true">
{#each ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as weekday (weekday)}<span
>{weekday}</span
>{/each}
</div>
<div class="show-calendar-grid">
{#each calendarDays as day (day.date)}<section
class:outside={!day.inSelectedMonth}
class:today={day.isToday}
class="show-calendar-day"
aria-label={day.date}
>
<strong>{day.dayNumber}</strong>
{#if day.inSelectedMonth}<div class="show-calendar-events">
{#each showsByDate[day.date] ?? [] as show (show.id)}<a
href={resolve('/sendungen/[id]', { id: show.id })}
class={`show-calendar-event ${show.status}`}
>
<time>{berlinTime(show.startsAtUtc!)}</time>
<span>{show.title}</span>
<small>{statusLabel(show.status)}</small>
</a>{/each}
</div>{/if}
</section>{/each}
</div>
</div>
<div class="show-agenda" aria-label={`Termine ${formatMonthLabel(data.selectedMonth)}`}>
{#if agendaDays.length}{#each agendaDays as day (day.date)}<section
class:today={day.isToday}
class="show-agenda-day"
>
<h3>{agendaDate(day.date)}</h3>
<div>
{#each showsByDate[day.date] ?? [] as show (show.id)}<a
class="show-agenda-event"
href={resolve('/sendungen/[id]', { id: show.id })}
>
<time>{berlinTime(show.startsAtUtc!)}</time>
<span
><strong>{show.title}</strong><small>{statusLabel(show.status)}</small></span
>
</a>{/each}
</div>
</section>{/each}{:else}<div class="empty-state compact">
<h2>Keine Termine in diesem Monat</h2>
</div>{/if}
</div>
{/if}
</section>
{#if undatedShows.length}<section
class="show-library show-undated"
aria-labelledby="undated-title"
>
<header>
<h2 id="undated-title">Ohne Termin</h2>
<span>{undatedShows.length}</span>
</header>
<div class="show-card-list">
{#each undatedShows as show (show.id)}<a
class="show-card"
href={resolve('/sendungen/[id]', { id: show.id })}
>
<div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
<h3>{show.title}</h3>
<p>Termin offen</p>
</div>
<div class="show-card-facts">
<b aria-hidden="true"><AppIcon name="arrow-right" /></b>
</div>
</a>{/each}
</div>
</section>{/if}
</div>