feat(core): Verschluesselung, Validierung und gemeinsame Helfer
- crypto.ts: AES-256-GCM fuer die Git-Access-Tokens, Schluessel per scrypt aus APP_SECRET abgeleitet. Chiffrat ist versioniert (v1.<iv>.<tag>.<data>), damit ein spaeterer Formatwechsel moeglich bleibt. Fehlt APP_SECRET, warnt die App und faellt auf einen Entwicklungsschluessel zurueck. - validation.ts: Zod-Schemas fuer alle Eingaben an der API-Grenze, inklusive Umwandlung leerer Strings zu null und Feldfehlern fuer die Oberflaeche. - status.ts: Metadaten zu Status, Prioritaeten und Akzentfarben an einer Stelle, damit Board, Karte und Detailseite nicht auseinanderlaufen. - utils.ts: Slug-Erzeugung, deutsche Datumsformate mit fester Zeitzone (verhindert Abweichungen zwischen Server-Render und Hydration) und relative Zeitangaben. - projects.ts: wiederverwendete Prisma-Selects. Die Verbindungsauswahl laesst tokenCipher bewusst weg, damit kein Token in eine Client-Komponente gelangt. - db.ts: Prisma-Client mit Cache ueber globalThis gegen Verbindungslecks beim Hot Reload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/** Kleine Helfer ohne Abhaengigkeiten. */
|
||||
|
||||
export function cn(...classes: Array<string | false | null | undefined>): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
const DATE_FORMAT = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
|
||||
const DATETIME_FORMAT = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
|
||||
export function formatDate(value: Date | string | null | undefined): string {
|
||||
if (!value) return "–";
|
||||
const date = typeof value === "string" ? new Date(value) : value;
|
||||
if (Number.isNaN(date.getTime())) return "–";
|
||||
return DATE_FORMAT.format(date);
|
||||
}
|
||||
|
||||
export function formatDateTime(value: Date | string | null | undefined): string {
|
||||
if (!value) return "–";
|
||||
const date = typeof value === "string" ? new Date(value) : value;
|
||||
if (Number.isNaN(date.getTime())) return "–";
|
||||
return DATETIME_FORMAT.format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* "vor 3 Tagen". Bewusst grob gerundet – kleine Abweichungen zwischen
|
||||
* Server-Render und Hydration fallen damit nicht auf.
|
||||
*/
|
||||
export function formatRelative(value: Date | string | null | undefined): string {
|
||||
if (!value) return "nie";
|
||||
const date = typeof value === "string" ? new Date(value) : value;
|
||||
if (Number.isNaN(date.getTime())) return "nie";
|
||||
|
||||
const seconds = Math.round((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return "gerade eben";
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `vor ${minutes} Min.`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `vor ${hours} Std.`;
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 31) return `vor ${days} ${days === 1 ? "Tag" : "Tagen"}`;
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 12) return `vor ${months} Mon.`;
|
||||
return `vor ${Math.round(months / 12)} J.`;
|
||||
}
|
||||
|
||||
export function truncate(value: string, max = 120): string {
|
||||
if (value.length <= max) return value;
|
||||
return `${value.slice(0, max - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function firstLine(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
return value.split("\n")[0] ?? "";
|
||||
}
|
||||
|
||||
export function parseTags(input: string): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
input
|
||||
.split(",")
|
||||
.map((tag) => tag.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
).slice(0, 12);
|
||||
}
|
||||
|
||||
export function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat("de-DE").format(value);
|
||||
}
|
||||
Reference in New Issue
Block a user