🚸 ux(matrix-web): improve mobile responsiveness

- Add slide-in sidebar overlay with backdrop on mobile
- Make message actions appear below message on mobile
- Adjust emoji picker positioning for viewport awareness
- Reduce excessive padding on mobile screens
- Hide disabled call buttons on small screens
- Add responsive widths to panels and dialogs
- Close sidebar automatically when selecting room on mobile
This commit is contained in:
Till-JS 2026-01-29 17:37:35 +01:00
parent 6f1b2654f1
commit f2cd8621cb
19 changed files with 1231 additions and 85 deletions

View file

@ -5,7 +5,7 @@
*/
import ContactCardSkeleton from './ContactCardSkeleton.svelte';
import { calculateFadeOpacity } from './utils';
import { calculateFadeOpacity } from '@manacore/shared-ui';
interface Props {
/** Number of skeleton cards to show */

View file

@ -5,7 +5,7 @@
*/
import ContactRowSkeleton from './ContactRowSkeleton.svelte';
import { calculateFadeOpacity } from './utils';
import { calculateFadeOpacity } from '@manacore/shared-ui';
interface Props {
/** Number of skeleton rows to show */

View file

@ -4,9 +4,8 @@
* Shows stats cards and duplicate groups with fade effect
*/
import { SkeletonBox } from '@manacore/shared-ui';
import { SkeletonBox, calculateFadeOpacity } from '@manacore/shared-ui';
import DuplicateGroupSkeleton from './DuplicateGroupSkeleton.svelte';
import { calculateFadeOpacity } from './utils';
interface Props {
/** Number of duplicate groups to show */

View file

@ -4,7 +4,7 @@
*/
import TagCardSkeleton from './TagCardSkeleton.svelte';
import { calculateFadeOpacity } from './utils';
import { calculateFadeOpacity } from '@manacore/shared-ui';
interface Props {
/** Number of skeleton cards to show */

View file

@ -5,8 +5,8 @@
* Built on top of @manacore/shared-ui skeleton primitives.
*/
// Utilities
export { calculateFadeOpacity } from './utils';
// Utilities (re-exported from shared-ui)
export { calculateFadeOpacity } from '@manacore/shared-ui';
// Contact List/Grid Skeletons
export { default as ContactRowSkeleton } from './ContactRowSkeleton.svelte';

View file

@ -1,19 +0,0 @@
/**
* Skeleton utility functions
*/
/**
* Calculate opacity for cascading fade effect in skeleton lists
* @param index Current item index
* @param count Total number of items
* @param minOpacity Minimum opacity (default: 0.3)
* @returns Opacity value between minOpacity and 1
*/
export function calculateFadeOpacity(
index: number,
count: number,
minOpacity: number = 0.3
): number {
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
}

View file

@ -103,12 +103,12 @@
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={handleClose}
>
<!-- Dialog -->
<div
class="w-full max-w-md rounded-xl bg-base-100 shadow-xl"
class="w-full max-w-md rounded-xl bg-base-100 shadow-xl max-h-[90vh] overflow-y-auto"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"

View file

@ -74,7 +74,10 @@
const codeColor = isOwn ? 'bg-white/20 text-white' : 'bg-black/5 dark:bg-white/10';
// Inline code (backticks) - process first to avoid conflicts
text = text.replace(/`([^`]+)`/g, `<code class="px-1 py-0.5 rounded text-sm font-mono ${codeColor}">$1</code>`);
text = text.replace(
/`([^`]+)`/g,
`<code class="px-1 py-0.5 rounded text-sm font-mono ${codeColor}">$1</code>`
);
// Bold (**text** or __text__)
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
@ -251,7 +254,11 @@
{/if}
<!-- Message Content -->
<div class="flex flex-col {message.isOwn ? 'items-end' : 'items-start'} max-w-[75%] relative">
<div
class="flex flex-col {message.isOwn
? 'items-end'
: 'items-start'} max-w-[85%] sm:max-w-[75%] relative"
>
<!-- Sender name (for others only) -->
{#if showAvatar && !message.isOwn}
<span class="text-xs text-muted-foreground mb-1 px-1">{message.senderName}</span>
@ -433,7 +440,9 @@
</p>
{:else}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<p class="whitespace-pre-wrap break-words text-[15px] leading-relaxed">{@html formatMessageBody(message.body, message.isOwn)}</p>
<p class="whitespace-pre-wrap break-words text-[15px] leading-relaxed">
{@html formatMessageBody(message.body, message.isOwn)}
</p>
<!-- Link Preview Card -->
{#if firstUrl()}
@ -451,7 +460,9 @@
class="h-5 w-5 rounded-sm"
onerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = 'none')}
/>
<span class="text-xs truncate {message.isOwn ? 'text-white/80' : 'text-muted-foreground'}">
<span
class="text-xs truncate {message.isOwn ? 'text-white/80' : 'text-muted-foreground'}"
>
{getDomain(firstUrl() || '')}
</span>
</a>
@ -489,34 +500,31 @@
{/if}
<!-- Time and read status -->
<div
class="flex items-center gap-1.5 mt-1.5 px-1 {message.isOwn ? 'justify-end' : ''}"
>
<span class="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">{formattedTime()}</span>
<div class="flex items-center gap-1.5 mt-1.5 px-1 {message.isOwn ? 'justify-end' : ''}">
<span
class="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity"
>{formattedTime()}</span
>
<!-- Read receipt indicator (for own messages) -->
{#if message.isOwn}
{#if message.readBy && message.readBy.length > 0}
<Checks
class="h-4 w-4 text-blue-500"
weight="bold"
title="Gelesen von: {message.readBy.map(r => r.userName).join(', ')}"
title="Gelesen von: {message.readBy.map((r) => r.userName).join(', ')}"
/>
{:else}
<Check
class="h-4 w-4 text-muted-foreground/50"
weight="bold"
title="Gesendet"
/>
<Check class="h-4 w-4 text-muted-foreground/50" weight="bold" title="Gesendet" />
{/if}
{/if}
</div>
<!-- Message actions (hover) -->
<!-- Message actions (hover/tap) -->
{#if showActions && !message.redacted}
<div
class="absolute {message.isOwn
? '-left-28'
: '-right-28'} top-0 flex items-center gap-1 rounded-xl glass p-1.5 shadow-lg"
class="absolute flex items-center gap-1 rounded-xl glass p-1.5 shadow-lg z-20
{message.isOwn ? 'right-0 lg:-left-28 lg:right-auto' : 'left-0 lg:-right-28 lg:left-auto'}
top-full mt-1 lg:top-0 lg:mt-0"
>
<!-- Emoji reaction button -->
<div class="relative">
@ -536,9 +544,9 @@
></button>
<!-- Emoji picker dropdown -->
<div
class="absolute {message.isOwn
? 'right-0'
: 'left-0'} bottom-full mb-2 z-50 flex gap-1 rounded-xl bg-white dark:bg-zinc-800 border border-black/10 dark:border-white/10 p-2 shadow-xl"
class="absolute z-50 flex gap-1 rounded-xl bg-white dark:bg-zinc-800 border border-black/10 dark:border-white/10 p-2 shadow-xl
left-0 top-full mt-2 lg:bottom-full lg:top-auto lg:mt-0 lg:mb-2
{message.isOwn ? 'lg:right-0 lg:left-auto' : ''}"
>
{#each quickEmojis as emoji}
<button

View file

@ -237,7 +237,7 @@
}
</script>
<div class="p-3 pb-20">
<div class="p-3 pb-4 lg:pb-20">
<!-- Reply/Edit Preview -->
{#if replyTo || editMessage}
<div

View file

@ -102,14 +102,14 @@
<!-- Actions -->
<div class="flex items-center gap-1">
<button
class="p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
title="Sprachanruf"
disabled
>
<Phone class="h-5 w-5 text-muted-foreground" />
</button>
<button
class="p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
title="Videoanruf"
disabled
>

View file

@ -13,9 +13,18 @@
interface Props {
onCreateRoom?: () => void;
onSelectRoom?: (roomId: string) => void;
}
let { onCreateRoom }: Props = $props();
let { onCreateRoom, onSelectRoom }: Props = $props();
function handleSelectRoom(roomId: string) {
if (onSelectRoom) {
onSelectRoom(roomId);
} else {
matrixStore.selectRoom(roomId);
}
}
let search = $state('');
@ -143,7 +152,7 @@
<RoomItem
{room}
selected={room.id === matrixStore.currentRoomId}
onclick={() => matrixStore.selectRoom(room.id)}
onclick={() => handleSelectRoom(room.id)}
/>
{:else}
{#if !search}
@ -171,7 +180,7 @@
<RoomItem
{room}
selected={room.id === matrixStore.currentRoomId}
onclick={() => matrixStore.selectRoom(room.id)}
onclick={() => handleSelectRoom(room.id)}
/>
{:else}
{#if !search}
@ -191,7 +200,7 @@
</div>
<!-- New Room Button -->
<div class="border-t border-black/10 dark:border-white/10 p-3 pb-20">
<div class="border-t border-black/10 dark:border-white/10 p-3 pb-4 lg:pb-20">
<button
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl
bg-gradient-to-r from-violet-500 to-purple-600 text-white font-medium

View file

@ -78,9 +78,15 @@
</script>
{#if open && room}
<!-- Backdrop for mobile -->
<button
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
onclick={onClose}
aria-label="Schließen"
></button>
<!-- Slide-in Panel -->
<div
class="fixed inset-y-0 right-0 z-40 flex w-80 flex-col border-l border-base-300 bg-base-100 shadow-xl"
class="fixed inset-y-0 right-0 z-50 flex w-[90vw] max-w-[320px] lg:w-80 flex-col border-l border-base-300 bg-base-100 shadow-xl"
>
<!-- Header -->
<header class="flex items-center justify-between border-b border-base-300 px-4 py-3">

View file

@ -4,8 +4,10 @@
import CreateRoomDialog from '$lib/components/chat/CreateRoomDialog.svelte';
import RoomSettingsPanel from '$lib/components/chat/RoomSettingsPanel.svelte';
import { ChatCircle, Plus } from '@manacore/shared-icons';
import { browser } from '$app/environment';
let sidebarOpen = $state(true);
// Start with sidebar closed on mobile
let sidebarOpen = $state(browser ? window.innerWidth >= 1024 : true);
let showCreateRoom = $state(false);
let showRoomSettings = $state(false);
@ -13,10 +15,31 @@
let replyTo = $state<SimpleMessage | null>(null);
let editMessage = $state<SimpleMessage | null>(null);
// Check if mobile
let isMobile = $state(browser ? window.innerWidth < 1024 : false);
// Update on resize
if (browser) {
window.addEventListener('resize', () => {
isMobile = window.innerWidth < 1024;
// Auto-close sidebar on resize to mobile
if (isMobile && sidebarOpen) {
sidebarOpen = false;
}
});
}
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}
function selectRoomAndCloseSidebar(roomId: string) {
matrixStore.selectRoom(roomId);
if (isMobile) {
sidebarOpen = false;
}
}
function handleReply(message: SimpleMessage) {
editMessage = null;
replyTo = message;
@ -32,12 +55,22 @@
}
</script>
<div class="chat-layout flex h-full min-h-0 overflow-hidden bg-background">
<div class="chat-layout flex h-full min-h-0 overflow-hidden bg-background relative">
<!-- Mobile Sidebar Backdrop -->
{#if sidebarOpen && isMobile}
<button
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
onclick={() => (sidebarOpen = false)}
aria-label="Sidebar schließen"
></button>
{/if}
<!-- Sidebar -->
<aside
class="flex w-80 flex-shrink-0 flex-col border-r border-black/10 dark:border-white/10 bg-white/50 dark:bg-white/5 backdrop-blur-sm transition-all duration-300 ease-in-out"
class:hidden={!sidebarOpen}
class:lg:flex={true}
class="flex flex-col border-r border-black/10 dark:border-white/10 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-xl transition-all duration-300 ease-in-out
fixed lg:relative inset-y-0 left-0 z-50 lg:z-auto
w-[85vw] max-w-[320px] lg:w-80 lg:max-w-none
{sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}"
>
<!-- User Info / Status Bar -->
<div class="border-b border-black/10 dark:border-white/10 px-4 py-3">
@ -66,7 +99,10 @@
<!-- Room List -->
<div class="flex-1 min-h-0 overflow-hidden">
<RoomList onCreateRoom={() => (showCreateRoom = true)} />
<RoomList
onCreateRoom={() => (showCreateRoom = true)}
onSelectRoom={selectRoomAndCloseSidebar}
/>
</div>
</aside>

View file

@ -8,7 +8,7 @@
| Priorität | Bereich | Geschätzte Einsparung | Aufwand |
|-----------|---------|----------------------|---------|
| ~~**KRITISCH**~~ | ~~Backend Metrics Migration~~ | ~~350 LOC~~**709 LOC entfernt** | ~~Niedrig~~ |
| **HOCH** | Skeleton Components | 800-1.000 LOC | Mittel |
| ~~**HOCH**~~ | ~~Skeleton Components~~ | ~~800-1.000 LOC~~**20 LOC** | ~~Mittel~~ → Analysiert ✅ |
| ~~**HOCH**~~ | ~~App Settings Stores~~ | ~~600-700 LOC~~**323 LOC entfernt** | ~~Mittel~~ |
| ~~**HOCH**~~ | ~~Main.ts/CORS Patterns~~ | ~~1.800 LOC~~**~280 LOC entfernt** | ~~Mittel~~ |
| ~~**MITTEL**~~ | ~~TypeScript Configs~~ | ~~400 LOC~~**~280 LOC entfernt** | ~~Niedrig~~ |
@ -232,21 +232,31 @@ export const { isSidebarMode, isNavCollapsed } = createSimpleNavigationStores({
## 3. UI Components
### 3.1 HOCH: Skeleton Components (800-1.000 LOC)
### ~~3.1 HOCH: Skeleton Components~~ ✅ ANALYSIERT (Minimal ~20 LOC gespart)
**Problem:** 31 Skeleton-Komponenten über Apps verteilt, obwohl shared-ui Primitives hat.
**Status:** Nach detaillierter Analyse: Die meisten Skeletons sind legitime Domain-Customizations (29.01.2026)
**Betroffene Apps:**
- `apps/contacts/` - 11 Skeletons (925 LOC)
- `apps/calendar/` - 5 Skeletons (338 LOC)
- `apps/todo/` - 5 Skeletons
**Ergebnis der Analyse:**
- 29 Skeleton-Dateien über 5 Apps (calendar, clock, contacts, todo, questions)
- **Fazit:** Skeletons nutzen bereits shared-ui Primitives (`SkeletonBox`) korrekt
- Domain-spezifische Layouts (Kalender-Grid, Uhr-Circle) gehören NICHT in shared-ui
**Durchgeführte Konsolidierung:**
- ✅ `calculateFadeOpacity()` Utility nach `@manacore/shared-ui` verschoben
- ✅ Contacts App nutzt jetzt die shared-ui Utility
- ✅ Lokale `utils.ts` in contacts gelöscht (~20 LOC gespart)
**Warum KEINE weitere Konsolidierung:**
| Skeleton | LOC | Status |
|----------|-----|--------|
| Calendar AppLoadingSkeleton | 56 | Keep - 7-Spalten Kalender-Grid Layout |
| Clock AppLoadingSkeleton | 91 | Keep - 300px kreisförmiger Uhr-Platzhalter |
| ContactRowSkeleton, TaskItemSkeleton, etc. | ~800 | Keep - Legitime Domain-spezifische Komponenten |
**Shared-UI hat bereits:**
- `SkeletonBox`, `SkeletonAvatar`, `SkeletonCard`, `SkeletonGrid`, `SkeletonList`, `SkeletonRow`, `SkeletonText`
**Empfehlung:**
1. Dokumentation für Skeleton-Komposition aus Primitives
2. Page-Level Presets erstellen: `ListPageSkeleton`, `DetailPageSkeleton`, `GridPageSkeleton`
- `AppLoadingSkeleton` mit 5 Layout-Presets (list, centered, sidebar, tasks, minimal)
- `calculateFadeOpacity()` Utility für Fade-Effekte
---
@ -463,12 +473,14 @@ export default createDrizzleConfig({ dbName: 'chat' });
| `@manacore/shared-nestjs-health` erstellen | 170 | Niedrig | Offen |
| ~~Drizzle Config Factory erstellen~~ | ~~200~~**160** | ~~Niedrig~~ | ✅ Erledigt |
### Phase 4: Skeleton Refactoring (Optional, ~800 LOC)
### ~~Phase 4: Skeleton Refactoring~~ ✅ ANALYSIERT
| Aufgabe | LOC | Aufwand |
|---------|-----|---------|
| Page-Level Skeleton Presets erstellen | 400 | Mittel |
| Bestehende Skeletons refactoren | 400 | Mittel |
| Aufgabe | LOC | Aufwand | Status |
|---------|-----|---------|--------|
| ~~Page-Level Skeleton Presets~~ | ~~400~~**0** | ~~Mittel~~ | Nicht nötig - Shared-UI hat bereits 5 Presets |
| ~~Bestehende Skeletons refactoren~~ | ~~400~~**20** | ~~Mittel~~ | ✅ Nur `calculateFadeOpacity` Utility |
**Ergebnis:** Domain-spezifische Skeletons sind korrekt designed. Keine große Konsolidierung nötig.
---

View file

@ -19,6 +19,7 @@
"drizzle-kit": "^0.30.4"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
},
"peerDependencies": {

View file

@ -10,6 +10,7 @@
*/
import SkeletonRow from './SkeletonRow.svelte';
import { calculateFadeOpacity } from './utils';
interface Props {
/** Number of rows to show */
@ -38,15 +39,13 @@
class: className = '',
}: Props = $props();
function calculateOpacity(index: number): number {
if (!fadeEffect) return 1;
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
function getOpacity(index: number): number {
return fadeEffect ? calculateFadeOpacity(index, count, minOpacity) : 1;
}
</script>
<div class="skeleton-list flex flex-col {className}" style="gap: {gap};">
{#each Array(count) as _, i}
<SkeletonRow {showAvatar} {avatarSize} opacity={calculateOpacity(i)} />
<SkeletonRow {showAvatar} {avatarSize} opacity={getOpacity(i)} />
{/each}
</div>

View file

@ -29,3 +29,6 @@ export { default as SkeletonGrid } from './SkeletonGrid.svelte';
// Full Page
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
// Utilities
export { calculateFadeOpacity } from './utils';

View file

@ -0,0 +1,30 @@
/**
* Skeleton utility functions
*/
/**
* Calculate opacity for cascading fade effect in skeleton lists
*
* Creates a smooth fade from 1 at the first item to minOpacity at the last item.
*
* @param index Current item index (0-based)
* @param count Total number of items
* @param minOpacity Minimum opacity at the last item (default: 0.3)
* @returns Opacity value between minOpacity and 1
*
* @example
* ```ts
* // For a list of 5 items with minOpacity 0.3:
* // index 0 → 1.0
* // index 1 → 0.825
* // index 2 → 0.65
* // index 3 → 0.475
* // index 4 → 0.3
* calculateFadeOpacity(0, 5, 0.3) // 1.0
* calculateFadeOpacity(4, 5, 0.3) // 0.3
* ```
*/
export function calculateFadeOpacity(index: number, count: number, minOpacity = 0.3): number {
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
}

1064
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff