Eigenes Logo und Favicon je Installation
Beides ist optional und wird ueber Einstellungen -> Darstellung gesetzt. Ohne eigenes Bild bleibt alles wie bisher. Ablage bewusst in der Datenbank (Tabelle branding_assets) und nicht im Dateisystem: So liegt das Bild automatisch im Backup, und das Deployment braucht kein zusaetzliches Volume. Es geht um wenige Kilobyte; der Upload ist auf 512 KB und auf Bildformate begrenzt, die ein Browser auch wirklich darstellt. Das Abrufen ist ohne Anmeldung moeglich, weil der Browser das Favicon schon vor dem Login holt - hochladen und entfernen duerfen nur Administratoren. Das Logo ersetzt in der Seitenleiste und auf der Anmeldeseite Zeichen und Schriftzug zusammen. Damit ein zu grosses oder sehr breites Bild das Layout nicht auseinanderziehen kann, ist die Hoehe per CSS gedeckelt und die Breite auf den Container begrenzt; object-fit haelt das Seitenverhaeltnis. Faellt das Laden fehl, erscheint wieder das eingebaute Zeichen. Als Standard-Favicon dient derselbe Barcode-Glyph wie im App-Icon, damit Web und iOS-App zusammenpassen. Getestet: Die Endpunkte sind gegen die laufende API geprueft - hochladen, abrufen ohne Anmeldung, Abweisen von falschem Dateityp (400), zu grosser Datei (400), unbekannter Bildart (404) und fehlenden Rechten (401), entfernen und der Rueckfall auf 404 danach. Web-Build laeuft durch, 40 pytest-Tests gruen. Die Darstellung im Browser habe ich nicht selbst angesehen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from .database import Base, SessionLocal, engine
|
|||||||
from .routers import (
|
from .routers import (
|
||||||
api_tokens,
|
api_tokens,
|
||||||
auth,
|
auth,
|
||||||
|
branding,
|
||||||
groups,
|
groups,
|
||||||
locations,
|
locations,
|
||||||
products,
|
products,
|
||||||
@@ -98,3 +99,4 @@ app.include_router(views.router)
|
|||||||
app.include_router(transfer.router)
|
app.include_router(transfer.router)
|
||||||
app.include_router(api_tokens.router)
|
app.include_router(api_tokens.router)
|
||||||
app.include_router(settings_router.router)
|
app.include_router(settings_router.router)
|
||||||
|
app.include_router(branding.router)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sqlalchemy import (
|
|||||||
Float,
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
|
LargeBinary,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
@@ -248,6 +249,24 @@ class Movement(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
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):
|
class Setting(Base):
|
||||||
__tablename__ = "settings"
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
|||||||
113
backend/app/routers/branding.py
Normal file
113
backend/app/routers/branding.py
Normal file
@@ -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)
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Project-Good – Lagerverwaltung</title>
|
<title>Project-Good – Lagerverwaltung</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
1
web/public/favicon.svg
Normal file
1
web/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#6b78ff" id="Layer_1" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m4 6h2v12h-2zm4 12h2v-12h-2zm12-12h-2v12h2zm-9 12h3v-12h-3zm-9 1v-3h-2v3c0 1.654 1.346 3 3 3h3v-2h-3c-.551 0-1-.449-1-1zm20 0c0 .551-.449 1-1 1h-3v2h3c1.654 0 3-1.346 3-3v-3h-2zm-1-17h-3v2h3c.551 0 1 .449 1 1v3h2v-3c0-1.654-1.346-3-3-3zm-21 3v3h2v-3c0-.551.449-1 1-1h3v-2h-3c-1.654 0-3 1.346-3 3z"/><path d="m16 6h-1v12h1z"/></svg>
|
||||||
|
After Width: | Height: | Size: 473 B |
@@ -1,6 +1,7 @@
|
|||||||
import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "./auth";
|
import { useAuth } from "./auth";
|
||||||
import Icon from "./components/Icon";
|
import Icon from "./components/Icon";
|
||||||
|
import BrandMark from "./components/BrandMark";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import Products from "./pages/Products";
|
import Products from "./pages/Products";
|
||||||
@@ -39,8 +40,7 @@ function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="sidebar-brand">
|
<div className="sidebar-brand">
|
||||||
<span className="mark"><Icon name="box" size={18} /></span>
|
<BrandMark />
|
||||||
Project-Good
|
|
||||||
</div>
|
</div>
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
<NavItem to="/" end icon="dashboard" label="Übersicht" />
|
<NavItem to="/" end icon="dashboard" label="Übersicht" />
|
||||||
|
|||||||
@@ -163,6 +163,14 @@ export const api = {
|
|||||||
createApiToken: (body) => request("/api-tokens", { method: "POST", body }),
|
createApiToken: (body) => request("/api-tokens", { method: "POST", body }),
|
||||||
deleteApiToken: (id) => request(`/api-tokens/${id}`, { method: "DELETE" }),
|
deleteApiToken: (id) => request(`/api-tokens/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Eigenes Logo / Favicon
|
||||||
|
uploadBranding: (kind, file) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
return request(`/branding/${kind}`, { method: "PUT", formData });
|
||||||
|
},
|
||||||
|
deleteBranding: (kind) => request(`/branding/${kind}`, { method: "DELETE" }),
|
||||||
|
|
||||||
// Einstellungen
|
// Einstellungen
|
||||||
listSettings: () => request("/settings"),
|
listSettings: () => request("/settings"),
|
||||||
setSetting: (key, value) =>
|
setSetting: (key, value) =>
|
||||||
|
|||||||
31
web/src/branding.js
Normal file
31
web/src/branding.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Eigenes Logo und Favicon der Installation.
|
||||||
|
// Beides ist optional: Ist nichts hinterlegt, antwortet das Backend mit 404
|
||||||
|
// und die Oberfläche bleibt bei ihrem eingebauten Zeichen.
|
||||||
|
|
||||||
|
export const BRANDING_KINDS = ["logo", "favicon"];
|
||||||
|
|
||||||
|
export function brandingUrl(kind, version) {
|
||||||
|
return `/api/branding/${kind}${version ? `?v=${version}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft, ob ein eigenes Bild hinterlegt ist (ohne es zu laden). */
|
||||||
|
export async function hasBranding(kind) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(brandingUrl(kind), { method: "HEAD" });
|
||||||
|
return resp.ok;
|
||||||
|
} catch {
|
||||||
|
return false; // Server nicht erreichbar: eingebautes Zeichen genügt.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt das Favicon auf das hinterlegte Bild.
|
||||||
|
* Läuft bewusst schon vor dem Login – der Browser holt das Favicon sofort.
|
||||||
|
*/
|
||||||
|
export async function applyFavicon() {
|
||||||
|
const link = document.querySelector("link[rel='icon']");
|
||||||
|
if (!link) return;
|
||||||
|
if (await hasBranding("favicon")) {
|
||||||
|
link.href = brandingUrl("favicon", Date.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
40
web/src/components/BrandMark.jsx
Normal file
40
web/src/components/BrandMark.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { brandingUrl, hasBranding } from "../branding";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopfzeichen der Oberfläche: entweder das hinterlegte Logo (dann ohne
|
||||||
|
* Schriftzug) oder das eingebaute Zeichen mit dem Namen.
|
||||||
|
*
|
||||||
|
* Die Größe des Bildes ist per CSS gedeckelt, damit ein zu großes oder sehr
|
||||||
|
* breites Logo das Layout nicht auseinanderziehen kann.
|
||||||
|
*/
|
||||||
|
export default function BrandMark() {
|
||||||
|
const [logo, setLogo] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let aktiv = true;
|
||||||
|
hasBranding("logo").then((da) => aktiv && setLogo(da));
|
||||||
|
return () => {
|
||||||
|
aktiv = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (logo) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className="brand-logo"
|
||||||
|
src={brandingUrl("logo")}
|
||||||
|
alt="Project-Good"
|
||||||
|
onError={() => setLogo(false)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="mark"><Icon name="box" size={18} /></span>
|
||||||
|
Project-Good
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
web/src/components/BrandingUpload.jsx
Normal file
78
web/src/components/BrandingUpload.jsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { brandingUrl, hasBranding } from "../branding";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Bild der Installation setzen oder wieder entfernen (Logo bzw. Favicon).
|
||||||
|
* Ohne eigenes Bild bleibt das eingebaute Zeichen der Oberfläche stehen.
|
||||||
|
*/
|
||||||
|
export default function BrandingUpload({ kind, title, hint, onChanged }) {
|
||||||
|
const [vorhanden, setVorhanden] = useState(false);
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [fehler, setFehler] = useState(null);
|
||||||
|
const dateiFeld = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hasBranding(kind).then(setVorhanden);
|
||||||
|
}, [kind]);
|
||||||
|
|
||||||
|
async function auswaehlen(event) {
|
||||||
|
const datei = event.target.files?.[0];
|
||||||
|
if (!datei) return;
|
||||||
|
setFehler(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.uploadBranding(kind, datei);
|
||||||
|
setVorhanden(true);
|
||||||
|
setVersion(Date.now());
|
||||||
|
onChanged?.();
|
||||||
|
} catch (err) {
|
||||||
|
setFehler(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
// Zuruecksetzen, damit dieselbe Datei erneut gewaehlt werden kann.
|
||||||
|
if (dateiFeld.current) dateiFeld.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entfernen() {
|
||||||
|
setFehler(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.deleteBranding(kind);
|
||||||
|
setVorhanden(false);
|
||||||
|
setVersion(Date.now());
|
||||||
|
onChanged?.();
|
||||||
|
} catch (err) {
|
||||||
|
setFehler(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: "var(--sp-4)" }}>
|
||||||
|
<label style={{ marginBottom: "var(--sp-2)" }}>{title}</label>
|
||||||
|
<div className="brand-preview">
|
||||||
|
{vorhanden ? (
|
||||||
|
<img src={brandingUrl(kind, version)} alt={title} />
|
||||||
|
) : (
|
||||||
|
<span className="muted small">Kein eigenes Bild – Standard wird verwendet</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="muted small mt-0">{hint}</p>
|
||||||
|
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
|
||||||
|
<div className="field-inline">
|
||||||
|
<input ref={dateiFeld} type="file" onChange={auswaehlen} disabled={busy}
|
||||||
|
accept="image/png,image/jpeg,image/svg+xml,image/webp,image/gif,image/x-icon" />
|
||||||
|
{vorhanden && (
|
||||||
|
<button type="button" className="btn ghost" onClick={entfernen} disabled={busy}>
|
||||||
|
<Icon name="trash" size={14} />Zurücksetzen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import App from "./App";
|
|||||||
import { AuthProvider } from "./auth";
|
import { AuthProvider } from "./auth";
|
||||||
import { SettingsProvider } from "./settings";
|
import { SettingsProvider } from "./settings";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
import { applyFavicon } from "./branding";
|
||||||
|
|
||||||
|
// Eigenes Favicon, falls hinterlegt - bewusst vor dem Login.
|
||||||
|
applyFavicon();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import BrandMark from "../components/BrandMark";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
@@ -29,8 +30,7 @@ export default function Login() {
|
|||||||
<div className="login-wrap">
|
<div className="login-wrap">
|
||||||
<form className="card login-card" onSubmit={onSubmit}>
|
<form className="card login-card" onSubmit={onSubmit}>
|
||||||
<div className="sidebar-brand">
|
<div className="sidebar-brand">
|
||||||
<span className="mark"><Icon name="box" size={18} /></span>
|
<BrandMark />
|
||||||
Project-Good
|
|
||||||
</div>
|
</div>
|
||||||
<p className="lead">Lebensmittel-Lagerverwaltung</p>
|
<p className="lead">Lebensmittel-Lagerverwaltung</p>
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import BrandingUpload from "../components/BrandingUpload";
|
||||||
import { DATE_FORMAT_KEY, useSettings } from "../settings";
|
import { DATE_FORMAT_KEY, useSettings } from "../settings";
|
||||||
import { DATE_FORMATS, formatDate } from "../units";
|
import { DATE_FORMATS, formatDate } from "../units";
|
||||||
|
import { applyFavicon } from "../branding";
|
||||||
|
|
||||||
const EXPIRY_KEY = "expiry_warning_days";
|
const EXPIRY_KEY = "expiry_warning_days";
|
||||||
|
|
||||||
@@ -78,6 +80,21 @@ export default function Settings() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<BrandingUpload
|
||||||
|
kind="logo"
|
||||||
|
title="Eigenes Logo"
|
||||||
|
hint="Ersetzt Zeichen und Schriftzug oben in der Seitenleiste. Die Höhe wird
|
||||||
|
begrenzt, damit das Layout erhalten bleibt. Ohne Angabe bleibt das
|
||||||
|
eingebaute Zeichen stehen."
|
||||||
|
/>
|
||||||
|
<BrandingUpload
|
||||||
|
kind="favicon"
|
||||||
|
title="Eigenes Favicon"
|
||||||
|
hint="Das Symbol im Browser-Tab. Ohne Angabe wird das Standardzeichen verwendet.
|
||||||
|
Änderungen werden nach einem Neuladen der Seite sichtbar."
|
||||||
|
onChanged={applyFavicon}
|
||||||
|
/>
|
||||||
<p className="muted small mt-0">Beispiel: {preview}</p>
|
<p className="muted small mt-0">Beispiel: {preview}</p>
|
||||||
|
|
||||||
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
|
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
|
||||||
|
|||||||
@@ -73,6 +73,30 @@ h2 { font-size: 0.95rem; font-weight: 650; margin: 0 0 var(--sp-3); letter-spaci
|
|||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
/* Eigenes Logo. Die Deckelung ist Absicht: Ein sehr grosses oder sehr breites
|
||||||
|
Bild darf die Seitenleiste nicht auseinanderziehen. Das Seitenverhaeltnis
|
||||||
|
bleibt durch object-fit erhalten, ueberstehendes wird nicht abgeschnitten. */
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 36px;
|
||||||
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
/* Vorschau in den Einstellungen: Karomuster zeigt Transparenz. */
|
||||||
|
.brand-preview {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
min-height: 64px; padding: var(--sp-3);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||||
|
background-color: var(--bg);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, var(--border) 25%, transparent 25%, transparent 75%, var(--border) 75%),
|
||||||
|
linear-gradient(45deg, var(--border) 25%, transparent 25%, transparent 75%, var(--border) 75%);
|
||||||
|
background-size: 14px 14px;
|
||||||
|
background-position: 0 0, 7px 7px;
|
||||||
|
}
|
||||||
|
.brand-preview img { max-width: 100%; max-height: 56px; width: auto; object-fit: contain; }
|
||||||
|
|
||||||
.sidebar-brand .mark {
|
.sidebar-brand .mark {
|
||||||
display: grid; place-items: center;
|
display: grid; place-items: center;
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||||
|
|||||||
Reference in New Issue
Block a user