feat(manalink): optimize mobile layout with bottom sheets, compact header, and touch interactions

- Add long-press on messages to open action bottom sheet (reply, forward, edit, delete, reactions)
- Compact mobile chat list header into single row (title + status + unread inline)
- Convert emoji picker and attachment menu to bottom sheets on mobile
- Shrink room avatar in header on mobile (h-8 instead of h-10)
- Hide PillNavigation and spacer in mobile room view for more chat space
- Use compact time format in room list (Min., Std., T., Wo.)
- Replace hover translate with active:scale tap feedback on room items
- Widen swipe-back edge zone (50px) and lower threshold (80px)
- Hide keyboard hint text on mobile
- Hide duplicate "Neu" button in room list on mobile
- Add slide-up animation for bottom sheets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-25 18:48:49 +01:00
parent 3500ac5e23
commit 1edbc190a6
9 changed files with 289 additions and 70 deletions

View file

@ -39,6 +39,20 @@
animation: fade-in 0.3s ease-out;
}
/* Slide-up animation for bottom sheets */
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.animate-slide-up {
animation: slide-up 0.25s ease-out;
}
/* Custom scrollbar for chat */
.chat-scrollbar::-webkit-scrollbar {
width: 6px;

View file

@ -60,10 +60,47 @@
);
let showActions = $state(false);
let showMobileActions = $state(false);
let showEmojiPicker = $state(false);
let imageLoading = $state(true);
let imageError = $state(false);
// Long-press for mobile
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let touchMoved = false;
function handleTouchStart() {
touchMoved = false;
longPressTimer = setTimeout(() => {
if (!touchMoved && !message.redacted) {
showMobileActions = true;
// Vibrate if available
if (navigator.vibrate) navigator.vibrate(20);
}
}, 500);
}
function handleTouchMove() {
touchMoved = true;
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function handleTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function closeMobileActions() {
showMobileActions = false;
showEmojiPicker = false;
showFullPicker = false;
}
// Quick reaction emojis (always visible)
const quickEmojis = ['👍', '❤️', '😂', '😮', '😢', '🎉'];
@ -320,6 +357,10 @@
role="article"
onmouseenter={() => (showActions = true)}
onmouseleave={() => (showActions = false)}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
ontouchcancel={handleTouchEnd}
>
<!-- Avatar -->
{#if showAvatar}
@ -339,7 +380,7 @@
<div
class="flex flex-col {message.isOwn
? 'items-end'
: 'items-start'} max-w-[85%] sm:max-w-[75%] relative"
: 'items-start'} max-w-[80%] sm:max-w-[75%] relative"
>
<!-- Sender name (for others only) -->
{#if showAvatar && !message.isOwn}
@ -725,3 +766,80 @@
{/if}
</div>
</div>
<!-- Mobile Action Bottom Sheet -->
{#if showMobileActions}
<button
class="fixed inset-0 z-[100] bg-black/40 backdrop-blur-sm"
onclick={closeMobileActions}
aria-label="Schließen"
></button>
<div
class="fixed bottom-0 left-0 right-0 z-[101] bg-surface-elevated border-t border-border rounded-t-2xl safe-area-bottom animate-slide-up"
>
<!-- Quick reactions row -->
<div class="flex items-center justify-center gap-3 px-4 pt-4 pb-2">
{#each quickEmojis as emoji}
<button
class="text-2xl p-2 rounded-full hover:bg-surface-hover active:scale-90 transition-all"
onclick={() => {
handleReaction(emoji);
closeMobileActions();
}}
>
{emoji}
</button>
{/each}
</div>
<div class="h-px bg-border mx-4"></div>
<!-- Action buttons -->
<div class="p-2">
<button
class="flex items-center gap-3 w-full px-4 py-3 rounded-xl active:bg-surface-hover transition-colors"
onclick={() => {
onReply?.(message);
closeMobileActions();
}}
>
<ArrowBendUpLeft class="h-5 w-5 text-muted-foreground" />
<span class="text-sm font-medium">Antworten</span>
</button>
<button
class="flex items-center gap-3 w-full px-4 py-3 rounded-xl active:bg-surface-hover transition-colors"
onclick={() => {
onForward?.(message);
closeMobileActions();
}}
>
<ArrowBendUpRight class="h-5 w-5 text-muted-foreground" />
<span class="text-sm font-medium">Weiterleiten</span>
</button>
{#if message.isOwn && message.type === 'm.text'}
<button
class="flex items-center gap-3 w-full px-4 py-3 rounded-xl active:bg-surface-hover transition-colors"
onclick={() => {
onEdit?.(message);
closeMobileActions();
}}
>
<PencilSimple class="h-5 w-5 text-muted-foreground" />
<span class="text-sm font-medium">Bearbeiten</span>
</button>
{/if}
{#if message.isOwn}
<button
class="flex items-center gap-3 w-full px-4 py-3 rounded-xl active:bg-surface-hover transition-colors"
onclick={() => {
handleDelete();
closeMobileActions();
}}
>
<Trash class="h-5 w-5 text-red-500" />
<span class="text-sm font-medium text-red-500">Löschen</span>
</button>
{/if}
</div>
</div>
{/if}

View file

@ -626,13 +626,13 @@
{#if showAttachMenu}
<!-- Backdrop -->
<button
class="fixed inset-0 z-40"
class="fixed inset-0 z-40 lg:bg-transparent bg-black/40"
onclick={() => (showAttachMenu = false)}
aria-label="Menü schließen"
></button>
<!-- Dropdown menu -->
<!-- Desktop: Dropdown above button -->
<div
class="absolute bottom-full left-0 mb-2 z-50 w-44 rounded-xl bg-surface-elevated border border-border p-1.5 shadow-xl"
class="hidden lg:block absolute bottom-full left-0 mb-2 z-50 w-44 rounded-xl bg-surface-elevated border border-border p-1.5 shadow-xl"
>
<button
onclick={() => {
@ -655,6 +655,33 @@
Datei
</button>
</div>
<!-- Mobile: Bottom sheet -->
<div
class="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-surface-elevated border-t border-border rounded-t-2xl safe-area-bottom animate-slide-up"
>
<div class="p-2">
<button
onclick={() => {
openFilePicker();
showAttachMenu = false;
}}
class="flex items-center gap-3 w-full px-4 py-3.5 rounded-xl active:bg-surface-hover transition-colors"
>
<Image class="h-5 w-5 text-muted-foreground" />
<span class="text-sm font-medium">Bild oder Video</span>
</button>
<button
onclick={() => {
openFilePicker();
showAttachMenu = false;
}}
class="flex items-center gap-3 w-full px-4 py-3.5 rounded-xl active:bg-surface-hover transition-colors"
>
<FileIcon class="h-5 w-5 text-muted-foreground" />
<span class="text-sm font-medium">Datei</span>
</button>
</div>
</div>
{/if}
</div>
@ -697,19 +724,18 @@
<Smiley size={22} class="text-muted-foreground" />
</button>
<!-- Emoji Picker Popup -->
<!-- Emoji Picker -->
{#if showEmojiPicker}
<!-- Backdrop -->
<button
class="fixed inset-0 z-40"
class="fixed inset-0 z-40 lg:bg-transparent bg-black/40"
onclick={() => (showEmojiPicker = false)}
aria-label="Emoji-Picker schließen"
></button>
<!-- Picker -->
<!-- Desktop: Popup above input -->
<div
class="absolute bottom-full right-0 mb-2 z-50 w-72 max-h-80 overflow-y-auto rounded-xl bg-surface-elevated border border-border p-2 shadow-xl"
class="hidden lg:block absolute bottom-full right-0 mb-2 z-50 w-72 max-h-80 overflow-y-auto rounded-xl bg-surface-elevated border border-border p-2 shadow-xl"
>
<!-- Recent/Frequently used emojis -->
{#if recentEmojis.length > 0}
<div class="mb-2">
<p class="text-[10px] text-muted-foreground uppercase font-medium px-1 mb-1">
@ -728,7 +754,6 @@
</div>
<div class="border-t border-border my-2"></div>
{/if}
<!-- All emojis -->
<div class="grid grid-cols-8 gap-1">
{#each commonEmojis as emoji}
<button
@ -740,6 +765,41 @@
{/each}
</div>
</div>
<!-- Mobile: Bottom sheet -->
<div
class="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-surface-elevated border-t border-border rounded-t-2xl safe-area-bottom animate-slide-up"
>
<div class="p-3 max-h-[50vh] overflow-y-auto">
{#if recentEmojis.length > 0}
<div class="mb-3">
<p class="text-[10px] text-muted-foreground uppercase font-medium px-1 mb-1">
Häufig benutzt
</p>
<div class="grid grid-cols-8 gap-1">
{#each recentEmojis as emoji}
<button
class="p-2 text-2xl active:scale-90 rounded-lg transition-transform"
onclick={() => insertEmoji(emoji)}
>
{emoji}
</button>
{/each}
</div>
</div>
<div class="border-t border-border my-2"></div>
{/if}
<div class="grid grid-cols-8 gap-1">
{#each commonEmojis as emoji}
<button
class="p-2 text-2xl active:scale-90 rounded-lg transition-transform"
onclick={() => insertEmoji(emoji)}
>
{emoji}
</button>
{/each}
</div>
</div>
</div>
{/if}
</div>
@ -775,8 +835,8 @@
{/if}
</div>
<!-- Hint -->
<p class="text-[10px] text-muted-foreground/60 text-center mt-1.5">
<!-- Hint (desktop only) -->
<p class="hidden lg:block text-[10px] text-muted-foreground/60 text-center mt-1.5">
{#if editMessage}
Enter = Speichern · Escape = Abbrechen
{:else}

View file

@ -98,13 +98,17 @@
<!-- Room avatar with online indicator -->
<div class="relative flex-shrink-0">
<div
class="flex h-10 w-10 items-center justify-center rounded-full shadow-md
class="flex h-8 w-8 lg:h-10 lg:w-10 items-center justify-center rounded-full shadow-md
bg-gradient-to-br from-violet-500 to-purple-600 text-white"
>
{#if room.avatar}
<img src={room.avatar} alt={room.name} class="h-10 w-10 rounded-full object-cover" />
<img
src={room.avatar}
alt={room.name}
class="h-8 w-8 lg:h-10 lg:w-10 rounded-full object-cover"
/>
{:else}
<span class="text-sm font-semibold">{room.name.charAt(0).toUpperCase()}</span>
<span class="text-xs lg:text-sm font-semibold">{room.name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<!-- Online indicator for DMs -->
@ -161,16 +165,16 @@
</div>
<!-- Actions -->
<div class="flex items-center gap-1">
<div class="flex items-center gap-0.5 lg:gap-1">
<button
class="p-2.5 rounded-xl glass-button shadow-sm"
class="p-2 lg:p-2.5 rounded-lg lg:rounded-xl lg:glass-button lg:shadow-sm hover:bg-surface-hover transition-colors"
title="Suchen"
onclick={onSearchClick}
>
<MagnifyingGlass class="h-5 w-5 text-muted-foreground" />
</button>
<button
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm transition-colors
class="hidden sm:flex p-2 lg:p-2.5 rounded-lg lg:rounded-xl lg:glass-button lg:shadow-sm transition-colors
{canCall ? 'hover:bg-green-500/10 hover:text-green-500' : 'opacity-40 cursor-not-allowed'}"
title={canCall ? 'Sprachanruf' : 'Anrufe nur in Direktnachrichten verfügbar'}
disabled={!canCall}
@ -179,7 +183,7 @@
<Phone class="h-5 w-5" />
</button>
<button
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm transition-colors
class="hidden sm:flex p-2 lg:p-2.5 rounded-lg lg:rounded-xl lg:glass-button lg:shadow-sm transition-colors
{canCall ? 'hover:bg-violet-500/10 hover:text-violet-500' : 'opacity-40 cursor-not-allowed'}"
title={canCall ? 'Videoanruf' : 'Anrufe nur in Direktnachrichten verfügbar'}
disabled={!canCall}
@ -188,7 +192,7 @@
<VideoCamera class="h-5 w-5" />
</button>
<button
class="p-2.5 rounded-xl glass-button shadow-sm"
class="p-2 lg:p-2.5 rounded-lg lg:rounded-xl lg:glass-button lg:shadow-sm hover:bg-surface-hover transition-colors"
title="Rauminfo"
onclick={onInfoClick}
>

View file

@ -16,6 +16,17 @@
if (!room.lastMessageTime) return '';
const date = new Date(room.lastMessageTime);
if (!isValid(date) || date.getTime() === 0) return '';
// Compact time format
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'jetzt';
if (diffMin < 60) return `${diffMin} Min.`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `${diffH} Std.`;
const diffD = Math.floor(diffH / 24);
if (diffD < 7) return `${diffD} T.`;
const diffW = Math.floor(diffD / 7);
if (diffW < 5) return `${diffW} Wo.`;
return formatDistanceToNow(date, { addSuffix: false, locale: de });
});
@ -49,7 +60,7 @@
class="flex w-full items-center gap-3 px-3 py-2.5 mb-1 rounded-xl transition-all duration-200
{selected
? 'bg-surface-elevated shadow-md border border-border'
: 'hover:bg-surface-hover hover:-translate-y-0.5'}"
: 'hover:bg-surface-hover lg:hover:-translate-y-0.5 active:scale-[0.98]'}"
{onclick}
>
<!-- Avatar with online indicator -->

View file

@ -45,7 +45,7 @@
<div class="flex h-full flex-col">
<!-- Room List with Sections -->
<div class="chat-scrollbar flex-1 overflow-y-auto px-3">
<!-- New Chat action row -->
<!-- Header row with room count + new chat (desktop only, mobile has it in page header) -->
<div class="flex items-center justify-between px-2 py-2 mb-1">
<span
class="text-xs font-semibold uppercase text-muted-foreground tracking-wide flex items-center gap-2"
@ -57,7 +57,7 @@
</span>
</span>
<button
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium
class="hidden lg:flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium
bg-gradient-to-r from-violet-500 to-purple-600 text-white
shadow-sm hover:shadow-md hover:-translate-y-px transition-all duration-200"
onclick={onCreateRoom}

View file

@ -87,6 +87,14 @@
// Navigation state
let isCollapsed = $state(false);
// Hide PillNavigation on mobile when inside a room view
let isMobileRoomView = $derived(
typeof window !== 'undefined' &&
window.innerWidth < 1024 &&
$page.url.pathname.startsWith('/chat/') &&
$page.url.pathname !== '/chat'
);
// Theme state
let isDark = $derived(theme.isDark);
@ -382,34 +390,36 @@
{:else if matrixStore.isReady}
<!-- Ready - Show navigation and content -->
<div class="layout-container">
<!-- PillNavigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Manalink"
homeRoute="/chat"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
allAppsHref="https://mana.how"
/>
<!-- PillNavigation (hidden on mobile when in a room) -->
{#if !isMobileRoomView}
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Manalink"
homeRoute="/chat"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
allAppsHref="https://mana.how"
/>
{/if}
<!-- Main Content -->
<main class="main-content bg-background">
@ -493,8 +503,10 @@
</div>
{/if}
<!-- Spacer for PillNavigation -->
<div class="pill-nav-spacer"></div>
<!-- Spacer for PillNavigation (hidden when nav is hidden) -->
{#if !isMobileRoomView}
<div class="pill-nav-spacer"></div>
{/if}
</div>
{:else}
<!-- Unknown state - redirect to login -->

View file

@ -177,24 +177,24 @@
{#if isMobile}
<!-- Mobile: Full-screen room list -->
<div class="flex flex-col h-full bg-background safe-area-bottom">
<!-- User Info / Status Bar -->
<div class="border-b border-border px-4 py-3 bg-surface-elevated safe-area-top">
<!-- Compact Mobile Header -->
<div class="border-b border-border px-4 py-2.5 bg-surface-elevated safe-area-top">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-bold text-foreground">Manalink</h1>
<p class="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
<div class="flex items-center gap-2">
<h1 class="text-lg font-bold text-foreground">Manalink</h1>
<span class="flex items-center gap-1 text-xs text-muted-foreground">
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
{matrixStore.syncState === 'SYNCING' ? 'Verbunden' : matrixStore.syncState}
{#if matrixStore.totalUnreadCount > 0}
<span
class="ml-2 px-1.5 py-0.5 rounded-full bg-gradient-to-r from-blue-500 to-indigo-600 text-white text-xs font-medium"
>
{matrixStore.totalUnreadCount} ungelesen
</span>
{/if}
</p>
</span>
{#if matrixStore.totalUnreadCount > 0}
<span
class="px-1.5 py-0.5 rounded-full bg-gradient-to-r from-blue-500 to-indigo-600 text-white text-[10px] font-medium"
>
{matrixStore.totalUnreadCount}
</span>
{/if}
</div>
<div class="flex items-center gap-1">
<div class="flex items-center gap-0.5">
<a
href="/settings"
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"

View file

@ -19,8 +19,8 @@
let touchStartY = 0;
let isSwiping = $state(false);
let swipeProgress = $state(0);
const SWIPE_THRESHOLD = 100; // px to trigger back navigation
const EDGE_ZONE = 30; // px from left edge to start swipe
const SWIPE_THRESHOLD = 80; // px to trigger back navigation
const EDGE_ZONE = 50; // px from left edge to start swipe
let showRoomSettings = $state(false);
let showSearch = $state(false);