Files
Matthias Giesselmann b90ff252d1 Add Logbuch: project update blog with admin, media and API
Public archive with sidebar navigation, project pages, month archive,
search and entry pages with image blocks. Admin area for brands,
projects, post types, users, api clients, media and the entry editor
with drag and drop images, preview per audience, scheduling and
publish checks. Read and write API with bearer tokens, audience
scoping, idempotent creation, OpenAPI document and editorial guide.
Magic link login with configurable allowed domains, whole app behind
the session gate. 456 tests including design rule checks.
2026-07-31 21:33:42 +02:00

308 lines
9.9 KiB
TypeScript

import { findMedia } from '~/data/repositories/media'
import { renderPng, renderVariant } from '~/lib/media-images'
import { uploadMedia } from '~/lib/media-upload'
import { getStorage, objectPath } from '~/lib/storage'
export const sampleWidth = 2000
export const sampleHeight = 1125
export type SampleImage = {
key: string
id: string
project: string
filename: string
alt: string
caption: string
svg: () => string
}
function rng(seed: number): () => number {
let state = seed % 4294967296
return () => {
state = (state * 1664525 + 1013904223) % 4294967296
return state / 4294967296
}
}
function round(value: number): number {
return Math.round(value * 100) / 100
}
function frame(body: string, tint: string): string {
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${sampleWidth}" height="${sampleHeight}" viewBox="0 0 ${sampleWidth} ${sampleHeight}">`,
'<rect width="100%" height="100%" fill="#f2f1ec"/>',
`<rect x="0" y="0" width="${sampleWidth}" height="14" fill="${tint}"/>`,
'<rect x="60" y="74" width="1880" height="991" fill="#fbfaf6" stroke="#dedcd3" stroke-width="2"/>',
body,
'</svg>',
].join('')
}
function ruleGrid(): string {
const lines: string[] = []
for (let y = 150; y < 1040; y += 74) {
lines.push(`<line x1="120" y1="${y}" x2="1880" y2="${y}" stroke="#dedcd3" stroke-width="1"/>`)
}
return lines.join('')
}
function ruleEngine(): string {
const tint = '#2e7d5b'
const parts: string[] = [ruleGrid()]
for (let index = 0; index < 3; index += 1) {
const y = 240 + index * 250
parts.push(`<rect x="180" y="${y}" width="520" height="150" rx="6" fill="#f2f1ec" stroke="#8a8f95" stroke-width="2"/>`)
parts.push(`<rect x="220" y="${y + 42}" width="${360 - index * 60}" height="16" rx="8" fill="#8a8f95"/>`)
parts.push(`<rect x="220" y="${y + 84}" width="${240 - index * 40}" height="12" rx="6" fill="#dedcd3"/>`)
parts.push(`<rect x="1300" y="${y}" width="520" height="150" rx="6" fill="${tint}" opacity="${round(0.9 - index * 0.2)}"/>`)
parts.push(`<rect x="1340" y="${y + 42}" width="${340 - index * 50}" height="16" rx="8" fill="#fbfaf6" opacity="0.85"/>`)
parts.push(
`<path d="M700 ${y + 75} C 900 ${y + 75}, 1100 ${y + 75}, 1300 ${y + 75}" fill="none" stroke="${tint}" stroke-width="4"/>`,
)
parts.push(`<circle cx="1300" cy="${y + 75}" r="10" fill="${tint}"/>`)
}
return frame(parts.join(''), tint)
}
function columnMenu(): string {
const tint = '#2b4a9b'
const parts: string[] = []
const columns = [180, 560, 940, 1320, 1620]
const widths = [340, 340, 340, 260, 220]
parts.push('<rect x="120" y="150" width="1760" height="70" fill="#f2f1ec"/>')
columns.forEach((x, column) => {
const width = widths[column] ?? 300
parts.push(`<rect x="${x}" y="176" width="${Math.round(width * 0.55)}" height="18" rx="9" fill="#4c5157"/>`)
for (let row = 0; row < 9; row += 1) {
const y = 260 + row * 84
const bar = Math.round(width * (0.45 + ((row + column) % 4) * 0.13))
const fill = column === 3 ? tint : '#dedcd3'
const opacity = column === 3 ? round(0.85 - (row % 4) * 0.15) : 1
parts.push(`<rect x="${x}" y="${y}" width="${bar}" height="16" rx="8" fill="${fill}" opacity="${opacity}"/>`)
}
})
for (let row = 0; row < 9; row += 1) {
const y = 244 + row * 84
parts.push(`<line x1="120" y1="${y}" x2="1880" y2="${y}" stroke="#dedcd3" stroke-width="1"/>`)
}
parts.push(`<rect x="1300" y="228" width="360" height="300" rx="6" fill="#fbfaf6" stroke="${tint}" stroke-width="3"/>`)
for (let entry = 0; entry < 4; entry += 1) {
parts.push(`<rect x="1340" y="${262 + entry * 66}" width="${280 - entry * 30}" height="14" rx="7" fill="#8a8f95"/>`)
}
return frame(parts.join(''), tint)
}
function trackerMap(): string {
const tint = '#b4761a'
const parts: string[] = []
const random = rng(20260730)
for (let x = 180; x < 1880; x += 106) {
parts.push(`<line x1="${x}" y1="120" x2="${x}" y2="1020" stroke="#f2f1ec" stroke-width="2"/>`)
}
for (let y = 150; y < 1020; y += 96) {
parts.push(`<line x1="120" y1="${y}" x2="1880" y2="${y}" stroke="#f2f1ec" stroke-width="2"/>`)
}
for (let index = 0; index < 220; index += 1) {
const x = round(160 + random() * 1700)
const y = round(140 + random() * 860)
const radius = round(3 + random() * 4)
parts.push(`<circle cx="${x}" cy="${y}" r="${radius}" fill="#8a8f95" opacity="0.55"/>`)
}
const clusters = [
{ x: 520, y: 400, size: 96 },
{ x: 1180, y: 620, size: 132 },
{ x: 1560, y: 320, size: 74 },
]
for (const cluster of clusters) {
parts.push(`<circle cx="${cluster.x}" cy="${cluster.y}" r="${cluster.size}" fill="${tint}" opacity="0.14"/>`)
parts.push(`<circle cx="${cluster.x}" cy="${cluster.y}" r="${cluster.size}" fill="none" stroke="${tint}" stroke-width="3"/>`)
parts.push(`<circle cx="${cluster.x}" cy="${cluster.y}" r="14" fill="${tint}"/>`)
}
parts.push(`<path d="M520 400 L1180 620 L1560 320" fill="none" stroke="${tint}" stroke-width="3" stroke-dasharray="14 12"/>`)
return frame(parts.join(''), tint)
}
function slaClock(): string {
const tint = '#2e7d5b'
const parts: string[] = []
const pauses = [
{ x: 700, width: 220 },
{ x: 1420, width: 220 },
]
for (const pause of pauses) {
parts.push(`<rect x="${pause.x}" y="150" width="${pause.width}" height="890" fill="#f2f1ec"/>`)
}
for (let index = 0; index < 7; index += 1) {
const y = 250 + index * 110
const start = 180 + index * 42
const width = 420 + ((index * 7) % 5) * 190
parts.push(`<line x1="120" y1="${y + 44}" x2="1880" y2="${y + 44}" stroke="#dedcd3" stroke-width="1"/>`)
parts.push(`<rect x="${start}" y="${y}" width="${width}" height="26" rx="13" fill="${tint}" opacity="${round(0.85 - index * 0.08)}"/>`)
parts.push(`<circle cx="${start}" cy="${y + 13}" r="9" fill="#4c5157"/>`)
}
parts.push('<rect x="120" y="150" width="1760" height="60" fill="#f2f1ec"/>')
for (let tick = 0; tick < 12; tick += 1) {
parts.push(`<rect x="${180 + tick * 146}" y="168" width="42" height="12" rx="6" fill="#8a8f95"/>`)
}
return frame(parts.join(''), tint)
}
function inbox(): string {
const tint = '#6b4ba8'
const parts: string[] = []
const lanes = [200, 700, 1200]
lanes.forEach((x, lane) => {
for (let row = 0; row < 4; row += 1) {
const y = 210 + row * 120
parts.push(`<rect x="${x}" y="${y}" width="420" height="88" rx="6" fill="#f2f1ec" stroke="#dedcd3" stroke-width="2"/>`)
parts.push(`<circle cx="${x + 44}" cy="${y + 44}" r="18" fill="${tint}" opacity="${round(0.75 - lane * 0.18)}"/>`)
parts.push(`<rect x="${x + 84}" y="${y + 28}" width="${280 - row * 40}" height="14" rx="7" fill="#8a8f95"/>`)
parts.push(`<rect x="${x + 84}" y="${y + 54}" width="${200 - row * 30}" height="10" rx="5" fill="#dedcd3"/>`)
}
parts.push(
`<path d="M${x + 210} 698 C ${x + 210} 800, 1000 800, 1000 880" fill="none" stroke="${tint}" stroke-width="3" opacity="0.6"/>`,
)
})
parts.push(`<rect x="640" y="880" width="720" height="120" rx="8" fill="${tint}"/>`)
parts.push('<rect x="700" y="920" width="420" height="18" rx="9" fill="#fbfaf6" opacity="0.9"/>')
parts.push('<rect x="700" y="954" width="300" height="14" rx="7" fill="#fbfaf6" opacity="0.6"/>')
return frame(parts.join(''), tint)
}
export const sampleImages: SampleImage[] = [
{
key: 'regel-engine',
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00001',
project: 'trakk',
filename: 'regel-engine-bedingung-aktion.png',
alt: 'Schema der Regel-Engine: links drei Bedingungen, rechts drei Aktionen, jeweils mit einer Linie verbunden',
caption: 'Jede Bedingung zeigt auf genau eine Aktion.',
svg: ruleEngine,
},
{
key: 'spaltenmenue',
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00002',
project: 'mta360',
filename: 'spaltenmenue-rechtsklick.png',
alt: 'Tabelle mit fünf Spalten, über der vierten Spalte ist ein Menü mit vier Einträgen geöffnet',
caption: 'Das Spaltenmenü öffnet sich über der Spalte, die es betrifft.',
svg: columnMenu,
},
{
key: 'tracker-karte',
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00003',
project: 'mta-telematik',
filename: 'tracker-karte-cluster.png',
alt: 'Kartenausschnitt mit vielen kleinen Punkten und drei hervorgehobenen Ballungen, die mit einer gestrichelten Linie verbunden sind',
caption: 'Dicht beieinander liegende Tracker werden zu Ballungen zusammengefasst.',
svg: trackerMap,
},
{
key: 'sla-uhr',
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00004',
project: 'trakk',
filename: 'sla-uhr-pausiert.png',
alt: 'Zeitleiste mit sieben Balken, zwei helle senkrechte Bänder markieren die Zeiten, in denen die Uhr steht',
caption: 'Die hellen Bänder sind Wochenenden und Feiertage, dort läuft die Uhr nicht.',
svg: slaClock,
},
{
key: 'eingang',
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00005',
project: 'orbit',
filename: 'kommentare-ein-eingang.png',
alt: 'Drei Spalten mit Kommentaren laufen über geschwungene Linien in einen gemeinsamen Eingang unten zusammen',
caption: 'Drei Kanäle, ein Eingang.',
svg: inbox,
},
]
export async function ensureSampleMedia(projectIds: Map<string, string>): Promise<Map<string, string>> {
const storage = getStorage()
const ids = new Map<string, string>()
for (const sample of sampleImages) {
const projectId = projectIds.get(sample.project)
if (!projectId) {
continue
}
ids.set(sample.key, sample.id)
const existing = await findMedia(sample.id)
if (existing) {
if (!(await storage.exists(existing.path))) {
const body = await renderPng(sample.svg())
await storage.put(existing.path, body, 'image/png')
for (const variant of existing.variants) {
const rendered = await renderVariant(body, variant.width)
await storage.put(variant.path, rendered.body, 'image/webp')
}
}
continue
}
const path = objectPath(projectId, sample.id, 'original.png')
const stored = await storage.read(path)
const body = stored ? stored.body : await renderPng(sample.svg())
await uploadMedia({
id: sample.id,
projectId,
filename: sample.filename,
body,
alt: sample.alt,
caption: sample.caption,
reuse: true,
})
}
return ids
}