Release 0.6.1
This commit is contained in:
1 parent
b80dded55d
commit
d586fbd8ce
29 files changed
+398
-66
No files matched your search
@@ -75,6 +75,7 @@ function pieceInput(data: FormData): PieceInput {
|
||||
summary: text(data, 'summary'),
|
||||
formatId: text(data, 'formatId') || null,
|
||||
status: text(data, 'status') as EditablePieceStatus,
|
||||
preparationEffort: (text(data, 'preparationEffort') || null) as PieceInput['preparationEffort'],
|
||||
durationSeconds: durationInSeconds(data),
|
||||
intro: text(data, 'intro'),
|
||||
researchText: text(data, 'researchText'),
|
||||
|
||||
@@ -347,6 +347,13 @@
|
||||
bind:value={durationMinutes}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Vorbereitungsaufwand<select name="preparationEffort" bind:value={draft.preparationEffort}
|
||||
><option value={null}>Nicht angegeben</option><option value="low">Gering</option><option
|
||||
value="medium">Mittel</option
|
||||
><option value="high">Hoch</option></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Format<select name="formatId" bind:value={draft.formatId}
|
||||
><option value={null}>Ohne Format</option
|
||||
@@ -542,7 +549,7 @@
|
||||
bind:value={draft.presentationText}
|
||||
/>
|
||||
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
|
||||
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
|
||||
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
|
||||
><input
|
||||
type="radio"
|
||||
name="presentationMode"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { requireUser } from '#lib/server/auth.js';
|
||||
import { requireBarkeeper, requireUser } from '#lib/server/auth.js';
|
||||
import { finalizeShow, getCompletionSummary } from '#lib/server/completion.js';
|
||||
import {
|
||||
completeCurrent,
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
startOrResumeLive
|
||||
} from '#lib/server/live.js';
|
||||
import { listShows } from '#lib/server/shows.js';
|
||||
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
|
||||
import { berlinLocalToUtc } from '#lib/server/time.js';
|
||||
|
||||
function text(data: FormData, name: string) {
|
||||
return String(data.get(name) || '');
|
||||
@@ -71,13 +73,15 @@ function attempt(intent: string, operation: () => unknown) {
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = ({ locals, url }) => {
|
||||
requireUser(locals.user);
|
||||
const actor = requireUser(locals.user);
|
||||
const showId = url.searchParams.get('show') || listShows()[0]?.id;
|
||||
if (!showId) error(404, 'Keine Sendung vorhanden');
|
||||
try {
|
||||
const live = getLiveView(showId);
|
||||
return {
|
||||
live,
|
||||
canManageShares: actor.role === 'barkeeper',
|
||||
shares: actor.role === 'barkeeper' ? listShares(actor, showId) : [],
|
||||
commandId: randomUUID(),
|
||||
completion:
|
||||
live.endedAtUtc && live.show.status !== 'completed' ? getCompletionSummary(showId) : null
|
||||
@@ -90,6 +94,45 @@ export const load: PageServerLoad = ({ locals, url }) => {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
createShare: async ({ locals, request }) => {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
const share = await createShare(actor, {
|
||||
showId: text(data, 'showId'), scope: text(data, 'scope'), password: text(data, 'password'),
|
||||
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
|
||||
});
|
||||
return { intent: 'createShare', success: 'Leselink angelegt.', shareUrl: `/freigabe/${share.token}` };
|
||||
} catch (cause) {
|
||||
const problem = actionError(cause);
|
||||
return fail(problem.status, { intent: 'createShare', ...problem });
|
||||
}
|
||||
},
|
||||
updateShare: async ({ locals, request }) => {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
await updateShare(actor, text(data, 'shareId'), {
|
||||
scope: text(data, 'scope'), passwordMode: text(data, 'passwordMode'), password: text(data, 'password'),
|
||||
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
|
||||
}, text(data, 'showId'));
|
||||
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
|
||||
} catch (cause) {
|
||||
const problem = actionError(cause);
|
||||
return fail(problem.status, { intent: 'updateShare', ...problem });
|
||||
}
|
||||
},
|
||||
deleteShare: async ({ locals, request }) => {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
deleteShare(actor, text(data, 'shareId'), text(data, 'showId'));
|
||||
return { intent: 'deleteShare', success: 'Freigabe gelöscht.' };
|
||||
} catch (cause) {
|
||||
const problem = actionError(cause);
|
||||
return fail(problem.status, { intent: 'deleteShare', ...problem });
|
||||
}
|
||||
},
|
||||
start: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { onMount, tick, untrack } from 'svelte';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
|
||||
import ShareManagement from '#lib/components/ShareManagement.svelte';
|
||||
import { formatClock, formatDuration } from '#lib/format.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
@@ -49,6 +50,9 @@
|
||||
let currentSection = $derived(
|
||||
data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null
|
||||
);
|
||||
let viewedSection = $derived(
|
||||
data.live.sections.find((section) => section.id === viewedUsage?.sectionId) ?? null
|
||||
);
|
||||
let presentedUsage = $derived(
|
||||
allUsages.find((usage) => usage.id === data.live.presentationUsageId) ?? null
|
||||
);
|
||||
@@ -101,6 +105,16 @@
|
||||
data.live.sectionTimer.sectionId === data.live.current.sectionId
|
||||
)
|
||||
);
|
||||
|
||||
async function copyTwitchTags() {
|
||||
if (!viewedSection?.twitchTags.length) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(viewedSection.twitchTags.join(', '));
|
||||
popupMessage = 'Twitch-Tags kopiert.';
|
||||
} catch {
|
||||
popupMessage = 'Kopieren wurde vom Browser blockiert.';
|
||||
}
|
||||
}
|
||||
let runningOffsetSeconds = $derived(
|
||||
data.live.totalTimer.runningSinceUtc ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0
|
||||
);
|
||||
@@ -577,6 +591,7 @@
|
||||
</section>
|
||||
<section>
|
||||
<strong>Ausgabekanal</strong>
|
||||
{#if data.canManageShares}<ShareManagement shows={[{ ...data.live.show, startsAtUtc: null }]} shares={data.shares} fixedShowId={data.live.show.id} {form} />{/if}
|
||||
<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>
|
||||
@@ -794,12 +809,14 @@
|
||||
>
|
||||
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
|
||||
<div class="live-piece-heading">
|
||||
<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 viewedSection?.twitchTags.length}<button class="text-button live-twitch-tags" type="button" onclick={copyTwitchTags}>
|
||||
<AppIcon name="copy" /> {viewedSection.twitchTags.join(' · ')}
|
||||
</button>{/if}
|
||||
|
||||
{#if viewedUsage.introHtml}<section class="live-script-section live-intro">
|
||||
<span>Einleitung</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
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';
|
||||
import { listPieces } from '#lib/server/content.js';
|
||||
import { berlinLocalToUtc } from '#lib/server/time.js';
|
||||
|
||||
export const load: PageServerLoad = ({ locals, url }) => {
|
||||
@@ -12,6 +13,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
|
||||
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
|
||||
return {
|
||||
shows: listShows(['draft', 'ready', 'live']),
|
||||
preparationPieces: listPieces({ statuses: ['preparing'] }),
|
||||
templates: listShowTemplates(),
|
||||
view: getShowOverviewView(user.id),
|
||||
selectedMonth,
|
||||
|
||||
@@ -125,6 +125,16 @@
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{#if data.preparationPieces.length}<section class="show-library preparation-overview" aria-labelledby="preparation-title">
|
||||
<header><h2 id="preparation-title">Noch vorzubereiten</h2><span>{data.preparationPieces.length}</span></header>
|
||||
<div class="preparation-list">
|
||||
{#each data.preparationPieces as piece (piece.id)}<a href={resolve('/fundus/[id]', { id: piece.id })}>
|
||||
<span><strong>{piece.title}</strong>{#if piece.summary}<small>{piece.summary}</small>{/if}</span>
|
||||
{#if piece.preparationEffort}<b class={`effort ${piece.preparationEffort}`}>{piece.preparationEffort === 'low' ? 'Gering' : piece.preparationEffort === 'medium' ? 'Mittel' : 'Hoch'}</b>{/if}
|
||||
</a>{/each}
|
||||
</div>
|
||||
</section>{/if}
|
||||
|
||||
<section class="show-library" aria-labelledby="show-library-title">
|
||||
<header>
|
||||
<h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2>
|
||||
|
||||
@@ -233,7 +233,8 @@ export const actions = {
|
||||
addShowSection(params.id, Number(data.get('revision')), {
|
||||
name: text(data, 'name'),
|
||||
categoryId: text(data, 'categoryId') || null,
|
||||
explicitDurationSeconds: nullableDuration(data)
|
||||
explicitDurationSeconds: nullableDuration(data),
|
||||
twitchTags: text(data, 'twitchTags').split(/[\n,]/)
|
||||
})
|
||||
);
|
||||
},
|
||||
@@ -244,7 +245,8 @@ export const actions = {
|
||||
updateShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision')), {
|
||||
name: text(data, 'name'),
|
||||
categoryId: text(data, 'categoryId') || null,
|
||||
explicitDurationSeconds: nullableDuration(data)
|
||||
explicitDurationSeconds: nullableDuration(data),
|
||||
twitchTags: text(data, 'twitchTags').split(/[\n,]/)
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
@@ -828,6 +828,13 @@
|
||||
value={section.explicitDurationSeconds ?? ''}
|
||||
placeholder="Summe der Pieces"
|
||||
/></label
|
||||
><label
|
||||
>Twitch-Tags<input
|
||||
name="twitchTags"
|
||||
maxlength="300"
|
||||
value={section.twitchTags.join(', ')}
|
||||
placeholder="Gaming, Deutsch"
|
||||
/></label
|
||||
><button class="secondary-button" type="submit">Abschnitt speichern</button>
|
||||
</form>
|
||||
<form
|
||||
@@ -885,6 +892,8 @@
|
||||
max="86400"
|
||||
placeholder="optional, Sekunden"
|
||||
/></label
|
||||
><label
|
||||
>Twitch-Tags<input name="twitchTags" maxlength="300" placeholder="Gaming, Deutsch" /></label
|
||||
><button class="primary-button" type="submit">Abschnitt hinzufügen</button>
|
||||
</form>
|
||||
|
||||
|
||||
Reference in new issue
Block a user