initial commit
This commit is contained in:
65
fluxer_app/src/components/emojis/EmojiInfoContent.module.css
Normal file
65
fluxer_app/src/components/emojis/EmojiInfoContent.module.css
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.guildRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.guildIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.25rem;
|
||||
--guild-icon-size: 1.25rem;
|
||||
}
|
||||
|
||||
.guildName {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
|
||||
.verifiedIcon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-primary);
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
94
fluxer_app/src/components/emojis/EmojiInfoContent.tsx
Normal file
94
fluxer_app/src/components/emojis/EmojiInfoContent.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {SealCheckIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GuildFeatures} from '~/Constants';
|
||||
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import type {Emoji} from '~/stores/EmojiStore';
|
||||
import GuildListStore from '~/stores/GuildListStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import styles from './EmojiInfoContent.module.css';
|
||||
|
||||
interface EmojiInfoContentProps {
|
||||
emoji: Emoji;
|
||||
}
|
||||
|
||||
export const EmojiInfoContent = observer(function EmojiInfoContent({emoji}: EmojiInfoContentProps) {
|
||||
const {t} = useLingui();
|
||||
const isCustomEmoji = Boolean(emoji.guildId || emoji.id);
|
||||
|
||||
if (!isCustomEmoji) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<span className={styles.text}>
|
||||
<Trans>This is a default emoji on Fluxer.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const guildId = emoji.guildId;
|
||||
const isMember = guildId ? GuildListStore.guilds.some((guild) => guild.id === guildId) : false;
|
||||
|
||||
if (!isMember) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<span className={styles.text}>
|
||||
<Trans>This is a custom emoji from a community. Ask the author for an invite to use this emoji.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const guild = guildId ? GuildStore.getGuild(guildId) : null;
|
||||
|
||||
if (!guild) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<span className={styles.text}>
|
||||
<Trans>This is a custom emoji from a community.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isVerified = guild.features.has(GuildFeatures.VERIFIED);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<span className={styles.text}>
|
||||
<Trans>This is a custom emoji from</Trans>
|
||||
</span>
|
||||
<div className={styles.guildRow}>
|
||||
<div className={styles.guildIcon}>
|
||||
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} sizePx={20} />
|
||||
</div>
|
||||
<span className={styles.guildName}>{guild.name}</span>
|
||||
{isVerified && (
|
||||
<Tooltip text={t`Verified Community`} position="top">
|
||||
<SealCheckIcon className={styles.verifiedIcon} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
371
fluxer_app/src/components/emojis/EmojiListItem.module.css
Normal file
371
fluxer_app/src/components/emojis/EmojiListItem.module.css
Normal file
@@ -0,0 +1,371 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.header {
|
||||
display: none;
|
||||
grid-template-columns: 72px minmax(200px, 1fr) minmax(180px, 0.85fr);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0 0.75rem 0.5rem calc(0.75rem + 1px);
|
||||
}
|
||||
|
||||
.headerCell:first-child {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.headerCell:nth-child(2) {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.headerCell {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.header {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--background-header-secondary);
|
||||
background-color: var(--background-secondary);
|
||||
padding: 0.75rem;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--background-modifier-accent);
|
||||
box-shadow: 0 10px 25px -18px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
.cardWrapper {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.gridCardWrapper,
|
||||
.listCardWrapper {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(40%, -40%);
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--background-header-secondary);
|
||||
background-color: var(--background-primary);
|
||||
padding: 0.5rem;
|
||||
color: var(--text-primary-muted);
|
||||
opacity: 0;
|
||||
z-index: 2;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
||||
0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
transition:
|
||||
opacity 150ms,
|
||||
background-color 150ms,
|
||||
border-color 150ms,
|
||||
color 150ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover .deleteButton {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cardWrapper:hover .deleteButton {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.deleteButton:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.deleteButton:hover {
|
||||
border-color: var(--status-danger);
|
||||
background-color: var(--status-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.deleteIcon {
|
||||
height: 0.75rem;
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.deleteButtonFloating {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
background-color: var(--background-secondary);
|
||||
transform: translate(40%, -40%);
|
||||
}
|
||||
|
||||
.listCard {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(200px, 1fr) minmax(180px, 0.85fr);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.listCard {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.listEmoji {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.listEmojiImage {
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.nameInlineEdit {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nameInlineEditButton {
|
||||
max-width: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nameInlineEditInput {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.listName {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.listName .nameInlineEdit {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.listName .nameInlineEditButton {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.listName .nameInlineEditInput {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nameInlineEdit[data-mode='idle'] .nameInlineEditInput {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.nameInlineEdit[data-mode='editing'] .nameInlineEditInput,
|
||||
.nameInlineEdit[data-mode='saving'] .nameInlineEditInput {
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
word-break: break-word;
|
||||
max-width: min(22ch, 100%);
|
||||
}
|
||||
|
||||
.listUploader {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.username {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
max-height: 1.25rem;
|
||||
}
|
||||
|
||||
.unknownUser {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.gridCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gridEmojiWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gridEmojiImage {
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.gridAvatar {
|
||||
position: absolute;
|
||||
top: -0.35rem;
|
||||
left: -0.35rem;
|
||||
height: 1.75rem;
|
||||
width: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid var(--background-secondary);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 0.15),
|
||||
0 2px 4px -2px rgb(0 0 0 / 0.12);
|
||||
background-color: var(--background-secondary);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.gridName {
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
min-height: 1.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gridNameText {
|
||||
width: 100%;
|
||||
max-width: 16ch;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gridNameButton {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridNameButton:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.renamePopout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
box-shadow:
|
||||
0 20px 38px -12px rgb(0 0 0 / 0.3),
|
||||
0 8px 16px -8px rgb(0 0 0 / 0.25);
|
||||
min-width: min(280px, 90vw);
|
||||
}
|
||||
|
||||
.renamePopoutHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.renamePopoutTitle {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.renamePopoutHint {
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.renamePopoutActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
299
fluxer_app/src/components/emojis/EmojiListItem.tsx
Normal file
299
fluxer_app/src/components/emojis/EmojiListItem.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {XIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as GuildEmojiActionCreators from '~/actions/GuildEmojiActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {GuildFeatures} from '~/Constants';
|
||||
import {Input} from '~/components/form/Input';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {InlineEdit} from '~/components/uikit/InlineEdit';
|
||||
import {Popout} from '~/components/uikit/Popout/Popout';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import type {GuildEmojiWithUser} from '~/records/GuildEmojiRecord';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import * as AvatarUtils from '~/utils/AvatarUtils';
|
||||
import styles from './EmojiListItem.module.css';
|
||||
|
||||
interface EmojiRenamePopoutContentProps {
|
||||
initialName: string;
|
||||
onSave: (newName: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EmojiRenamePopoutContent: React.FC<EmojiRenamePopoutContentProps> = ({initialName, onSave, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const [draft, setDraft] = React.useState(initialName);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const sanitizedDraft = draft.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
const isDraftValid = sanitizedDraft.length >= 2 && sanitizedDraft.length <= 32;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isDraftValid || isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(sanitizedDraft);
|
||||
onClose();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const {value, selectionStart, selectionEnd} = e.target;
|
||||
const next = value.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
const removed = value.length - next.length;
|
||||
setDraft(next);
|
||||
|
||||
if (inputRef.current && selectionStart !== null && selectionEnd !== null) {
|
||||
const newStart = Math.max(0, selectionStart - removed);
|
||||
const newEnd = Math.max(0, selectionEnd - removed);
|
||||
requestAnimationFrame(() => inputRef.current?.setSelectionRange(newStart, newEnd));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={styles.renamePopout}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className={styles.renamePopoutHeader}>
|
||||
<span className={styles.renamePopoutTitle}>
|
||||
<Trans>Rename Emoji</Trans>
|
||||
</span>
|
||||
<span className={styles.renamePopoutHint}>
|
||||
<Trans>2-32 characters, letters, numbers, underscores.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={handleInputChange}
|
||||
maxLength={32}
|
||||
placeholder={t`Emoji name`}
|
||||
/>
|
||||
<div className={styles.renamePopoutActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
small
|
||||
onClick={() => {
|
||||
setDraft(initialName);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" small disabled={!isDraftValid || isSaving} submitting={isSaving}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmojiListHeader: React.FC = observer(() => (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerCell}>
|
||||
<Trans>Emoji</Trans>
|
||||
</div>
|
||||
<div className={styles.headerCell}>
|
||||
<Trans>Name</Trans>
|
||||
</div>
|
||||
<div className={styles.headerCell}>
|
||||
<Trans>Uploaded By</Trans>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
export const EmojiListItem: React.FC<{
|
||||
guildId: string;
|
||||
emoji: GuildEmojiWithUser;
|
||||
layout: 'list' | 'grid';
|
||||
onRename: (emojiId: string, newName: string) => void;
|
||||
onRemove: (emojiId: string) => void;
|
||||
}> = observer(({guildId, emoji, layout, onRename, onRemove}) => {
|
||||
const {t} = useLingui();
|
||||
const avatarUrl = emoji.user ? AvatarUtils.getUserAvatarURL(emoji.user, false) : null;
|
||||
const gridNameButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const handleSave = async (newName: string) => {
|
||||
const sanitizedName = newName.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
if (sanitizedName.length < 2) {
|
||||
ToastActionCreators.error(t`Emoji name must be at least 2 characters long`);
|
||||
throw new Error('Name too short');
|
||||
}
|
||||
if (sanitizedName.length > 32) {
|
||||
ToastActionCreators.error(t`Emoji name must be at most 32 characters long`);
|
||||
throw new Error('Name too long');
|
||||
}
|
||||
if (sanitizedName === emoji.name) return;
|
||||
|
||||
const prevName = emoji.name;
|
||||
onRename(emoji.id, sanitizedName);
|
||||
|
||||
try {
|
||||
await GuildEmojiActionCreators.update(guildId, emoji.id, {name: sanitizedName});
|
||||
} catch (err) {
|
||||
onRename(emoji.id, prevName);
|
||||
console.error('Failed to update emoji name:', err);
|
||||
ToastActionCreators.error(t`Failed to update emoji name. Reverted to the previous name.`);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const guild = GuildStore.getGuild(guildId);
|
||||
const canExpressionPurge = guild?.features.has(GuildFeatures.EXPRESSION_PURGE_ALLOWED) ?? false;
|
||||
|
||||
const handleDelete = () => {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Delete Emoji`}
|
||||
description={t`Are you sure you want to delete :${emoji.name}:? This action cannot be undone.`}
|
||||
primaryText={t`Delete`}
|
||||
primaryVariant="danger-primary"
|
||||
checkboxContent={
|
||||
canExpressionPurge ? <Checkbox>{t`Purge this emoji from storage and CDN`}</Checkbox> : undefined
|
||||
}
|
||||
onPrimary={async (checkboxChecked = false) => {
|
||||
await GuildEmojiActionCreators.remove(guildId, emoji.id, checkboxChecked && canExpressionPurge);
|
||||
onRemove(emoji.id);
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
const emojiUrl = AvatarUtils.getEmojiURL({id: emoji.id, animated: emoji.animated});
|
||||
|
||||
if (layout === 'grid') {
|
||||
return (
|
||||
<div className={clsx(styles.cardWrapper, styles.gridCardWrapper)}>
|
||||
<div className={clsx(styles.card, styles.gridCard)}>
|
||||
<div className={styles.gridEmojiWrapper}>
|
||||
<img src={emojiUrl} alt={emoji.name} className={styles.gridEmojiImage} loading="lazy" />
|
||||
{emoji.user && avatarUrl && (
|
||||
<Tooltip text={emoji.user.username}>
|
||||
<img src={avatarUrl} alt="" className={styles.gridAvatar} loading="lazy" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.gridName}>
|
||||
<Popout
|
||||
position="bottom"
|
||||
offsetMainAxis={8}
|
||||
offsetCrossAxis={0}
|
||||
returnFocusRef={gridNameButtonRef}
|
||||
render={({onClose}) => (
|
||||
<EmojiRenamePopoutContent initialName={emoji.name} onSave={handleSave} onClose={onClose} />
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
ref={gridNameButtonRef}
|
||||
className={styles.gridNameButton}
|
||||
aria-label={t`Rename :${emoji.name}:`}
|
||||
>
|
||||
<span className={styles.gridNameText}>:{emoji.name}:</span>
|
||||
</button>
|
||||
</Popout>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tooltip text={t`Delete`}>
|
||||
<FocusRing offset={-2}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className={clsx(styles.deleteButton, styles.deleteButtonFloating)}
|
||||
>
|
||||
<XIcon className={styles.deleteIcon} weight="bold" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.cardWrapper, styles.listCardWrapper)}>
|
||||
<div className={clsx(styles.card, styles.listCard)}>
|
||||
<div className={styles.listEmoji}>
|
||||
<img src={emojiUrl} alt={emoji.name} className={styles.listEmojiImage} loading="lazy" />
|
||||
</div>
|
||||
|
||||
<div className={styles.listName}>
|
||||
<InlineEdit
|
||||
value={emoji.name}
|
||||
onSave={handleSave}
|
||||
prefix=":"
|
||||
suffix=":"
|
||||
maxLength={32}
|
||||
width="100%"
|
||||
className={styles.nameInlineEdit}
|
||||
inputClassName={styles.nameInlineEditInput}
|
||||
buttonClassName={styles.nameInlineEditButton}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.listUploader}>
|
||||
{emoji.user && avatarUrl ? (
|
||||
<>
|
||||
<img src={avatarUrl} alt="" className={styles.avatar} loading="lazy" />
|
||||
<span className={styles.username}>{emoji.user.username}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.unknownUser}>
|
||||
<Trans>Unknown</Trans>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tooltip text={t`Delete`}>
|
||||
<FocusRing offset={-2}>
|
||||
<button type="button" onClick={handleDelete} className={styles.deleteButton}>
|
||||
<XIcon className={styles.deleteIcon} weight="bold" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user