Release 0.7.0
This commit is contained in:
1 parent
d586fbd8ce
commit
6d96900d81
27 files changed
+2060
-25
No files matched your search
@@ -0,0 +1,105 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' } };
|
||||
const usageId = { name: 'usageId', in: 'path', required: true, schema: { type: 'string' } };
|
||||
const idempotency = {
|
||||
name: 'Idempotency-Key',
|
||||
in: 'header',
|
||||
required: true,
|
||||
schema: { type: 'string' }
|
||||
};
|
||||
const ok = { '200': { description: 'Erfolgreich' } };
|
||||
const jsonBody = (schema: Record<string, unknown>) => ({
|
||||
required: true,
|
||||
content: { 'application/json': { schema } }
|
||||
});
|
||||
|
||||
export const GET: RequestHandler = () =>
|
||||
json({
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'Sendewerk API',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'Versionierte API für technische Redaktionszugänge. Schreibzugriffe benötigen einen Idempotency-Key.'
|
||||
},
|
||||
servers: [{ url: '/api/v1' }],
|
||||
security: [{ bearerAuth: [] }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'Sendewerk technical token' }
|
||||
},
|
||||
schemas: {
|
||||
PieceWrite: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
status: { enum: ['inbox', 'preparing', 'ready'] },
|
||||
durationSeconds: { type: 'integer', minimum: 0 }
|
||||
}
|
||||
},
|
||||
ShowWrite: {
|
||||
type: 'object',
|
||||
required: ['title'],
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
startsAtUtc: { type: ['string', 'null'], format: 'date-time' },
|
||||
templateId: { type: ['string', 'null'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
paths: {
|
||||
'/pieces': {
|
||||
get: { summary: 'Pieces lesen', responses: ok },
|
||||
post: {
|
||||
summary: 'Piece anlegen',
|
||||
parameters: [idempotency],
|
||||
requestBody: jsonBody({ $ref: '#/components/schemas/PieceWrite' }),
|
||||
responses: { '201': { description: 'Angelegt' } }
|
||||
}
|
||||
},
|
||||
'/pieces/{id}': {
|
||||
get: { summary: 'Piece lesen', parameters: [id], responses: ok },
|
||||
patch: {
|
||||
summary: 'Piece ändern',
|
||||
parameters: [id],
|
||||
requestBody: jsonBody({
|
||||
allOf: [
|
||||
{ $ref: '#/components/schemas/PieceWrite' },
|
||||
{ type: 'object', required: ['revision'] }
|
||||
]
|
||||
}),
|
||||
responses: ok
|
||||
}
|
||||
},
|
||||
'/shows': {
|
||||
get: { summary: 'Sendungen lesen', responses: ok },
|
||||
post: {
|
||||
summary: 'Sendung anlegen',
|
||||
parameters: [idempotency],
|
||||
requestBody: jsonBody({ $ref: '#/components/schemas/ShowWrite' }),
|
||||
responses: { '201': { description: 'Angelegt' } }
|
||||
}
|
||||
},
|
||||
'/shows/{id}': { get: { summary: 'Sendung lesen', parameters: [id], responses: ok } },
|
||||
'/shows/{id}/usages': {
|
||||
post: {
|
||||
summary: 'Piece einplanen',
|
||||
parameters: [id, idempotency],
|
||||
requestBody: jsonBody({ type: 'object', required: ['revision', 'pieceId'] }),
|
||||
responses: { '201': { description: 'Eingeplant' } }
|
||||
}
|
||||
},
|
||||
'/shows/{id}/usages/{usageId}': {
|
||||
patch: {
|
||||
summary: 'Einplanung sortieren',
|
||||
parameters: [id, usageId, idempotency],
|
||||
requestBody: jsonBody({ type: 'object', required: ['revision', 'position'] }),
|
||||
responses: ok
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { apiBody, apiFailure, pageInput } from '#lib/server/api-http.js';
|
||||
import {
|
||||
createPiece,
|
||||
getPiece,
|
||||
listPieces,
|
||||
savePiece,
|
||||
type PieceStatus
|
||||
} from '#lib/server/content.js';
|
||||
import {
|
||||
auditTechnical,
|
||||
authenticateTechnical,
|
||||
idempotentResponse
|
||||
} from '#lib/server/technical-access.js';
|
||||
|
||||
export const GET: RequestHandler = ({ request, url }) => {
|
||||
try {
|
||||
authenticateTechnical(request);
|
||||
const { limit, offset } = pageInput(url);
|
||||
const status = url.searchParams.get('status') as PieceStatus | null;
|
||||
const statuses = status ? [status] : (['inbox', 'preparing', 'ready'] as PieceStatus[]);
|
||||
const all = listPieces({ statuses, query: url.searchParams.get('q') ?? '' });
|
||||
return json({
|
||||
data: all.slice(offset, offset + limit),
|
||||
pagination: { limit, offset, total: all.length }
|
||||
});
|
||||
} catch (cause) {
|
||||
return apiFailure(cause);
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const actor = authenticateTechnical(request, true);
|
||||
const body = await apiBody(request);
|
||||
const result = idempotentResponse(actor, request, body, () => {
|
||||
const id = createPiece('blank');
|
||||
const piece = getPiece(id)!;
|
||||
savePiece(id, piece.revision, {
|
||||
...piece,
|
||||
title: String(body.title ?? ''),
|
||||
summary: String(body.summary ?? ''),
|
||||
status: ['inbox', 'preparing', 'ready'].includes(String(body.status))
|
||||
? (body.status as 'inbox' | 'preparing' | 'ready')
|
||||
: 'preparing',
|
||||
durationSeconds: Number(body.durationSeconds ?? 0),
|
||||
sources: Array.isArray(body.sources)
|
||||
? body.sources.map((source) => ({
|
||||
label: String((source as Record<string, unknown>).label ?? ''),
|
||||
url: String((source as Record<string, unknown>).url ?? ''),
|
||||
timecodeSeconds:
|
||||
(source as Record<string, unknown>).timecodeSeconds == null
|
||||
? null
|
||||
: Number((source as Record<string, unknown>).timecodeSeconds)
|
||||
}))
|
||||
: []
|
||||
});
|
||||
auditTechnical(actor, 'piece.create', 'piece', id);
|
||||
return { status: 201, body: { data: getPiece(id) } };
|
||||
});
|
||||
return json(result.body, {
|
||||
status: result.status,
|
||||
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
|
||||
});
|
||||
} catch (cause) {
|
||||
return apiFailure(cause);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { apiBody, apiFailure } from '#lib/server/api-http.js';
|
||||
import { getPiece, savePiece } from '#lib/server/content.js';
|
||||
import { auditTechnical, authenticateTechnical } from '#lib/server/technical-access.js';
|
||||
|
||||
export const GET: RequestHandler = ({ request, params }) => {
|
||||
try {
|
||||
authenticateTechnical(request);
|
||||
const piece = getPiece(params.id);
|
||||
if (!piece) throw new Error('NOT_FOUND');
|
||||
return json({ data: piece });
|
||||
} catch (cause) {
|
||||
return apiFailure(cause);
|
||||
}
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ request, params }) => {
|
||||
try {
|
||||
const actor = authenticateTechnical(request, true);
|
||||
const body = await apiBody(request);
|
||||
const piece = getPiece(params.id);
|
||||
if (!piece) throw new Error('NOT_FOUND');
|
||||
if (piece.status === 'archived') throw new Error('INVALID_STATUS');
|
||||
if (Number(body.revision) !== piece.revision) throw new Error('CONFLICT');
|
||||
const status =
|
||||
body.status !== undefined
|
||||
? (String(body.status) as 'inbox' | 'preparing' | 'ready')
|
||||
: piece.status;
|
||||
savePiece(params.id, piece.revision, {
|
||||
...piece,
|
||||
status,
|
||||
...(body.title !== undefined ? { title: String(body.title) } : {}),
|
||||
...(body.summary !== undefined ? { summary: String(body.summary) } : {}),
|
||||
...(body.durationSeconds !== undefined
|
||||
? { durationSeconds: Number(body.durationSeconds) }
|
||||
: {})
|
||||
});
|
||||
auditTechnical(actor, 'piece.update', 'piece', params.id);
|
||||
return json({ data: getPiece(params.id) });
|
||||
} catch (cause) {
|
||||
return apiFailure(cause);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { apiBody, apiFailure, pageInput } from '#lib/server/api-http.js';
|
||||
import { createShow, getShow, listShows } from '#lib/server/shows.js';
|
||||
import {
|
||||
auditTechnical,
|
||||
authenticateTechnical,
|
||||
idempotentResponse
|
||||
} from '#lib/server/technical-access.js';
|
||||
|
||||
export const GET: RequestHandler = ({ request, url }) => {
|
||||
try {
|
||||
authenticateTechnical(request);
|
||||
const { limit, offset } = pageInput(url);
|
||||
const all = listShows();
|
||||
return json({
|
||||
data: all.slice(offset, offset + limit),
|
||||
pagination: { limit, offset, total: all.length }
|
||||
});
|
||||
} catch (cause) {
|
||||
return apiFailure(cause);
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const actor = authenticateTechnical(request, true);
|
||||
const body = await apiBody(request);
|
||||
const result = idempotentResponse(actor, request, body, () => {
|
||||
const created = createShow({
|
||||
title: String(body.title ?? ''),
|
||||
startsAtUtc: body.startsAtUtc ? String(body.startsAtUtc) : null,
|
||||
templateId: body.templateId ? String(body.templateId) : null
|
||||
});
|
||||
auditTechnical(actor, 'show.create', 'show', created.id);
|
||||
return { status: 201, body: { data: getShow(created.id) } };
|
||||
});
|
||||
return json(result.body, {
|
||||
status: result.status,
|
||||
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
|
||||
});
|
||||
} catch (cause) {
|
||||
return apiFailure(cause);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { apiFailure } from '#lib/server/api-http.js';
|
||||
import { getShow } from '#lib/server/shows.js';
|
||||
import { authenticateTechnical } from '#lib/server/technical-access.js';
|
||||
|
||||
export const GET: RequestHandler = ({ request, params }) => {
|
||||
try {
|
||||
authenticateTechnical(request);
|
||||
const show = getShow(params.id);
|
||||
if (!show) throw new Error('NOT_FOUND');
|
||||
return json({ data: show });
|
||||
} catch (cause) {
|
||||
return apiFailure(cause);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { apiBody, apiFailure } from '#lib/server/api-http.js';
|
||||
import { addPieceUsage, getShow } from '#lib/server/shows.js';
|
||||
import {
|
||||
auditTechnical,
|
||||
authenticateTechnical,
|
||||
idempotentResponse
|
||||
} from '#lib/server/technical-access.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, params }) => {
|
||||
try {
|
||||
const actor = authenticateTechnical(request, true);
|
||||
const body = await apiBody(request);
|
||||
const result = idempotentResponse(actor, request, body, () => {
|
||||
const created = addPieceUsage(params.id, Number(body.revision), {
|
||||
pieceId: String(body.pieceId ?? ''),
|
||||
sectionId: body.sectionId ? String(body.sectionId) : null,
|
||||
position: body.position == null ? undefined : Number(body.position)
|
||||
});
|
||||
auditTechnical(actor, 'usage.create', 'usage', created.id);
|
||||
return { status: 201, body: { data: getShow(params.id), usageId: created.id } };
|
||||
});
|
||||
return json(result.body, {
|
||||
status: result.status,
|
||||
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
|
||||
});
|
||||
} catch (cause) {
|
||||
return apiFailure(cause);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { apiBody, apiFailure } from '#lib/server/api-http.js';
|
||||
import { getShow, movePieceUsage } from '#lib/server/shows.js';
|
||||
import {
|
||||
auditTechnical,
|
||||
authenticateTechnical,
|
||||
idempotentResponse
|
||||
} from '#lib/server/technical-access.js';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ request, params }) => {
|
||||
try {
|
||||
const actor = authenticateTechnical(request, true);
|
||||
const body = await apiBody(request);
|
||||
const result = idempotentResponse(actor, request, body, () => {
|
||||
movePieceUsage(params.id, params.usageId, Number(body.revision), {
|
||||
sectionId: body.sectionId ? String(body.sectionId) : null,
|
||||
position: Number(body.position)
|
||||
});
|
||||
auditTechnical(actor, 'usage.move', 'usage', params.usageId);
|
||||
return { status: 200, body: { data: getShow(params.id) } };
|
||||
});
|
||||
return json(result.body, {
|
||||
status: result.status,
|
||||
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
|
||||
});
|
||||
} catch (cause) {
|
||||
return apiFailure(cause);
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,16 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { requireUser } from '#lib/server/auth.js';
|
||||
import { captureInbox, listPieces, setPieceDeleted } from '#lib/server/content.js';
|
||||
import { captureInbox, getTaxonomy, listPieces, setPieceDeleted } from '#lib/server/content.js';
|
||||
import { discardFeedEntries, importFeedEntries, listFeedEntries } from '#lib/server/feeds.js';
|
||||
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
requireUser(locals.user);
|
||||
return { pieces: listPieces({ statuses: ['inbox'] }) };
|
||||
return {
|
||||
pieces: listPieces({ statuses: ['inbox'] }),
|
||||
feedEntries: listFeedEntries(),
|
||||
taxonomy: getTaxonomy()
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -36,5 +41,27 @@ export const actions = {
|
||||
} catch {
|
||||
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.' });
|
||||
}
|
||||
},
|
||||
importFeeds: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
const entryIds = data.getAll('entryIds').map(String);
|
||||
if (!entryIds.length) return fail(400, { feedError: 'Mindestens einen Beitrag auswählen.' });
|
||||
try {
|
||||
const pieceIds = importFeedEntries(entryIds, data.getAll('categoryIds').map(String));
|
||||
return {
|
||||
feedSuccess: `${pieceIds.length} ${pieceIds.length === 1 ? 'Beitrag' : 'Beiträge'} in den Eingang übernommen.`
|
||||
};
|
||||
} catch {
|
||||
return fail(400, { feedError: 'Die Auswahl konnte nicht übernommen werden.' });
|
||||
}
|
||||
},
|
||||
discardFeeds: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
const entryIds = data.getAll('entryIds').map(String);
|
||||
if (!entryIds.length) return fail(400, { feedError: 'Mindestens einen Beitrag auswählen.' });
|
||||
const count = discardFeedEntries(entryIds);
|
||||
return { feedSuccess: `${count} ${count === 1 ? 'Beitrag' : 'Beiträge'} verworfen.` };
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve -- RSS links are validated external URLs. */
|
||||
import { resolve } from '$app/paths';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
@@ -43,6 +44,65 @@
|
||||
<button class="primary-button" type="submit">Im Eingang ablegen</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="quiet-panel feed-inbox">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">RSS-Importeingang</p>
|
||||
<h2>Beiträge sichten</h2>
|
||||
</div>
|
||||
<span>{data.feedEntries.length} neu</span>
|
||||
</div>
|
||||
<p>Beiträge bleiben hier getrennt, bis du sie auswählst und in den Fundus übernimmst.</p>
|
||||
{#if form?.feedError}<div class="form-message error" role="alert">{form.feedError}</div>{/if}
|
||||
{#if form?.feedSuccess}<div class="form-message success" role="status">
|
||||
{form.feedSuccess}
|
||||
</div>{/if}
|
||||
{#if data.feedEntries.length}
|
||||
<form method="POST" class="feed-selection">
|
||||
<fieldset class="feed-categories">
|
||||
<legend>Kategorien für die Auswahl <span>(Feed-Standard gilt ohne Auswahl)</span></legend>
|
||||
{#each data.taxonomy.categories as category (category.id)}
|
||||
<label
|
||||
><input type="checkbox" name="categoryIds" value={category.id} />
|
||||
{category.name}</label
|
||||
>
|
||||
{/each}
|
||||
</fieldset>
|
||||
<div class="feed-entry-list">
|
||||
{#each data.feedEntries as entry (entry.id)}
|
||||
<label class="feed-entry">
|
||||
<input type="checkbox" name="entryIds" value={entry.id} />
|
||||
<span
|
||||
><small
|
||||
>{entry.feedName}{entry.publishedAtUtc
|
||||
? ` · ${new Date(entry.publishedAtUtc).toLocaleDateString('de-DE')}`
|
||||
: ''}</small
|
||||
><strong>{entry.title}</strong>{#if entry.summary}<span>{entry.summary}</span>{/if}
|
||||
<a
|
||||
href={entry.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onclick={(event) => event.stopPropagation()}>Original öffnen</a
|
||||
></span
|
||||
>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="feed-actions">
|
||||
<button class="primary-button" type="submit" formaction="?/importFeeds"
|
||||
>Auswahl übernehmen</button
|
||||
>
|
||||
<button class="text-button danger-text" type="submit" formaction="?/discardFeeds"
|
||||
>Auswahl verwerfen</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="empty-copy">
|
||||
Keine neuen Feed-Beiträge. Aktivierte Feeds werden automatisch abgerufen.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{#if data.pieces.length}
|
||||
<section class="inbox-list">
|
||||
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
|
||||
@@ -81,3 +141,59 @@
|
||||
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
|
||||
</section>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.feed-inbox {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
.feed-selection,
|
||||
.feed-entry-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.feed-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem 1rem;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.feed-categories legend {
|
||||
width: 100%;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.feed-categories legend span {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.feed-entry {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.8rem;
|
||||
align-items: start;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.feed-entry > span {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.feed-entry small {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.feed-entry a {
|
||||
width: fit-content;
|
||||
}
|
||||
.feed-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,12 @@ import { createShare, deleteShare, listShares, updateShare } from '#lib/server/s
|
||||
import { listShows } from '#lib/server/shows.js';
|
||||
import { berlinLocalToUtc } from '#lib/server/time.js';
|
||||
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
|
||||
import { deleteFeedSource, fetchFeed, listFeedSources, saveFeedSource } from '#lib/server/feeds.js';
|
||||
import {
|
||||
createTechnicalAccess,
|
||||
listTechnicalAccesses,
|
||||
revokeTechnicalAccess
|
||||
} from '#lib/server/technical-access.js';
|
||||
import {
|
||||
inviteUser,
|
||||
isRole,
|
||||
@@ -33,6 +39,8 @@ export const load: PageServerLoad = ({ locals }) => {
|
||||
tools: listTools(true),
|
||||
shows: listShows(),
|
||||
shares: listShares(locals.user!),
|
||||
feeds: listFeedSources(),
|
||||
technicalAccesses: listTechnicalAccesses(),
|
||||
mailTransport: getMailConfig().transport
|
||||
};
|
||||
};
|
||||
@@ -59,6 +67,8 @@ function actionError(cause: unknown): string {
|
||||
if (code === 'INVALID_EXPIRY') return 'Der Ablaufzeitpunkt muss in der Zukunft liegen.';
|
||||
if (code === 'INVALID_SCOPE') return 'Der Informationsumfang ist ungültig.';
|
||||
if (code === 'INVALID_PASSWORD_MODE') return 'Die Passwortoption ist ungültig.';
|
||||
if (code === 'INVALID_FEED') return 'Name, URL und Abrufintervall bitte prüfen.';
|
||||
if (code === 'INVALID_ACCESS') return 'Name und Berechtigung bitte prüfen.';
|
||||
return 'Die Aktion konnte nicht ausgeführt werden.';
|
||||
}
|
||||
|
||||
@@ -249,5 +259,78 @@ export const actions = {
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'tool', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
saveFeed: async ({ locals, request }) => {
|
||||
requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
saveFeedSource(String(data.get('feedId') || '') || null, {
|
||||
name: String(data.get('name') || ''),
|
||||
url: String(data.get('url') || ''),
|
||||
isEnabled: data.get('isEnabled') === '1',
|
||||
refreshIntervalMinutes: Number(data.get('refreshIntervalMinutes')),
|
||||
defaultCategoryId: String(data.get('defaultCategoryId') || '') || null
|
||||
});
|
||||
return { intent: 'feed', success: 'Feed gespeichert.' };
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'feed', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
deleteFeed: async ({ locals, request }) => {
|
||||
requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
deleteFeedSource(String(data.get('feedId') || ''));
|
||||
return { intent: 'feed', success: 'Feed entfernt.' };
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'feed', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
fetchFeed: async ({ locals, request }) => {
|
||||
requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
const result = await fetchFeed(String(data.get('feedId') || ''));
|
||||
return {
|
||||
intent: 'feed',
|
||||
success: result.unchanged
|
||||
? 'Feed ist unverändert.'
|
||||
: `${result.inserted} neue Beiträge abgerufen.`
|
||||
};
|
||||
} catch {
|
||||
return fail(400, {
|
||||
intent: 'feed',
|
||||
error: 'Feed konnte nicht sicher abgerufen oder gelesen werden.'
|
||||
});
|
||||
}
|
||||
},
|
||||
createTechnicalAccess: async ({ locals, request }) => {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
const created = createTechnicalAccess(
|
||||
actor,
|
||||
String(data.get('name') || ''),
|
||||
String(data.get('permissions') || '')
|
||||
);
|
||||
return {
|
||||
intent: 'technicalAccess',
|
||||
success:
|
||||
'Technischer Zugang angelegt. Token jetzt sicher hinterlegen; es wird nicht erneut angezeigt.',
|
||||
technicalToken: created.token
|
||||
};
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'technicalAccess', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
revokeTechnicalAccess: async ({ locals, request }) => {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
revokeTechnicalAccess(actor, String(data.get('accessId') || ''));
|
||||
return { intent: 'technicalAccess', success: 'Technischer Zugang widerrufen.' };
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'technicalAccess', error: actionError(cause) });
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import IconPicker from '#lib/components/IconPicker.svelte';
|
||||
import ShareManagement from '#lib/components/ShareManagement.svelte';
|
||||
@@ -32,6 +33,176 @@
|
||||
{form.success}
|
||||
</div>{/if}
|
||||
<ShareManagement shows={data.shows} shares={data.shares} {form} />
|
||||
<section class="quiet-panel integration-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Import</p>
|
||||
<h2>RSS-Feeds</h2>
|
||||
</div>
|
||||
<span>{data.feeds.length} eingerichtet</span>
|
||||
</div>
|
||||
<p>
|
||||
Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im
|
||||
Eingang.
|
||||
</p>
|
||||
<form class="integration-form" method="POST" action="?/saveFeed">
|
||||
<label>Name<input name="name" required maxlength="120" /></label>
|
||||
<label
|
||||
>Feed-URL<input
|
||||
name="url"
|
||||
type="url"
|
||||
required
|
||||
maxlength="2000"
|
||||
placeholder="https://example.org/feed.xml"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Intervall in Minuten<input
|
||||
name="refreshIntervalMinutes"
|
||||
type="number"
|
||||
min="15"
|
||||
max="10080"
|
||||
value="60"
|
||||
required
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Standardkategorie<select name="defaultCategoryId"
|
||||
><option value="">Keine</option
|
||||
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id}
|
||||
>{category.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label class="check-row"
|
||||
><input type="checkbox" name="isEnabled" value="1" checked /> Automatisch abrufen</label
|
||||
>
|
||||
<button class="primary-button" type="submit">Feed anlegen</button>
|
||||
</form>
|
||||
<div class="integration-list">
|
||||
{#each data.feeds as feed (feed.id)}
|
||||
<details>
|
||||
<summary
|
||||
><span
|
||||
><strong>{feed.name}</strong><small
|
||||
>{feed.lastError
|
||||
? `Fehler: ${feed.lastError}`
|
||||
: feed.lastFetchedAtUtc
|
||||
? `Zuletzt: ${new Date(feed.lastFetchedAtUtc).toLocaleString('de-DE')}`
|
||||
: 'Noch nicht abgerufen'}</small
|
||||
></span
|
||||
><AppIcon name="pen" /></summary
|
||||
>
|
||||
<form class="integration-form" method="POST" action="?/saveFeed">
|
||||
<input type="hidden" name="feedId" value={feed.id} />
|
||||
<label>Name<input name="name" required maxlength="120" value={feed.name} /></label>
|
||||
<label
|
||||
>Feed-URL<input
|
||||
name="url"
|
||||
type="url"
|
||||
required
|
||||
maxlength="2000"
|
||||
value={feed.url}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Intervall in Minuten<input
|
||||
name="refreshIntervalMinutes"
|
||||
type="number"
|
||||
min="15"
|
||||
max="10080"
|
||||
value={feed.refreshIntervalMinutes}
|
||||
required
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Standardkategorie<select name="defaultCategoryId"
|
||||
><option value="">Keine</option
|
||||
>{#each data.taxonomy.categories as category (category.id)}<option
|
||||
value={category.id}
|
||||
selected={feed.defaultCategoryId === category.id}>{category.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label class="check-row"
|
||||
><input type="checkbox" name="isEnabled" value="1" checked={feed.isEnabled} /> Automatisch
|
||||
abrufen</label
|
||||
>
|
||||
<button class="secondary-button" type="submit">Speichern</button>
|
||||
</form>
|
||||
<div class="row-actions">
|
||||
<form method="POST" action="?/fetchFeed">
|
||||
<input type="hidden" name="feedId" value={feed.id} /><button
|
||||
class="text-button"
|
||||
type="submit">Jetzt abrufen</button
|
||||
>
|
||||
</form>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteFeed"
|
||||
onsubmit={(event) =>
|
||||
!confirm('Feed und seinen Importeingang löschen?') && event.preventDefault()}
|
||||
>
|
||||
<input type="hidden" name="feedId" value={feed.id} /><button
|
||||
class="text-button danger-text"
|
||||
type="submit">Feed löschen</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="quiet-panel integration-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Externe Assistenz</p>
|
||||
<h2>Technische Zugänge</h2>
|
||||
</div>
|
||||
<a href={resolve('/api/v1/openapi.json')} target="_blank">OpenAPI</a>
|
||||
</div>
|
||||
<p>
|
||||
Tokens gelten ausschließlich für die API. Schreibende Aufrufe werden protokolliert und müssen
|
||||
idempotent sein.
|
||||
</p>
|
||||
{#if form?.technicalToken}<div class="token-reveal" role="status">
|
||||
<strong>Token, einmalig sichtbar</strong><code>{form.technicalToken}</code>
|
||||
</div>{/if}
|
||||
<form class="integration-form" method="POST" action="?/createTechnicalAccess">
|
||||
<label
|
||||
>Name<input name="name" required maxlength="120" placeholder="Redaktionsassistenz" /></label
|
||||
>
|
||||
<label
|
||||
>Berechtigung<select name="permissions"
|
||||
><option value="read">Nur lesen</option><option value="read_write"
|
||||
>Lesen und schreiben</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<button class="primary-button" type="submit">Zugang anlegen</button>
|
||||
</form>
|
||||
<div class="integration-list">
|
||||
{#each data.technicalAccesses as access (access.id)}
|
||||
<div class="access-row">
|
||||
<span
|
||||
><strong>{access.name}</strong><small
|
||||
>{access.permissions === 'read' ? 'Nur lesen' : 'Lesen und schreiben'} · {access.revokedAtUtc
|
||||
? 'Widerrufen'
|
||||
: access.lastUsedAtUtc
|
||||
? `Zuletzt genutzt ${new Date(access.lastUsedAtUtc).toLocaleString('de-DE')}`
|
||||
: 'Noch nicht genutzt'}</small
|
||||
></span
|
||||
>{#if !access.revokedAtUtc}<form method="POST" action="?/revokeTechnicalAccess">
|
||||
<input type="hidden" name="accessId" value={access.id} /><button
|
||||
class="secondary-button"
|
||||
type="submit">Widerrufen</button
|
||||
>
|
||||
</form>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="quiet-panel account-panel">
|
||||
<div class="panel-heading">
|
||||
@@ -272,3 +443,67 @@
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.integration-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
.integration-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: 0.8rem;
|
||||
align-items: end;
|
||||
}
|
||||
.integration-form label:not(.check-row) {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.check-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
min-height: 2.6rem;
|
||||
}
|
||||
.integration-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.integration-list details,
|
||||
.access-row {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.8rem;
|
||||
}
|
||||
.integration-list summary,
|
||||
.access-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
.integration-list summary > span,
|
||||
.access-row > span {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.integration-list small {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.token-reveal {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
padding: 0.8rem;
|
||||
border: 2px solid var(--color-accent);
|
||||
border-radius: 0.7rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.token-reveal code {
|
||||
user-select: all;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user