feat: wählbares Gruppen-Icon, geteilter Kalender markiert, Ersteller bei Gruppen-Terminen
- Gruppe: wählbares Emoji-Icon (groups.icon-Spalte + PUT /api/groups/{id});
wird in der Sidebar statt des Zahnrads angezeigt; Verwalten jetzt klares "⋯".
Gruppe umbenennen möglich (war vorher gesperrt).
- "Meine Kalender": der aktuell für Gruppen sichtbare Kalender wird mit 👥
gekennzeichnet.
- Gruppenansicht: Gruppenkalender-Termine zeigen, wer sie hinzugefügt hat
(👥 Vorname: Titel) und sind nach Ersteller eingefärbt; jeder kann weiterhin
Termine im Gruppenkalender anlegen. Version v34.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -175,6 +175,12 @@ def _migrate():
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE groups ADD COLUMN icon VARCHAR(16)"))
|
||||
conn.commit()
|
||||
logging.info("Migration: added icon to groups")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_migrate()
|
||||
|
||||
|
||||
@@ -268,6 +268,7 @@ class Group(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
icon = Column(String(16), nullable=True) # emoji shown for the group
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(String(50), nullable=True) # ISO 8601
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ def _now_iso() -> str:
|
||||
class GroupCreate(BaseModel):
|
||||
name: str
|
||||
member_ids: List[int] = []
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class GroupUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class MemberAdd(BaseModel):
|
||||
@@ -86,7 +92,8 @@ def create_group(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
group = models.Group(name=data.name, created_by=current_user.id, created_at=_now_iso())
|
||||
group = models.Group(name=data.name, icon=(data.icon or None),
|
||||
created_by=current_user.id, created_at=_now_iso())
|
||||
db.add(group)
|
||||
db.flush()
|
||||
|
||||
@@ -135,6 +142,7 @@ def list_groups(
|
||||
out.append({
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
"icon": group.icon,
|
||||
"role": m.role,
|
||||
"member_count": member_count,
|
||||
"group_calendar_id": _group_calendar_id(db, group.id),
|
||||
@@ -155,12 +163,30 @@ def _group_detail(db: Session, group: models.Group, current_user: models.User) -
|
||||
return {
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
"icon": group.icon,
|
||||
"created_by": group.created_by,
|
||||
"members": member_dicts,
|
||||
"group_calendar_id": _group_calendar_id(db, group.id),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{group_id}")
|
||||
def update_group(
|
||||
group_id: int,
|
||||
data: GroupUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
group = _get_group_or_404(db, group_id)
|
||||
_require_owner(db, group, current_user)
|
||||
if data.name is not None and data.name.strip():
|
||||
group.name = data.name.strip()
|
||||
if data.icon is not None:
|
||||
group.icon = data.icon or None
|
||||
db.commit()
|
||||
return _group_detail(db, group, current_user)
|
||||
|
||||
|
||||
@router.get("/{group_id}")
|
||||
def get_group(
|
||||
group_id: int,
|
||||
|
||||
@@ -1923,3 +1923,19 @@ a { color: var(--primary); text-decoration: none; }
|
||||
user-select: none;
|
||||
}
|
||||
.cal-item.cal-dragging { opacity: .5; }
|
||||
|
||||
/* Group emoji + icon picker */
|
||||
.group-emoji { flex: 0 0 auto; font-size: 16px; cursor: pointer; line-height: 1; }
|
||||
.cal-shared-flag { flex: 0 0 auto; font-size: 12px; opacity: .8; }
|
||||
.group-icon-picker { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.group-icon-opt {
|
||||
width: 38px; height: 38px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 18px; line-height: 1;
|
||||
background: var(--bg-app);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.group-icon-opt:hover { background: var(--bg-surface); }
|
||||
.group-icon-opt.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
|
||||
|
||||
@@ -376,6 +376,10 @@
|
||||
<label data-i18n="group_name">Name</label>
|
||||
<input type="text" id="group-name" data-i18n-placeholder="group_name_ph" placeholder="Gruppenname" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="group_icon">Icon</label>
|
||||
<div id="group-icon-picker" class="group-icon-picker"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="group_members">Mitglieder</label>
|
||||
<div id="group-member-picker" class="share-user-picker"></div>
|
||||
|
||||
@@ -299,9 +299,17 @@ async function fetchAndRender(force = false, silent = false) {
|
||||
// cryptic initials); your own events stay unprefixed. Colour-code by
|
||||
// owner so each member reads as a group.
|
||||
let title = ev.title;
|
||||
if (ev.is_group_event) title = `👥 ${ev.title}`;
|
||||
else if (ownerName && !isMine) title = `${firstName(ownerName)}: ${ev.title}`;
|
||||
const color = ev.is_group_event ? ev.color : (ownerColor(ownerId) || ev.color);
|
||||
let color;
|
||||
if (ev.is_group_event) {
|
||||
// Group calendar: everyone adds; mark who added it.
|
||||
const cName = ev.creator && ev.creator.display_name;
|
||||
const cId = ev.creator && ev.creator.id;
|
||||
title = (cName && cId !== me.id) ? `👥 ${firstName(cName)}: ${ev.title}` : `👥 ${ev.title}`;
|
||||
color = ownerColor(cId) || ev.color;
|
||||
} else {
|
||||
if (ownerName && !isMine) title = `${firstName(ownerName)}: ${ev.title}`;
|
||||
color = ownerColor(ownerId) || ev.color;
|
||||
}
|
||||
return { ...ev, title, color };
|
||||
});
|
||||
eventCache.start = null; eventCache.end = null; // invalidate normal cache
|
||||
@@ -652,10 +660,12 @@ function renderCalendarList() {
|
||||
sourceLabel: acc.name, remove: { icon: EYE_OFF, title: t('hide_cal') } });
|
||||
});
|
||||
});
|
||||
const groupVisibleId = state.settings && state.settings.group_visible_calendar_id;
|
||||
state.localCalendars.filter(c => c.owned !== false && !c.group).forEach(cal => {
|
||||
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
|
||||
name: cal.name, color: cal.color, enabled: cal.enabled,
|
||||
sourceLabel: t('cal_local'), remove: { icon: TRASH, title: t('remove_cal') } });
|
||||
sourceLabel: t('cal_local'), groupVisible: cal.id === groupVisibleId,
|
||||
remove: { icon: TRASH, title: t('remove_cal') } });
|
||||
});
|
||||
state.localCalendars.filter(c => c.owned === false && !c.group).forEach(cal => {
|
||||
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
|
||||
@@ -704,6 +714,7 @@ function renderCalendarList() {
|
||||
<input type="checkbox" ${e.enabled ? 'checked' : ''} data-source="${e.source}" ${e.dataId} />
|
||||
<div class="cal-item-dot" style="background:${e.color}" data-source="${e.source}" ${e.dataId} title="${t('change_color')}"></div>
|
||||
<span class="cal-item-name" data-source="${e.source}">${escHtml(e.name)}</span>
|
||||
${e.groupVisible ? `<span class="cal-shared-flag" title="${t('group_visible_flag')}">👥</span>` : ''}
|
||||
${e.remove ? `<button class="icon-btn mini-btn cal-item-remove" data-source="${e.source}" ${e.dataId} title="${e.remove.title}">${e.remove.icon}</button>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
@@ -2267,9 +2278,10 @@ function renderGroupList() {
|
||||
}
|
||||
el.innerHTML = state.groups.map(g =>
|
||||
`<div class="cal-item group-item ${g.id === state.activeGroupId ? 'group-item-active' : ''}" data-group-id="${g.id}">
|
||||
<span class="group-emoji" data-group-open="${g.id}">${escHtml(g.icon || '👥')}</span>
|
||||
<span class="cal-item-name" data-group-open="${g.id}">${escHtml(g.name)}</span>
|
||||
<button class="icon-btn mini-btn" data-group-edit="${g.id}" title="${t('group_manage')}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 8a4 4 0 100 8 4 4 0 000-8zm8.94 4a6.96 6.96 0 00-.14-1.32l2.03-1.58-2-3.46-2.39.96a7 7 0 00-2.28-1.32L15.4 2h-4l-.76 2.96a7 7 0 00-2.28 1.32l-2.39-.96-2 3.46 2.03 1.58A6.96 6.96 0 003.86 12c0 .45.05.89.14 1.32L1.97 14.9l2 3.46 2.39-.96a7 7 0 002.28 1.32L9.4 22h4l.76-2.96a7 7 0 002.28-1.32l2.39.96 2-3.46-2.03-1.58c.09-.43.14-.87.14-1.32z"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
|
||||
</button>
|
||||
</div>`
|
||||
).join('');
|
||||
@@ -2316,7 +2328,11 @@ async function openGroupModal(groupId) {
|
||||
const existingMemberIds = new Set((detail ? detail.members : []).map(m => m.id));
|
||||
|
||||
document.getElementById('group-name').value = detail ? detail.name : '';
|
||||
document.getElementById('group-name').disabled = isEdit; // rename not supported by API
|
||||
document.getElementById('group-name').disabled = false; // rename supported via PUT
|
||||
|
||||
// Icon picker
|
||||
modal.__icon = (detail && detail.icon) || '👥';
|
||||
renderGroupIconPicker();
|
||||
|
||||
// Member picker: current members are checked; the owner (me) is excluded.
|
||||
modal.__directory = directory;
|
||||
@@ -2326,6 +2342,23 @@ async function openGroupModal(groupId) {
|
||||
openModal('modal-group');
|
||||
}
|
||||
|
||||
const GROUP_ICONS = ['👥', '👨👩👧', '🏠', '❤️', '🧑🤝🧑', '⚽', '🎓', '💼', '🎉', '🐶', '✈️', '🎵', '🍕', '📚', '🌳', '⭐'];
|
||||
function renderGroupIconPicker() {
|
||||
const modal = document.getElementById('modal-group');
|
||||
const sel = modal.__icon || '👥';
|
||||
const picker = document.getElementById('group-icon-picker');
|
||||
if (!picker) return;
|
||||
picker.innerHTML = GROUP_ICONS.map(ic =>
|
||||
`<button type="button" class="group-icon-opt ${ic === sel ? 'on' : ''}" data-icon="${ic}">${ic}</button>`
|
||||
).join('');
|
||||
picker.querySelectorAll('.group-icon-opt').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
modal.__icon = b.dataset.icon;
|
||||
renderGroupIconPicker();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderGroupMemberPicker() {
|
||||
const modal = document.getElementById('modal-group');
|
||||
const dir = modal.__directory || [];
|
||||
@@ -2360,8 +2393,12 @@ function bindGroupUI() {
|
||||
const groupId = modal.dataset.groupId;
|
||||
const memberIds = [...(modal.__memberIds || new Set())];
|
||||
try {
|
||||
const name = document.getElementById('group-name').value.trim();
|
||||
const icon = modal.__icon || '👥';
|
||||
if (!name) { showToast(t('error_enter_title'), true); return; }
|
||||
if (groupId) {
|
||||
// Manage mode: sync member additions/removals.
|
||||
// Manage mode: update name/icon, then sync member additions/removals.
|
||||
await api.put(`/groups/${groupId}`, { name, icon });
|
||||
const detail = await api.get(`/groups/${groupId}`);
|
||||
const me = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const current = new Set(detail.members.map(m => m.id).filter(id => id !== me.id));
|
||||
@@ -2369,9 +2406,7 @@ function bindGroupUI() {
|
||||
for (const id of current) if (!memberIds.includes(id)) await api.delete(`/groups/${groupId}/members/${id}`);
|
||||
showToast(t('group_saved'));
|
||||
} else {
|
||||
const name = document.getElementById('group-name').value.trim();
|
||||
if (!name) { showToast(t('error_enter_title'), true); return; }
|
||||
await api.post('/groups/', { name, member_ids: memberIds });
|
||||
await api.post('/groups/', { name, member_ids: memberIds, icon });
|
||||
showToast(t('group_created'));
|
||||
}
|
||||
closeModal('modal-group');
|
||||
|
||||
@@ -121,6 +121,8 @@ const translations = {
|
||||
group_created: 'Gruppe erstellt',
|
||||
group_view_label: 'Gruppenansicht: {name}',
|
||||
group_exit: 'Gruppenansicht verlassen',
|
||||
group_icon: 'Icon',
|
||||
group_visible_flag: 'Für deine Gruppen sichtbar',
|
||||
upload_too_large: 'Datei zu groß (Server-Limit). Bitte Upload-Limit erhöhen.',
|
||||
shared_with_me: 'Mit dir geteilt',
|
||||
settings_calendars: 'Kalender',
|
||||
@@ -385,6 +387,8 @@ const translations = {
|
||||
group_created: 'Group created',
|
||||
group_view_label: 'Group view: {name}',
|
||||
group_exit: 'Exit group view',
|
||||
group_icon: 'Icon',
|
||||
group_visible_flag: 'Visible to your groups',
|
||||
upload_too_large: 'File too large (server limit). Please raise the upload limit.',
|
||||
shared_with_me: 'Shared with me',
|
||||
settings_calendars: 'Calendars',
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v33';
|
||||
export const APP_VERSION = 'v34';
|
||||
|
||||
Reference in New Issue
Block a user