Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"build": "tsc -p tsconfig.json",
"prepack": "npm run build",
"test:typescript": "ts-node ./tests/generate.ts",
"test:send-components-v2": "ts-node ./tests/components-v2-send.ts",
"lint": "prettier --write --cache . && eslint --cache --fix .",
"typecheck": "tsc -p tsconfig.eslint.json"
},
Expand Down
161 changes: 128 additions & 33 deletions src/generator/renderers/components.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,135 @@
import { DiscordActionRow, DiscordButton } from '@derockdev/discord-components-react';
import { ButtonStyle, ComponentType, type MessageActionRowComponent, type ActionRow } from 'discord.js';
import { DiscordActionRow, DiscordAttachment, DiscordSpoiler } from '@derockdev/discord-components-react';
import {
ComponentType,
type ThumbnailComponent,
type MessageActionRowComponent,
type TopLevelComponent,
} from 'discord.js';
import React from 'react';
import { parseDiscordEmoji } from '../../utils/utils';
import DiscordSelectMenu from './components/Select Menu';
import DiscordContainer from './components/Container';
import DiscordSection from './components/section/Section';
import DiscordMediaGallery from './components/Media Gallery';
import DiscordSeparator from './components/Spacing';
import DiscordButton from './components/Button';
import DiscordThumbnail from './components/Thumbnail';
import MessageContent from './content';
import { RenderType } from './content';
import type { RenderMessageContext } from '..';
import { ButtonStyleMapping } from './components/styles';

export default function ComponentRow({ row, id }: { row: ActionRow<MessageActionRowComponent>; id: number }) {
return (
<DiscordActionRow key={id}>
{row.components.map((component, id) => (
<Component component={component} id={id} key={id} />
))}
</DiscordActionRow>
);
}
export default function ComponentRow({
component,
id,
context,
}: {
component: TopLevelComponent;
id: number;
context: RenderMessageContext;
}) {
switch (component.type) {
case ComponentType.ActionRow:
return (
<DiscordActionRow key={id}>
<>
{component.components.map((nestedComponent, id) => (
<Component component={nestedComponent} id={id} key={id} />
))}
</>
</DiscordActionRow>
);

case ComponentType.Container:
return (
<DiscordContainer key={id}>
<>
{component.components.map((nestedComponent, id) => (
<ComponentRow component={nestedComponent} id={id} key={id} context={context} />
))}
</>
</DiscordContainer>
);

case ComponentType.File:
return (
<>
{component.spoiler ? (
<DiscordSpoiler key={component.id} slot="attachment">
<DiscordAttachment
type="file"
key={component.id}
slot="attachment"
url={component.file.url}
alt="Discord Attachment"
/>
</DiscordSpoiler>
) : (
<DiscordAttachment
type="file"
key={component.id}
slot="attachment"
url={component.file.url}
alt="Discord Attachment"
/>
)}
</>
);

case ComponentType.MediaGallery:
return <DiscordMediaGallery component={component} key={id} />;

case ComponentType.Section:
return (
<DiscordSection key={id} accessory={component.accessory} id={id}>
{component.components.map((nestedComponent, id) => (
<ComponentRow component={nestedComponent} id={id} key={id} context={context} />
))}
</DiscordSection>
);

case ComponentType.Separator:
return <DiscordSeparator key={id} spacing={component.spacing} divider={component.divider} />;

const ButtonStyleMapping = {
[ButtonStyle.Primary]: 'primary',
[ButtonStyle.Secondary]: 'secondary',
[ButtonStyle.Success]: 'success',
[ButtonStyle.Danger]: 'destructive',
[ButtonStyle.Link]: 'secondary',
[ButtonStyle.Premium]: 'primary',
} satisfies Record<ButtonStyle, Parameters<typeof DiscordButton>[0]['type']>;

export function Component({ component, id }: { component: MessageActionRowComponent; id: number }) {
if (component.type === ComponentType.Button) {
return (
<DiscordButton
key={id}
type={ButtonStyleMapping[component.style]}
url={component.url ?? undefined}
emoji={component.emoji ? parseDiscordEmoji(component.emoji) : undefined}
>
{component.label}
</DiscordButton>
);
case ComponentType.TextDisplay:
return <MessageContent key={id} content={component.content} context={{ ...context, type: RenderType.NORMAL }} />;

default:
return null;
}
}

export function Component({
component,
id,
}: {
component: MessageActionRowComponent | ThumbnailComponent;
id: number;
}) {
switch (component.type) {
case ComponentType.Button:
return (
<DiscordButton
key={id}
type={ButtonStyleMapping[component.style as keyof typeof ButtonStyleMapping]}
url={component.url ?? undefined}
emoji={component.emoji ? parseDiscordEmoji(component.emoji) : undefined}
>
{component.label}
</DiscordButton>
);

return undefined;
case ComponentType.StringSelect:
case ComponentType.UserSelect:
case ComponentType.RoleSelect:
case ComponentType.MentionableSelect:
case ComponentType.ChannelSelect:
return <DiscordSelectMenu key={id} component={component} />;

case ComponentType.Thumbnail:
return <DiscordThumbnail key={id} url={component.media.url} />;

default:
return undefined;
}
}
37 changes: 37 additions & 0 deletions src/generator/renderers/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';

interface DiscordButtonProps {
type: string;
url?: string;
emoji?: string;
children: React.ReactNode;
}

export function DiscordButton({ type, url, emoji, children }: DiscordButtonProps) {
return (
<a href={url} target="_blank" className={`discord-button discord-button-${type}`}>
{emoji && (
<span style={{ display: 'flex', alignItems: 'center' }}>
<img src={emoji} alt="emoji" style={{ width: '16px', height: '16px', marginRight: '8px' }} />
</span>
)}
<span style={{ display: 'flex', alignItems: 'center' }}>{children}</span>
{url && (
<span style={{ marginLeft: '8px', display: 'flex', alignItems: 'center' }}>
<svg role="img" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M15 2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V4.41l-4.3 4.3a1 1 0 1 1-1.4-1.42L19.58 3H16a1 1 0 0 1-1-1Z"
/>
<path
fill="currentColor"
d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-6a1 1 0 1 0-2 0v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h6a1 1 0 1 0 0-2H5Z"
/>
</svg>
</span>
)}
</a>
);
}

export default DiscordButton;
24 changes: 24 additions & 0 deletions src/generator/renderers/components/Container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';

function DiscordContainer({ children }: { children: React.ReactNode }) {
return (
<div
style={{
display: 'flex',
width: '500px',
flexDirection: 'column',
backgroundColor: '#3f4248',
padding: '16px',
border: '1px solid #4f5359',
marginTop: '2px',
marginBottom: '2px',
borderRadius: '10px',
gap: '8px',
}}
>
{children}
</div>
);
}

export default DiscordContainer;
53 changes: 53 additions & 0 deletions src/generator/renderers/components/Media Gallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';
import type { MediaGalleryComponent } from 'discord.js';
import { getGalleryLayout, getImageStyle } from './utils';

function DiscordMediaGallery({ component }: { component: MediaGalleryComponent }) {
if (!component.items || component.items.length === 0) {
return null;
}

const count = component.items.length;
const imagesToShow = component.items.slice(0, 10);
const hasMore = component.items.length > 10;

return (
<div style={getGalleryLayout(count)}>
{imagesToShow.map((media, idx) => (
<div key={idx} style={getImageStyle(idx, count)}>
<img
src={media.media.url}
alt={media.description || 'Media content'}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
{hasMore && idx === imagesToShow.length - 1 && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
color: 'white',
fontSize: '20px',
fontWeight: 'bold',
}}
>
+{component.items.length - 10}
</div>
)}
</div>
))}
</div>
);
}

export default DiscordMediaGallery;
59 changes: 59 additions & 0 deletions src/generator/renderers/components/Select Menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import { type MessageActionRowComponent, ComponentType } from 'discord.js';
import { parseDiscordEmoji } from '../../../utils/utils';
import { getSelectTypeLabel } from './utils';

function DiscordSelectMenu({
component,
}: {
component: Exclude<MessageActionRowComponent, { type: ComponentType.Button }>;
}) {
const isStringSelect = component.type === ComponentType.StringSelect;
const placeholder = component.placeholder || getSelectTypeLabel(component.type);

return (
<div className="discord-select-menu">
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{placeholder}</div>
<div style={{ display: 'flex', alignItems: 'center', marginLeft: '8px' }}>
<svg width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M7 10L12 15L17 10H7Z" />
</svg>
</div>
{isStringSelect && component.options && component.options.length > 0 && (
<div
style={{
display: 'none',
position: 'absolute',
top: '44px',
left: '0',
width: '100%',
backgroundColor: '#2b2d31',
borderRadius: '4px',
zIndex: 10,
border: '1px solid #1e1f22',
maxHeight: '320px',
overflowY: 'auto',
}}
>
{component.options.map((option, idx) => (
<div
key={idx}
style={{
padding: '8px 12px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
borderBottom: idx < component.options.length - 1 ? '1px solid #1e1f22' : 'none',
}}
>
{option.emoji && <span style={{ marginRight: '8px' }}>{parseDiscordEmoji(option.emoji)}</span>}
<span>{option.label}</span>
</div>
))}
</div>
)}
</div>
);
}

export default DiscordSelectMenu;
17 changes: 17 additions & 0 deletions src/generator/renderers/components/Spacing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';
import { SeparatorSpacingSize } from 'discord.js';

function DiscordSeparator({ divider, spacing }: { divider: boolean; spacing: SeparatorSpacingSize }) {
return (
<div
style={{
width: '100%',
height: divider ? '1px' : '0px',
backgroundColor: '#4f5359',
margin: spacing === SeparatorSpacingSize.Large ? '8px 0' : '0',
}}
/>
);
}

export default DiscordSeparator;
18 changes: 18 additions & 0 deletions src/generator/renderers/components/Thumbnail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';

function DiscordThumbnail({ url }: { url: string }) {
return (
<img
src={url}
alt="Thumbnail"
style={{
width: '85px',
height: '85px',
objectFit: 'cover',
borderRadius: '8px',
}}
/>
);
}

export default DiscordThumbnail;
Loading