Projektlogo: quadratisches Logo hochladen & anzeigen
ci / quality (push) Failing after 42s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 53s

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) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-06 20:44:58 +02:00
1 parent 45a87711fb
commit 1206dc5e0f
9 files changed
+3652 -119

No files matched your search

+1
View File
@@ -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,
@@ -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<string> {
const dataUrl = await new Promise<string>((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<HTMLImageElement>((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 (
<img
src={url}
alt=""
className={clsx('shrink-0 rounded-[var(--radius-md)] object-cover', className)}
style={{ width: size, height: size }}
/>
);
}
const initials = name.trim().slice(0, 2).toUpperCase() || '–';
return (
<div
className={clsx(
'flex shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-black/5 font-semibold text-[var(--color-ink-muted)]',
className,
)}
style={{ width: size, height: size, fontSize: Math.round(size * 0.34) }}
aria-hidden
>
{initials}
</div>
);
}
export function LogoUpload({
value,
name,
onChange,
className,
}: {
value: string | null;
name: string;
onChange: (v: string | null) => void;
className?: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className={clsx('flex items-center gap-3', className)}>
<ProjectLogo url={value} name={name} size={56} />
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={busy}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--color-border)] px-2.5 py-1 text-xs hover:border-[var(--color-ink)] disabled:opacity-50"
>
<Upload size={13} /> {busy ? 'Lädt…' : value ? 'Ändern' : 'Logo hochladen'}
</button>
{value && (
<button
type="button"
onClick={() => onChange(null)}
className="inline-flex items-center gap-1 text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ampel-red-fg)]"
>
<X size={13} /> Entfernen
</button>
)}
</div>
{error ? (
<span className="text-xs text-[var(--color-ampel-red-fg)]">{error}</span>
) : (
<span className="text-xs text-[var(--color-ink-faint)]">Quadratisch, wird auf 256px verkleinert.</span>
)}
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFile(e.target.files?.[0])}
/>
</div>
);
}
+12 -2
View File
@@ -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) => (
<Link key={p.id} to={`/projekt/${p.id}`} className="contents">
<Card.Root className="cursor-pointer hover:shadow-[var(--shadow-md)]">
<Card.Body className="flex items-center justify-between">
<div>
<Card.Body className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<ProjectLogo url={p.logoUrl} name={p.name} size={40} />
<div className="min-w-0">
<p className="flex items-center gap-2 font-medium">
{p.name}
{p.modus !== 'standard' && (
@@ -123,6 +127,7 @@ function Section({
<p className="text-xs text-[var(--color-ink-muted)]">
{p.deadline ? `bis ${p.deadline}` : 'kein Deadline'}
</p>
</div>
</div>
<PflichtMinimumIndicator state={p.pflichtMinimumState} />
</Card.Body>
@@ -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<string | null>(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"
/>
</Field>
<Field label="Logo (optional)">
<LogoUpload value={logoUrl} name={name} onChange={setLogoUrl} />
</Field>
<Field label="Bereich">
<select
required
+63 -1
View File
@@ -12,6 +12,7 @@ import { voice } from '../lib/voice';
import { toast } from 'sonner';
import { clsx } from 'clsx';
import { LEAD_STATUS_META, type LeadStatus } from '../lib/lead-meta';
import { ProjectLogo, LogoUpload } from '../components/project/ProjectLogo';
import {
MODUS_META,
MODI,
@@ -109,7 +110,10 @@ export function ProjektRoute() {
</p>
</div>
<div className="mt-2 flex items-start justify-between gap-4">
<h1 className="text-3xl font-bold">{p.name}</h1>
<div className="flex items-center gap-3">
<HeaderLogo projectId={p.id} logoUrl={p.logoUrl} name={p.name} />
<h1 className="text-3xl font-bold">{p.name}</h1>
</div>
{isP1 && !p.archiviert && (
<Button variant="secondary" onClick={() => setAbschlussOpen(true)}>
Projekt abschließen
@@ -239,6 +243,64 @@ function ModusControl({ projectId, modus }: { projectId: string; modus: ProjectM
}
// --- Ziel & Maxime + SMART-Ziel, Nicht-Ziele, Liefergegenstände, Zieldreieck -
function HeaderLogo({
projectId,
logoUrl,
name,
}: {
projectId: string;
logoUrl: string | null;
name: string;
}) {
const utils = trpc.useUtils();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<string | null>(logoUrl);
const update = trpc.project.update.useMutation({
onSuccess: async () => {
toast(voice.toast.saved);
await utils.project.byId.invalidate({ id: projectId });
setEditing(false);
},
});
if (editing) {
return (
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] p-3">
<LogoUpload value={draft} name={name} onChange={setDraft} />
<div className="mt-2 flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
Abbrechen
</Button>
<Button
type="button"
size="sm"
disabled={update.isPending}
onClick={() => update.mutate({ projectId, logoUrl: draft })}
>
Speichern
</Button>
</div>
</div>
);
}
return (
<button
type="button"
onClick={() => {
setDraft(logoUrl);
setEditing(true);
}}
className="group relative shrink-0"
title="Logo ändern"
>
<ProjectLogo url={logoUrl} name={name} size={56} />
<span className="absolute inset-0 flex items-center justify-center rounded-[var(--radius-md)] bg-black/40 text-white opacity-0 transition-opacity group-hover:opacity-100">
<Pencil size={16} />
</span>
</button>
);
}
function ZielSection({ projectId, project: p }: { projectId: string; project: ProjectDetail }) {
const utils = trpc.useUtils();
const [editing, setEditing] = useState(false);