diff --git a/public/app.js b/public/app.js
index 7d5d0c3..66bb51b 100644
--- a/public/app.js
+++ b/public/app.js
@@ -474,7 +474,7 @@ function renderPersons() {
const li = document.createElement('li');
if (p.active === false) li.classList.add('inactive');
const av = avatarEl(p, 'plain');
- av.title = 'Foto hochladen (Klick)';
+ av.title = p.hasPhoto ? 'Foto ändern' : 'Foto hochladen';
av.tabIndex = 0;
av.setAttribute('role', 'button');
av.setAttribute('aria-label', `Foto für ${fullName(p)} hochladen`);
@@ -501,6 +501,12 @@ function renderPersons() {
edit.textContent = 'Bearbeiten';
edit.addEventListener('click', () => editPerson(p));
li.appendChild(edit);
+ const photoBtn = document.createElement('button');
+ photoBtn.type = 'button';
+ photoBtn.className = 'ghost';
+ photoBtn.textContent = p.hasPhoto ? 'Foto ändern' : 'Foto hochladen';
+ photoBtn.addEventListener('click', () => choosePhoto(p.id));
+ li.appendChild(photoBtn);
if (p.hasPhoto) {
const rm = document.createElement('button');
rm.type = 'button';
@@ -523,6 +529,95 @@ function renderPersons() {
}
function tag(text, cls = '') { const s = document.createElement('span'); s.className = `tag ${cls}`; s.textContent = text; return s; }
+// Foto im Formular: pending = neues Bild (Blob), remove = vorhandenes Foto beim Speichern löschen
+const photoState = { pending: null, remove: false, person: null, url: null };
+function setPendingPhoto(blob) {
+ if (photoState.url) URL.revokeObjectURL(photoState.url);
+ photoState.pending = blob;
+ photoState.remove = false;
+ photoState.url = blob ? URL.createObjectURL(blob) : null;
+ renderPhotoField();
+}
+function renderPhotoField() {
+ const preview = $('#photo-preview');
+ const status = $('#photo-status');
+ const clear = $('#photo-clear');
+ preview.textContent = '';
+ preview.style.backgroundImage = '';
+ preview.classList.remove('empty');
+ const p = photoState.person;
+ const hasExisting = Boolean(p && p.hasPhoto && !photoState.remove);
+ if (photoState.pending) {
+ preview.style.backgroundImage = `url("${photoState.url}")`;
+ preview.style.backgroundSize = 'cover';
+ status.textContent = 'Neues Foto gewählt. Wird mit „Speichern" bzw. „Person anlegen" übernommen.';
+ clear.hidden = false;
+ clear.textContent = 'Auswahl verwerfen';
+ } else if (hasExisting) {
+ preview.style.backgroundImage = `url("photos/${p.id}?v=${p.photoVersion || 0}")`;
+ preview.style.backgroundSize = 'cover';
+ status.textContent = 'Vorhandenes Foto. Datei wählen, hierher ziehen oder mit Strg+V ersetzen.';
+ clear.hidden = false;
+ clear.textContent = 'Foto entfernen';
+ } else {
+ preview.classList.add('empty');
+ preview.textContent = p ? initials(p) : '';
+ status.textContent = photoState.remove ? 'Foto wird beim Speichern entfernt.' : 'Kein Foto. Datei wählen, hierher ziehen oder mit Strg+V aus der Zwischenablage einfügen.';
+ clear.hidden = !photoState.remove;
+ clear.textContent = 'Entfernen rückgängig';
+ }
+}
+async function acceptPhotoFile(file) {
+ if (!file || !file.type.startsWith('image/')) { toast('Das ist kein Bild.', true); return; }
+ try {
+ setPendingPhoto(await squareJpeg(file, 320));
+ $('#photo-field').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ } catch (err) { toast(`Bild konnte nicht verarbeitet werden: ${err.message}`, true); }
+}
+function imageFromClipboard(data) {
+ if (!data) return null;
+ for (const item of data.items || []) {
+ if (item.kind === 'file' && item.type.startsWith('image/')) return item.getAsFile();
+ }
+ for (const file of data.files || []) if (file.type.startsWith('image/')) return file;
+ return null;
+}
+// Strg+V überall in der Personenansicht: Bild aus der Zwischenablage landet im Formular
+document.addEventListener('paste', (e) => {
+ if ($('#view-personen').hidden || !state.data) return;
+ const file = imageFromClipboard(e.clipboardData);
+ if (!file) return;
+ e.preventDefault();
+ acceptPhotoFile(file);
+ toast('Bild aus der Zwischenablage übernommen.');
+});
+$('#photo-paste').addEventListener('click', async () => {
+ // Schaltfläche für Mäuse und Touch: liest die Zwischenablage, wenn der Browser es erlaubt
+ if (!navigator.clipboard?.read) { toast('Dein Browser erlaubt das nur per Tastatur: Strg+V im Formular drücken.', true); return; }
+ try {
+ for (const item of await navigator.clipboard.read()) {
+ const type = item.types.find((t) => t.startsWith('image/'));
+ if (type) { await acceptPhotoFile(new File([await item.getType(type)], 'clipboard', { type })); return; }
+ }
+ toast('Kein Bild in der Zwischenablage.', true);
+ } catch { toast('Zugriff auf die Zwischenablage abgelehnt. Alternativ Strg+V drücken.', true); }
+});
+$('#photo-choose').addEventListener('click', () => { photoTarget = null; $('#photo-input').value = ''; $('#photo-input').click(); });
+$('#photo-clear').addEventListener('click', () => {
+ if (photoState.pending) { setPendingPhoto(null); return; }
+ photoState.remove = !photoState.remove;
+ renderPhotoField();
+});
+const photoField = $('#photo-field');
+photoField.addEventListener('dragover', (e) => { e.preventDefault(); photoField.classList.add('dragover'); });
+photoField.addEventListener('dragleave', () => photoField.classList.remove('dragover'));
+photoField.addEventListener('drop', (e) => {
+ e.preventDefault();
+ photoField.classList.remove('dragover');
+ const file = [...(e.dataTransfer?.files || [])].find((f) => f.type.startsWith('image/'));
+ if (file) acceptPhotoFile(file); else toast('Bitte eine Bilddatei ablegen.', true);
+});
+
function editPerson(p) {
const f = personForm.elements;
f.id.value = p.id;
@@ -534,6 +629,9 @@ function editPerson(p) {
f.canCo.checked = Boolean(p.canCo);
f.active.checked = p.active !== false;
for (const cb of personForm.querySelectorAll('input[name="freeDays"]')) cb.checked = (p.freeDays || []).includes(Number(cb.value));
+ photoState.person = p;
+ photoState.remove = false;
+ setPendingPhoto(null);
$('#person-submit').textContent = 'Änderungen speichern';
$('#person-cancel').hidden = false;
personForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -542,6 +640,9 @@ function editPerson(p) {
function resetPersonForm() {
personForm.reset();
personForm.elements.id.value = '';
+ photoState.person = null;
+ photoState.remove = false;
+ setPendingPhoto(null);
$('#person-submit').textContent = 'Person anlegen';
$('#person-cancel').hidden = true;
}
@@ -552,8 +653,11 @@ personForm.addEventListener('submit', async (e) => {
const freeDays = [...personForm.querySelectorAll('input[name="freeDays"]:checked')].map((cb) => Number(cb.value));
const body = { firstName: f.firstName.value, lastName: f.lastName.value, email: f.email.value, note: f.note.value, canMain: f.canMain.checked, canCo: f.canCo.checked, active: f.active.checked, freeDays };
try {
- if (f.id.value) await api(`api/persons/${f.id.value}`, { method: 'PUT', body });
- else await api('api/persons', { method: 'POST', body });
+ const saved = f.id.value
+ ? await api(`api/persons/${f.id.value}`, { method: 'PUT', body })
+ : await api('api/persons', { method: 'POST', body });
+ if (photoState.pending) await api(`api/persons/${saved.id}/photo`, { method: 'PUT', raw: { body: photoState.pending, type: 'image/jpeg' } });
+ else if (photoState.remove) await api(`api/persons/${saved.id}/photo`, { method: 'DELETE' });
resetPersonForm();
await load();
toast('Gespeichert.');
@@ -626,17 +730,32 @@ contactsForm.addEventListener('submit', async (e) => {
try {
const res = await api('api/nextcloud/contacts/import', { method: 'POST', body: { uids, addressbook: contactsForm.elements.addressbook.value } });
await load();
- out.textContent = `${res.created} Person(en) angelegt, ${res.withPhoto} mit Foto.${res.skipped ? ` ${res.skipped} übersprungen.` : ''}`;
+ out.textContent = `${res.created} Person(en) angelegt, ${res.withPhoto} mit Foto.${res.placeholders ? ` ${res.placeholders} Initialen-Platzhalter aus Nextcloud nicht übernommen.` : ''}${res.skipped ? ` ${res.skipped} übersprungen.` : ''}`;
await loadContacts(contactsForm.elements.addressbook.value);
} catch (err) { out.className = 'result error'; out.textContent = err.message; }
btn.disabled = false;
});
+$('#remove-placeholders').addEventListener('click', async () => {
+ const btn = $('#remove-placeholders');
+ btn.disabled = true;
+ try {
+ const { ids } = await api('api/persons/placeholder-photos');
+ if (ids.length === 0) { toast('Keine Platzhalter-Fotos gefunden.'); btn.disabled = false; return; }
+ if (!confirm(`${ids.length} Foto(s) sehen wie Initialen-Platzhalter aus (eine Hintergrundfarbe mit Buchstaben). Entfernen? Die Personen bekommen dann Initialen im Rotatory-Stil.`)) { btn.disabled = false; return; }
+ const res = await api('api/persons/placeholder-photos/remove', { method: 'POST' });
+ await load();
+ toast(`${res.removed} Platzhalter-Foto(s) entfernt.`);
+ } catch (err) { toast(err.message, true); }
+ btn.disabled = false;
+});
+
let photoTarget = null;
function choosePhoto(id) { photoTarget = id; $('#photo-input').value = ''; $('#photo-input').click(); }
$('#photo-input').addEventListener('change', async (e) => {
const file = e.target.files[0];
- if (!file || !photoTarget) return;
+ if (!file) return;
+ if (!photoTarget) { acceptPhotoFile(file); return; }
try {
const blob = await squareJpeg(file, 320);
await api(`api/persons/${photoTarget}/photo`, { method: 'PUT', raw: { body: blob, type: 'image/jpeg' } });
@@ -845,6 +964,7 @@ $('#holiday-add').addEventListener('submit', async (e) => {
function renderAll() {
renderCalendar();
renderPersons();
+ renderPhotoField();
renderAbsences();
renderSettings();
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
diff --git a/public/index.html b/public/index.html
index 68f5672..9409f31 100644
--- a/public/index.html
+++ b/public/index.html
@@ -64,6 +64,18 @@
+
+
+
+
Foto
+
Kein Foto. Datei wählen, hierher ziehen oder mit Strg+V aus der Zwischenablage einfügen.
+
+
+
+
+
+
+