b90ff252d1
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.
308 lines
9.9 KiB
TypeScript
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
|
|
}
|