feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
This commit is contained in:
178
packages/shared/src/components/channel/PinnedMessageRow.tsx
Normal file
178
packages/shared/src/components/channel/PinnedMessageRow.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* PinnedMessageRow — shared card used by the desktop pins popover,
|
||||
* the mobile Pins drawer, and the pin confirmation modal. Matches
|
||||
* the new UI's behaviour:
|
||||
*
|
||||
* - `showHoverActions` (desktop) → card is non-clickable; a "Jump"
|
||||
* button + close X appear on the right side of the meta row and
|
||||
* fire `onJumpTo` / `onUnpin` respectively.
|
||||
* - `showHoverActions === false` (mobile / preview) → whole card
|
||||
* is tappable to `onJumpTo`; no hover buttons. The confirmation
|
||||
* modal renders with `onJumpTo={undefined}` so the card is a
|
||||
* read-only preview.
|
||||
*/
|
||||
import { Flag, X } from '@phosphor-icons/react';
|
||||
import { Avatar } from '@discord-clone/ui';
|
||||
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
|
||||
import styles from './PinnedMessageRow.module.css';
|
||||
|
||||
export interface PinnedMessage {
|
||||
id: string;
|
||||
authorName: string;
|
||||
authorAvatarUrl: string | null;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
attachments?: AttachmentMetadata[];
|
||||
}
|
||||
|
||||
interface PinnedMessageRowProps {
|
||||
message: PinnedMessage;
|
||||
/** Handler for navigating to the pinned message. */
|
||||
onJumpTo?: () => void;
|
||||
/** Handler for unpinning the message. Only rendered when the
|
||||
* caller also enables `showHoverActions`. */
|
||||
onUnpin?: () => void;
|
||||
/**
|
||||
* Desktop layout flag. When true, the card is not clickable and
|
||||
* the meta row shows a Jump button + close X. When false (mobile
|
||||
* / static preview), the whole card is the button.
|
||||
*/
|
||||
showHoverActions?: boolean;
|
||||
/** Whether the viewer has permission to unpin. Hides the X when
|
||||
* they don't, even if `showHoverActions` is on. */
|
||||
canUnpin?: boolean;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const isYesterday = date.toDateString() === yesterday.toDateString();
|
||||
const time = date.toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
if (isToday) return `Today at ${time}`;
|
||||
if (isYesterday) return `Yesterday at ${time}`;
|
||||
return `${date.toLocaleDateString()}, ${time}`;
|
||||
}
|
||||
|
||||
export function PinnedMessageRow({
|
||||
message,
|
||||
onJumpTo,
|
||||
onUnpin,
|
||||
showHoverActions = false,
|
||||
canUnpin = true,
|
||||
}: PinnedMessageRowProps) {
|
||||
const isClickable = !showHoverActions && !!onJumpTo;
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (isClickable && onJumpTo) onJumpTo();
|
||||
};
|
||||
|
||||
const handleJumpClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onJumpTo?.();
|
||||
};
|
||||
|
||||
const handleUnpinClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onUnpin?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.card} ${isClickable ? '' : styles.cardStatic}`}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
onClick={isClickable ? handleCardClick : undefined}
|
||||
onKeyDown={
|
||||
isClickable
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleCardClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className={styles.avatar}>
|
||||
<Avatar
|
||||
src={message.authorAvatarUrl || undefined}
|
||||
fallback={message.authorName}
|
||||
size={36}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.meta}>
|
||||
<span className={styles.author}>{message.authorName}</span>
|
||||
<span className={styles.timestamp}>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
{showHoverActions && (
|
||||
<div className={styles.hoverActions}>
|
||||
{onJumpTo && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.jumpButton}
|
||||
onClick={handleJumpClick}
|
||||
>
|
||||
Jump
|
||||
</button>
|
||||
)}
|
||||
{canUnpin && onUnpin && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
onClick={handleUnpinClick}
|
||||
aria-label="Unpin message"
|
||||
title="Unpin message"
|
||||
>
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{message.content ? (
|
||||
<div className={styles.content}>{message.content}</div>
|
||||
) : !message.attachments || message.attachments.length === 0 ? (
|
||||
<div className={`${styles.content} ${styles.undecryptable}`}>
|
||||
(no preview)
|
||||
</div>
|
||||
) : null}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div
|
||||
className={styles.attachments}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{message.attachments.map((att, i) => (
|
||||
<EncryptedAttachment
|
||||
key={`${message.id}-att-${i}`}
|
||||
metadata={att}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "You've reached the end" terminator used below the list and as
|
||||
* the empty state. */
|
||||
export function ReachedEndNotice() {
|
||||
return (
|
||||
<div className={styles.reachedEnd}>
|
||||
<Flag size={32} weight="regular" className={styles.reachedEndIcon} />
|
||||
<div className={styles.reachedEndTitle}>You've reached the end</div>
|
||||
<div className={styles.reachedEndBody}>
|
||||
Members with the "Pin Messages" permission can pin messages for
|
||||
everyone to see.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user