diff --git a/backend/app/main.py b/backend/app/main.py
index 1f2a474..1c77946 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -9,6 +9,7 @@ from .database import Base, SessionLocal, engine
from .routers import (
api_tokens,
auth,
+ branding,
groups,
locations,
products,
@@ -98,3 +99,4 @@ app.include_router(views.router)
app.include_router(transfer.router)
app.include_router(api_tokens.router)
app.include_router(settings_router.router)
+app.include_router(branding.router)
diff --git a/backend/app/models.py b/backend/app/models.py
index d20bc0d..0503700 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -11,6 +11,7 @@ from sqlalchemy import (
Float,
ForeignKey,
Integer,
+ LargeBinary,
String,
Text,
UniqueConstraint,
@@ -248,6 +249,24 @@ class Movement(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+class BrandingAsset(Base):
+ """Eigenes Logo bzw. Favicon der Installation.
+
+ Bewusst in der Datenbank und nicht im Dateisystem: So landet das Bild
+ automatisch im Backup und das Deployment braucht kein zusätzliches Volume.
+ Es geht um wenige Kilobyte, die Größe ist beim Upload begrenzt.
+ """
+
+ __tablename__ = "branding_assets"
+
+ kind: Mapped[str] = mapped_column(String(16), primary_key=True) # "logo" | "favicon"
+ content_type: Mapped[str] = mapped_column(String(64), nullable=False)
+ data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=_now, onupdate=_now
+ )
+
+
class Setting(Base):
__tablename__ = "settings"
diff --git a/backend/app/routers/branding.py b/backend/app/routers/branding.py
new file mode 100644
index 0000000..2d12d52
--- /dev/null
+++ b/backend/app/routers/branding.py
@@ -0,0 +1,113 @@
+"""Eigenes Logo und Favicon der Installation.
+
+Die Bilder liegen in der Datenbank (siehe models.BrandingAsset). Das Abrufen ist
+bewusst ohne Anmeldung möglich – das Favicon wird vom Browser schon vor dem
+Login geladen, und es handelt sich um nichts Schützenswertes. Hochladen und
+Entfernen dürfen nur Administratoren.
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
+from fastapi.responses import Response
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from ..deps import require_admin
+from ..models import BrandingAsset, User
+
+router = APIRouter(prefix="/branding", tags=["branding"])
+
+KINDS = ("logo", "favicon")
+
+# Bewusst eng: genau die Formate, die ein Browser als Logo/Favicon darstellt.
+ALLOWED_TYPES = {
+ "image/png": "png",
+ "image/jpeg": "jpg",
+ "image/svg+xml": "svg",
+ "image/webp": "webp",
+ "image/x-icon": "ico",
+ "image/vnd.microsoft.icon": "ico",
+ "image/gif": "gif",
+}
+
+MAX_BYTES = 512 * 1024 # 512 KB reichen für ein Logo deutlich aus
+
+
+def _check_kind(kind: str) -> str:
+ if kind not in KINDS:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Unbekannte Bildart")
+ return kind
+
+
+@router.get("/{kind}")
+def get_branding(kind: str, db: Session = Depends(get_db)) -> Response:
+ """Liefert das hinterlegte Bild – oder 404, wenn keines gesetzt ist.
+
+ Die Oberfläche fällt bei 404 auf ihr eingebautes Zeichen zurück.
+ """
+ _check_kind(kind)
+ asset = db.get(BrandingAsset, kind)
+ if asset is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein eigenes Bild hinterlegt")
+ return Response(
+ content=asset.data,
+ media_type=asset.content_type,
+ headers={
+ # Kurz cachen, damit ein Wechsel schnell sichtbar wird.
+ "Cache-Control": "public, max-age=60",
+ "ETag": f'"{kind}-{int(asset.updated_at.timestamp())}"',
+ },
+ )
+
+
+@router.put("/{kind}")
+async def put_branding(
+ kind: str,
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+ _: User = Depends(require_admin),
+) -> Response:
+ _check_kind(kind)
+
+ if file.content_type not in ALLOWED_TYPES:
+ erlaubt = ", ".join(sorted(set(ALLOWED_TYPES.values())))
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ f"Dieses Dateiformat wird nicht unterstützt. Erlaubt sind: {erlaubt}.",
+ )
+
+ data = await file.read()
+ if not data:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.")
+ if len(data) > MAX_BYTES:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ f"Die Datei ist zu groß ({len(data) // 1024} KB). "
+ f"Erlaubt sind höchstens {MAX_BYTES // 1024} KB.",
+ )
+
+ asset = db.get(BrandingAsset, kind)
+ if asset is None:
+ asset = BrandingAsset(kind=kind, content_type=file.content_type, data=data)
+ db.add(asset)
+ else:
+ asset.content_type = file.content_type
+ asset.data = data
+ db.commit()
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+@router.delete("/{kind}")
+def delete_branding(
+ kind: str,
+ db: Session = Depends(get_db),
+ _: User = Depends(require_admin),
+) -> Response:
+ """Zurück auf das eingebaute Zeichen."""
+ _check_kind(kind)
+ asset = db.get(BrandingAsset, kind)
+ if asset is not None:
+ db.delete(asset)
+ db.commit()
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
diff --git a/web/index.html b/web/index.html
index 5f54410..99d37e5 100644
--- a/web/index.html
+++ b/web/index.html
@@ -2,6 +2,7 @@
+
Project-Good – Lagerverwaltung
diff --git a/web/public/favicon.svg b/web/public/favicon.svg
new file mode 100644
index 0000000..07558be
--- /dev/null
+++ b/web/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/src/App.jsx b/web/src/App.jsx
index 25e1152..12b217e 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -1,6 +1,7 @@
import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { useAuth } from "./auth";
import Icon from "./components/Icon";
+import BrandMark from "./components/BrandMark";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import Products from "./pages/Products";
@@ -39,8 +40,7 @@ function Sidebar() {
return (