aa609389fa
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>
235 lines
8.0 KiB
TypeScript
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';
|
|
}
|