feat(profil): eigenes Profilbild hochladen
Jeder setzt sein Bild unter "Mein Konto", Vorgesetzte und Administratoren auch fuer die Benutzer, die sie verwalten. Wo bisher die Initialen standen, steht jetzt das Bild - Seitenleiste, Kopfzeile, Team, Dashboard, Reports, Projekte, Kunden und die Buchungsliste. Ohne Bild bleiben die Initialen. Der Browser verkleinert das Bild vor dem Hochladen auf ein Quadrat von 256 px und schneidet mittig zu (lib/imageCompress.ts): aus einem 4-MB-Foto werden wenige Kilobyte, und der Server muss kein Bild verarbeiten. Gespeichert wird es als Data-URL in pulse_users.avatar (LONGTEXT). Kein Upload-Verzeichnis, das gesichert und beim Deployment am Leben gehalten werden muesste. Geprueft wird serverseitig in lib/avatar.js: nur PNG, JPEG, GIF, WebP und AVIF als Data-URL, hoechstens 2 MB. SVG ist bewusst nicht erlaubt, es kann Skripte tragen. Externe http(s)-Adressen ebenso wenig - sonst laedt jeder Seitenaufruf ein Bild von einem fremden Server und verraet, wer wann mit Pulse arbeitet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2d3d843988
commit
9576d18abc
@@ -74,6 +74,23 @@ Ein Klick auf eine Zeile öffnet dieselbe Detailansicht wie eine Kachel, hier un
|
||||
mindestens Vorgesetzter ist.
|
||||
|
||||
|
||||
## Profilbilder
|
||||
|
||||
Jeder kann sein eigenes Bild hochladen (*Mein Konto*), Vorgesetzte und Administratoren
|
||||
auch für die Benutzer, die sie verwalten (*Team → Bearbeiten*). Der Browser verkleinert
|
||||
das Bild vor dem Hochladen auf ein Quadrat von 256 px und schneidet mittig zu
|
||||
(`src/lib/imageCompress.ts`); aus einem 4-MB-Handyfoto werden so wenige Kilobyte.
|
||||
|
||||
Gespeichert wird es als Data-URL in `pulse_users.avatar` (LONGTEXT) – kein Upload-Verzeichnis,
|
||||
das gesichert und beim Deployment am Leben gehalten werden müsste. Der Server prüft in
|
||||
`server/src/lib/avatar.js`: nur PNG, JPEG, GIF, WebP und AVIF als Data-URL, höchstens 2 MB.
|
||||
**SVG ist bewusst nicht erlaubt** – es kann Skripte tragen. Externe http(s)-Adressen ebenso
|
||||
wenig: sonst lädt jeder Seitenaufruf ein Bild von einem fremden Server und verrät, wer wann
|
||||
mit Pulse arbeitet.
|
||||
|
||||
Angezeigt wird es überall dort, wo bisher die Initialen standen: Seitenleiste, Kopfzeile,
|
||||
Team, Dashboard, Reports, Projekt- und Kundenansicht. Ohne Bild bleiben die Initialen.
|
||||
|
||||
## SSO / Active Directory
|
||||
|
||||
**Einrichtung** (nur Administrator, *Einstellungen → SSO / Entra ID*): Microsoft Entra ID per OpenID Connect (Authorization Code, Client Secret AES-verschlüsselt in der DB). App-Registrierung mit Redirect-URI `https://pulse.perinco.de/api/auth/sso/callback`, delegiert `openid profile email`, Anwendung `User.Read.All` mit Administratorzustimmung.
|
||||
@@ -155,6 +172,7 @@ Aus Auftrag und Call-Mitschrift.
|
||||
| Buchung: zusätzliche Listenansicht (Kunde, Projekt, Task, Billable/Non-billable Zeit) für Woche/Monat/Jahr | ✅ | Buchung → Kalender/Liste |
|
||||
| Klick auf einen Mitarbeiter im Zeitreport zeigt dessen Buchungen des Zeitraums als Liste | ✅ | Reports → Zeitreport → Mitarbeiter |
|
||||
| Kunden: zusätzliche Listenansicht | ✅ | Kunden → Karten/Liste |
|
||||
| Eigenes Profilbild hochladen | ✅ | Mein Konto, Team → Bearbeiten |
|
||||
| Projekte: zusätzliche Listenansicht | ✅ | Projekte → Karten/Liste |
|
||||
| Grünes Plus → Projekt, Task, optionaler Freitext, Zeit rechts daneben, `0:00`, `1:23` und `1,5` | ✅ | Buchungsdialog |
|
||||
| Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog |
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"test": "node --test test/sso-filter.test.mjs"
|
||||
"test": "node --test \"test/*.test.mjs\""
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -40,6 +40,7 @@ export async function initDatabase() {
|
||||
}
|
||||
|
||||
await migrateAbrufType();
|
||||
await migrateAvatarColumn();
|
||||
await ensureAdminUser();
|
||||
await ensureInternalCustomer();
|
||||
}
|
||||
@@ -69,6 +70,21 @@ async function migrateAbrufType() {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Profilbild am Benutzer. Steht als Data-URL in der Spalte - siehe
|
||||
* lib/avatar.js, warum kein Dateisystem.
|
||||
*/
|
||||
async function migrateAvatarColumn() {
|
||||
const [columns] = await pool.query(
|
||||
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pulse_users' AND COLUMN_NAME = 'avatar'`
|
||||
);
|
||||
if (!columns.length) {
|
||||
await pool.query('ALTER TABLE pulse_users ADD COLUMN avatar LONGTEXT NULL AFTER color');
|
||||
console.log('[Pulse] Spalte pulse_users.avatar ergaenzt.');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAdminUser() {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users');
|
||||
if (rows[0].n > 0) return;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Profilbild eines Benutzers.
|
||||
*
|
||||
* Das Bild liegt als Data-URL in der Datenbank (pulse_users.avatar, LONGTEXT).
|
||||
* Kein Dateisystem: der Dienst laeuft als eigener Benutzer in /var/www/pulse,
|
||||
* ein Upload-Verzeichnis muesste gesichert, gesichert-gesichert und beim Deploy
|
||||
* am Leben gehalten werden. Die Bilder sind nach dem Verkleinern im Browser
|
||||
* wenige Kilobyte gross, das traegt eine Spalte problemlos.
|
||||
*
|
||||
* Erlaubt sind nur Bild-Data-URLs. Eine externe http(s)-Adresse waere bequem,
|
||||
* wuerde aber bedeuten, dass jeder Seitenaufruf ein Bild von einem fremden
|
||||
* Server nachlaedt - das gibt die Benutzung von Pulse nach draussen preis.
|
||||
*/
|
||||
|
||||
/** Data-URL eines Bildes. SVG ist bewusst nicht dabei: es kann Skripte tragen. */
|
||||
const AVATAR_RE = /^data:image\/(png|jpe?g|gif|webp|avif);base64,[A-Za-z0-9+/=\s]+$/i;
|
||||
|
||||
/**
|
||||
* Obergrenze der Data-URL. Das JSON-Limit der Anwendung ist 4 MB; hier bleibt
|
||||
* bewusst Luft fuer den Rest des Requests.
|
||||
*/
|
||||
export const AVATAR_MAX_LENGTH = 2_000_000;
|
||||
|
||||
/** Prueft ein Profilbild; null bzw. '' entfernt es. */
|
||||
export function normalizeAvatar(value) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') return { value: null };
|
||||
const avatar = String(value).trim();
|
||||
if (avatar.length > AVATAR_MAX_LENGTH) {
|
||||
return { error: 'Das Bild ist zu groß. Bitte ein kleineres verwenden.' };
|
||||
}
|
||||
if (!AVATAR_RE.test(avatar)) {
|
||||
return { error: 'Ungültiges Bild (erwartet PNG, JPEG, GIF, WebP oder AVIF).' };
|
||||
}
|
||||
return { value: avatar };
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
|
||||
const [rows] = await pool.query(
|
||||
`SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable,
|
||||
e.created_at, e.updated_at,
|
||||
u.name AS user_name, u.color AS user_color, u.is_external, u.company AS user_company, u.weekly_hours,
|
||||
u.name AS user_name, u.color AS user_color, u.avatar AS user_avatar, u.is_external, u.company AS user_company, u.weekly_hours,
|
||||
u.manager_id,
|
||||
p.name AS project_name, p.code AS project_code, p.type AS project_type, p.billing_unit,
|
||||
p.rate AS project_rate,
|
||||
|
||||
@@ -186,7 +186,7 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
|
||||
pool.query(`SELECT * FROM pulse_budget_pots WHERE project_id IN (${inList}) ORDER BY valid_from, id`, ids),
|
||||
pool.query(`SELECT * FROM pulse_tasks WHERE project_id IN (${inList}) ORDER BY name`, ids),
|
||||
pool.query(
|
||||
`SELECT m.project_id, u.id, u.name, u.color, u.is_external
|
||||
`SELECT m.project_id, u.id, u.name, u.color, u.avatar, u.is_external
|
||||
FROM pulse_project_members m JOIN pulse_users u ON u.id = m.user_id
|
||||
WHERE m.project_id IN (${inList}) AND u.role <> 'admin' ORDER BY u.name`,
|
||||
ids
|
||||
@@ -242,7 +242,7 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
|
||||
.map((t) => ({ ...t, billable: Boolean(t.billable), is_active: Boolean(t.is_active), used_minutes: taskMinutes.get(t.id) || 0 })),
|
||||
members: members
|
||||
.filter((m) => m.project_id === p.id)
|
||||
.map((m) => ({ id: m.id, name: m.name, color: m.color, is_external: Boolean(m.is_external) })),
|
||||
.map((m) => ({ id: m.id, name: m.name, color: m.color, avatar: m.avatar ?? null, is_external: Boolean(m.is_external) })),
|
||||
used_minutes: usedMinutes,
|
||||
billable_minutes: Number(usage?.billable_minutes || 0),
|
||||
// Was gegen das Budget zaehlt (bei Billable nur abrechenbare Stunden).
|
||||
|
||||
@@ -25,7 +25,7 @@ export function verifyShort(token) {
|
||||
}
|
||||
|
||||
export const USER_COLUMNS = `id, username, email, name, role, manager_id, job_title, is_external, company,
|
||||
source, external_id, weekly_hours, color, theme, theme_mode, is_active, last_login_at, created_at, updated_at`;
|
||||
source, external_id, weekly_hours, color, avatar, theme, theme_mode, is_active, last_login_at, created_at, updated_at`;
|
||||
|
||||
/**
|
||||
* Prueft das Bearer-Token und laedt den Benutzer bei jeder Anfrage frisch aus
|
||||
@@ -84,6 +84,7 @@ export function publicUser(row) {
|
||||
source: row.source,
|
||||
weekly_hours: row.weekly_hours == null ? null : Number(row.weekly_hours),
|
||||
color: row.color ?? null,
|
||||
avatar: row.avatar ?? null,
|
||||
theme: row.theme ?? null,
|
||||
theme_mode: row.theme_mode ?? null,
|
||||
is_active: Boolean(row.is_active),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { pool } from '../db.js';
|
||||
import { publicUser, requireAuth, signShort, signToken, verifyShort } from '../middleware/auth.js';
|
||||
import { getSettings } from '../lib/settings.js';
|
||||
import { authorizeUrl, exchangeCode, ssoConfigured } from '../lib/sso.js';
|
||||
import { normalizeAvatar } from '../lib/avatar.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -70,7 +71,7 @@ router.post('/login', async (req, res, next) => {
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => res.json({ user: publicUser(req.user) }));
|
||||
|
||||
// Eigene Darstellung: Design, Modus, Farbe.
|
||||
// Eigene Darstellung: Design, Modus, Farbe, Profilbild.
|
||||
router.patch('/me', requireAuth, async (req, res, next) => {
|
||||
const updates = {};
|
||||
if (req.body?.theme !== undefined) {
|
||||
@@ -90,6 +91,11 @@ router.patch('/me', requireAuth, async (req, res, next) => {
|
||||
if (color !== null && !/^#[0-9a-f]{6}$/i.test(color)) return res.status(400).json({ error: 'Ungültige Farbe.' });
|
||||
updates.color = color;
|
||||
}
|
||||
if (req.body?.avatar !== undefined) {
|
||||
const avatar = normalizeAvatar(req.body.avatar);
|
||||
if (avatar.error) return res.status(400).json({ error: avatar.error });
|
||||
updates.avatar = avatar.value;
|
||||
}
|
||||
const keys = Object.keys(updates);
|
||||
if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
|
||||
try {
|
||||
|
||||
@@ -61,7 +61,7 @@ router.get('/', async (req, res, next) => {
|
||||
|
||||
const filter = userFilter(userIds, 'id');
|
||||
const [users] = await pool.query(
|
||||
`SELECT id, name, color, role, weekly_hours, is_external, company, job_title FROM pulse_users
|
||||
`SELECT id, name, color, avatar, role, weekly_hours, is_external, company, job_title FROM pulse_users
|
||||
WHERE is_active = 1 AND role <> 'admin' AND ${filter.sql} ORDER BY name`,
|
||||
filter.params
|
||||
);
|
||||
@@ -86,6 +86,7 @@ router.get('/', async (req, res, next) => {
|
||||
user_id: u.id,
|
||||
name: u.name,
|
||||
color: u.color,
|
||||
avatar: u.avatar ?? null,
|
||||
job_title: u.job_title,
|
||||
is_external: Boolean(u.is_external),
|
||||
...totals,
|
||||
@@ -127,6 +128,7 @@ router.get('/', async (req, res, next) => {
|
||||
user_id: e.user_id,
|
||||
user_name: e.user_name,
|
||||
user_color: e.user_color,
|
||||
user_avatar: e.user_avatar ?? null,
|
||||
project_display: e.project_display,
|
||||
customer_color: e.customer_color,
|
||||
task_name: e.task_name,
|
||||
|
||||
@@ -14,6 +14,7 @@ function publicEntry(entry, showAmounts) {
|
||||
id: entry.id,
|
||||
user_id: entry.user_id,
|
||||
user_name: entry.user_name,
|
||||
user_avatar: entry.user_avatar ?? null,
|
||||
project_id: entry.project_id,
|
||||
project_name: entry.project_name,
|
||||
project_code: entry.project_code,
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function buildTimeReport(viewer, query) {
|
||||
|
||||
const filter = userFilter(userIds, 'id');
|
||||
const [users] = await pool.query(
|
||||
`SELECT id, name, color, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`,
|
||||
`SELECT id, name, color, avatar, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`,
|
||||
filter.params
|
||||
);
|
||||
const capTo = to > today() ? today() : to;
|
||||
@@ -60,12 +60,12 @@ export async function buildTimeReport(viewer, query) {
|
||||
const amount = entries.reduce((s, e) => s + e.amount, 0);
|
||||
const fixedAmount = entries.reduce((s, e) => s + e.fixed_amount, 0);
|
||||
|
||||
const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, is_external: e.is_external }), includeFixed);
|
||||
const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, avatar: e.user_avatar ?? null, is_external: e.is_external }), includeFixed);
|
||||
// Mitarbeiter ohne Buchung trotzdem zeigen (nur aktive, nicht bei Kunden-/Projektfilter).
|
||||
if (!query.customer_id && !query.project_id) {
|
||||
for (const u of users) {
|
||||
if (u.is_active && !userRows.some((row) => row.user_id === u.id)) {
|
||||
userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 });
|
||||
userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, avatar: u.avatar ?? null, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
|
||||
import { pool } from '../db.js';
|
||||
import { ROLES, USER_COLUMNS, publicUser, requireRole } from '../middleware/auth.js';
|
||||
import { visibleUserIds } from '../lib/access.js';
|
||||
import { normalizeAvatar } from '../lib/avatar.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -81,6 +82,11 @@ function normalize(body, { creating }) {
|
||||
if (body.is_external !== undefined) out.is_external = body.is_external ? 1 : 0;
|
||||
if (body.company !== undefined) out.company = str(body.company, 160);
|
||||
if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null;
|
||||
if (body.avatar !== undefined) {
|
||||
const avatar = normalizeAvatar(body.avatar);
|
||||
if (avatar.error) errors.push(avatar.error);
|
||||
else out.avatar = avatar.value;
|
||||
}
|
||||
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
|
||||
if (body.weekly_hours !== undefined) {
|
||||
out.weekly_hours = body.weekly_hours === '' || body.weekly_hours == null ? null : Number(body.weekly_hours);
|
||||
@@ -93,7 +99,7 @@ function normalize(body, { creating }) {
|
||||
router.get('/directory', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, color, role, manager_id, is_external, company, job_title, is_active FROM pulse_users ORDER BY name'
|
||||
'SELECT id, name, color, avatar, role, manager_id, is_external, company, job_title, is_active FROM pulse_users ORDER BY name'
|
||||
);
|
||||
res.json(rows.map((r) => ({ ...r, is_external: Boolean(r.is_external), is_active: Boolean(r.is_active) })));
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS pulse_users (
|
||||
external_id VARCHAR(64) NULL,
|
||||
weekly_hours DECIMAL(5,2) NULL,
|
||||
color VARCHAR(7) NULL,
|
||||
avatar LONGTEXT NULL,
|
||||
theme VARCHAR(20) NULL,
|
||||
theme_mode VARCHAR(10) NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeAvatar, AVATAR_MAX_LENGTH } from '../src/lib/avatar.js';
|
||||
|
||||
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==';
|
||||
|
||||
test('nimmt Bild-Data-URLs an', () => {
|
||||
for (const typ of ['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif']) {
|
||||
const url = `data:image/${typ};base64,AAAA`;
|
||||
assert.equal(normalizeAvatar(url).value, url, typ);
|
||||
}
|
||||
});
|
||||
|
||||
test('leer entfernt das Bild', () => {
|
||||
for (const wert of [null, undefined, '', ' ']) {
|
||||
assert.deepEqual(normalizeAvatar(wert), { value: null });
|
||||
}
|
||||
});
|
||||
|
||||
test('weist alles ab, was kein Bild ist', () => {
|
||||
// SVG kann Skripte tragen, eine externe Adresse verraet die Benutzung nach draussen.
|
||||
for (const wert of [
|
||||
'data:image/svg+xml;base64,PHN2Zy8+',
|
||||
'data:text/html;base64,PGgxPmhpPC9oMT4',
|
||||
'https://example.com/foto.png',
|
||||
'javascript:alert(1)',
|
||||
'hallo',
|
||||
]) {
|
||||
assert.ok(normalizeAvatar(wert).error, wert);
|
||||
}
|
||||
});
|
||||
|
||||
test('begrenzt die Groesse', () => {
|
||||
const zuGross = `data:image/png;base64,${'A'.repeat(AVATAR_MAX_LENGTH)}`;
|
||||
assert.match(normalizeAvatar(zuGross).error, /zu groß/);
|
||||
assert.equal(normalizeAvatar(PNG).error, undefined);
|
||||
});
|
||||
@@ -192,7 +192,7 @@ export const Booking: React.FC = () => {
|
||||
|
||||
{readOnly && viewedUser && (
|
||||
<div className="animate-fade mb-4 flex items-center gap-3 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm font-semibold text-sky-700">
|
||||
<Avatar name={viewedUser.name} color={viewedUser.color} id={viewedUser.id} size={28} />
|
||||
<Avatar name={viewedUser.name} color={viewedUser.color} avatar={viewedUser.avatar} id={viewedUser.id} size={28} />
|
||||
Du siehst die Zeiten von {viewedUser.name} (nur lesen).
|
||||
<button className="ml-auto text-xs font-bold underline" onClick={() => setViewUserId(user!.id)}>
|
||||
Zurück zu meinen Zeiten
|
||||
|
||||
@@ -111,7 +111,7 @@ export const EntryList: React.FC<{
|
||||
{showUser && (
|
||||
<td className="px-3 py-2.5 align-top">
|
||||
<span className="flex items-center gap-2">
|
||||
<Avatar id={e.user_id} name={e.user_name} size={22} />
|
||||
<Avatar id={e.user_id} name={e.user_name} avatar={e.user_avatar} size={22} />
|
||||
<span className="truncate font-semibold text-ink-700">{e.user_name}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -296,7 +296,7 @@ const CustomerCard: React.FC<{
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{p.members.map((m) => (
|
||||
<span key={m.id} className="flex items-center gap-1.5 rounded-full bg-ink-50 py-0.5 pl-0.5 pr-2 text-[11px] font-semibold text-ink-600">
|
||||
<Avatar id={m.id} name={m.name} color={m.color} size={18} external={m.is_external} />
|
||||
<Avatar id={m.id} name={m.name} color={m.color} avatar={m.avatar} size={18} external={m.is_external} />
|
||||
{m.name}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -203,7 +203,7 @@ const Ranking: React.FC<{ data: DashboardData; rank: RankPeriod; onRank: (r: Ran
|
||||
style={{ height: ROW, transform: `translateY(${pos * ROW}px)` }}
|
||||
>
|
||||
<span className="tabular w-5 shrink-0 text-center text-xs font-extrabold text-ink-300">{pos + 1}</span>
|
||||
<Avatar id={r.user_id} name={r.name} color={r.color} size={34} external={r.is_external} />
|
||||
<Avatar id={r.user_id} name={r.name} color={r.color} avatar={r.avatar} size={34} external={r.is_external} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<button onClick={() => navigate('reports')} className="truncate text-left text-sm font-bold text-ink-800 hover:text-brand-700" title="Zu den Reports">
|
||||
@@ -240,7 +240,7 @@ const RecentFeed: React.FC<{ data: DashboardData }> = ({ data }) => (
|
||||
<ol className="relative space-y-1 before:absolute before:bottom-3 before:left-[17px] before:top-3 before:w-px before:bg-ink-100">
|
||||
{data.recent.map((e, i) => (
|
||||
<li key={e.id} className="animate-rise relative flex gap-3 rounded-xl p-1.5 hover:bg-ink-50" style={{ '--i': i } as React.CSSProperties}>
|
||||
<Avatar id={e.user_id} name={e.user_name} color={e.user_color} size={34} />
|
||||
<Avatar id={e.user_id} name={e.user_name} color={e.user_color} avatar={e.user_avatar} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="truncate text-sm font-bold text-ink-800">{e.user_name}</p>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const MobileNav: React.FC<{ current: ViewKey; onNavigate: (view: ViewKey)
|
||||
>
|
||||
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-ink-200" />
|
||||
<button onClick={() => { setMoreOpen(false); onAccount(); }} className="mb-3 flex w-full items-center gap-3 rounded-2xl bg-ink-50 p-3 text-left">
|
||||
<Avatar name={user.name} color={user.color} id={user.id} size={40} />
|
||||
<Avatar name={user.name} color={user.color} avatar={user.avatar} id={user.id} size={40} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold text-ink-900">{user.name}</span>
|
||||
<span className="block text-xs text-ink-400">{ROLE_LABELS[user.role]} · Mein Konto</span>
|
||||
|
||||
@@ -89,7 +89,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ current, onNavigate, collapsed
|
||||
<div className={`shrink-0 border-t border-ink-100 ${collapsed ? 'flex flex-col items-center gap-2 p-3' : 'p-4'}`}>
|
||||
<div className="flex w-full min-w-0 items-center justify-between gap-2">
|
||||
<button onClick={onAccount} className="flex min-w-0 items-center gap-3 rounded-xl text-left transition hover:opacity-80" title="Mein Konto">
|
||||
<Avatar name={user.name} color={user.color} id={user.id} size={36} external={user.is_external} />
|
||||
<Avatar name={user.name} color={user.color} avatar={user.avatar} id={user.id} size={36} external={user.is_external} />
|
||||
{!collapsed && (
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-bold text-ink-900">{user.name}</span>
|
||||
|
||||
@@ -311,7 +311,7 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{p.members.map((m) => (
|
||||
<span key={m.id} className="flex items-center gap-2 rounded-full border border-ink-200 py-1 pl-1 pr-3 text-xs font-bold text-ink-700">
|
||||
<Avatar id={m.id} name={m.name} color={m.color} size={22} external={m.is_external} />
|
||||
<Avatar id={m.id} name={m.name} color={m.color} avatar={m.avatar} size={22} external={m.is_external} />
|
||||
{m.name}
|
||||
</span>
|
||||
))}
|
||||
@@ -446,7 +446,7 @@ const MembersDialog: React.FC<{ project: Project; users: DirectoryUser[]; onClos
|
||||
return (
|
||||
<label key={u.id} className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-transparent hover:bg-ink-50'}`}>
|
||||
<input type="checkbox" checked={checked} onChange={() => setIds(checked ? ids.filter((id) => id !== u.id) : [...ids, u.id])} />
|
||||
<Avatar id={u.id} name={u.name} color={u.color} size={30} external={u.is_external} />
|
||||
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={30} external={u.is_external} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold text-ink-800">{u.name}</span>
|
||||
<span className="block truncate text-xs text-ink-400">{u.is_external ? `Extern · ${u.company ?? ''}` : u.job_title ?? ''}</span>
|
||||
|
||||
@@ -372,7 +372,7 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
|
||||
return (
|
||||
<label key={u.id} className={`flex cursor-pointer items-center gap-2 rounded-xl border px-2.5 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-ink-200 hover:bg-ink-50'}`}>
|
||||
<input type="checkbox" className="accent-[var(--color-brand-500)]" checked={checked} onChange={() => setMembers(checked ? members.filter((id) => id !== u.id) : [...members, u.id])} />
|
||||
<Avatar id={u.id} name={u.name} color={u.color} size={24} external={u.is_external} />
|
||||
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={24} external={u.is_external} />
|
||||
<span className="min-w-0 truncate text-xs font-bold text-ink-700">{u.name}</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -77,7 +77,7 @@ export const ReportTable: React.FC<{
|
||||
<td className="py-3 pl-4 pr-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{kind === 'users' || kind === 'team' ? (
|
||||
<Avatar id={r.user_id} name={r.name} color={r.color} size={28} external={r.is_external} />
|
||||
<Avatar id={r.user_id} name={r.name} color={r.color} avatar={r.avatar} size={28} external={r.is_external} />
|
||||
) : kind !== 'tasks' ? (
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: colorFor(r.customer_id ?? r.project_id ?? r.name, r.color) }} />
|
||||
) : null}
|
||||
|
||||
@@ -300,7 +300,7 @@ const UserEntriesDialog: React.FC<{
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-3 pb-4">
|
||||
<Avatar id={row.user_id} name={row.name} color={row.color} size={40} external={row.is_external} />
|
||||
<Avatar id={row.user_id} name={row.name} color={row.color} avatar={row.avatar} size={40} external={row.is_external} />
|
||||
<p className="text-sm font-semibold text-ink-500">
|
||||
{entries.data ? `${entries.data.length} Buchungen` : 'Lade Buchungen…'}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { Avatar, Button, Field, inputClass } from '../ui/controls';
|
||||
import { Button, Field, inputClass } from '../ui/controls';
|
||||
import { AvatarPicker } from '../ui/AvatarPicker';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { authApi } from '../../lib/api';
|
||||
import { useToast } from '../Toast';
|
||||
@@ -8,7 +9,7 @@ import { ROLE_LABELS } from '../../types';
|
||||
|
||||
const COLORS = ['#6ba04a', '#0ea5e9', '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#eab308', '#6366f1', '#10b981', '#ec4899'];
|
||||
|
||||
/** Mein Konto: Farbe, Passwort, Abmelden - fuer jede Rolle. */
|
||||
/** Mein Konto: Profilbild, Farbe, Passwort, Abmelden - fuer jede Rolle. */
|
||||
export const AccountDialog: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const { user, updateUser, logout, settings } = useAuth();
|
||||
const { addToast } = useToast();
|
||||
@@ -27,6 +28,21 @@ export const AccountDialog: React.FC<{ onClose: () => void }> = ({ onClose }) =>
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Das Bild wird sofort gespeichert, nicht erst beim Schliessen: der Dialog
|
||||
* hat keinen Speichern-Knopf, und eine halb uebernommene Aenderung waere
|
||||
* schlimmer als eine, die sofort steht.
|
||||
*/
|
||||
const changeAvatar = async (avatar: string) => {
|
||||
try {
|
||||
const { user: saved } = await authApi.updateMe({ avatar: avatar || null });
|
||||
updateUser(saved);
|
||||
addToast(avatar ? 'Profilbild gespeichert.' : 'Profilbild entfernt.', 'success');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (next !== repeat) return addToast('Die neuen Passwörter stimmen nicht überein.', 'error');
|
||||
@@ -46,8 +62,8 @@ export const AccountDialog: React.FC<{ onClose: () => void }> = ({ onClose }) =>
|
||||
|
||||
return (
|
||||
<Modal title="Mein Konto" subtitle={`${ROLE_LABELS[user.role]} · ${user.username}`} onClose={onClose}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={user.name} color={user.color} id={user.id} size={56} external={user.is_external} />
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<AvatarPicker value={user.avatar ?? ''} onChange={changeAvatar} name={user.name} color={user.color} id={user.id} size={72} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-lg font-extrabold text-ink-900">{user.name}</p>
|
||||
<p className="truncate text-sm text-ink-500">{user.email || '–'}</p>
|
||||
|
||||
@@ -135,7 +135,7 @@ export const Team: React.FC<{ embedded?: boolean }> = ({ embedded }) => {
|
||||
<tr key={u.id} className={`animate-rise border-b border-ink-100 last:border-0 hover:bg-ink-50 ${u.is_active ? '' : 'opacity-50'}`} style={{ '--i': Math.min(i, 12) } as React.CSSProperties}>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar id={u.id} name={u.name} color={u.color} size={34} external={u.is_external} />
|
||||
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={34} external={u.is_external} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-bold text-ink-900">{u.name}</p>
|
||||
<p className="truncate text-xs text-ink-400">{u.email || u.username}</p>
|
||||
@@ -236,7 +236,7 @@ const OrgNode: React.FC<{
|
||||
u.id === meId ? 'border-brand-300' : 'border-ink-200'
|
||||
} ${u.is_active ? '' : 'opacity-50'}`}
|
||||
>
|
||||
<Avatar id={u.id} name={u.name} color={u.color} size={40} external={u.is_external} />
|
||||
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={40} external={u.is_external} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-extrabold text-ink-900">
|
||||
{u.name}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { Button, Field, Toggle, inputClass } from '../ui/controls';
|
||||
import { AvatarPicker } from '../ui/AvatarPicker';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { usersApi } from '../../lib/api';
|
||||
import { useToast } from '../Toast';
|
||||
@@ -51,6 +52,7 @@ export const UserForm: React.FC<{
|
||||
company: user?.company ?? '',
|
||||
weekly_hours: user?.weekly_hours == null ? '' : String(user.weekly_hours),
|
||||
color: user?.color ?? COLORS[Math.floor(Math.random() * COLORS.length)],
|
||||
avatar: user?.avatar ?? '',
|
||||
is_active: user?.is_active ?? true,
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -75,6 +77,7 @@ export const UserForm: React.FC<{
|
||||
company: form.is_external ? form.company : null,
|
||||
weekly_hours: form.weekly_hours === '' ? null : Number(form.weekly_hours.replace(',', '.')),
|
||||
color: form.color,
|
||||
avatar: form.avatar || null,
|
||||
is_active: form.is_active,
|
||||
};
|
||||
if (!sso) {
|
||||
@@ -190,6 +193,15 @@ export const UserForm: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AvatarPicker
|
||||
value={form.avatar}
|
||||
onChange={(avatar) => set({ avatar })}
|
||||
name={form.name || 'Neuer Benutzer'}
|
||||
color={form.color}
|
||||
id={user?.id}
|
||||
size={72}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLORS.map((c) => (
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Avatar } from './controls';
|
||||
import { Icon } from './Icon';
|
||||
import { useToast } from '../Toast';
|
||||
import { compressImage, formatBytes } from '../../lib/imageCompress';
|
||||
|
||||
/**
|
||||
* Profilbild auswählen, mit Vorschau. Das Bild wird im Browser auf ein Quadrat
|
||||
* von 256 px verkleinert, bevor es zum Server geht - so bleiben die Data-URLs
|
||||
* in der Datenbank klein und das Ergebnis entspricht genau dem Ausschnitt, den
|
||||
* die runde Anzeige später zeigt.
|
||||
*/
|
||||
export const AvatarPicker: React.FC<{
|
||||
/** Data-URL oder '' für kein Bild. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
id?: number;
|
||||
size?: number;
|
||||
disabled?: boolean;
|
||||
}> = ({ value, onChange, name, color, id, size = 96, disabled }) => {
|
||||
const { addToast } = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
const pick = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
// Zurücksetzen, damit dieselbe Datei nach einem Entfernen erneut geht.
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await compressImage(file);
|
||||
onChange(result.dataUrl);
|
||||
addToast(`Bild übernommen (${formatBytes(result.sourceBytes)} → ${formatBytes(result.resultBytes)}).`, 'success');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Das Bild konnte nicht verarbeitet werden.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="group relative" style={{ width: size, height: size }}>
|
||||
<Avatar name={name} color={color} avatar={value || null} id={id} size={size} />
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => input.current?.click()}
|
||||
disabled={busy}
|
||||
aria-label="Profilbild wählen"
|
||||
className={`absolute inset-0 flex items-center justify-center rounded-full bg-ink-950/50 text-[10px] font-extrabold uppercase tracking-wider text-white transition ${
|
||||
busy ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{busy ? <span className="h-5 w-5 animate-spin rounded-full border-2 border-white/40 border-t-white" /> : 'Ändern'}
|
||||
</button>
|
||||
)}
|
||||
<input ref={input} type="file" accept="image/*" className="hidden" onChange={pick} disabled={disabled || busy} />
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => input.current?.click()}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-700 transition hover:bg-ink-50 disabled:opacity-60"
|
||||
>
|
||||
<Icon name="user" className="h-3.5 w-3.5" />
|
||||
Bild wählen
|
||||
</button>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-60"
|
||||
>
|
||||
<Icon name="trash" className="h-3.5 w-3.5" />
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-snug text-ink-400">
|
||||
JPEG, PNG, GIF, WebP oder AVIF. Das Bild wird mittig auf ein Quadrat zugeschnitten und verkleinert.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -194,19 +194,30 @@ export const ErrorBox: React.FC<{ message: string; onRetry?: () => void }> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Avatar: React.FC<{ name: string; color?: string | null; size?: number; id?: number; external?: boolean; className?: string }> = ({
|
||||
name,
|
||||
color,
|
||||
size = 32,
|
||||
id,
|
||||
external,
|
||||
className = '',
|
||||
}) => (
|
||||
export const Avatar: React.FC<{
|
||||
name: string;
|
||||
color?: string | null;
|
||||
/** Profilbild als Data-URL; ohne Bild bleiben die Initialen stehen. */
|
||||
avatar?: string | null;
|
||||
size?: number;
|
||||
id?: number;
|
||||
external?: boolean;
|
||||
className?: string;
|
||||
}> = ({ name, color, avatar, size = 32, id, external, className = '' }) => (
|
||||
<span
|
||||
title={external ? `${name} (extern)` : name}
|
||||
className={`relative inline-flex shrink-0 items-center justify-center rounded-full font-extrabold text-white ring-2 ring-surface ${className}`}
|
||||
style={{ width: size, height: size, fontSize: Math.max(9, size * 0.36), backgroundColor: colorFor(id ?? name, color) }}
|
||||
>
|
||||
{avatar ? (
|
||||
/*
|
||||
* Rund wird das Bild selbst, nicht der Rahmen: ein overflow-hidden am
|
||||
* Rahmen wuerde die Kennzeichnung fuer externe Mitarbeiter abschneiden,
|
||||
* die bewusst ueber den Rand ragt. Die Initialen bleiben darunter
|
||||
* stehen - laedt das Bild nicht, sieht man sie.
|
||||
*/
|
||||
<img src={avatar} alt="" className="absolute inset-0 h-full w-full rounded-full object-cover" loading="lazy" />
|
||||
) : null}
|
||||
{initials(name)}
|
||||
{external && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-amber-400 text-[7px] text-white ring-2 ring-surface">
|
||||
@@ -216,14 +227,14 @@ export const Avatar: React.FC<{ name: string; color?: string | null; size?: numb
|
||||
</span>
|
||||
);
|
||||
|
||||
export const AvatarStack: React.FC<{ people: { id: number; name: string; color: string | null; is_external?: boolean }[]; max?: number; size?: number }> = ({
|
||||
people,
|
||||
max = 5,
|
||||
size = 26,
|
||||
}) => (
|
||||
export const AvatarStack: React.FC<{
|
||||
people: { id: number; name: string; color: string | null; avatar?: string | null; is_external?: boolean }[];
|
||||
max?: number;
|
||||
size?: number;
|
||||
}> = ({ people, max = 5, size = 26 }) => (
|
||||
<div className="flex items-center -space-x-1">
|
||||
{people.slice(0, max).map((p) => (
|
||||
<Avatar key={p.id} id={p.id} name={p.name} color={p.color} size={size} external={p.is_external} />
|
||||
<Avatar key={p.id} id={p.id} name={p.name} color={p.color} avatar={p.avatar} size={size} external={p.is_external} />
|
||||
))}
|
||||
{people.length > max && (
|
||||
<span
|
||||
|
||||
+4
-1
@@ -125,7 +125,7 @@ export const authApi = {
|
||||
login: (identifier: string, password: string) =>
|
||||
send<{ token: string; user: AppUser }>('POST', '/auth/login', { identifier, password }),
|
||||
me: () => get<{ user: AppUser }>('/auth/me'),
|
||||
updateMe: (changes: Partial<Pick<AppUser, 'theme' | 'theme_mode' | 'color'>>) =>
|
||||
updateMe: (changes: Partial<Pick<AppUser, 'theme' | 'theme_mode' | 'color' | 'avatar'>>) =>
|
||||
send<{ user: AppUser }>('PATCH', '/auth/me', changes),
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
send<{ ok: true }>('POST', '/auth/change-password', { current_password, new_password }),
|
||||
@@ -278,6 +278,7 @@ export interface DashboardData {
|
||||
user_id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
avatar: string | null;
|
||||
job_title: string | null;
|
||||
is_external: boolean;
|
||||
minutes: number;
|
||||
@@ -293,6 +294,7 @@ export interface DashboardData {
|
||||
user_id: number;
|
||||
user_name: string;
|
||||
user_color: string | null;
|
||||
user_avatar: string | null;
|
||||
project_display: string;
|
||||
customer_color: string | null;
|
||||
task_name: string | null;
|
||||
@@ -312,6 +314,7 @@ export interface GroupRow {
|
||||
key: string | number;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
avatar?: string | null;
|
||||
minutes: number;
|
||||
billable_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Profilbilder werden als Data-URL in der Datenbank gespeichert. Angezeigt
|
||||
* werden sie höchstens mit 96 px Kantenlänge, deshalb wird vor dem Hochladen
|
||||
* im Browser auf ein Quadrat verkleinert - das drückt ein 4-MB-Handyfoto auf
|
||||
* wenige Kilobyte, und der Server muss kein Bild verarbeiten.
|
||||
*/
|
||||
|
||||
/** Zielkantenlänge; 256 px reicht für die größte Anzeige auch auf Retina. */
|
||||
const TARGET_SIZE = 256;
|
||||
|
||||
/** WebP ist deutlich kleiner als JPEG; ohne Unterstützung wird JPEG genutzt. */
|
||||
const CANDIDATES: { type: string; quality: number }[] = [
|
||||
{ type: 'image/webp', quality: 0.82 },
|
||||
{ type: 'image/jpeg', quality: 0.82 },
|
||||
];
|
||||
|
||||
/** Quelldateien werden vor dem Dekodieren begrenzt - ein 100-MB-TIFF soll den Browser nicht lahmlegen. */
|
||||
export const MAX_SOURCE_BYTES = 25_000_000;
|
||||
|
||||
export interface CompressedImage {
|
||||
dataUrl: string;
|
||||
sourceBytes: number;
|
||||
resultBytes: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
function loadImage(file: File): Promise<HTMLImageElement> {
|
||||
const url = URL.createObjectURL(file);
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Das Bild konnte nicht gelesen werden.'));
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/** Länge einer Data-URL in Bytes (Base64 trägt 4 Zeichen pro 3 Bytes). */
|
||||
function dataUrlBytes(dataUrl: string): number {
|
||||
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
|
||||
return Math.round((base64.length * 3) / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verkleinert das Bild auf ein Quadrat und schneidet dabei mittig zu - genau
|
||||
* wie die Anzeige später, damit das Ergebnis dem entspricht, was man sieht.
|
||||
*/
|
||||
export async function compressImage(file: File): Promise<CompressedImage> {
|
||||
if (!file.type.startsWith('image/')) throw new Error('Bitte eine Bilddatei wählen.');
|
||||
if (file.size > MAX_SOURCE_BYTES) throw new Error('Die Datei ist zu groß (max. 25 MB).');
|
||||
|
||||
const image = await loadImage(file);
|
||||
const { naturalWidth: width, naturalHeight: height } = image;
|
||||
if (!width || !height) throw new Error('Das Bild hat keine lesbaren Abmessungen.');
|
||||
|
||||
// Kleine Bilder werden nicht vergrößert.
|
||||
const side = Math.min(TARGET_SIZE, width, height);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = side;
|
||||
canvas.height = side;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Der Browser stellt keinen Canvas bereit.');
|
||||
|
||||
const crop = Math.min(width, height);
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, (width - crop) / 2, (height - crop) / 2, crop, crop, 0, 0, side, side);
|
||||
|
||||
for (const candidate of CANDIDATES) {
|
||||
const dataUrl = canvas.toDataURL(candidate.type, candidate.quality);
|
||||
// Liefert der Browser das Format nicht, fällt er still auf PNG zurück.
|
||||
if (dataUrl.startsWith(`data:${candidate.type}`)) {
|
||||
return { dataUrl, sourceBytes: file.size, resultBytes: dataUrlBytes(dataUrl), type: candidate.type };
|
||||
}
|
||||
}
|
||||
|
||||
const png = canvas.toDataURL('image/png');
|
||||
return { dataUrl: png, sourceBytes: file.size, resultBytes: dataUrlBytes(png), type: 'image/png' };
|
||||
}
|
||||
|
||||
/** "2,4 MB" bzw. "18 KB" */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1).replace('.', ',')} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1000))} KB`;
|
||||
}
|
||||
@@ -39,6 +39,8 @@ export interface AppUser {
|
||||
source: 'local' | 'sso';
|
||||
weekly_hours: number | null;
|
||||
color: string | null;
|
||||
/** Profilbild als Data-URL; null = Initialen. */
|
||||
avatar: string | null;
|
||||
theme: ThemePalette | null;
|
||||
theme_mode: 'light' | 'dark' | null;
|
||||
is_active: boolean;
|
||||
@@ -57,6 +59,7 @@ export interface DirectoryUser {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
avatar: string | null;
|
||||
role: Role;
|
||||
manager_id: number | null;
|
||||
is_external: boolean;
|
||||
@@ -133,6 +136,7 @@ export interface MemberRef {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
avatar: string | null;
|
||||
is_external: boolean;
|
||||
}
|
||||
|
||||
@@ -174,6 +178,7 @@ export interface TimeEntry {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_name: string;
|
||||
user_avatar: string | null;
|
||||
project_id: number;
|
||||
project_name: string;
|
||||
project_code: string;
|
||||
|
||||
Reference in New Issue
Block a user