diff --git a/backend/main.py b/backend/main.py index 027e1c4..2ce442c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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() diff --git a/backend/models.py b/backend/models.py index 2ace8c2..d515637 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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 diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py index bc72b65..73fc163 100644 --- a/backend/routers/groups_router.py +++ b/backend/routers/groups_router.py @@ -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, diff --git a/frontend/css/app.css b/frontend/css/app.css index 5b62aaf..80e40d1 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -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; } diff --git a/frontend/index.html b/frontend/index.html index f3125d1..97ecf10 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -376,6 +376,10 @@ +
+ +
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 0eb2f13..212d1e3 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -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() {
${escHtml(e.name)} + ${e.groupVisible ? `πŸ‘₯` : ''} ${e.remove ? `` : ''}
` ).join(''); @@ -2267,9 +2278,10 @@ function renderGroupList() { } el.innerHTML = state.groups.map(g => `
+ ${escHtml(g.icon || 'πŸ‘₯')} ${escHtml(g.name)}
` ).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 => + `` + ).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'); diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index b26bd6c..1adb819 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -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', diff --git a/frontend/js/version.js b/frontend/js/version.js index a1a28e2..09e33fd 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,2 +1,2 @@ // Increment APP_VERSION with every code change -export const APP_VERSION = 'v33'; +export const APP_VERSION = 'v34';