From 1206dc5e0f8410be6f445e99f80ad58b69cdc9d0 Mon Sep 17 00:00:00 2001 From: DeeCee03 Date: Sat, 6 Jun 2026 20:44:58 +0200 Subject: [PATCH] Projektlogo: quadratisches Logo hochladen & anzeigen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logo kann bei der Projekterstellung und nachträglich im Projekt-Header hochgeladen werden. Client-seitiger Center-Crop auf 256px WebP, gespeichert als Data-URL in neuer Spalte project.logo_url (Migration 0016). Anzeige in der Bereichs-Projektliste (Card) und im Detail-Header, mit Initialen- Platzhalter wenn kein Logo gesetzt ist. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/routers/project.ts | 1 + .../src/components/project/ProjectLogo.tsx | 135 + apps/web/src/routes/bereich.tsx | 14 +- apps/web/src/routes/projekt.tsx | 64 +- .../db/migrations/0016_project_logo_url.sql | 1 + .../db/migrations/meta/0016_snapshot.json | 3305 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 239 +- packages/db/src/schema.ts | 2 + packages/shared/src/schemas/project.ts | 10 + 9 files changed, 3652 insertions(+), 119 deletions(-) create mode 100644 apps/web/src/components/project/ProjectLogo.tsx create mode 100644 packages/db/migrations/0016_project_logo_url.sql create mode 100644 packages/db/migrations/meta/0016_snapshot.json diff --git a/apps/api/src/routers/project.ts b/apps/api/src/routers/project.ts index 039eb9d..b2489b5 100644 --- a/apps/api/src/routers/project.ts +++ b/apps/api/src/routers/project.ts @@ -71,6 +71,7 @@ export const projectRouter = router({ areaId: input.areaId, name: input.name, description: input.description ?? null, + logoUrl: input.logoUrl ?? null, kind: input.kind, verantwortlicheUserId: input.verantwortlicheUserId ?? null, arbeitsmodus: input.arbeitsmodus ?? null, diff --git a/apps/web/src/components/project/ProjectLogo.tsx b/apps/web/src/components/project/ProjectLogo.tsx new file mode 100644 index 0000000..9d45a35 --- /dev/null +++ b/apps/web/src/components/project/ProjectLogo.tsx @@ -0,0 +1,135 @@ +import { useRef, useState } from 'react'; +import { clsx } from 'clsx'; +import { Upload, X } from 'lucide-react'; + +// Datei → zentriert quadratisch zugeschnittene, auf `size` verkleinerte WebP-Data-URL. +async function fileToSquareDataUrl(file: File, size = 256): Promise { + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.')); + reader.readAsDataURL(file); + }); + const img = await new Promise((resolve, reject) => { + const i = new Image(); + i.onload = () => resolve(i); + i.onerror = () => reject(new Error('Bild konnte nicht geladen werden.')); + i.src = dataUrl; + }); + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas nicht verfügbar.'); + // Center-Crop (cover): kürzere Kante bestimmt das Quadrat. + const s = Math.min(img.width, img.height); + const sx = (img.width - s) / 2; + const sy = (img.height - s) / 2; + ctx.drawImage(img, sx, sy, s, s, 0, 0, size, size); + return canvas.toDataURL('image/webp', 0.85); +} + +export function ProjectLogo({ + url, + name, + size = 40, + className, +}: { + url: string | null | undefined; + name: string; + size?: number; + className?: string; +}) { + if (url) { + return ( + + ); + } + const initials = name.trim().slice(0, 2).toUpperCase() || '–'; + return ( +
+ {initials} +
+ ); +} + +export function LogoUpload({ + value, + name, + onChange, + className, +}: { + value: string | null; + name: string; + onChange: (v: string | null) => void; + className?: string; +}) { + const inputRef = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function handleFile(file: File | undefined) { + if (!file) return; + setError(null); + setBusy(true); + try { + onChange(await fileToSquareDataUrl(file)); + } catch (e) { + setError(e instanceof Error ? e.message : 'Upload fehlgeschlagen.'); + } finally { + setBusy(false); + if (inputRef.current) inputRef.current.value = ''; + } + } + + return ( +
+ +
+
+ + {value && ( + + )} +
+ {error ? ( + {error} + ) : ( + Quadratisch, wird auf 256px verkleinert. + )} +
+ handleFile(e.target.files?.[0])} + /> +
+ ); +} diff --git a/apps/web/src/routes/bereich.tsx b/apps/web/src/routes/bereich.tsx index c6999fd..878c3c0 100644 --- a/apps/web/src/routes/bereich.tsx +++ b/apps/web/src/routes/bereich.tsx @@ -9,6 +9,7 @@ import { toast } from 'sonner'; import { clsx } from 'clsx'; import { LEAD_STATUS_META, type LeadStatus } from '../lib/lead-meta'; import { MODUS_META, type ProjectModus } from '../lib/project-meta'; +import { ProjectLogo, LogoUpload } from '../components/project/ProjectLogo'; const BEREICHE: Array<{ slug: 'entwicklung' | 'vertrieb' | 'marketing'; label: string }> = [ { slug: 'entwicklung', label: 'Entwicklung' }, @@ -85,6 +86,7 @@ function Section({ projects: Array<{ id: string; name: string; + logoUrl: string | null; statusCache: 'green' | 'amber' | 'red' | null; deadline: string | null; modus: ProjectModus; @@ -105,8 +107,10 @@ function Section({ {projects.map((p) => ( - -
+ +
+ +

{p.name} {p.modus !== 'standard' && ( @@ -123,6 +127,7 @@ function Section({

{p.deadline ? `bis ${p.deadline}` : 'kein Deadline'}

+
@@ -175,6 +180,7 @@ function ProjectCreateDialog({ const defaultArea = areas.find((a) => a.slug === defaultSlug); const [name, setName] = useState(''); const [description, setDescription] = useState(''); + const [logoUrl, setLogoUrl] = useState(null); const [areaId, setAreaId] = useState(defaultArea?.id ?? ''); // Areas werden asynchron geladen; sobald sie da sind und noch nichts gewählt @@ -207,6 +213,7 @@ function ProjectCreateDialog({ areaId, name, description: description.trim() || undefined, + logoUrl: logoUrl ?? undefined, kind, arbeitsmodus, outlineSteckbriefUrl: steckbrief, @@ -243,6 +250,9 @@ function ProjectCreateDialog({ className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm" /> + + +