Files
ChatApp/apps/desktop/src/components/MediaFilesDrawer.tsx
T
byGalax aa609389fa feat(chat): render Media/Files + Group Info drawers inline, not overlaid
Both right-hand panels used absolute inset-y-0 right-0 and floated on
top of the conversation, hiding the messages directly underneath the
panel and looking unlike Discord's actual layout. Restructure:

* MediaFilesDrawer: drop absolute/z-index/shadow chrome, become a
  static flex column (w-[380px] shrink-0) with a left border. Internal
  layout unchanged.
* GroupInfoPanel: same treatment (w-[320px] shrink-0). Dropped the
  backdrop-blur and slide-up animation that only made sense as a modal.
* ConversationPage: wrap the chat content (voice rail, in-call panel,
  messages list, drag-overlay, input form) in a new
  `flex min-w-0 flex-1 flex-col` chat-column, and make that column a
  sibling of the drawers inside a new `flex flex-1 flex-row` row. The
  conversation header + search bar stay full-width above the row.

Result: opening a drawer narrows the chat column instead of covering
it, matching Discord's behaviour. The chat-column wrapper also carries
the `relative` anchor previously held by the outer wrapper so the
drag-and-drop overlay positions correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:21:05 +02:00

235 lines
8.0 KiB
TypeScript

import { useMemo, useState } from 'react';
import type {
ConversationAttachmentIndex,
ConversationAttachmentItem,
} from '../lib/conversationFeatures';
import { AttachmentGeneric } from './AttachmentGeneric';
import { AttachmentImage } from './AttachmentImage';
import { ArrowRightIcon, FileIcon, ImageIcon, MicIcon, VideoIcon, XIcon } from './icons';
type DrawerTab = 'media' | 'files' | 'audio';
interface Props {
open: boolean;
index: ConversationAttachmentIndex;
senderNameFor: (senderId: string) => string;
onJumpToMessage: (messageId: string) => void;
onClose: () => void;
}
const TAB_LABELS: Record<DrawerTab, string> = {
media: 'Medien',
files: 'Dateien',
audio: 'Audio',
};
export function MediaFilesDrawer({ open, index, senderNameFor, onJumpToMessage, onClose }: Props) {
const [tab, setTab] = useState<DrawerTab>('media');
const items = index[tab];
if (!open) return null;
return (
<aside className="flex w-[380px] shrink-0 flex-col border-l border-line bg-surface-2 dark:bg-[#2b2d31]">
<header className="flex min-h-[65px] items-center gap-3 border-b border-line px-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/10 text-accent">
<ImageIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-fg">Medien & Dateien</p>
<p className="text-xs text-fg-muted">{index.all.length} Elemente im geladenen Verlauf</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Drawer schließen"
title="Drawer schließen"
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="grid grid-cols-3 gap-1 border-b border-line p-2">
{(['media', 'files', 'audio'] as DrawerTab[]).map((item) => (
<button
key={item}
type="button"
onClick={() => setTab(item)}
className={
'cursor-pointer rounded-lg px-2 py-2 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(tab === item
? 'bg-accent text-accent-fg'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg dark:hover:bg-[#383a40]')
}
>
{TAB_LABELS[item]} · {index[item].length}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{items.length === 0 ? (
<EmptyState tab={tab} />
) : tab === 'media' ? (
<div className="grid grid-cols-2 gap-2">
{items.map((item) => (
<MediaTile
key={item.handle.id}
item={item}
senderName={senderNameFor(item.senderId)}
onJumpToMessage={onJumpToMessage}
/>
))}
</div>
) : (
<div className="space-y-2">
{items.map((item) => (
<FileRow
key={item.handle.id}
item={item}
senderName={senderNameFor(item.senderId)}
onJumpToMessage={onJumpToMessage}
/>
))}
</div>
)}
</div>
</aside>
);
}
function MediaTile({
item,
senderName,
onJumpToMessage,
}: {
item: ConversationAttachmentItem;
senderName: string;
onJumpToMessage: (messageId: string) => void;
}) {
const isImage = item.handle.mimeType.startsWith('image/');
const isVideo = item.handle.mimeType.startsWith('video/');
return (
<article className="overflow-hidden rounded-lg border border-line bg-surface-3 dark:bg-[#313338]">
<div className="flex aspect-square items-center justify-center overflow-hidden bg-black/20">
{isImage ? (
<div className="flex max-h-full max-w-full items-center justify-center p-1 [&_button]:m-0 [&_img]:max-h-36">
<AttachmentImage handle={item.handle} />
</div>
) : isVideo ? (
<div className="flex flex-col items-center gap-2 text-fg-muted">
<VideoIcon className="h-7 w-7" />
<span className="text-xs">Video</span>
</div>
) : (
<ImageIcon className="h-7 w-7 text-fg-muted" />
)}
</div>
<AttachmentMeta item={item} senderName={senderName} onJumpToMessage={onJumpToMessage} />
</article>
);
}
function FileRow({
item,
senderName,
onJumpToMessage,
}: {
item: ConversationAttachmentItem;
senderName: string;
onJumpToMessage: (messageId: string) => void;
}) {
const isAudio = item.handle.mimeType.startsWith('audio/');
return (
<article className="rounded-lg border border-line bg-surface-3 p-2 dark:bg-[#313338]">
<div className="flex items-center gap-2">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent">
{isAudio ? <MicIcon className="h-5 w-5" /> : <FileIcon className="h-5 w-5" />}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-fg">{labelFor(item.handle.mimeType)}</p>
<p className="truncate text-xs text-fg-muted">
{formatSize(item.handle.sizeBytes)} · {senderName}
</p>
</div>
</div>
{!isAudio && <AttachmentGeneric handle={item.handle} />}
<AttachmentMeta
item={item}
senderName={senderName}
onJumpToMessage={onJumpToMessage}
compact
/>
</article>
);
}
function AttachmentMeta({
item,
senderName,
onJumpToMessage,
compact = false,
}: {
item: ConversationAttachmentItem;
senderName: string;
onJumpToMessage: (messageId: string) => void;
compact?: boolean;
}) {
const date = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(item.createdAt)),
[item.createdAt],
);
return (
<div className={(compact ? 'pt-2' : 'p-2') + ' flex items-center gap-2'}>
<div className="min-w-0 flex-1">
{!compact && <p className="truncate text-xs font-semibold text-fg">{senderName}</p>}
<p className="truncate text-[11px] text-fg-muted">{date}</p>
</div>
<button
type="button"
onClick={() => onJumpToMessage(item.messageId)}
aria-label="Zur Nachricht springen"
title="Zur Nachricht springen"
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
>
<ArrowRightIcon className="h-4 w-4" />
</button>
</div>
);
}
function EmptyState({ tab }: { tab: DrawerTab }) {
const Icon = tab === 'media' ? ImageIcon : tab === 'audio' ? MicIcon : FileIcon;
return (
<div className="flex h-full min-h-[280px] flex-col items-center justify-center gap-3 text-center text-fg-muted">
<span className="flex h-12 w-12 items-center justify-center rounded-xl bg-surface-3 dark:bg-[#313338]">
<Icon className="h-6 w-6" />
</span>
<p className="max-w-48 text-sm">
Noch keine {TAB_LABELS[tab].toLowerCase()} im geladenen Verlauf.
</p>
</div>
);
}
function labelFor(mimeType: string): string {
if (mimeType.startsWith('audio/')) return 'Audiodatei';
if (mimeType === 'application/pdf') return 'PDF-Datei';
if (mimeType.startsWith('text/')) return 'Textdatei';
return mimeType || 'Datei';
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}